Update dependencies for Grammy framework; add new packages for enhanced bot functionality and improve logging configuration.

This commit is contained in:
vchikalkin 2025-08-14 15:15:45 +03:00
parent 8225bbe546
commit 140477c87a
11 changed files with 193 additions and 50 deletions

View File

@ -13,7 +13,11 @@
"license": "ISC",
"packageManager": "pnpm@9.15.9+sha512.68046141893c66fad01c079231128e9afb89ef87e2691d69e4d40eee228988295fd4682181bae55b58418c3a253bde65a505ec7c5f9403ece5cc3cd37dcf2531",
"dependencies": {
"@grammyjs/auto-chat-action": "^0.1.1",
"@grammyjs/hydrate": "^1.4.1",
"@grammyjs/i18n": "^1.1.2",
"@grammyjs/parse-mode": "^2.2.0",
"@grammyjs/types": "^3.21.0",
"@tobyg74/tiktok-api-dl": "^1.3.4",
"@types/node": "^24.2.1",
"grammy": "^1.37.0",

42
pnpm-lock.yaml generated
View File

@ -8,9 +8,21 @@ importers:
.:
dependencies:
'@grammyjs/auto-chat-action':
specifier: ^0.1.1
version: 0.1.1(grammy@1.37.0)
'@grammyjs/hydrate':
specifier: ^1.4.1
version: 1.4.1(grammy@1.37.0)
'@grammyjs/i18n':
specifier: ^1.1.2
version: 1.1.2(grammy@1.37.0)
'@grammyjs/parse-mode':
specifier: ^2.2.0
version: 2.2.0(grammy@1.37.0)
'@grammyjs/types':
specifier: ^3.21.0
version: 3.21.0
'@tobyg74/tiktok-api-dl':
specifier: ^1.3.4
version: 1.3.4(@types/node@24.2.1)(canvas@3.1.2)(typescript@5.9.2)
@ -403,12 +415,29 @@ packages:
resolution: {integrity: sha512-YF4gZ4sLYRQfctpUR2uhb5UyPUYY5n/bi3OaED/Q4awKjPjlaF8tInO3uja7pnLQcmLTURkZL7L9zxv2Z5NDwg==}
engines: {node: '>=12.0.0', npm: '>=7.0.0'}
'@grammyjs/auto-chat-action@0.1.1':
resolution: {integrity: sha512-70Jdy+keIri4452tluaZpKtHag5gD8pNczoXqajsnsx7pJC5wg3DAQ5unpt0xJb8KsVBAGWuJb298SBl3TVecg==}
peerDependencies:
grammy: ^1.0.0
'@grammyjs/hydrate@1.4.1':
resolution: {integrity: sha512-xvd7XXoVHEhxBV2M8UHDRYkZlv32JioHfs+0l+eN34mkJJJBchuRDgRY3fkVJko8o1hGFHHLAJJTlEs2OAMolA==}
engines: {node: ^12.20.0 || >=14.13.1}
peerDependencies:
grammy: ^1.20.1
'@grammyjs/i18n@1.1.2':
resolution: {integrity: sha512-PcK06mxuDDZjxdZ5HywBhr+erEITsR816KP4DNIDDds1jpA45pfz/nS9FdZmzF8H6lMyPix3mV5WL1rT4q+BuA==}
engines: {node: '>=12'}
peerDependencies:
grammy: ^1.10.0
'@grammyjs/parse-mode@2.2.0':
resolution: {integrity: sha512-sI5xjXYn1ihEEf1bJx4ew2KPsX1O3jsd2V/MpA1CX2tCYlxquidr7agk4IOR5bGEK38pyNVxVBdyCiy/eMxEfQ==}
engines: {node: '>=14.13.1'}
peerDependencies:
grammy: ^1.36.1
'@grammyjs/types@3.21.0':
resolution: {integrity: sha512-IMj0EpmglPCICuyfGRx4ENKPSuzS2xMSoPgSPzHC6FtnWKDEmJLBP/GbPv/h3TAeb27txqxm/BUld+gbJk6ccQ==}
@ -3816,6 +3845,15 @@ snapshots:
'@fluent/langneg@0.6.2': {}
'@grammyjs/auto-chat-action@0.1.1(grammy@1.37.0)':
dependencies:
grammy: 1.37.0
'@grammyjs/hydrate@1.4.1(grammy@1.37.0)':
dependencies:
abort-controller: 3.0.0
grammy: 1.37.0
'@grammyjs/i18n@1.1.2(grammy@1.37.0)':
dependencies:
'@deno/shim-deno': 0.18.2
@ -3823,6 +3861,10 @@ snapshots:
'@fluent/langneg': 0.6.2
grammy: 1.37.0
'@grammyjs/parse-mode@2.2.0(grammy@1.37.0)':
dependencies:
grammy: 1.37.0
'@grammyjs/types@3.21.0': {}
'@graphql-eslint/eslint-plugin@4.4.0(@types/node@24.2.1)(eslint@9.33.0(jiti@2.5.1))(graphql@16.11.0)(typescript@5.9.2)':

13
src/bot/context.ts Normal file
View File

@ -0,0 +1,13 @@
import { logger } from '@/utils/logger';
import { HydrateFlavor } from '@grammyjs/hydrate';
import { I18nFlavor } from '@grammyjs/i18n';
import { Context as DefaultContext } from 'grammy';
import type { AutoChatActionFlavor } from '@grammyjs/auto-chat-action';
export type Context = HydrateFlavor<
DefaultContext &
AutoChatActionFlavor &
I18nFlavor & {
logger: typeof logger;
}
>;

View File

@ -0,0 +1,42 @@
import { validateTikTokUrl } from '@/utils/urls';
import type { Context } from '../context';
import { logHandle } from '../helpers/logging';
import { Composer, InputFile } from 'grammy';
import { Downloader } from '@tobyg74/tiktok-api-dl';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
feature.on('message:text', logHandle('download-message'), async (ctx) => {
try {
const url = ctx.message.text;
if (!validateTikTokUrl(url)) return ctx.reply(ctx.t('invalid_url'));
const { result, message } = await Downloader(url, { version: 'v3' });
if (message) throw new Error(message);
const videoUrl = result?.videoHD || result?.videoSD || result?.videoWatermark;
const imagesUrls = result?.images;
if (!videoUrl && !imagesUrls?.length) {
return ctx.reply(ctx.t('invalid_download_urls'));
}
if (result?.type === 'video' && videoUrl) {
return ctx.replyWithVideo(new InputFile({ url: videoUrl }));
}
if (result?.type === 'image' && imagesUrls) {
return ctx.replyWithMediaGroup(imagesUrls.map((image) => ({ media: image, type: 'photo' })));
}
} catch (error) {
ctx.logger.error(error);
return ctx.reply(ctx.t('generic'));
}
});
export { composer as download };

View File

@ -0,0 +1 @@
export * from './download';

View File

@ -0,0 +1,12 @@
import type { Context } from '../context';
import type { ErrorHandler } from 'grammy';
import { getUpdateInfo } from '../helpers/logging';
export const errorHandler: ErrorHandler<Context> = (error) => {
const { ctx } = error;
ctx.logger.error({
err: error.error,
update: getUpdateInfo(ctx),
});
};

View File

@ -0,0 +1,20 @@
import type { Context } from '../context';
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 './context';
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;

38
src/bot/index.ts Normal file
View File

@ -0,0 +1,38 @@
import { Bot } from 'grammy';
import { logger } from '@/utils/logger';
import { Context } from './context';
import { i18n } from './i18n';
import { errorHandler } from './handlers/errors';
import * as features from './features';
import { hydrate } from '@grammyjs/hydrate';
import { autoChatAction } from '@grammyjs/auto-chat-action';
type Params = {
token: string;
apiRoot: string;
};
export function createBot({ token, apiRoot }: Params) {
const bot = new Bot<Context>(token, {
client: {
apiRoot,
},
});
bot.use(async (ctx, next) => {
ctx.logger = logger.child({
update_id: ctx.update.update_id,
});
await next();
});
const protectedBot = bot.errorBoundary(errorHandler);
protectedBot.use(i18n);
protectedBot.use(autoChatAction(bot.api));
protectedBot.use(hydrate());
protectedBot.use(features.download);
return bot;
}

View File

@ -1,56 +1,10 @@
import { createBot } from './bot';
import { env } from './config/env';
import { Downloader } from '@tobyg74/tiktok-api-dl';
import { Bot, Context, InputFile } from 'grammy';
import { logger } from './utils/logger';
import { validateTikTokUrl } from './utils/urls';
import { I18n, I18nFlavor } from '@grammyjs/i18n';
type MyContext = Context & I18nFlavor;
const bot = new Bot<MyContext>(env.BOT_TOKEN, {
client: {
apiRoot: env.TELEGRAM_API_ROOT,
},
});
const i18n = new I18n<MyContext>({
defaultLocale: 'en', // see below for more information
directory: 'locales', // Load all translation files from locales/.
});
bot.use(i18n);
bot.on('message:text', async (ctx) => {
try {
const url = ctx.message.text;
if (!validateTikTokUrl(url)) return ctx.reply(ctx.t('invalid_url'));
const { result, message } = await Downloader(url, { version: 'v3' });
if (message) throw new Error(message);
const videoUrl = result?.videoHD || result?.videoSD || result?.videoWatermark;
const imagesUrls = result?.images;
if (!videoUrl && !imagesUrls?.length) {
return ctx.reply(ctx.t('invalid_download_urls'));
}
if (result?.type === 'video' && videoUrl) {
await ctx.replyWithChatAction('upload_video');
return ctx.replyWithVideo(new InputFile({ url: videoUrl }));
}
if (result?.type === 'image' && imagesUrls) {
await ctx.replyWithChatAction('upload_photo');
return ctx.replyWithMediaGroup(imagesUrls.map((image) => ({ media: image, type: 'photo' })));
}
} catch (error) {
logger.error(error);
return ctx.reply(ctx.t('generic'));
}
const bot = createBot({
token: env.BOT_TOKEN,
apiRoot: env.TELEGRAM_API_ROOT,
});
// Stopping the bot when the Node.js process

View File

@ -3,8 +3,10 @@ import pino from 'pino';
export const logger = pino({
transport: {
target: 'pino-pretty',
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
options: {
colorize: true,
translateTime: true,
},
},
});