diff --git a/backend/.env.example b/backend/.env.example index 1d19bdd..7fde0f9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/src/bot/filters/index.ts b/backend/src/bot/filters/index.ts new file mode 100644 index 0000000..0fd68cd --- /dev/null +++ b/backend/src/bot/filters/index.ts @@ -0,0 +1,5 @@ +import type { ChatTypeContext, Context } from 'grammy' + +export function pm(ctx: C): ctx is ChatTypeContext { + return ctx.chat?.type === 'private' +} diff --git a/backend/src/bot/index.ts b/backend/src/bot/index.ts index 161b54d..af2493c 100644 --- a/backend/src/bot/index.ts +++ b/backend/src/bot/index.ts @@ -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 } diff --git a/backend/src/config/schema.ts b/backend/src/config/schema.ts index 031e498..4105e87 100644 --- a/backend/src/config/schema.ts +++ b/backend/src/config/schema.ts @@ -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 diff --git a/backend/src/domain/index.ts b/backend/src/domain/index.ts index 23f7792..ddd674f 100644 --- a/backend/src/domain/index.ts +++ b/backend/src/domain/index.ts @@ -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 @@ -40,4 +40,25 @@ export class Domain { }) return User.parse(user) } + + public async getStudentBetterThanPercent({ + telegramId, + }: { + telegramId: number + }): Promise { + 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 }) + } } diff --git a/backend/src/main.ts b/backend/src/main.ts index 0983ca9..89beb63 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -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() @@ -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, + }), }), }) diff --git a/backend/src/services/database/migrations/20240310111305_add_user_model/migration.sql b/backend/src/services/database/migrations/20240310140842_add_user_model/migration.sql similarity index 93% rename from backend/src/services/database/migrations/20240310111305_add_user_model/migration.sql rename to backend/src/services/database/migrations/20240310140842_add_user_model/migration.sql index f6fa7dc..2c180f4 100644 --- a/backend/src/services/database/migrations/20240310111305_add_user_model/migration.sql +++ b/backend/src/services/database/migrations/20240310140842_add_user_model/migration.sql @@ -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") diff --git a/backend/src/services/database/schema.prisma b/backend/src/services/database/schema.prisma index 633d269..4d7a315 100644 --- a/backend/src/services/database/schema.prisma +++ b/backend/src/services/database/schema.prisma @@ -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 } diff --git a/backend/src/services/sport/client.ts b/backend/src/services/sport/client.ts new file mode 100644 index 0000000..cced9be --- /dev/null +++ b/backend/src/services/sport/client.ts @@ -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({ + method, + path, + responseSchema, + data, + }: { + method: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH' + path: string + responseSchema: S + data?: any + }): Promise> { + const response = await this.axios.request({ + method: method, + url: path, + data: data, + }) + return responseSchema.parse(response.data) + } +} diff --git a/backend/src/services/sport/index.ts b/backend/src/services/sport/index.ts index 26a6fc9..83dae76 100644 --- a/backend/src/services/sport/index.ts +++ b/backend/src/services/sport/index.ts @@ -1,3 +1 @@ -export class Sport { - -} +export * from './client' diff --git a/backend/src/translations/_en.ts b/backend/src/translations/_en.ts index 1018424..ddd07eb 100644 --- a/backend/src/translations/_en.ts +++ b/backend/src/translations/_en.ts @@ -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 🤷‍♂️', } diff --git a/backend/src/translations/_ru.ts b/backend/src/translations/_ru.ts index c99cf61..57a52c2 100644 --- a/backend/src/translations/_ru.ts +++ b/backend/src/translations/_ru.ts @@ -1,5 +1,9 @@ import type { Translation } from '.' export const ru: Translation = { - Welcome: 'Всё работает!', + 'Welcome': 'Всё работает!', + + 'HowGoodAmI.Thinking': 'Дай-ка подумаю 🤔', + 'HowGoodAmI.Answer': (percent: number) => `Ты лучше чем ${percent}% студентов!`, + 'HowGoodAmI.Failed': 'Я не знаю 🤷‍♂️', }