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_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
|