diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 122534f0..1fb0b587 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ version: 2 updates: - - package-ecosystem: 'npm' - directory: '/' + - package-ecosystem: npm + directory: / schedule: - interval: 'daily' + interval: daily diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 33036114..4f7de364 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,7 +34,7 @@ jobs: id: metadata uses: dependabot/fetch-metadata@v2 with: - github-token: "${{ secrets.GITHUB_TOKEN }}" + github-token: '${{ secrets.GITHUB_TOKEN }}' - name: Auto-merge if: > ${{ steps.metadata.outputs.update-type == 'version-update:semver-minor' && diff --git a/README.md b/README.md index 6602c9fd..50d9aaed 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ -Bot starter template based on [grammY](https://grammy.dev/) bot framework. +Bot starter template based on [grammY](https://grammy.dev/) bot framework. ## Features @@ -33,7 +33,7 @@ Follow these steps to set up and run your bot using this template: Start by creating a new repository using this template. You can do this by clicking [here](https://github.com/bot-base/telegram-bot-template/generate). 2. **Environment Variables Setup** - + Create an environment variables file by copying the provided example file: ```bash cp .env.example .env @@ -41,11 +41,11 @@ Follow these steps to set up and run your bot using this template: Open the newly created `.env` file and set the `BOT_TOKEN` environment variable. 3. **Launching the Bot** - + You can run your bot in both development and production modes. **Development Mode:** - + Install the required dependencies: ```bash npm install @@ -56,14 +56,14 @@ Follow these steps to set up and run your bot using this template: ``` **Production Mode:** - + Install only production dependencies (no development dependencies): ```bash npm install --only=prod ``` - - Set `NODE_ENV` environment variable to `production` in your `.env` file. - Update `BOT_WEBHOOK` with the actual URL where your bot will receive updates. + + Set `NODE_ENV` environment variable to `production` in your `.env` file. + Update `BOT_WEBHOOK` with the actual URL where your bot will receive updates. Update `BOT_WEBHOOK_SECRET` with a random secret token. ```dotenv @@ -71,7 +71,7 @@ Follow these steps to set up and run your bot using this template: BOT_WEBHOOK=/webhook BOT_WEBHOOK_SECRET= ``` - + Start the bot in production mode: ```bash npm start # with type checking (requires development dependencies) @@ -117,7 +117,7 @@ project-root/ ### Docker ([docker.com](https://docker.com)) Branch: -[deploy/docker-compose](https://github.com/bot-base/telegram-bot-template/tree/deploy/docker-compose) +[deploy/docker-compose](https://github.com/bot-base/telegram-bot-template/tree/deploy/docker-compose) ([open diff](https://github.com/bot-base/telegram-bot-template/compare/deploy/docker-compose)) Use in your project: @@ -138,7 +138,7 @@ git merge template/deploy/docker-compose -X theirs --squash --no-commit --allow- ### Vercel ([vercel.com](https://vercel.com)) Branch: -[deploy/vercel](https://github.com/bot-base/telegram-bot-template/tree/deploy/vercel) +[deploy/vercel](https://github.com/bot-base/telegram-bot-template/tree/deploy/vercel) ([open diff](https://github.com/bot-base/telegram-bot-template/compare/deploy/vercel)) Use in your project: @@ -161,7 +161,7 @@ git merge template/deploy/vercel -X theirs --squash --no-commit --allow-unrelate ### grammY conversations ([grammy.dev/plugins/conversations](https://grammy.dev/plugins/conversations)) Branch: -[example/plugin-conversations](https://github.com/bot-base/telegram-bot-template/tree/example/plugin-conversations) +[example/plugin-conversations](https://github.com/bot-base/telegram-bot-template/tree/example/plugin-conversations) ([open diff](https://github.com/bot-base/telegram-bot-template/compare/example/plugin-conversations)) Use in your project: @@ -188,7 +188,7 @@ npm i @grammyjs/conversations ### grammY runner ([grammy.dev/plugins/runner](https://grammy.dev/plugins/runner)) Branch: -[example/plugin-runner](https://github.com/bot-base/telegram-bot-template/tree/example/plugin-runner) +[example/plugin-runner](https://github.com/bot-base/telegram-bot-template/tree/example/plugin-runner) ([open diff](https://github.com/bot-base/telegram-bot-template/compare/example/plugin-runner)) Use in your project: @@ -215,7 +215,7 @@ npm i @grammyjs/runner ### Prisma ORM ([prisma.io](https://prisma.io)) Branch: -[example/orm-prisma](https://github.com/bot-base/telegram-bot-template/tree/example/orm-prisma) +[example/orm-prisma](https://github.com/bot-base/telegram-bot-template/tree/example/orm-prisma) ([open diff](https://github.com/bot-base/telegram-bot-template/compare/example/orm-prisma)) Use in your project: @@ -243,7 +243,7 @@ npm i @prisma/client ### Bun ([bun.sh](https://bun.sh)) Branch: -[example/runtime-bun](https://github.com/bot-base/telegram-bot-template/tree/example/runtime-bun) +[example/runtime-bun](https://github.com/bot-base/telegram-bot-template/tree/example/runtime-bun) ([open diff](https://github.com/bot-base/telegram-bot-template/compare/example/runtime-bun)) Use in your project: @@ -381,11 +381,11 @@ bun add -d @types/bun Array of Number - Optional. - Administrator user IDs. + Optional. + Administrator user IDs. Use this to specify user IDs that have special privileges, such as executing /setcommands.
Defaults to an empty array. - \ No newline at end of file + diff --git a/src/bot/callback-data/change-language.ts b/src/bot/callback-data/change-language.ts index ca4bf18c..17ee5395 100644 --- a/src/bot/callback-data/change-language.ts +++ b/src/bot/callback-data/change-language.ts @@ -1,5 +1,5 @@ -import { createCallbackData } from "callback-data"; +import { createCallbackData } from 'callback-data' -export const changeLanguageData = createCallbackData("language", { +export const changeLanguageData = createCallbackData('language', { code: String, -}); +}) diff --git a/src/bot/callback-data/index.ts b/src/bot/callback-data/index.ts index 8b4fda70..21c84d0b 100644 --- a/src/bot/callback-data/index.ts +++ b/src/bot/callback-data/index.ts @@ -1 +1 @@ -export * from "./change-language.js"; +export * from './change-language.js' diff --git a/src/bot/context.ts b/src/bot/context.ts index 09e3b008..ccff3acb 100644 --- a/src/bot/context.ts +++ b/src/bot/context.ts @@ -1,43 +1,43 @@ -import { Update, UserFromGetMe } from "@grammyjs/types"; -import { Context as DefaultContext, SessionFlavor, type Api } from "grammy"; -import type { AutoChatActionFlavor } from "@grammyjs/auto-chat-action"; -import type { HydrateFlavor } from "@grammyjs/hydrate"; -import type { I18nFlavor } from "@grammyjs/i18n"; -import type { ParseModeFlavor } from "@grammyjs/parse-mode"; -import type { Logger } from "#root/logger.js"; +import type { Update, UserFromGetMe } from '@grammyjs/types' +import { type Api, Context as DefaultContext, type SessionFlavor } from 'grammy' +import type { AutoChatActionFlavor } from '@grammyjs/auto-chat-action' +import type { HydrateFlavor } from '@grammyjs/hydrate' +import type { I18nFlavor } from '@grammyjs/i18n' +import type { ParseModeFlavor } from '@grammyjs/parse-mode' +import type { Logger } from '#root/logger.js' -export type SessionData = { +export interface SessionData { // field?: string; -}; +} -type ExtendedContextFlavor = { - logger: Logger; -}; +interface ExtendedContextFlavor { + logger: Logger +} export type Context = ParseModeFlavor< HydrateFlavor< DefaultContext & - ExtendedContextFlavor & - SessionFlavor & - I18nFlavor & - AutoChatActionFlavor + ExtendedContextFlavor & + SessionFlavor & + I18nFlavor & + AutoChatActionFlavor > ->; +> interface Dependencies { - logger: Logger; + logger: Logger } export function createContextConstructor({ logger }: Dependencies) { return class extends DefaultContext implements ExtendedContextFlavor { - logger: Logger; + logger: Logger constructor(update: Update, api: Api, me: UserFromGetMe) { - super(update, api, me); + super(update, api, me) this.logger = logger.child({ update_id: this.update.update_id, - }); + }) } - } as unknown as new (update: Update, api: Api, me: UserFromGetMe) => Context; + } as unknown as new (update: Update, api: Api, me: UserFromGetMe) => Context } diff --git a/src/bot/features/admin.ts b/src/bot/features/admin.ts index 142d6929..0bb84461 100644 --- a/src/bot/features/admin.ts +++ b/src/bot/features/admin.ts @@ -1,19 +1,19 @@ -import { chatAction } from "@grammyjs/auto-chat-action"; -import { Composer } from "grammy"; -import type { Context } from "#root/bot/context.js"; -import { isAdmin } from "#root/bot/filters/index.js"; -import { setCommandsHandler } from "#root/bot/handlers/index.js"; -import { logHandle } from "#root/bot/helpers/logging.js"; +import { chatAction } from '@grammyjs/auto-chat-action' +import { Composer } from 'grammy' +import type { Context } from '#root/bot/context.js' +import { isAdmin } from '#root/bot/filters/index.js' +import { setCommandsHandler } from '#root/bot/handlers/index.js' +import { logHandle } from '#root/bot/helpers/logging.js' -const composer = new Composer(); +const composer = new Composer() -const feature = composer.chatType("private").filter(isAdmin); +const feature = composer.chatType('private').filter(isAdmin) feature.command( - "setcommands", - logHandle("command-setcommands"), - chatAction("typing"), + 'setcommands', + logHandle('command-setcommands'), + chatAction('typing'), setCommandsHandler, -); +) -export { composer as adminFeature }; +export { composer as adminFeature } diff --git a/src/bot/features/index.ts b/src/bot/features/index.ts index 77f2609e..b838f8b4 100644 --- a/src/bot/features/index.ts +++ b/src/bot/features/index.ts @@ -1,4 +1,4 @@ -export * from "./admin.js"; -export * from "./language.js"; -export * from "./unhandled.js"; -export * from "./welcome.js"; +export * from './admin.js' +export * from './language.js' +export * from './unhandled.js' +export * from './welcome.js' diff --git a/src/bot/features/language.ts b/src/bot/features/language.ts index b6fdbb3e..8626eae2 100644 --- a/src/bot/features/language.ts +++ b/src/bot/features/language.ts @@ -1,36 +1,36 @@ -import { Composer } from "grammy"; -import { changeLanguageData } from "#root/bot/callback-data/index.js"; -import type { Context } from "#root/bot/context.js"; -import { logHandle } from "#root/bot/helpers/logging.js"; -import { i18n } from "#root/bot/i18n.js"; -import { createChangeLanguageKeyboard } from "#root/bot/keyboards/index.js"; +import { Composer } from 'grammy' +import { changeLanguageData } from '#root/bot/callback-data/index.js' +import type { Context } from '#root/bot/context.js' +import { logHandle } from '#root/bot/helpers/logging.js' +import { i18n } from '#root/bot/i18n.js' +import { createChangeLanguageKeyboard } from '#root/bot/keyboards/index.js' -const composer = new Composer(); +const composer = new Composer() -const feature = composer.chatType("private"); +const feature = composer.chatType('private') -feature.command("language", logHandle("command-language"), async (ctx) => { - return ctx.reply(ctx.t("language.select"), { +feature.command('language', logHandle('command-language'), async (ctx) => { + return ctx.reply(ctx.t('language.select'), { reply_markup: await createChangeLanguageKeyboard(ctx), - }); -}); + }) +}) feature.callbackQuery( changeLanguageData.filter(), - logHandle("keyboard-language-select"), + logHandle('keyboard-language-select'), async (ctx) => { const { code: languageCode } = changeLanguageData.unpack( ctx.callbackQuery.data, - ); + ) if (i18n.locales.includes(languageCode)) { - await ctx.i18n.setLocale(languageCode); + await ctx.i18n.setLocale(languageCode) - return ctx.editMessageText(ctx.t("language.changed"), { + return ctx.editMessageText(ctx.t('language.changed'), { reply_markup: await createChangeLanguageKeyboard(ctx), - }); + }) } }, -); +) -export { composer as languageFeature }; +export { composer as languageFeature } diff --git a/src/bot/features/unhandled.ts b/src/bot/features/unhandled.ts index d4562ffb..af2b2e7d 100644 --- a/src/bot/features/unhandled.ts +++ b/src/bot/features/unhandled.ts @@ -1,17 +1,17 @@ -import { Composer } from "grammy"; -import type { Context } from "#root/bot/context.js"; -import { logHandle } from "#root/bot/helpers/logging.js"; +import { Composer } from 'grammy' +import type { Context } from '#root/bot/context.js' +import { logHandle } from '#root/bot/helpers/logging.js' -const composer = new Composer(); +const composer = new Composer() -const feature = composer.chatType("private"); +const feature = composer.chatType('private') -feature.on("message", logHandle("unhandled-message"), (ctx) => { - return ctx.reply(ctx.t("unhandled")); -}); +feature.on('message', logHandle('unhandled-message'), (ctx) => { + return ctx.reply(ctx.t('unhandled')) +}) -feature.on("callback_query", logHandle("unhandled-callback-query"), (ctx) => { - return ctx.answerCallbackQuery(); -}); +feature.on('callback_query', logHandle('unhandled-callback-query'), (ctx) => { + return ctx.answerCallbackQuery() +}) -export { composer as unhandledFeature }; +export { composer as unhandledFeature } diff --git a/src/bot/features/welcome.ts b/src/bot/features/welcome.ts index c1ef7e70..5f6b1072 100644 --- a/src/bot/features/welcome.ts +++ b/src/bot/features/welcome.ts @@ -1,13 +1,13 @@ -import { Composer } from "grammy"; -import type { Context } from "#root/bot/context.js"; -import { logHandle } from "#root/bot/helpers/logging.js"; +import { Composer } from 'grammy' +import type { Context } from '#root/bot/context.js' +import { logHandle } from '#root/bot/helpers/logging.js' -const composer = new Composer(); +const composer = new Composer() -const feature = composer.chatType("private"); +const feature = composer.chatType('private') -feature.command("start", logHandle("command-start"), (ctx) => { - return ctx.reply(ctx.t("welcome")); -}); +feature.command('start', logHandle('command-start'), (ctx) => { + return ctx.reply(ctx.t('welcome')) +}) -export { composer as welcomeFeature }; +export { composer as welcomeFeature } diff --git a/src/bot/filters/index.ts b/src/bot/filters/index.ts index f699fb48..43036632 100644 --- a/src/bot/filters/index.ts +++ b/src/bot/filters/index.ts @@ -1 +1 @@ -export * from "./is-admin.js"; +export * from './is-admin.js' diff --git a/src/bot/filters/is-admin.ts b/src/bot/filters/is-admin.ts index a2a3e1f2..969cb08e 100644 --- a/src/bot/filters/is-admin.ts +++ b/src/bot/filters/is-admin.ts @@ -1,4 +1,4 @@ -import { isUserHasId } from "grammy-guard"; -import { config } from "#root/config.js"; +import { isUserHasId } from 'grammy-guard' +import { config } from '#root/config.js' -export const isAdmin = isUserHasId(...config.BOT_ADMINS); +export const isAdmin = isUserHasId(...config.BOT_ADMINS) diff --git a/src/bot/handlers/commands/setcommands.ts b/src/bot/handlers/commands/setcommands.ts index ee2be2e2..d43a6c61 100644 --- a/src/bot/handlers/commands/setcommands.ts +++ b/src/bot/handlers/commands/setcommands.ts @@ -1,41 +1,40 @@ -import { BotCommand } from "@grammyjs/types"; -import { CommandContext } from "grammy"; -import { i18n, isMultipleLocales } from "#root/bot/i18n.js"; -import { config } from "#root/config.js"; -import type { Context } from "#root/bot/context.js"; +import type { BotCommand } from '@grammyjs/types' +import type { CommandContext } from 'grammy' +import { i18n, isMultipleLocales } from '#root/bot/i18n.js' +import { config } from '#root/config.js' +import type { Context } from '#root/bot/context.js' function getLanguageCommand(localeCode: string): BotCommand { return { - command: "language", - description: i18n.t(localeCode, "language_command.description"), - }; + command: 'language', + description: i18n.t(localeCode, 'language_command.description'), + } } function getPrivateChatCommands(localeCode: string): BotCommand[] { return [ { - command: "start", - description: i18n.t(localeCode, "start_command.description"), + command: 'start', + description: i18n.t(localeCode, 'start_command.description'), }, - ]; + ] } function getPrivateChatAdminCommands(localeCode: string): BotCommand[] { return [ { - command: "setcommands", - description: i18n.t(localeCode, "setcommands_command.description"), + command: 'setcommands', + description: i18n.t(localeCode, 'setcommands_command.description'), }, - ]; + ] } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function getGroupChatCommands(localeCode: string): BotCommand[] { - return []; +function getGroupChatCommands(_localeCode: string): BotCommand[] { + return [] } export async function setCommandsHandler(ctx: CommandContext) { - const DEFAULT_LANGUAGE_CODE = "en"; + const DEFAULT_LANGUAGE_CODE = 'en' // set private chat commands await ctx.api.setMyCommands( @@ -45,13 +44,13 @@ export async function setCommandsHandler(ctx: CommandContext) { ], { scope: { - type: "all_private_chats", + type: 'all_private_chats', }, }, - ); + ) if (isMultipleLocales) { - const requests = i18n.locales.map((code) => + const requests = i18n.locales.map(code => ctx.api.setMyCommands( [ ...getPrivateChatCommands(code), @@ -62,33 +61,33 @@ export async function setCommandsHandler(ctx: CommandContext) { { language_code: code, scope: { - type: "all_private_chats", + type: 'all_private_chats', }, }, ), - ); + ) - await Promise.all(requests); + await Promise.all(requests) } // set group chat commands await ctx.api.setMyCommands(getGroupChatCommands(DEFAULT_LANGUAGE_CODE), { scope: { - type: "all_group_chats", + type: 'all_group_chats', }, - }); + }) if (isMultipleLocales) { - const requests = i18n.locales.map((code) => + const requests = i18n.locales.map(code => ctx.api.setMyCommands(getGroupChatCommands(code), { language_code: code, scope: { - type: "all_group_chats", + type: 'all_group_chats', }, }), - ); + ) - await Promise.all(requests); + await Promise.all(requests) } // set private chat commands for owner @@ -100,11 +99,11 @@ export async function setCommandsHandler(ctx: CommandContext) { ], { scope: { - type: "chat", + type: 'chat', chat_id: Number(config.BOT_ADMINS), }, }, - ); + ) - return ctx.reply(ctx.t("admin.commands-updated")); + return ctx.reply(ctx.t('admin.commands-updated')) } diff --git a/src/bot/handlers/error.ts b/src/bot/handlers/error.ts index 9d7c1ea2..936d962b 100644 --- a/src/bot/handlers/error.ts +++ b/src/bot/handlers/error.ts @@ -1,12 +1,12 @@ -import { ErrorHandler } from "grammy"; -import type { Context } from "#root/bot/context.js"; -import { getUpdateInfo } from "#root/bot/helpers/logging.js"; +import type { ErrorHandler } from 'grammy' +import type { Context } from '#root/bot/context.js' +import { getUpdateInfo } from '#root/bot/helpers/logging.js' export const errorHandler: ErrorHandler = (error) => { - const { ctx } = error; + const { ctx } = error ctx.logger.error({ err: error.error, update: getUpdateInfo(ctx), - }); -}; + }) +} diff --git a/src/bot/handlers/index.ts b/src/bot/handlers/index.ts index 3a6b76e3..46ed09fb 100644 --- a/src/bot/handlers/index.ts +++ b/src/bot/handlers/index.ts @@ -1,2 +1,2 @@ -export * from "./error.js"; -export * from "./commands/setcommands.js"; +export * from './error.js' +export * from './commands/setcommands.js' diff --git a/src/bot/helpers/keyboard.ts b/src/bot/helpers/keyboard.ts index c3b1f53a..d97ee33e 100644 --- a/src/bot/helpers/keyboard.ts +++ b/src/bot/helpers/keyboard.ts @@ -1,7 +1,7 @@ export function chunk(array: T[], size: number) { - const result = []; - for (let index = 0; index < array.length; index += size) { - result.push(array.slice(index, index + size)); - } - return result; + const result = [] + for (let index = 0; index < array.length; index += size) + result.push(array.slice(index, index + size)) + + return result } diff --git a/src/bot/helpers/logging.ts b/src/bot/helpers/logging.ts index 401a2b89..58fb2f4f 100644 --- a/src/bot/helpers/logging.ts +++ b/src/bot/helpers/logging.ts @@ -1,21 +1,20 @@ -import { Middleware } from "grammy"; -import type { Update } from "@grammyjs/types"; -import type { Context } from "#root/bot/context.js"; +import type { Middleware } from 'grammy' +import type { Update } from '@grammyjs/types' +import type { Context } from '#root/bot/context.js' -export function getUpdateInfo(ctx: Context): Omit { - // eslint-disable-next-line camelcase, @typescript-eslint/no-unused-vars - const { update_id, ...update } = ctx.update; +export function getUpdateInfo(ctx: Context): Omit { + const { update_id, ...update } = ctx.update - return update; + return update } export function logHandle(id: string): Middleware { return (ctx, next) => { ctx.logger.info({ msg: `Handle "${id}"`, - ...(id.startsWith("unhandled") ? { update: getUpdateInfo(ctx) } : {}), - }); + ...(id.startsWith('unhandled') ? { update: getUpdateInfo(ctx) } : {}), + }) - return next(); - }; + return next() + } } diff --git a/src/bot/i18n.ts b/src/bot/i18n.ts index f909bcd5..ec90901e 100644 --- a/src/bot/i18n.ts +++ b/src/bot/i18n.ts @@ -1,14 +1,15 @@ -import path from "node:path"; -import { I18n } from "@grammyjs/i18n"; -import type { Context } from "#root/bot/context.js"; +import process from 'node:process' +import path from 'node:path' +import { I18n } from '@grammyjs/i18n' +import type { Context } from '#root/bot/context.js' export const i18n = new I18n({ - defaultLocale: "en", - directory: path.resolve(process.cwd(), "locales"), + defaultLocale: 'en', + directory: path.resolve(process.cwd(), 'locales'), useSession: true, fluentBundleOptions: { useIsolating: false, }, -}); +}) -export const isMultipleLocales = i18n.locales.length > 1; +export const isMultipleLocales = i18n.locales.length > 1 diff --git a/src/bot/index.ts b/src/bot/index.ts index 653285e7..cdfe48ce 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -1,67 +1,68 @@ -import { autoChatAction } from "@grammyjs/auto-chat-action"; -import { hydrate } from "@grammyjs/hydrate"; -import { hydrateReply, parseMode } from "@grammyjs/parse-mode"; -import { BotConfig, StorageAdapter, Bot as TelegramBot, session } from "grammy"; -import { +import { autoChatAction } from '@grammyjs/auto-chat-action' +import { hydrate } from '@grammyjs/hydrate' +import { hydrateReply, parseMode } from '@grammyjs/parse-mode' +import type { BotConfig, StorageAdapter } from 'grammy' +import { Bot as TelegramBot, session } from 'grammy' +import type { Context, SessionData, +} from '#root/bot/context.js' +import { createContextConstructor, -} from "#root/bot/context.js"; +} from '#root/bot/context.js' import { adminFeature, languageFeature, unhandledFeature, welcomeFeature, -} from "#root/bot/features/index.js"; -import { errorHandler } from "#root/bot/handlers/index.js"; -import { i18n, isMultipleLocales } from "#root/bot/i18n.js"; -import { updateLogger } from "#root/bot/middlewares/index.js"; -import { config } from "#root/config.js"; -import { logger } from "#root/logger.js"; +} from '#root/bot/features/index.js' +import { errorHandler } from '#root/bot/handlers/index.js' +import { i18n, isMultipleLocales } from '#root/bot/i18n.js' +import { updateLogger } from '#root/bot/middlewares/index.js' +import { config } from '#root/config.js' +import { logger } from '#root/logger.js' -type Options = { - sessionStorage?: StorageAdapter; - config?: Omit, "ContextConstructor">; -}; +interface Options { + sessionStorage?: StorageAdapter + config?: Omit, 'ContextConstructor'> +} export function createBot(token: string, options: Options = {}) { - const { sessionStorage } = options; + const { sessionStorage } = options const bot = new TelegramBot(token, { ...options.config, ContextConstructor: createContextConstructor({ logger }), - }); - const protectedBot = bot.errorBoundary(errorHandler); + }) + const protectedBot = bot.errorBoundary(errorHandler) // Middlewares - bot.api.config.use(parseMode("HTML")); + bot.api.config.use(parseMode('HTML')) - if (config.isDev) { - protectedBot.use(updateLogger()); - } + if (config.isDev) + protectedBot.use(updateLogger()) - protectedBot.use(autoChatAction(bot.api)); - protectedBot.use(hydrateReply); - protectedBot.use(hydrate()); + protectedBot.use(autoChatAction(bot.api)) + protectedBot.use(hydrateReply) + protectedBot.use(hydrate()) protectedBot.use( session({ initial: () => ({}), storage: sessionStorage, }), - ); - protectedBot.use(i18n); + ) + protectedBot.use(i18n) // Handlers - protectedBot.use(welcomeFeature); - protectedBot.use(adminFeature); + protectedBot.use(welcomeFeature) + protectedBot.use(adminFeature) - if (isMultipleLocales) { - protectedBot.use(languageFeature); - } + if (isMultipleLocales) + protectedBot.use(languageFeature) // must be the last handler - protectedBot.use(unhandledFeature); + protectedBot.use(unhandledFeature) - return bot; + return bot } -export type Bot = ReturnType; +export type Bot = ReturnType diff --git a/src/bot/keyboards/change-language.ts b/src/bot/keyboards/change-language.ts index b6a5a2a0..5f0da8f8 100644 --- a/src/bot/keyboards/change-language.ts +++ b/src/bot/keyboards/change-language.ts @@ -1,22 +1,22 @@ -import { InlineKeyboard } from "grammy"; -import ISO6391 from "iso-639-1"; -import { changeLanguageData } from "#root/bot/callback-data/index.js"; -import type { Context } from "#root/bot/context.js"; -import { i18n } from "#root/bot/i18n.js"; -import { chunk } from "#root/bot/helpers/keyboard.js"; +import { InlineKeyboard } from 'grammy' +import ISO6391 from 'iso-639-1' +import { changeLanguageData } from '#root/bot/callback-data/index.js' +import type { Context } from '#root/bot/context.js' +import { i18n } from '#root/bot/i18n.js' +import { chunk } from '#root/bot/helpers/keyboard.js' -export const createChangeLanguageKeyboard = async (ctx: Context) => { - const currentLocaleCode = await ctx.i18n.getLocale(); +export async function createChangeLanguageKeyboard(ctx: Context) { + const currentLocaleCode = await ctx.i18n.getLocale() const getLabel = (code: string) => { - const isActive = code === currentLocaleCode; + const isActive = code === currentLocaleCode - return `${isActive ? "✅ " : ""}${ISO6391.getNativeName(code)}`; - }; + return `${isActive ? '✅ ' : ''}${ISO6391.getNativeName(code)}` + } return InlineKeyboard.from( chunk( - i18n.locales.map((localeCode) => ({ + i18n.locales.map(localeCode => ({ text: getLabel(localeCode), callback_data: changeLanguageData.pack({ code: localeCode, @@ -24,5 +24,5 @@ export const createChangeLanguageKeyboard = async (ctx: Context) => { })), 2, ), - ); -}; + ) +} diff --git a/src/bot/keyboards/index.ts b/src/bot/keyboards/index.ts index 8b4fda70..21c84d0b 100644 --- a/src/bot/keyboards/index.ts +++ b/src/bot/keyboards/index.ts @@ -1 +1 @@ -export * from "./change-language.js"; +export * from './change-language.js' diff --git a/src/bot/middlewares/index.ts b/src/bot/middlewares/index.ts index 56e1e5ec..e8a6812f 100644 --- a/src/bot/middlewares/index.ts +++ b/src/bot/middlewares/index.ts @@ -1 +1 @@ -export * from "./update-logger.js"; +export * from './update-logger.js' diff --git a/src/bot/middlewares/update-logger.ts b/src/bot/middlewares/update-logger.ts index 96811203..082943c6 100644 --- a/src/bot/middlewares/update-logger.ts +++ b/src/bot/middlewares/update-logger.ts @@ -1,34 +1,35 @@ -import { performance } from "node:perf_hooks"; -import { Middleware } from "grammy"; -import type { Context } from "#root/bot/context.js"; -import { getUpdateInfo } from "#root/bot/helpers/logging.js"; +import { performance } from 'node:perf_hooks' +import type { Middleware } from 'grammy' +import type { Context } from '#root/bot/context.js' +import { getUpdateInfo } from '#root/bot/helpers/logging.js' export function updateLogger(): Middleware { return async (ctx, next) => { ctx.api.config.use((previous, method, payload, signal) => { ctx.logger.debug({ - msg: "Bot API call", + msg: 'Bot API call', method, payload, - }); + }) - return previous(method, payload, signal); - }); + return previous(method, payload, signal) + }) ctx.logger.debug({ - msg: "Update received", + msg: 'Update received', update: getUpdateInfo(ctx), - }); + }) - const startTime = performance.now(); + const startTime = performance.now() try { - await next(); - } finally { - const endTime = performance.now(); + await next() + } + finally { + const endTime = performance.now() ctx.logger.debug({ - msg: "Update processed", + msg: 'Update processed', elapsed: endTime - startTime, - }); + }) } - }; + } } diff --git a/src/config.ts b/src/config.ts index 3cd597f9..0ff44397 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,60 +1,61 @@ -import { loadEnvFile } from "node:process"; -import z from "zod"; -import { parseEnv, port } from "znv"; -import { API_CONSTANTS } from "grammy"; +import process from 'node:process' +import z from 'zod' +import { parseEnv, port } from 'znv' +import { API_CONSTANTS } from 'grammy' try { - loadEnvFile(); -} catch { + process.loadEnvFile() +} +catch { // No .env file found } -const createConfigFromEnvironment = (environment: NodeJS.ProcessEnv) => { +function createConfigFromEnvironment(environment: NodeJS.ProcessEnv) { const config = parseEnv(environment, { - NODE_ENV: z.enum(["development", "production"]), + NODE_ENV: z.enum(['development', 'production']), LOG_LEVEL: z - .enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]) - .default("info"), + .enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent']) + .default('info'), BOT_MODE: { - schema: z.enum(["polling", "webhook"]), + schema: z.enum(['polling', 'webhook']), defaults: { - production: "webhook" as const, - development: "polling" as const, + production: 'webhook' as const, + development: 'polling' as const, }, }, BOT_TOKEN: z.string(), - BOT_WEBHOOK: z.string().default(""), - BOT_WEBHOOK_SECRET: z.string().default(""), - BOT_SERVER_HOST: z.string().default("0.0.0.0"), + BOT_WEBHOOK: z.string().default(''), + BOT_WEBHOOK_SECRET: z.string().default(''), + BOT_SERVER_HOST: z.string().default('0.0.0.0'), BOT_SERVER_PORT: port().default(80), BOT_ALLOWED_UPDATES: z .array(z.enum(API_CONSTANTS.ALL_UPDATE_TYPES)) .default([]), BOT_ADMINS: z.array(z.number()).default([]), - }); + }) - if (config.BOT_MODE === "webhook") { + if (config.BOT_MODE === 'webhook') { // validate webhook url in webhook mode z.string() .url() .parse(config.BOT_WEBHOOK, { - path: ["BOT_WEBHOOK"], - }); + path: ['BOT_WEBHOOK'], + }) // validate webhook secret in webhook mode z.string() .min(1) .parse(config.BOT_WEBHOOK_SECRET, { - path: ["BOT_WEBHOOK_SECRET"], - }); + path: ['BOT_WEBHOOK_SECRET'], + }) } return { ...config, - isDev: process.env.NODE_ENV === "development", - isProd: process.env.NODE_ENV === "production", - }; -}; + isDev: process.env.NODE_ENV === 'development', + isProd: process.env.NODE_ENV === 'production', + } +} -export type Config = ReturnType; +export type Config = ReturnType -export const config = createConfigFromEnvironment(process.env); +export const config = createConfigFromEnvironment(process.env) diff --git a/src/logger.ts b/src/logger.ts index b69dda8c..811c92e5 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,5 +1,5 @@ -import { pino } from "pino"; -import { config } from "#root/config.js"; +import { pino } from 'pino' +import { config } from '#root/config.js' export const logger = pino({ level: config.LOG_LEVEL, @@ -8,10 +8,10 @@ export const logger = pino({ ...(config.isDev ? [ { - target: "pino-pretty", + target: 'pino-pretty', level: config.LOG_LEVEL, options: { - ignore: "pid,hostname", + ignore: 'pid,hostname', colorize: true, translateTime: true, }, @@ -19,13 +19,13 @@ export const logger = pino({ ] : [ { - target: "pino/file", + target: 'pino/file', level: config.LOG_LEVEL, options: {}, }, ]), ], }, -}); +}) -export type Logger = typeof logger; +export type Logger = typeof logger diff --git a/src/main.ts b/src/main.ts index dfb4f2f8..939cc2ab 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,48 +1,50 @@ #!/usr/bin/env tsx -import { serve } from "@hono/node-server"; -import { createBot } from "#root/bot/index.js"; -import { config } from "#root/config.js"; -import { logger } from "#root/logger.js"; -import { createServer } from "#root/server/index.js"; -import { AddressInfo } from "node:net"; +import process from 'node:process' +import type { AddressInfo } from 'node:net' +import { serve } from '@hono/node-server' +import { createBot } from '#root/bot/index.js' +import { config } from '#root/config.js' +import { logger } from '#root/logger.js' +import { createServer } from '#root/server/index.js' function onShutdown(cleanUp: () => Promise) { - let isShuttingDown = false; + let isShuttingDown = false const handleShutdown = async () => { - if (isShuttingDown) return; - isShuttingDown = true; - logger.info("Shutdown"); - await cleanUp(); - }; - process.on("SIGINT", handleShutdown); - process.on("SIGTERM", handleShutdown); + if (isShuttingDown) + return + isShuttingDown = true + logger.info('Shutdown') + await cleanUp() + } + process.on('SIGINT', handleShutdown) + process.on('SIGTERM', handleShutdown) } async function startPolling() { - const bot = createBot(config.BOT_TOKEN); + const bot = createBot(config.BOT_TOKEN) // graceful shutdown onShutdown(async () => { - await bot.stop(); - }); + await bot.stop() + }) // start bot await bot.start({ allowed_updates: config.BOT_ALLOWED_UPDATES, onStart: ({ username }) => logger.info({ - msg: "Bot running...", + msg: 'Bot running...', username, }), - }); + }) } async function startWebhook() { - const bot = createBot(config.BOT_TOKEN); - const server = createServer(bot); + const bot = createBot(config.BOT_TOKEN) + const server = createServer(bot) - let serverHandle: undefined | ReturnType; + let serverHandle: undefined | ReturnType const startServer = () => new Promise((resolve) => { serverHandle = serve( @@ -51,54 +53,53 @@ async function startWebhook() { hostname: config.BOT_SERVER_HOST, port: config.BOT_SERVER_PORT, }, - (info) => resolve(info), - ); - }); + info => resolve(info), + ) + }) const stopServer = async () => new Promise((resolve) => { - if (serverHandle) { - serverHandle.close(() => resolve()); - } else { - resolve(); - } - }); + if (serverHandle) + serverHandle.close(() => resolve()) + else + resolve() + }) // graceful shutdown onShutdown(async () => { - await stopServer(); - }); + await stopServer() + }) // to prevent receiving updates before the bot is ready - await bot.init(); + await bot.init() // start server - const info = await startServer(); + const info = await startServer() logger.info({ - msg: "Server started", + msg: 'Server started', url: - info.family === "IPv6" + info.family === 'IPv6' ? `http://[${info.address}]:${info.port}` : `http://${info.address}:${info.port}`, - }); + }) // set webhook await bot.api.setWebhook(config.BOT_WEBHOOK, { allowed_updates: config.BOT_ALLOWED_UPDATES, secret_token: config.BOT_WEBHOOK_SECRET, - }); + }) logger.info({ - msg: "Webhook was set", + msg: 'Webhook was set', url: config.BOT_WEBHOOK, - }); + }) } try { - if (config.BOT_MODE === "webhook") { - await startWebhook(); - } else if (config.BOT_MODE === "polling") { - await startPolling(); - } -} catch (error) { - logger.error(error); - process.exit(1); + if (config.BOT_MODE === 'webhook') + await startWebhook() + else if (config.BOT_MODE === 'polling') + await startPolling() +} +catch (error) { + logger.error(error) + process.exit(1) } diff --git a/src/server/index.ts b/src/server/index.ts index 900645d2..7121bbed 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,39 +1,38 @@ -import { Hono } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { webhookCallback } from "grammy"; -import type { Bot } from "#root/bot/index.js"; -import { config } from "#root/config.js"; -import { requestLogger } from "#root/server/middlewares/request-logger.js"; -import { getPath } from "hono/utils/url"; -import { Logger } from "#root/logger.js"; -import { requestId } from "./middlewares/request-id.js"; -import { logger } from "./middlewares/logger.js"; +import { Hono } from 'hono' +import { HTTPException } from 'hono/http-exception' +import { webhookCallback } from 'grammy' +import { getPath } from 'hono/utils/url' +import { requestId } from './middlewares/request-id.js' +import { logger } from './middlewares/logger.js' +import type { Bot } from '#root/bot/index.js' +import { config } from '#root/config.js' +import { requestLogger } from '#root/server/middlewares/request-logger.js' +import type { Logger } from '#root/logger.js' -type Variables = { - requestId: string; - logger: Logger; -}; +interface Env { + Variables: { + requestId: string + logger: Logger + } +} -export const createServer = (bot: Bot) => { - const server = new Hono<{ - Variables: Variables; - }>(); +export function createServer(bot: Bot) { + const server = new Hono() - server.use(requestId()); - server.use(logger()); + server.use(requestId()) + server.use(logger()) - if (config.isDev) { - server.use(requestLogger()); - } + if (config.isDev) + server.use(requestLogger()) server.onError(async (error, c) => { if (error instanceof HTTPException) { - if (error.status < 500) { - c.var.logger.info(error); - } else { - c.var.logger.error(error); - } - return error.getResponse(); + if (error.status < 500) + c.var.logger.info(error) + else + c.var.logger.error(error) + + return error.getResponse() } // unexpected error @@ -41,25 +40,25 @@ export const createServer = (bot: Bot) => { err: error, method: c.req.raw.method, path: getPath(c.req.raw), - }); + }) return c.json( { - error: "Oops! Something went wrong.", + error: 'Oops! Something went wrong.', }, 500, - ); - }); + ) + }) - server.get("/", (c) => c.json({ status: true })); + server.get('/', c => c.json({ status: true })) server.post( - "/webhook", - webhookCallback(bot, "hono", { + '/webhook', + webhookCallback(bot, 'hono', { secretToken: config.BOT_WEBHOOK_SECRET, }), - ); + ) - return server; -}; + return server +} -export type Server = Awaited>; +export type Server = Awaited> diff --git a/src/server/middlewares/logger.ts b/src/server/middlewares/logger.ts index 255cefff..fb553c3b 100644 --- a/src/server/middlewares/logger.ts +++ b/src/server/middlewares/logger.ts @@ -1,15 +1,15 @@ -import { logger as _logger } from "#root/logger.js"; -import { MiddlewareHandler } from "hono"; +import type { MiddlewareHandler } from 'hono' +import { logger as _logger } from '#root/logger.js' export function logger(): MiddlewareHandler { return async (c, next) => { c.set( - "logger", + 'logger', _logger.child({ - requestId: c.get("requestId"), + requestId: c.get('requestId'), }), - ); + ) - await next(); - }; + await next() + } } diff --git a/src/server/middlewares/request-id.ts b/src/server/middlewares/request-id.ts index b375484b..5ddeadeb 100644 --- a/src/server/middlewares/request-id.ts +++ b/src/server/middlewares/request-id.ts @@ -1,10 +1,10 @@ -import { MiddlewareHandler } from "hono"; -import { randomUUID } from "node:crypto"; +import { randomUUID } from 'node:crypto' +import type { MiddlewareHandler } from 'hono' export function requestId(): MiddlewareHandler { return async (c, next) => { - c.set("requestId", randomUUID()); + c.set('requestId', randomUUID()) - await next(); - }; + await next() + } } diff --git a/src/server/middlewares/request-logger.ts b/src/server/middlewares/request-logger.ts index 213546a9..efbdc5a1 100644 --- a/src/server/middlewares/request-logger.ts +++ b/src/server/middlewares/request-logger.ts @@ -1,27 +1,27 @@ -import { MiddlewareHandler } from "hono"; -import { getPath } from "hono/utils/url"; +import type { MiddlewareHandler } from 'hono' +import { getPath } from 'hono/utils/url' export function requestLogger(): MiddlewareHandler { return async (c, next) => { - const { method } = c.req; - const path = getPath(c.req.raw); + const { method } = c.req + const path = getPath(c.req.raw) c.var.logger.debug({ - msg: "Incoming request", + msg: 'Incoming request', method, path, - }); - const startTime = performance.now(); + }) + const startTime = performance.now() - await next(); + await next() - const endTime = performance.now(); + const endTime = performance.now() c.var.logger.debug({ - msg: "Request completed", + msg: 'Request completed', method, path, status: c.res.status, elapsed: endTime - startTime, - }); - }; + }) + } } diff --git a/tsconfig.json b/tsconfig.json index 6991dda9..fca6c24c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,21 +1,21 @@ { "compilerOptions": { - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "preserveWatchOutput": true, - "noEmit": true, - "module": "NodeNext", "target": "ES2021", - "moduleResolution": "NodeNext", - "sourceMap": true, - "outDir": "build", "rootDir": ".", + "module": "NodeNext", + "moduleResolution": "NodeNext", "paths": { "#root/*": [ "./src/*" ] - } + }, + "strict": true, + "noEmit": true, + "outDir": "build", + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + "preserveWatchOutput": true }, "include": [ "src/**/*"