Initialize deploy/docker-compose

This commit is contained in:
Lucid Kobold
2025-02-19 10:12:52 -05:00
committed by GitHub
commit 621f177653
42 changed files with 8193 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import { createCallbackData } from 'callback-data'
export const changeLanguageData = createCallbackData('language', {
code: String,
})

26
src/bot/context.ts Normal file
View 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
View 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 }

View 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 }

View 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 }

View 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 }

View 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)
}

View 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
View 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),
})
}

View 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
}

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

View 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,
),
)
}

View 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: () => ({}),
})
}

View 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,
})
}
}
}