Skip to content

Commit

Permalink
feat(bot): begin with Sport API, add /howgoodami command
Browse files Browse the repository at this point in the history
  • Loading branch information
evermake committed Mar 10, 2024
1 parent eb640a8 commit 948ef9e
Show file tree
Hide file tree
Showing 12 changed files with 177 additions and 10 deletions.
10 changes: 10 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,13 @@ POSTGRES_URL=
#
# Example: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11".
BOT_TOKEN=

# InnoSport API base URL.
#
# Example: "https://sport.innopolis.university/api".
SPORT_API_BASE_URL=

# InnoSport API token.
#
# Example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9".
SPORT_TOKEN=
5 changes: 5 additions & 0 deletions backend/src/bot/filters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { ChatTypeContext, Context } from 'grammy'

export function pm<C extends Context>(ctx: C): ctx is ChatTypeContext<C, 'private'> {
return ctx.chat?.type === 'private'
}
24 changes: 24 additions & 0 deletions backend/src/bot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,29 @@ export function createBot({
ctx.reply(`${ctx.t.Welcome}\n\n${JSON.stringify(ctx.user)}`)
})

bot.command('howgoodami', async (ctx) => {
await ctx.api.sendMessage(
ctx.chat.id,
ctx.t['HowGoodAmI.Thinking'],
)

ctx.domain.getStudentBetterThanPercent(ctx.user!)
.then((percent) => {
ctx.api.sendMessage(
ctx.chat.id,
ctx.t['HowGoodAmI.Answer'](percent),
{ reply_parameters: { message_id: ctx.update.message!.message_id } },
)
})
.catch((err) => {
ctx.logger.error(err)
ctx.api.sendMessage(
ctx.chat.id,
ctx.t['HowGoodAmI.Failed'],
{ reply_parameters: { message_id: ctx.update.message!.message_id } },
)
})
})

return bot
}
10 changes: 10 additions & 0 deletions backend/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,15 @@ export const Config = z.object({
.string()
.describe('Telegram Bot API token.\n\nExample: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11".'),
}),

sport: z.object({
apiBaseUrl: z
.string()
.describe('InnoSport API base URL.\n\nExample: "https://sport.innopolis.university/api".'),

token: z
.string()
.describe('InnoSport API token.\n\nExample: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9".'),
}),
})
export type Config = z.infer<typeof Config>
27 changes: 24 additions & 3 deletions backend/src/domain/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { User } from './schemas/user'
import type { Logger } from '~/lib/logging'
import type { Database } from '~/services/database'
import type { Sport } from '~/services/sport'
import type { SportClient } from '~/services/sport'

export class Domain {
private logger: Logger
private db: Database
private sport: Sport
private sport: SportClient

constructor(options: {
logger: Logger
db: Database
sport: Sport
sport: SportClient
}) {
this.logger = options.logger
this.db = options.db
Expand Down Expand Up @@ -40,4 +40,25 @@ export class Domain {
})
return User.parse(user)
}

public async getStudentBetterThanPercent({
telegramId,
}: {
telegramId: number
}): Promise<number> {
const user = await this.db.user.findUnique({
where: { telegramId },
select: { sportId: true },
})

if (!user) {
throw new Error(`User with Telegram ID ${telegramId} is not found.`)
}

if (!user.sportId) {
throw new Error(`User with Telegram ID ${telegramId} has no sport ID.`)
}

return await this.sport.getBetterThan({ studentId: user.sportId })
}
}
8 changes: 6 additions & 2 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createLogger } from './lib/logging'
import { createBot } from './bot'
import { Domain } from './domain'
import { createDatabase } from './services/database'
import { Sport } from './services/sport'
import { SportClient } from './services/sport'

async function main() {
const config = loadConfigFromEnv()
Expand All @@ -22,7 +22,11 @@ async function main() {
logger: logger.child({ tag: 'database' }),
connectionUrl: config.postgresUrl,
}),
sport: new Sport(),
sport: new SportClient({
logger: logger.child({ tag: 'sport' }),
baseUrl: config.sport.apiBaseUrl,
token: config.sport.token,
}),
}),
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ CREATE TABLE "User" (
"firstName" TEXT NOT NULL,
"lastName" TEXT,
"language" TEXT,
"sportId" INTEGER,
"notificationPreferences" JSONB NOT NULL,

CONSTRAINT "User_pkey" PRIMARY KEY ("telegramId")
Expand Down
3 changes: 3 additions & 0 deletions backend/src/services/database/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@ model User {
// or null if user did not specify it.
language String?
// User's ID in the InnoSport API.
sportId Int?
notificationPreferences Json
}
83 changes: 83 additions & 0 deletions backend/src/services/sport/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import axios from 'axios'
import { z } from 'zod'
import type { AxiosInstance } from 'axios'
import type { Logger } from '~/lib/logging'

export class SportClient {
private logger: Logger
private axios: AxiosInstance

constructor({
logger,
baseUrl,
token,
}: {
logger: Logger
baseUrl: string
token: string
}) {
const axiosInstance = axios.create({
baseURL: baseUrl,
headers: {
Authorization: `Bearer ${token}`,
},
})

axiosInstance.interceptors.request.use(
(config) => {
logger.debug({
msg: 'API request initiated',
config: config,
})
return config
},
)

axiosInstance.interceptors.response.use(
(response) => {
logger.debug({
msg: 'API request finished',
response: response,
})
return response
},
(error) => {
logger.error({
msg: 'YooKassa API request failed',
error: error,
})
return Promise.reject(error)
},
)

this.logger = logger
this.axios = axiosInstance
}

public async getBetterThan({ studentId }: { studentId: number }) {
return this.request({
method: 'GET',
path: `/attendance/${studentId}/better_than`,
responseSchema: z.number(),
})
}

private async request<S extends z.ZodSchema>({
method,
path,
responseSchema,
data,
}: {
method: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH'
path: string
responseSchema: S
data?: any
}): Promise<z.infer<S>> {
const response = await this.axios.request({
method: method,
url: path,
data: data,
})
return responseSchema.parse(response.data)
}
}
4 changes: 1 addition & 3 deletions backend/src/services/sport/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
export class Sport {

}
export * from './client'
6 changes: 5 additions & 1 deletion backend/src/translations/_en.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export const en = {
Welcome: 'Up and running!',
'Welcome': 'Up and running!',

'HowGoodAmI.Thinking': 'Hmm... Let me think 🤔',
'HowGoodAmI.Answer': (percent: number) => `You're better than ${percent}% of students!`,
'HowGoodAmI.Failed': 'I don\'t know 🤷‍♂️',
}
6 changes: 5 additions & 1 deletion backend/src/translations/_ru.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { Translation } from '.'

export const ru: Translation = {
Welcome: 'Всё работает!',
'Welcome': 'Всё работает!',

'HowGoodAmI.Thinking': 'Дай-ка подумаю 🤔',
'HowGoodAmI.Answer': (percent: number) => `Ты лучше чем ${percent}% студентов!`,
'HowGoodAmI.Failed': 'Я не знаю 🤷‍♂️',
}

0 comments on commit 948ef9e

Please sign in to comment.