fix: replace Telegraf with grammY to fix Bun TypeError crash (#1042)

Telegraf v4's internal `redactToken()` assigns to readonly `error.message`
properties, which crashes under Bun's strict ESM mode. Telegraf is EOL.

Changes:
- Replace `telegraf` dependency with `grammy` ^1.36.0
- Migrate adapter from Telegraf API to grammY API (Bot, bot.api, bot.start)
- Use grammY's `onStart` callback pattern for async polling launch
- Preserve 409 retry logic and all existing behavior
- Update test mocks from telegraf types to grammy types

Fixes #1042

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cole Medin 2026-04-10 17:38:43 -05:00
parent 2732288f07
commit da1f8b7d97
4 changed files with 72 additions and 66 deletions

View file

@ -32,7 +32,7 @@
"@octokit/rest": "^22.0.0",
"@slack/bolt": "^4.6.0",
"discord.js": "^14.16.0",
"telegraf": "^4.16.0",
"grammy": "^1.36.0",
"telegramify-markdown": "^1.3.0",
},
"peerDependencies": {
@ -452,6 +452,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@grammyjs/types": ["@grammyjs/types@3.26.0", "", {}, "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@hono/zod-openapi": ["@hono/zod-openapi@0.19.10", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.0", "@hono/zod-validator": "^0.7.1", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.3.6", "zod": ">=3.0.0" } }, "sha512-dpoS6DenvoJyvxtQ7Kd633FRZ/Qf74+4+o9s+zZI8pEqnbjdF/DtxIib08WDpCaWabMEJOL5TXpMgNEZvb7hpA=="],
@ -874,8 +876,6 @@
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.22", "", {}, "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g=="],
"@telegraf/types": ["@telegraf/types@7.1.0", "", {}, "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw=="],
"@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@ -1066,14 +1066,8 @@
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"buffer-alloc": ["buffer-alloc@1.2.0", "", { "dependencies": { "buffer-alloc-unsafe": "^1.1.0", "buffer-fill": "^1.0.0" } }, "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow=="],
"buffer-alloc-unsafe": ["buffer-alloc-unsafe@1.1.0", "", {}, "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-fill": ["buffer-fill@1.0.0", "", {}, "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@ -1448,6 +1442,8 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"grammy": ["grammy@1.42.0", "", { "dependencies": { "@grammyjs/types": "3.26.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g=="],
"graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="],
"h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
@ -1856,8 +1852,6 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@ -1938,7 +1932,7 @@
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"p-timeout": ["p-timeout@4.1.0", "", {}, "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw=="],
"p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="],
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
@ -2162,14 +2156,10 @@
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-compare": ["safe-compare@1.1.4", "", { "dependencies": { "buffer-alloc": "^1.2.0" } }, "sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sandwich-stream": ["sandwich-stream@2.0.2", "", {}, "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ=="],
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@ -2266,8 +2256,6 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"telegraf": ["telegraf@4.16.3", "", { "dependencies": { "@telegraf/types": "^7.1.0", "abort-controller": "^3.0.0", "debug": "^4.3.4", "mri": "^1.2.0", "node-fetch": "^2.7.0", "p-timeout": "^4.1.0", "safe-compare": "^1.1.4", "sandwich-stream": "^2.0.2" }, "bin": { "telegraf": "lib/cli.mjs" } }, "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w=="],
"telegramify-markdown": ["telegramify-markdown@1.3.2", "", { "dependencies": { "mdast-util-gfm-table": "^0.1.6", "mdast-util-to-markdown": "^0.6.2", "remark-gfm": "^1.0.0", "remark-parse": "^9.0.0", "remark-remove-comments": "^0.2.0", "remark-stringify": "^9.0.1", "unified": "^9.0.0", "unist-util-remove": "^2.0.1", "unist-util-visit": "^2.0.3" } }, "sha512-otv/SSjJD4MQGBYcRqkSchs84nYBYQoE2BqplQTIoIMN4nT0tDZgxbU5yjdBLkNxaQfkzYja27Hl/hcVJwewcg=="],
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
@ -2634,8 +2622,6 @@
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-queue/p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="],
"parse-entities/character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="],
"parse-entities/is-alphanumerical": ["is-alphanumerical@1.0.4", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="],

View file

@ -22,7 +22,7 @@
"@octokit/rest": "^22.0.0",
"@slack/bolt": "^4.6.0",
"discord.js": "^14.16.0",
"telegraf": "^4.16.0",
"grammy": "^1.36.0",
"telegramify-markdown": "^1.3.0"
},
"peerDependencies": {

View file

@ -52,7 +52,7 @@ describe('TelegramAdapter', () => {
const adapter = new TelegramAdapter('fake-token-for-testing');
const bot = adapter.getBot();
expect(bot).toBeDefined();
expect(bot.telegram).toBeDefined();
expect(bot.api).toBeDefined();
});
});
@ -64,9 +64,8 @@ describe('TelegramAdapter', () => {
adapter = new TelegramAdapter('fake-token-for-testing');
mockSendMessage = mock(() => Promise.resolve());
// Override bot's sendMessage
(
adapter.getBot().telegram as unknown as { sendMessage: Mock<() => Promise<void>> }
).sendMessage = mockSendMessage;
(adapter.getBot().api as unknown as { sendMessage: Mock<() => Promise<void>> }).sendMessage =
mockSendMessage;
});
test('should send with MarkdownV2 parse_mode', async () => {
@ -172,7 +171,7 @@ describe('TelegramAdapter', () => {
const adapter = new TelegramAdapter('fake-token-for-testing');
const ctx = {
chat: { id: 12345 },
} as unknown as import('telegraf').Context;
} as unknown as import('grammy').Context;
expect(adapter.getConversationId(ctx)).toBe('12345');
});
@ -181,7 +180,7 @@ describe('TelegramAdapter', () => {
const adapter = new TelegramAdapter('fake-token-for-testing');
const ctx = {
chat: { id: -987654321 },
} as unknown as import('telegraf').Context;
} as unknown as import('grammy').Context;
expect(adapter.getConversationId(ctx)).toBe('-987654321');
});
@ -190,7 +189,7 @@ describe('TelegramAdapter', () => {
const adapter = new TelegramAdapter('fake-token-for-testing');
const ctx = {
chat: { id: -1001234567890 },
} as unknown as import('telegraf').Context;
} as unknown as import('grammy').Context;
expect(adapter.getConversationId(ctx)).toBe('-1001234567890');
});
@ -199,7 +198,7 @@ describe('TelegramAdapter', () => {
const adapter = new TelegramAdapter('fake-token-for-testing');
const ctx = {
chat: undefined,
} as unknown as import('telegraf').Context;
} as unknown as import('grammy').Context;
expect(() => adapter.getConversationId(ctx)).toThrow('No chat in context');
});
@ -208,7 +207,7 @@ describe('TelegramAdapter', () => {
const adapter = new TelegramAdapter('fake-token-for-testing');
const ctx = {
chat: null,
} as unknown as import('telegraf').Context;
} as unknown as import('grammy').Context;
expect(() => adapter.getConversationId(ctx)).toThrow('No chat in context');
});
@ -243,14 +242,20 @@ describe('TelegramAdapter', () => {
test('should retry on 409 and succeed on second attempt', async () => {
const adapter = new TelegramAdapter('fake-token-for-testing');
const mockLaunch = mock<() => Promise<void>>()
// grammY's start() resolves when bot stops, not when started — onStart fires on startup
const mockStart = mock<
(opts?: { drop_pending_updates?: boolean; onStart?: () => void }) => Promise<void>
>()
.mockRejectedValueOnce(new Error('409: Conflict: terminated by other getUpdates request'))
.mockResolvedValueOnce(undefined);
(adapter.getBot() as unknown as { launch: typeof mockLaunch }).launch = mockLaunch;
.mockImplementationOnce(opts => {
opts?.onStart?.();
return new Promise(() => {});
});
(adapter.getBot() as unknown as { start: typeof mockStart }).start = mockStart;
await adapter.start({ retryDelayMs: 0 });
expect(mockLaunch).toHaveBeenCalledTimes(2);
expect(mockStart).toHaveBeenCalledTimes(2);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({ attempt: 1, maxAttempts: 3 }),
'telegram.start_conflict_retrying'
@ -260,41 +265,48 @@ describe('TelegramAdapter', () => {
test('should throw immediately on non-409 error', async () => {
const adapter = new TelegramAdapter('fake-token-for-testing');
const mockLaunch = mock<() => Promise<void>>().mockRejectedValueOnce(
new Error('401: Unauthorized')
);
(adapter.getBot() as unknown as { launch: typeof mockLaunch }).launch = mockLaunch;
const mockStart = mock<
(opts?: { drop_pending_updates?: boolean; onStart?: () => void }) => Promise<void>
>().mockRejectedValueOnce(new Error('401: Unauthorized'));
(adapter.getBot() as unknown as { start: typeof mockStart }).start = mockStart;
await expect(adapter.start({ retryDelayMs: 0 })).rejects.toThrow('401: Unauthorized');
expect(mockLaunch).toHaveBeenCalledTimes(1);
expect(mockStart).toHaveBeenCalledTimes(1);
});
test('should retry twice on 409 and succeed on third attempt', async () => {
const adapter = new TelegramAdapter('fake-token-for-testing');
const conflictError = new Error('409: Conflict: terminated by other getUpdates request');
const mockLaunch = mock<() => Promise<void>>()
const mockStart = mock<
(opts?: { drop_pending_updates?: boolean; onStart?: () => void }) => Promise<void>
>()
.mockRejectedValueOnce(conflictError)
.mockRejectedValueOnce(conflictError)
.mockResolvedValueOnce(undefined);
(adapter.getBot() as unknown as { launch: typeof mockLaunch }).launch = mockLaunch;
.mockImplementationOnce(opts => {
opts?.onStart?.();
return new Promise(() => {});
});
(adapter.getBot() as unknown as { start: typeof mockStart }).start = mockStart;
await adapter.start({ retryDelayMs: 0 });
expect(mockLaunch).toHaveBeenCalledTimes(3);
expect(mockStart).toHaveBeenCalledTimes(3);
expect(mockLogger.warn).toHaveBeenCalledTimes(2);
});
test('should throw after exhausting all 409 retry attempts', async () => {
const adapter = new TelegramAdapter('fake-token-for-testing');
const conflictError = new Error('409: Conflict: terminated by other getUpdates request');
const mockLaunch = mock<() => Promise<void>>()
const mockStart = mock<
(opts?: { drop_pending_updates?: boolean; onStart?: () => void }) => Promise<void>
>()
.mockRejectedValueOnce(conflictError)
.mockRejectedValueOnce(conflictError)
.mockRejectedValueOnce(conflictError);
(adapter.getBot() as unknown as { launch: typeof mockLaunch }).launch = mockLaunch;
(adapter.getBot() as unknown as { start: typeof mockStart }).start = mockStart;
await expect(adapter.start({ retryDelayMs: 0 })).rejects.toThrow('409');
expect(mockLaunch).toHaveBeenCalledTimes(3);
expect(mockStart).toHaveBeenCalledTimes(3);
});
});
});

View file

@ -1,8 +1,8 @@
/**
* Telegram platform adapter using Telegraf SDK
* Telegram platform adapter using grammY SDK
* Handles message sending with 4096 character limit splitting
*/
import { Telegraf, Context } from 'telegraf';
import { Bot, Context } from 'grammy';
import type { IPlatformAdapter, MessageMetadata } from '@archon/core';
import { createLogger } from '@archon/paths';
import { parseAllowedUserIds, isUserAuthorized } from './auth';
@ -20,17 +20,14 @@ function getLog(): ReturnType<typeof createLogger> {
const MAX_LENGTH = 4096;
export class TelegramAdapter implements IPlatformAdapter {
private bot: Telegraf;
private bot: Bot;
private streamingMode: 'stream' | 'batch';
private allowedUserIds: number[];
private messageHandler: ((ctx: TelegramMessageContext) => Promise<void>) | null = null;
constructor(token: string, mode: 'stream' | 'batch' = 'stream') {
// Disable handler timeout to support long-running AI operations
// Default is 90 seconds which is too short for complex coding tasks
this.bot = new Telegraf(token, {
handlerTimeout: Infinity,
});
// grammY does not impose a handler timeout by default (unlike Telegraf's 90s limit)
this.bot = new Bot(token);
this.streamingMode = mode;
// Parse Telegram user whitelist (optional - empty = open access)
@ -87,20 +84,20 @@ export class TelegramAdapter implements IPlatformAdapter {
let subChunk = '';
for (const line of lines) {
if (subChunk.length + line.length + 1 > MAX_LENGTH - 100) {
if (subChunk) await this.bot.telegram.sendMessage(id, subChunk);
if (subChunk) await this.bot.api.sendMessage(id, subChunk);
subChunk = line;
} else {
subChunk += (subChunk ? '\n' : '') + line;
}
}
if (subChunk) await this.bot.telegram.sendMessage(id, subChunk);
if (subChunk) await this.bot.api.sendMessage(id, subChunk);
return;
}
// Try MarkdownV2 formatting
const formatted = convertToTelegramMarkdown(chunk);
try {
await this.bot.telegram.sendMessage(id, formatted, { parse_mode: 'MarkdownV2' });
await this.bot.api.sendMessage(id, formatted, { parse_mode: 'MarkdownV2' });
getLog().debug({ chunkLength: chunk.length }, 'telegram.markdownv2_chunk_sent');
} catch (error) {
// Fallback to stripped plain text for this chunk
@ -113,14 +110,14 @@ export class TelegramAdapter implements IPlatformAdapter {
},
'telegram.markdownv2_failed'
);
await this.bot.telegram.sendMessage(id, stripMarkdown(chunk));
await this.bot.api.sendMessage(id, stripMarkdown(chunk));
}
}
/**
* Get the Telegraf bot instance
* Get the grammY bot instance
*/
getBot(): Telegraf {
getBot(): Bot {
return this.bot;
}
@ -171,14 +168,12 @@ export class TelegramAdapter implements IPlatformAdapter {
*/
async start(options?: { retryDelayMs?: number }): Promise<void> {
// Register message handler before launch
this.bot.on('message', ctx => {
if (!('text' in ctx.message)) return;
this.bot.on('message:text', ctx => {
const message = ctx.message.text;
if (!message) return;
// Authorization check - verify sender is in whitelist
const userId = ctx.from.id;
const userId = ctx.from?.id;
if (!isUserAuthorized(userId, this.allowedUserIds)) {
// Log unauthorized attempt (mask user ID for privacy)
const maskedId = `${String(userId).slice(0, 4)}***`;
@ -200,9 +195,22 @@ export class TelegramAdapter implements IPlatformAdapter {
const RETRY_DELAY_MS = options?.retryDelayMs ?? 60_000;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
// dropPendingUpdates: true — discard queued messages from while the bot was offline
// drop_pending_updates: true — discard queued messages from while the bot was offline
// to avoid reprocessing stale commands after a container restart.
await this.bot.launch({ dropPendingUpdates: true });
// grammY's start() resolves only when the bot stops; use onStart callback to detect
// successful launch and return immediately while the bot continues running in background.
await new Promise<void>((resolve, reject) => {
this.bot
.start({
drop_pending_updates: true,
onStart: () => {
resolve();
},
})
.catch((err: unknown) => {
reject(err instanceof Error ? err : new Error(String(err)));
});
});
getLog().info('telegram.bot_started');
return;
} catch (err) {