Initialize deploy/docker-compose
This commit is contained in:
5
src/bot/callback-data/change-language.ts
Normal file
5
src/bot/callback-data/change-language.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createCallbackData } from 'callback-data'
|
||||
|
||||
export const changeLanguageData = createCallbackData('language', {
|
||||
code: String,
|
||||
})
|
||||
26
src/bot/context.ts
Normal file
26
src/bot/context.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Config } from '#root/config.js'
|
||||
import type { Logger } from '#root/logger.js'
|
||||
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 { Context as DefaultContext, SessionFlavor } from 'grammy'
|
||||
|
||||
export interface SessionData {
|
||||
// field?: string;
|
||||
}
|
||||
|
||||
interface ExtendedContextFlavor {
|
||||
logger: Logger
|
||||
config: Config
|
||||
}
|
||||
|
||||
export type Context = ParseModeFlavor<
|
||||
HydrateFlavor<
|
||||
DefaultContext &
|
||||
ExtendedContextFlavor &
|
||||
SessionFlavor<SessionData> &
|
||||
I18nFlavor &
|
||||
AutoChatActionFlavor
|
||||
>
|
||||
>
|
||||
21
src/bot/features/admin.ts
Normal file
21
src/bot/features/admin.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Context } from '#root/bot/context.js'
|
||||
import { isAdmin } from '#root/bot/filters/is-admin.js'
|
||||
import { setCommandsHandler } from '#root/bot/handlers/commands/setcommands.js'
|
||||
import { logHandle } from '#root/bot/helpers/logging.js'
|
||||
import { chatAction } from '@grammyjs/auto-chat-action'
|
||||
import { Composer } from 'grammy'
|
||||
|
||||
const composer = new Composer<Context>()
|
||||
|
||||
const feature = composer
|
||||
.chatType('private')
|
||||
.filter(isAdmin)
|
||||
|
||||
feature.command(
|
||||
'setcommands',
|
||||
logHandle('command-setcommands'),
|
||||
chatAction('typing'),
|
||||
setCommandsHandler,
|
||||
)
|
||||
|
||||
export { composer as adminFeature }
|
||||
36
src/bot/features/language.ts
Normal file
36
src/bot/features/language.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Context } from '#root/bot/context.js'
|
||||
import { changeLanguageData } from '#root/bot/callback-data/change-language.js'
|
||||
import { logHandle } from '#root/bot/helpers/logging.js'
|
||||
import { i18n } from '#root/bot/i18n.js'
|
||||
import { createChangeLanguageKeyboard } from '#root/bot/keyboards/change-language.js'
|
||||
import { Composer } from 'grammy'
|
||||
|
||||
const composer = new Composer<Context>()
|
||||
|
||||
const feature = composer.chatType('private')
|
||||
|
||||
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'),
|
||||
async (ctx) => {
|
||||
const { code: languageCode } = changeLanguageData.unpack(
|
||||
ctx.callbackQuery.data,
|
||||
)
|
||||
|
||||
if (i18n.locales.includes(languageCode)) {
|
||||
await ctx.i18n.setLocale(languageCode)
|
||||
|
||||
return ctx.editMessageText(ctx.t('language-changed'), {
|
||||
reply_markup: await createChangeLanguageKeyboard(ctx),
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export { composer as languageFeature }
|
||||
17
src/bot/features/unhandled.ts
Normal file
17
src/bot/features/unhandled.ts
Normal file
@@ -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<Context>()
|
||||
|
||||
const feature = composer.chatType('private')
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
export { composer as unhandledFeature }
|
||||
13
src/bot/features/welcome.ts
Normal file
13
src/bot/features/welcome.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Context } from '#root/bot/context.js'
|
||||
import { logHandle } from '#root/bot/helpers/logging.js'
|
||||
import { Composer } from 'grammy'
|
||||
|
||||
const composer = new Composer<Context>()
|
||||
|
||||
const feature = composer.chatType('private')
|
||||
|
||||
feature.command('start', logHandle('command-start'), (ctx) => {
|
||||
return ctx.reply(ctx.t('welcome'))
|
||||
})
|
||||
|
||||
export { composer as welcomeFeature }
|
||||
5
src/bot/filters/is-admin.ts
Normal file
5
src/bot/filters/is-admin.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { Context } from '#root/bot/context.js'
|
||||
|
||||
export function isAdmin(ctx: Context) {
|
||||
return !!ctx.from && ctx.config.botAdmins.includes(ctx.from.id)
|
||||
}
|
||||
49
src/bot/handlers/commands/setcommands.ts
Normal file
49
src/bot/handlers/commands/setcommands.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Context } from '#root/bot/context.js'
|
||||
import type { LanguageCode } from '@grammyjs/types'
|
||||
import type { CommandContext } from 'grammy'
|
||||
import { i18n } from '#root/bot/i18n.js'
|
||||
import { Command, CommandGroup } from '@grammyjs/commands'
|
||||
|
||||
function addCommandLocalizations(command: Command) {
|
||||
i18n.locales.forEach((locale) => {
|
||||
command.localize(
|
||||
locale as LanguageCode,
|
||||
command.name,
|
||||
i18n.t(locale, `${command.name}.description`),
|
||||
)
|
||||
})
|
||||
return command
|
||||
}
|
||||
|
||||
function addCommandToChats(command: Command, chats: number[]) {
|
||||
for (const chatId of chats) {
|
||||
command.addToScope({
|
||||
type: 'chat',
|
||||
chat_id: chatId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function setCommandsHandler(ctx: CommandContext<Context>) {
|
||||
const start = new Command('start', i18n.t('en', 'start.description'))
|
||||
.addToScope({ type: 'all_private_chats' })
|
||||
addCommandLocalizations(start)
|
||||
addCommandToChats(start, ctx.config.botAdmins)
|
||||
|
||||
const language = new Command('language', i18n.t('en', 'language.description'))
|
||||
.addToScope({ type: 'all_private_chats' })
|
||||
addCommandLocalizations(language)
|
||||
addCommandToChats(language, ctx.config.botAdmins)
|
||||
|
||||
const setcommands = new Command('setcommands', i18n.t('en', 'setcommands.description'))
|
||||
addCommandToChats(setcommands, ctx.config.botAdmins)
|
||||
|
||||
const commands = new CommandGroup()
|
||||
.add(start)
|
||||
.add(language)
|
||||
.add(setcommands)
|
||||
|
||||
await commands.setCommands(ctx)
|
||||
|
||||
return ctx.reply(ctx.t('admin-commands-updated'))
|
||||
}
|
||||
12
src/bot/handlers/error.ts
Normal file
12
src/bot/handlers/error.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Context } from '#root/bot/context.js'
|
||||
import type { ErrorHandler } from 'grammy'
|
||||
import { getUpdateInfo } from '#root/bot/helpers/logging.js'
|
||||
|
||||
export const errorHandler: ErrorHandler<Context> = (error) => {
|
||||
const { ctx } = error
|
||||
|
||||
ctx.logger.error({
|
||||
err: error.error,
|
||||
update: getUpdateInfo(ctx),
|
||||
})
|
||||
}
|
||||
7
src/bot/helpers/keyboard.ts
Normal file
7
src/bot/helpers/keyboard.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function chunk<T>(array: T[], size: number) {
|
||||
const result = []
|
||||
for (let index = 0; index < array.length; index += size)
|
||||
result.push(array.slice(index, index + size))
|
||||
|
||||
return result
|
||||
}
|
||||
20
src/bot/helpers/logging.ts
Normal file
20
src/bot/helpers/logging.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Context } from '#root/bot/context.js'
|
||||
import type { Update } from '@grammyjs/types'
|
||||
import type { Middleware } from 'grammy'
|
||||
|
||||
export function getUpdateInfo(ctx: Context): Omit<Update, 'update_id'> {
|
||||
const { update_id, ...update } = ctx.update
|
||||
|
||||
return update
|
||||
}
|
||||
|
||||
export function logHandle(id: string): Middleware<Context> {
|
||||
return (ctx, next) => {
|
||||
ctx.logger.info({
|
||||
msg: `Handle "${id}"`,
|
||||
...(id.startsWith('unhandled') ? { update: getUpdateInfo(ctx) } : {}),
|
||||
})
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
15
src/bot/i18n.ts
Normal file
15
src/bot/i18n.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Context } from '#root/bot/context.js'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { I18n } from '@grammyjs/i18n'
|
||||
|
||||
export const i18n = new I18n<Context>({
|
||||
defaultLocale: 'en',
|
||||
directory: path.resolve(process.cwd(), 'locales'),
|
||||
useSession: true,
|
||||
fluentBundleOptions: {
|
||||
useIsolating: false,
|
||||
},
|
||||
})
|
||||
|
||||
export const isMultipleLocales = i18n.locales.length > 1
|
||||
75
src/bot/index.ts
Normal file
75
src/bot/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Context } from '#root/bot/context.js'
|
||||
import type { Config } from '#root/config.js'
|
||||
import type { Logger } from '#root/logger.js'
|
||||
import type { BotConfig } from 'grammy'
|
||||
import { adminFeature } from '#root/bot/features/admin.js'
|
||||
import { languageFeature } from '#root/bot/features/language.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 { i18n, isMultipleLocales } from '#root/bot/i18n.js'
|
||||
import { session } from '#root/bot/middlewares/session.js'
|
||||
import { updateLogger } from '#root/bot/middlewares/update-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 { MemorySessionStorage, Bot as TelegramBot } from 'grammy'
|
||||
|
||||
interface Dependencies {
|
||||
config: Config
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
function getSessionKey(ctx: Omit<Context, 'session'>) {
|
||||
return ctx.chat?.id.toString()
|
||||
}
|
||||
|
||||
export function createBot(token: string, dependencies: Dependencies, botConfig?: BotConfig<Context>) {
|
||||
const {
|
||||
config,
|
||||
logger,
|
||||
} = dependencies
|
||||
|
||||
const bot = new TelegramBot<Context>(token, botConfig)
|
||||
|
||||
bot.use(async (ctx, next) => {
|
||||
ctx.config = config
|
||||
ctx.logger = logger.child({
|
||||
update_id: ctx.update.update_id,
|
||||
})
|
||||
|
||||
await next()
|
||||
})
|
||||
|
||||
const protectedBot = bot.errorBoundary(errorHandler)
|
||||
|
||||
// Middlewares
|
||||
bot.api.config.use(parseMode('HTML'))
|
||||
|
||||
if (config.isPollingMode)
|
||||
protectedBot.use(sequentialize(getSessionKey))
|
||||
if (config.isDebug)
|
||||
protectedBot.use(updateLogger())
|
||||
protectedBot.use(autoChatAction(bot.api))
|
||||
protectedBot.use(hydrateReply)
|
||||
protectedBot.use(hydrate())
|
||||
protectedBot.use(session({
|
||||
getSessionKey,
|
||||
storage: new MemorySessionStorage(),
|
||||
}))
|
||||
protectedBot.use(i18n)
|
||||
|
||||
// Handlers
|
||||
protectedBot.use(welcomeFeature)
|
||||
protectedBot.use(adminFeature)
|
||||
if (isMultipleLocales)
|
||||
protectedBot.use(languageFeature)
|
||||
|
||||
// must be the last handler
|
||||
protectedBot.use(unhandledFeature)
|
||||
|
||||
return bot
|
||||
}
|
||||
|
||||
export type Bot = ReturnType<typeof createBot>
|
||||
28
src/bot/keyboards/change-language.ts
Normal file
28
src/bot/keyboards/change-language.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Context } from '#root/bot/context.js'
|
||||
import { changeLanguageData } from '#root/bot/callback-data/change-language.js'
|
||||
import { chunk } from '#root/bot/helpers/keyboard.js'
|
||||
import { i18n } from '#root/bot/i18n.js'
|
||||
import { InlineKeyboard } from 'grammy'
|
||||
import ISO6391 from 'iso-639-1'
|
||||
|
||||
export async function createChangeLanguageKeyboard(ctx: Context) {
|
||||
const currentLocaleCode = await ctx.i18n.getLocale()
|
||||
|
||||
const getLabel = (code: string) => {
|
||||
const isActive = code === currentLocaleCode
|
||||
|
||||
return `${isActive ? '✅ ' : ''}${ISO6391.getNativeName(code)}`
|
||||
}
|
||||
|
||||
return InlineKeyboard.from(
|
||||
chunk(
|
||||
i18n.locales.map(localeCode => ({
|
||||
text: getLabel(localeCode),
|
||||
callback_data: changeLanguageData.pack({
|
||||
code: localeCode,
|
||||
}),
|
||||
})),
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
13
src/bot/middlewares/session.ts
Normal file
13
src/bot/middlewares/session.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Context, SessionData } from '#root/bot/context.js'
|
||||
import type { Middleware, SessionOptions } from 'grammy'
|
||||
import { session as createSession } from 'grammy'
|
||||
|
||||
type Options = Pick<SessionOptions<SessionData, Context>, 'getSessionKey' | 'storage'>
|
||||
|
||||
export function session(options: Options): Middleware<Context> {
|
||||
return createSession({
|
||||
getSessionKey: options.getSessionKey,
|
||||
storage: options.storage,
|
||||
initial: () => ({}),
|
||||
})
|
||||
}
|
||||
35
src/bot/middlewares/update-logger.ts
Normal file
35
src/bot/middlewares/update-logger.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Context } from '#root/bot/context.js'
|
||||
import type { Middleware } from 'grammy'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { getUpdateInfo } from '#root/bot/helpers/logging.js'
|
||||
|
||||
export function updateLogger(): Middleware<Context> {
|
||||
return async (ctx, next) => {
|
||||
ctx.api.config.use((previous, method, payload, signal) => {
|
||||
ctx.logger.debug({
|
||||
msg: 'Bot API call',
|
||||
method,
|
||||
payload,
|
||||
})
|
||||
|
||||
return previous(method, payload, signal)
|
||||
})
|
||||
|
||||
ctx.logger.debug({
|
||||
msg: 'Update received',
|
||||
update: getUpdateInfo(ctx),
|
||||
})
|
||||
|
||||
const startTime = performance.now()
|
||||
try {
|
||||
await next()
|
||||
}
|
||||
finally {
|
||||
const endTime = performance.now()
|
||||
ctx.logger.debug({
|
||||
msg: 'Update processed',
|
||||
elapsed: endTime - startTime,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/config.ts
Normal file
98
src/config.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import process from 'node:process'
|
||||
import { API_CONSTANTS } from 'grammy'
|
||||
import * as v from 'valibot'
|
||||
|
||||
const baseConfigSchema = v.object({
|
||||
debug: v.optional(v.pipe(v.string(), v.transform(JSON.parse), v.boolean()), 'false'),
|
||||
logLevel: v.optional(v.pipe(v.string(), v.picklist(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent'])), 'info'),
|
||||
botToken: v.pipe(v.string(), v.regex(/^\d+:[\w-]+$/, 'Invalid token')),
|
||||
botAllowedUpdates: v.optional(v.pipe(v.string(), v.transform(JSON.parse), v.array(v.picklist(API_CONSTANTS.ALL_UPDATE_TYPES))), '[]'),
|
||||
botAdmins: v.optional(v.pipe(v.string(), v.transform(JSON.parse), v.array(v.number())), '[]'),
|
||||
})
|
||||
|
||||
const configSchema = v.variant('botMode', [
|
||||
// polling config
|
||||
v.pipe(
|
||||
v.object({
|
||||
botMode: v.literal('polling'),
|
||||
...baseConfigSchema.entries,
|
||||
}),
|
||||
v.transform(input => ({
|
||||
...input,
|
||||
isDebug: input.debug,
|
||||
isWebhookMode: false as const,
|
||||
isPollingMode: true as const,
|
||||
})),
|
||||
),
|
||||
// webhook config
|
||||
v.pipe(
|
||||
v.object({
|
||||
botMode: v.literal('webhook'),
|
||||
...baseConfigSchema.entries,
|
||||
botWebhook: v.pipe(v.string(), v.url()),
|
||||
botWebhookSecret: v.pipe(v.string(), v.minLength(12)),
|
||||
serverHost: v.optional(v.string(), '0.0.0.0'),
|
||||
serverPort: v.optional(v.pipe(v.string(), v.transform(Number), v.number()), '80'),
|
||||
}),
|
||||
v.transform(input => ({
|
||||
...input,
|
||||
isDebug: input.debug,
|
||||
isWebhookMode: true as const,
|
||||
isPollingMode: false as const,
|
||||
})),
|
||||
),
|
||||
])
|
||||
|
||||
export type Config = v.InferOutput<typeof configSchema>
|
||||
export type PollingConfig = v.InferOutput<typeof configSchema['options'][0]>
|
||||
export type WebhookConfig = v.InferOutput<typeof configSchema['options'][1]>
|
||||
|
||||
export function createConfig(input: v.InferInput<typeof configSchema>) {
|
||||
return v.parse(configSchema, input)
|
||||
}
|
||||
|
||||
export const config = createConfigFromEnvironment()
|
||||
|
||||
function createConfigFromEnvironment() {
|
||||
type CamelCase<S extends string> = S extends `${infer P1}_${infer P2}${infer P3}`
|
||||
? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
|
||||
: Lowercase<S>
|
||||
|
||||
type KeysToCamelCase<T> = {
|
||||
[K in keyof T as CamelCase<string & K>]: T[K] extends object ? KeysToCamelCase<T[K]> : T[K]
|
||||
}
|
||||
|
||||
function toCamelCase(str: string): string {
|
||||
return str.toLowerCase().replace(/_([a-z])/g, (_match, p1) => p1.toUpperCase())
|
||||
}
|
||||
|
||||
function convertKeysToCamelCase<T>(obj: T): KeysToCamelCase<T> {
|
||||
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
|
||||
}
|
||||
|
||||
try {
|
||||
process.loadEnvFile()
|
||||
}
|
||||
catch {
|
||||
// No .env file found
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error create config from environment variables
|
||||
const config = createConfig(convertKeysToCamelCase(process.env))
|
||||
|
||||
return config
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error('Invalid config', {
|
||||
cause: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
31
src/logger.ts
Normal file
31
src/logger.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { config } from '#root/config.js'
|
||||
import { pino } from 'pino'
|
||||
|
||||
export const logger = pino({
|
||||
level: config.logLevel,
|
||||
transport: {
|
||||
targets: [
|
||||
...(config.isDebug
|
||||
? [
|
||||
{
|
||||
target: 'pino-pretty',
|
||||
level: config.logLevel,
|
||||
options: {
|
||||
ignore: 'pid,hostname',
|
||||
colorize: true,
|
||||
translateTime: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
target: 'pino/file',
|
||||
level: config.logLevel,
|
||||
options: {},
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
export type Logger = typeof logger
|
||||
111
src/main.ts
Normal file
111
src/main.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env tsx
|
||||
/* eslint-disable antfu/no-top-level-await */
|
||||
|
||||
import type { PollingConfig, WebhookConfig } from '#root/config.js'
|
||||
import type { RunnerHandle } from '@grammyjs/runner'
|
||||
import process from 'node:process'
|
||||
import { createBot } from '#root/bot/index.js'
|
||||
import { config } from '#root/config.js'
|
||||
import { logger } from '#root/logger.js'
|
||||
import { createServer, createServerManager } from '#root/server/index.js'
|
||||
import { run } from '@grammyjs/runner'
|
||||
|
||||
async function startPolling(config: PollingConfig) {
|
||||
const bot = createBot(config.botToken, {
|
||||
config,
|
||||
logger,
|
||||
})
|
||||
let runner: undefined | RunnerHandle
|
||||
|
||||
// graceful shutdown
|
||||
onShutdown(async () => {
|
||||
logger.info('Shutdown')
|
||||
await runner?.stop()
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
bot.init(),
|
||||
bot.api.deleteWebhook(),
|
||||
])
|
||||
|
||||
// start bot
|
||||
runner = run(bot, {
|
||||
runner: {
|
||||
fetch: {
|
||||
allowed_updates: config.botAllowedUpdates,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
logger.info({
|
||||
msg: 'Bot running...',
|
||||
username: bot.botInfo.username,
|
||||
})
|
||||
}
|
||||
|
||||
async function startWebhook(config: WebhookConfig) {
|
||||
const bot = createBot(config.botToken, {
|
||||
config,
|
||||
logger,
|
||||
})
|
||||
const server = createServer({
|
||||
bot,
|
||||
config,
|
||||
logger,
|
||||
})
|
||||
const serverManager = createServerManager(server, {
|
||||
host: config.serverHost,
|
||||
port: config.serverPort,
|
||||
})
|
||||
|
||||
// graceful shutdown
|
||||
onShutdown(async () => {
|
||||
logger.info('Shutdown')
|
||||
await serverManager.stop()
|
||||
})
|
||||
|
||||
// to prevent receiving updates before the bot is ready
|
||||
await bot.init()
|
||||
|
||||
// start server
|
||||
const info = await serverManager.start()
|
||||
logger.info({
|
||||
msg: 'Server started',
|
||||
url: info.url,
|
||||
})
|
||||
|
||||
// set webhook
|
||||
await bot.api.setWebhook(config.botWebhook, {
|
||||
allowed_updates: config.botAllowedUpdates,
|
||||
secret_token: config.botWebhookSecret,
|
||||
})
|
||||
logger.info({
|
||||
msg: 'Webhook was set',
|
||||
url: config.botWebhook,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.isWebhookMode)
|
||||
await startWebhook(config)
|
||||
else if (config.isPollingMode)
|
||||
await startPolling(config)
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Utils
|
||||
|
||||
function onShutdown(cleanUp: () => Promise<void>) {
|
||||
let isShuttingDown = false
|
||||
const handleShutdown = async () => {
|
||||
if (isShuttingDown)
|
||||
return
|
||||
isShuttingDown = true
|
||||
await cleanUp()
|
||||
}
|
||||
process.on('SIGINT', handleShutdown)
|
||||
process.on('SIGTERM', handleShutdown)
|
||||
}
|
||||
8
src/server/environment.ts
Normal file
8
src/server/environment.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Logger } from '#root/logger.js'
|
||||
|
||||
export interface Env {
|
||||
Variables: {
|
||||
requestId: string
|
||||
logger: Logger
|
||||
}
|
||||
}
|
||||
102
src/server/index.ts
Normal file
102
src/server/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Bot } from '#root/bot/index.js'
|
||||
import type { Config } from '#root/config.js'
|
||||
import type { Logger } from '#root/logger.js'
|
||||
import type { Env } from '#root/server/environment.js'
|
||||
import { setLogger } from '#root/server/middlewares/logger.js'
|
||||
import { requestId } from '#root/server/middlewares/request-id.js'
|
||||
import { requestLogger } from '#root/server/middlewares/request-logger.js'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { webhookCallback } from 'grammy'
|
||||
import { Hono } from 'hono'
|
||||
import { HTTPException } from 'hono/http-exception'
|
||||
import { getPath } from 'hono/utils/url'
|
||||
|
||||
interface Dependencies {
|
||||
bot: Bot
|
||||
config: Config
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
export function createServer(dependencies: Dependencies) {
|
||||
const {
|
||||
bot,
|
||||
config,
|
||||
logger,
|
||||
} = dependencies
|
||||
|
||||
const server = new Hono<Env>()
|
||||
|
||||
server.use(requestId())
|
||||
server.use(setLogger(logger))
|
||||
if (config.isDebug)
|
||||
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()
|
||||
}
|
||||
|
||||
// unexpected error
|
||||
c.var.logger.error({
|
||||
err: error,
|
||||
method: c.req.raw.method,
|
||||
path: getPath(c.req.raw),
|
||||
})
|
||||
return c.json(
|
||||
{
|
||||
error: 'Oops! Something went wrong.',
|
||||
},
|
||||
500,
|
||||
)
|
||||
})
|
||||
|
||||
server.get('/', c => c.json({ status: true }))
|
||||
|
||||
if (config.isWebhookMode) {
|
||||
server.post(
|
||||
'/webhook',
|
||||
webhookCallback(bot, 'hono', {
|
||||
secretToken: config.botWebhookSecret,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
export type Server = Awaited<ReturnType<typeof createServer>>
|
||||
|
||||
export function createServerManager(server: Server, options: { host: string, port: number }) {
|
||||
let handle: undefined | ReturnType<typeof serve>
|
||||
return {
|
||||
start() {
|
||||
return new Promise<{ url: string }>((resolve) => {
|
||||
handle = serve(
|
||||
{
|
||||
fetch: server.fetch,
|
||||
hostname: options.host,
|
||||
port: options.port,
|
||||
},
|
||||
info => resolve({
|
||||
url: info.family === 'IPv6'
|
||||
? `http://[${info.address}]:${info.port}`
|
||||
: `http://${info.address}:${info.port}`,
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
stop() {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (handle)
|
||||
handle.close(() => resolve())
|
||||
else
|
||||
resolve()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
15
src/server/middlewares/logger.ts
Normal file
15
src/server/middlewares/logger.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Logger } from '#root/logger.js'
|
||||
import type { MiddlewareHandler } from 'hono'
|
||||
|
||||
export function setLogger(logger: Logger): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
c.set(
|
||||
'logger',
|
||||
logger.child({
|
||||
requestId: c.get('requestId'),
|
||||
}),
|
||||
)
|
||||
|
||||
await next()
|
||||
}
|
||||
}
|
||||
10
src/server/middlewares/request-id.ts
Normal file
10
src/server/middlewares/request-id.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { MiddlewareHandler } from 'hono'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export function requestId(): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
c.set('requestId', randomUUID())
|
||||
|
||||
await next()
|
||||
}
|
||||
}
|
||||
27
src/server/middlewares/request-logger.ts
Normal file
27
src/server/middlewares/request-logger.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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)
|
||||
|
||||
c.var.logger.debug({
|
||||
msg: 'Incoming request',
|
||||
method,
|
||||
path,
|
||||
})
|
||||
const startTime = performance.now()
|
||||
|
||||
await next()
|
||||
|
||||
const endTime = performance.now()
|
||||
c.var.logger.debug({
|
||||
msg: 'Request completed',
|
||||
method,
|
||||
path,
|
||||
status: c.res.status,
|
||||
elapsed: endTime - startTime,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user