diff --git a/.gitignore b/.gitignore index 9170b038..9506f5e8 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,7 @@ data/ # Ignore SQLite database *.db -*.db-journal \ No newline at end of file +*.db-journal + +# Vercel project configuration +.vercel diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 00000000..e6905a23 --- /dev/null +++ b/.vercelignore @@ -0,0 +1 @@ +.env* \ No newline at end of file diff --git a/README.md b/README.md index 06e9df56..6068ad59 100644 --- a/README.md +++ b/README.md @@ -54,20 +54,56 @@ Follow these steps to set up and run your bot using this template: npm run dev ``` - **Production Mode:** + **Production Mode:** - Install only production dependencies: + Install Vercel CLI: ```bash - npm install --only=prod + npm i -g vercel ``` - Set `DEBUG` environment variable to `false` in your `.env` file. + Create a project: + ```bash + vercel link + ``` + ### ---------- !! Do not use sensitive flag for variables !! ---------- + + Set `NODEJS_HELPERS` environment variable to `0`: + ```bash + vercel env add NODEJS_HELPERS + ``` + + Set `BOT_MODE` environment variable to `webhook`: + ```bash + vercel env add BOT_MODE + ``` - Start the bot in production mode: + Set `BOT_TOKEN` environment variable: + ```bash + vercel env add BOT_TOKEN + ``` + + Set `BOT_WEBHOOK_SECRET` environment variable to a random secret token: + ```bash + # Generate and set secret token using Node + node -e "console.log(crypto.randomBytes(256*0.75).toString('base64url'))" | vercel env add BOT_WEBHOOK_SECRET + ``` + ```bash + # OR using Python + python3 -c "import secrets; print(secrets.token_urlsafe(256))" | vercel env add BOT_WEBHOOK_SECRET + ``` ```bash - npm run start:force # skip type checking and start - # or - npm start # with type checking (requires development dependencies) + # OR set manually: + vercel env add BOT_WEBHOOK_SECRET + ``` + + Deploy your bot: + ```bash + vercel + ``` + + After successful deployment, set up a webhook to connect your Vercel app with Telegram, modify the below URL to your credentials and visit it from your browser: + ``` + https://APP_NAME.vercel.app/BOT_TOKEN ``` ### List of Available Commands @@ -302,16 +338,6 @@ bun add -d @types/bun Enables debug mode. You may use config.isDebug flag to enable debugging functions. - - BOT_WEBHOOK - - String - - - Optional in polling mode. - Webhook endpoint URL, used to configure webhook. - - BOT_WEBHOOK_SECRET diff --git a/api/index.ts b/api/index.ts new file mode 100644 index 00000000..cfb1aa9c --- /dev/null +++ b/api/index.ts @@ -0,0 +1,21 @@ +import { createBot } from '#root/bot/index.js'; +import { convertKeysToCamelCase, createConfig } from "#root/config.js"; +import { logger } from '#root/logger.js'; +import { createServer } from '#root/server/index.js'; +import { handle } from '@hono/node-server/vercel'; +import process from 'node:process'; + +// @ts-expect-error create config from environment variables +const config = createConfig(convertKeysToCamelCase(process.env)) + +const bot = createBot(config.botToken, { + config, + logger, +}) +const server = createServer({ + bot, + config, + logger, +}) + +export default handle(server) diff --git a/locales/en.ftl b/locales/en.ftl index 9c663031..8083d65a 100644 --- a/locales/en.ftl +++ b/locales/en.ftl @@ -3,6 +3,7 @@ start-command-description = Start the bot language-command-description = Change language setcommands-command-description = Set bot commands +ping-pong-command-description = Test bot work. Ping-Pong ## Welcome Feature diff --git a/locales/uk.ftl b/locales/uk.ftl new file mode 100644 index 00000000..51754d56 --- /dev/null +++ b/locales/uk.ftl @@ -0,0 +1,22 @@ +## Commands + +start-command-description = Запустити бота +language-command-description = Змінити мову +setcommands-command-description = Встановити команди бота +ping-pong-command-description = Тест бота. Ping-Pong +## Welcome Feature + +welcome = Ласкаво просимо! + +## Language Feature + +language-select = Будь ласка, оберіть мову +language-changed = Мову успішно змінено! + +## Admin Feature + +admin-commands-updated = Команди оновлено. + +## Unhandled Feature + +unhandled = Невідома команда. Спробуйте /start diff --git a/package-lock.json b/package-lock.json index ed84ab6d..96e5f369 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@grammyjs/types": "3.11.1", "@hono/node-server": "1.12.0", "callback-data": "1.1.1", + "dotenv": "^16.4.7", "grammy": "1.27.0", "grammy-guard": "0.5.0", "hono": "4.5.2", @@ -2369,6 +2370,18 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", diff --git a/package.json b/package.json index 7f98bdab..ec8317da 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@grammyjs/types": "3.11.1", "@hono/node-server": "1.12.0", "callback-data": "1.1.1", + "dotenv": "^16.4.7", "grammy": "1.27.0", "grammy-guard": "0.5.0", "hono": "4.5.2", diff --git a/src/bot/features/ping.ts b/src/bot/features/ping.ts new file mode 100644 index 00000000..3b05ca88 --- /dev/null +++ b/src/bot/features/ping.ts @@ -0,0 +1,17 @@ +import type { Context } from '#root/bot/context.js' +import { logHandle } from '#root/bot/helpers/logging.js' +import { Composer } from 'grammy' + +const composer = new Composer() + +const feature = composer.chatType('private') + +feature.command('ping', logHandle('command-ping-pong'), (ctx) => { + return ctx.reply('pong') +}) + +feature.filter((ctx) => ctx.msg?.text?.toLocaleLowerCase() === 'ping').on('msg:text', logHandle('msg-ping-pong'), (ctx) => { + return ctx.reply('pong') +}) + +export { composer as PingFeature } diff --git a/src/bot/features/welcome.ts b/src/bot/features/welcome.ts index 5f6b1072..54113acc 100644 --- a/src/bot/features/welcome.ts +++ b/src/bot/features/welcome.ts @@ -1,13 +1,15 @@ -import { Composer } from 'grammy' import type { Context } from '#root/bot/context.js' import { logHandle } from '#root/bot/helpers/logging.js' +import { Composer, Keyboard } from 'grammy' const composer = new Composer() const feature = composer.chatType('private') -feature.command('start', logHandle('command-start'), (ctx) => { - return ctx.reply(ctx.t('welcome')) +feature.command('start', logHandle('command-start'), async (ctx) => { + return ctx.reply(ctx.t('welcome'), { + reply_markup: new Keyboard().text('ping').resized(), + }) }) export { composer as welcomeFeature } diff --git a/src/bot/handlers/commands/command-definitions.ts b/src/bot/handlers/commands/command-definitions.ts new file mode 100644 index 00000000..f6dab9a6 --- /dev/null +++ b/src/bot/handlers/commands/command-definitions.ts @@ -0,0 +1,34 @@ +import { i18n } from "#root/bot/i18n.js"; + +export interface Command { + command: string; + description: (lang: string) => string; + isAdmin?: true; + scope?: "private" | "group" | "all"; +} + +export const commands: Command[] = [ + { + command: "start", + description: (lang) => i18n.t(lang, "start-command-description"), + scope: "private", + }, + { + command: "ping", + description: (lang) => i18n.t(lang, "ping-pong-command-description"), + scope: "private", + }, + { + command: "setcommands", + description: (lang) => i18n.t(lang, "setcommands-command-description"), + isAdmin: true, + scope: "private", + }, +]; + +export const languageCommand = { + command: "language", + description: (localeCode: string) => + i18n.t(localeCode, "language-command-description"), + scope: "private", +} as const; diff --git a/src/bot/handlers/commands/setcommands.ts b/src/bot/handlers/commands/setcommands.ts index dd694027..46a9c964 100644 --- a/src/bot/handlers/commands/setcommands.ts +++ b/src/bot/handlers/commands/setcommands.ts @@ -1,108 +1,94 @@ -import type { BotCommand, LanguageCode } from '@grammyjs/types' -import type { CommandContext } from 'grammy' -import { i18n, isMultipleLocales } from '#root/bot/i18n.js' -import type { Context } from '#root/bot/context.js' +import type { Context } from "#root/bot/context.js"; +import { i18n, isMultipleLocales } from "#root/bot/i18n.js"; +import type { BotCommand, LanguageCode } from "@grammyjs/types"; +import type { CommandContext } from "grammy"; +import { commands, languageCommand, type Command } from "./command-definitions.js"; -function getLanguageCommand(localeCode: string): BotCommand { - return { - command: 'language', - description: i18n.t(localeCode, 'language-command-description'), - } -} +type CommandScope = { + type: "all_private_chats" | "all_group_chats"; +} | { + type: "chat"; + chat_id: number; +}; -function getPrivateChatCommands(localeCode: string): BotCommand[] { - return [ - { - command: 'start', - description: i18n.t(localeCode, 'start-command-description'), - }, - ] -} +type SetCommandsOptions = { + language_code?: LanguageCode; + scope: CommandScope; +}; -function getPrivateChatAdminCommands(localeCode: string): BotCommand[] { - return [ - { - command: 'setcommands', - description: i18n.t(localeCode, 'setcommands-command-description'), - }, - ] -} +const filterCommands = (commands: Command[], { isAdminOnly = false, scope }: { isAdminOnly?: boolean; scope?: Command["scope"] }) => + commands.filter(cmd => { + if (scope && cmd.scope !== scope) return false; + return isAdminOnly ? cmd.isAdmin : !cmd.isAdmin; + }); -function getGroupChatCommands(_localeCode: string): BotCommand[] { - return [] -} +const formatBotCommand = (command: Command, localeCode: string): BotCommand => ({ + command: command.command, + description: command.description(localeCode), +}); -export async function setCommandsHandler(ctx: CommandContext) { - const DEFAULT_LANGUAGE_CODE = 'en' +const formatLanguageCommand = (localeCode: string): BotCommand => ({ + command: languageCommand.command, + description: languageCommand.description(localeCode), +}); - // set private chat commands - await ctx.api.setMyCommands( - [ - ...getPrivateChatCommands(DEFAULT_LANGUAGE_CODE), - ...(isMultipleLocales ? [getLanguageCommand(DEFAULT_LANGUAGE_CODE)] : []), - ], - { - scope: { - type: 'all_private_chats', - }, - }, - ) +const setCommandsForScope = async ( + ctx: CommandContext, + localeCode: string, + filteredCommands: Command[], + options: SetCommandsOptions, +) => { + const botCommands = [ + ...filteredCommands.map(cmd => formatBotCommand(cmd, localeCode)), + ...(isMultipleLocales ? [formatLanguageCommand(localeCode)] : []), + ]; + + await ctx.api.setMyCommands(botCommands, options); +}; - if (isMultipleLocales) { - const requests = i18n.locales.map(code => - ctx.api.setMyCommands( - [ - ...getPrivateChatCommands(code), - ...(isMultipleLocales - ? [getLanguageCommand(DEFAULT_LANGUAGE_CODE)] - : []), - ], - { - language_code: code as LanguageCode, - scope: { - type: 'all_private_chats', - }, - }, - ), - ) +const setCommandsForAllLocales = async ( + ctx: CommandContext, + filteredCommands: Command[], + options: Omit, +) => { + if (!isMultipleLocales) return; - await Promise.all(requests) - } + const requests = i18n.locales.map(code => + setCommandsForScope(ctx, code, filteredCommands, { + ...options, + language_code: code as LanguageCode, + }), + ); - // set group chat commands - await ctx.api.setMyCommands(getGroupChatCommands(DEFAULT_LANGUAGE_CODE), { - scope: { - type: 'all_group_chats', - }, - }) + await Promise.all(requests); +}; - if (isMultipleLocales) { - const requests = i18n.locales.map(code => - ctx.api.setMyCommands(getGroupChatCommands(code), { - language_code: code as LanguageCode, - scope: { - type: 'all_group_chats', - }, - }), - ) +export async function setCommandsHandler(ctx: CommandContext) { + const defaultLocale = (await ctx.i18n.getLocale()) || "en"; + + // Set commands for private chats + const privateCommands = filterCommands(commands, { scope: "private" }); + await setCommandsForScope(ctx, defaultLocale, privateCommands, { + scope: { type: "all_private_chats" }, + }); + await setCommandsForAllLocales(ctx, privateCommands, { + scope: { type: "all_private_chats" }, + }); - await Promise.all(requests) - } + // Set commands for group chats + const groupCommands = filterCommands(commands, { scope: "group" }); + await setCommandsForScope(ctx, defaultLocale, groupCommands, { + scope: { type: "all_group_chats" }, + }); + await setCommandsForAllLocales(ctx, groupCommands, { + scope: { type: "all_group_chats" }, + }); - // set private chat commands for owner - await ctx.api.setMyCommands( - [ - ...getPrivateChatCommands(DEFAULT_LANGUAGE_CODE), - ...getPrivateChatAdminCommands(DEFAULT_LANGUAGE_CODE), - ...(isMultipleLocales ? [getLanguageCommand(DEFAULT_LANGUAGE_CODE)] : []), - ], - { - scope: { - type: 'chat', - chat_id: Number(ctx.config.botAdmins), - }, - }, - ) + // Set admin commands + const adminCommands = [...privateCommands, ...filterCommands(commands, { isAdminOnly: true, scope: "private" })]; + await setCommandsForScope(ctx, defaultLocale, adminCommands, { + scope: { type: "chat", chat_id: Number(ctx.config.botAdmins) }, + }); - return ctx.reply(ctx.t('admin-commands-updated')) + return ctx.reply(ctx.t("admin-commands-updated")); } diff --git a/src/bot/index.ts b/src/bot/index.ts index facc7f2b..c757ec52 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -1,21 +1,22 @@ -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 } from 'grammy' -import { sequentialize } from '@grammyjs/runner' -import { welcomeFeature } from '#root/bot/features/welcome.js' +import type { Context, SessionData } from '#root/bot/context.js' +import { createContextConstructor } from '#root/bot/context.js' import { adminFeature } from '#root/bot/features/admin.js' import { languageFeature } from '#root/bot/features/language.js' +import { PingFeature } from '#root/bot/features/ping.js' import { unhandledFeature } from '#root/bot/features/unhandled.js' +import { welcomeFeature } from '#root/bot/features/welcome.js' import { errorHandler } from '#root/bot/handlers/error.js' -import { updateLogger } from '#root/bot/middlewares/update-logger.js' -import { session } from '#root/bot/middlewares/session.js' -import type { Context, SessionData } from '#root/bot/context.js' -import { createContextConstructor } from '#root/bot/context.js' import { i18n, isMultipleLocales } from '#root/bot/i18n.js' -import type { Logger } from '#root/logger.js' +import { session } from '#root/bot/middlewares/session.js' +import { updateLogger } from '#root/bot/middlewares/update-logger.js' import type { Config } from '#root/config.js' +import type { Logger } from '#root/logger.js' +import { autoChatAction } from '@grammyjs/auto-chat-action' +import { hydrate } from '@grammyjs/hydrate' +import { hydrateReply, parseMode } from '@grammyjs/parse-mode' +import { sequentialize } from '@grammyjs/runner' +import type { BotConfig, StorageAdapter } from 'grammy' +import { Bot as TelegramBot } from 'grammy' interface Dependencies { config: Config @@ -61,6 +62,7 @@ export function createBot(token: string, dependencies: Dependencies, options: Op // Handlers protectedBot.use(welcomeFeature) + protectedBot.use(PingFeature) protectedBot.use(adminFeature) if (isMultipleLocales) protectedBot.use(languageFeature) diff --git a/src/config.ts b/src/config.ts index aee61c40..a1682f1b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,18 @@ -import process from 'node:process' -import * as v from 'valibot' -import { API_CONSTANTS } from 'grammy' +import dotenv from "dotenv"; +import { API_CONSTANTS } from 'grammy'; +import process from 'node:process'; +import * as v from 'valibot'; + +dotenv.config(); + +type CamelCase = S extends `${infer P1}_${infer P2}${infer P3}` +? `${Lowercase}${Uppercase}${CamelCase}` +: Lowercase + +type KeysToCamelCase = { +[K in keyof T as CamelCase]: T[K] extends object ? KeysToCamelCase : T[K] +} + const baseConfigSchema = v.object({ debug: v.optional(v.pipe(v.string(), v.transform(JSON.parse), v.boolean()), 'false'), @@ -43,47 +55,26 @@ const configSchema = v.variant('botMode', [ ), ]) -export type Config = v.InferOutput -export type PollingConfig = v.InferOutput -export type WebhookConfig = v.InferOutput - -export function createConfig(input: v.InferInput) { - return v.parse(configSchema, input) +function toCamelCase(str: string): string { + return str.toLowerCase().replace(/_([a-z])/g, (_match, p1) => p1.toUpperCase()) } -export const config = createConfigFromEnvironment() - -function createConfigFromEnvironment() { - type CamelCase = S extends `${infer P1}_${infer P2}${infer P3}` - ? `${Lowercase}${Uppercase}${CamelCase}` - : Lowercase - - type KeysToCamelCase = { - [K in keyof T as CamelCase]: T[K] extends object ? KeysToCamelCase : T[K] - } - - function toCamelCase(str: string): string { - return str.toLowerCase().replace(/_([a-z])/g, (_match, p1) => p1.toUpperCase()) - } - - function convertKeysToCamelCase(obj: T): KeysToCamelCase { - const result: any = {} - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { - const camelCaseKey = toCamelCase(key) - result[camelCaseKey] = obj[key] - } +function convertKeysToCamelCase(obj: T): KeysToCamelCase { + const result: any = {} + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const camelCaseKey = toCamelCase(key) + result[camelCaseKey] = obj[key] } - return result } + return result +} - try { - process.loadEnvFile() - } - catch { - // No .env file found - } +function createConfig(input: v.InferInput) { + return v.parse(configSchema, input) +} +function createConfigFromEnvironment() { try { // @ts-expect-error create config from environment variables const config = createConfig(convertKeysToCamelCase(process.env)) @@ -96,3 +87,14 @@ function createConfigFromEnvironment() { }) } } + +const config = createConfigFromEnvironment() + +export type Config = v.InferOutput +export type PollingConfig = v.InferOutput +export type WebhookConfig = v.InferOutput + +export { + config, convertKeysToCamelCase, createConfig +}; + diff --git a/src/server/index.ts b/src/server/index.ts index 82d83c45..a6d75196 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -58,6 +58,24 @@ export function createServer(dependencies: Dependencies) { server.get('/', c => c.json({ status: true })) if (config.isWebhookMode) { + server.get(`/${bot.token}`, async (c) => { + const hostname = c.req.header('x-forwarded-host') + if (typeof hostname === 'string') { + const webhookUrl = new URL('webhook', `https://${hostname}`).href + await bot.api.setWebhook(webhookUrl, { + allowed_updates: config.botAllowedUpdates, + secret_token: config.botWebhookSecret, + }) + return c.json({ + status: true, + }) + } + c.status(500) + return c.json({ + status: false, + }) + }) + server.post( '/webhook', webhookCallback(bot, 'hono', { diff --git a/tsconfig.json b/tsconfig.json index a95cd27b..ca9baa47 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "preserveWatchOutput": true }, "include": [ + "api/**/*", "src/**/*" ] } diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..f96d4935 --- /dev/null +++ b/vercel.json @@ -0,0 +1,12 @@ +{ + "installCommand": "npm install", + "buildCommand": "npm run build", + "devCommand": "npm run dev", + "outputDirectory": "build", + "rewrites": [ + { + "source": "/(.*)", + "destination": "/api" + } + ] +} \ No newline at end of file