From da1f8b7d977668a6e2b3eafc82498648bd12b5db Mon Sep 17 00:00:00 2001 From: Cole Medin Date: Fri, 10 Apr 2026 17:38:43 -0500 Subject: [PATCH] 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) --- bun.lock | 26 ++------ packages/adapters/package.json | 2 +- .../src/chat/telegram/adapter.test.ts | 62 +++++++++++-------- .../adapters/src/chat/telegram/adapter.ts | 48 ++++++++------ 4 files changed, 72 insertions(+), 66 deletions(-) diff --git a/bun.lock b/bun.lock index 356a76ed..cf5b5efd 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/adapters/package.json b/packages/adapters/package.json index 607770f2..0e2fb23d 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -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": { diff --git a/packages/adapters/src/chat/telegram/adapter.test.ts b/packages/adapters/src/chat/telegram/adapter.test.ts index 58588780..bb96cdb0 100644 --- a/packages/adapters/src/chat/telegram/adapter.test.ts +++ b/packages/adapters/src/chat/telegram/adapter.test.ts @@ -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> } - ).sendMessage = mockSendMessage; + (adapter.getBot().api as unknown as { sendMessage: Mock<() => Promise> }).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>() + // 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 + >() .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>().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 + >().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>() + const mockStart = mock< + (opts?: { drop_pending_updates?: boolean; onStart?: () => void }) => Promise + >() .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>() + const mockStart = mock< + (opts?: { drop_pending_updates?: boolean; onStart?: () => void }) => Promise + >() .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); }); }); }); diff --git a/packages/adapters/src/chat/telegram/adapter.ts b/packages/adapters/src/chat/telegram/adapter.ts index c8006120..d7d11bc3 100644 --- a/packages/adapters/src/chat/telegram/adapter.ts +++ b/packages/adapters/src/chat/telegram/adapter.ts @@ -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 { 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) | 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 { // 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((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) {