From 19291511030769f6210abe82bad81e842177ac5d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 3 Mar 2026 02:31:49 +0000 Subject: [PATCH] refactor(telegram): extract sequential key module --- src/telegram/bot.create-telegram-bot.test.ts | 90 ------------------- src/telegram/bot.ts | 59 +------------ src/telegram/sequential-key.test.ts | 92 ++++++++++++++++++++ src/telegram/sequential-key.ts | 54 ++++++++++++ 4 files changed, 149 insertions(+), 146 deletions(-) create mode 100644 src/telegram/sequential-key.test.ts create mode 100644 src/telegram/sequential-key.ts diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index 2b29bbe746a..378c1eb1065 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { Chat, Message } from "@grammyjs/types"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; import { withEnvAsync } from "../test-utils/env.js"; @@ -39,14 +38,6 @@ const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); const upsertChannelPairingRequest = getUpsertChannelPairingRequestMock(); const ORIGINAL_TZ = process.env.TZ; -const mockChat = (chat: Pick & Partial>): Chat => - chat as Chat; -const mockMessage = (message: Pick & Partial): Message => - ({ - message_id: 1, - date: 0, - ...message, - }) as Message; const TELEGRAM_TEST_TIMINGS = { mediaGroupFlushMs: 20, textFragmentGapMs: 30, @@ -124,87 +115,6 @@ describe("createTelegramBot", () => { expect(sequentializeSpy).toHaveBeenCalledTimes(1); expect(middlewareUseSpy).toHaveBeenCalledWith(sequentializeSpy.mock.results[0]?.value); expect(sequentializeKey).toBe(getTelegramSequentialKey); - const cases = [ - [{ message: mockMessage({ chat: mockChat({ id: 123 }) }) }, "telegram:123"], - [ - { - message: mockMessage({ - chat: mockChat({ id: 123, type: "private" }), - message_thread_id: 9, - }), - }, - "telegram:123:topic:9", - ], - [ - { - message: mockMessage({ - chat: mockChat({ id: 123, type: "supergroup" }), - message_thread_id: 9, - }), - }, - "telegram:123", - ], - [ - { - message: mockMessage({ - chat: mockChat({ id: 123, type: "supergroup", is_forum: true }), - }), - }, - "telegram:123:topic:1", - ], - [{ update: { message: mockMessage({ chat: mockChat({ id: 555 }) }) } }, "telegram:555"], - [ - { - channelPost: mockMessage({ chat: mockChat({ id: -100777111222, type: "channel" }) }), - }, - "telegram:-100777111222", - ], - [ - { - update: { - channel_post: mockMessage({ chat: mockChat({ id: -100777111223, type: "channel" }) }), - }, - }, - "telegram:-100777111223", - ], - [ - { message: mockMessage({ chat: mockChat({ id: 123 }), text: "/stop" }) }, - "telegram:123:control", - ], - [{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/status" }) }, "telegram:123"], - [ - { message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop" }) }, - "telegram:123:control", - ], - [ - { message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop please" }) }, - "telegram:123:control", - ], - [ - { message: mockMessage({ chat: mockChat({ id: 123 }), text: "do not do that" }) }, - "telegram:123:control", - ], - [ - { message: mockMessage({ chat: mockChat({ id: 123 }), text: "остановись" }) }, - "telegram:123:control", - ], - [ - { message: mockMessage({ chat: mockChat({ id: 123 }), text: "halt" }) }, - "telegram:123:control", - ], - [{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort" }) }, "telegram:123"], - [ - { message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort now" }) }, - "telegram:123", - ], - [ - { message: mockMessage({ chat: mockChat({ id: 123 }), text: "please do not do that" }) }, - "telegram:123", - ], - ] as const; - for (const [input, expected] of cases) { - expect(getTelegramSequentialKey(input)).toBe(expected); - } }); it("routes callback_query payloads as messages and answers callbacks", async () => { createTelegramBot({ token: "tok" }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 1c06da199c5..29540b21cf9 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -1,11 +1,9 @@ import { sequentialize } from "@grammyjs/runner"; import { apiThrottler } from "@grammyjs/transformer-throttler"; -import { type Message, type UserFromGetMe } from "@grammyjs/types"; import type { ApiClientOptions } from "grammy"; import { Bot, webhookCallback } from "grammy"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; -import { isAbortRequestText } from "../auto-reply/reply/abort.js"; import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js"; import { isNativeCommandsExplicitlyDisabled, @@ -34,13 +32,10 @@ import { resolveTelegramUpdateId, type TelegramUpdateKeyContext, } from "./bot-updates.js"; -import { - buildTelegramGroupPeerId, - resolveTelegramForumThreadId, - resolveTelegramStreamMode, -} from "./bot/helpers.js"; +import { buildTelegramGroupPeerId, resolveTelegramStreamMode } from "./bot/helpers.js"; import { resolveTelegramFetch } from "./fetch.js"; import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js"; +import { getTelegramSequentialKey } from "./sequential-key.js"; export type TelegramBotOptions = { token: string; @@ -63,55 +58,7 @@ export type TelegramBotOptions = { }; }; -export function getTelegramSequentialKey(ctx: { - chat?: { id?: number }; - me?: UserFromGetMe; - message?: Message; - channelPost?: Message; - editedChannelPost?: Message; - update?: { - message?: Message; - edited_message?: Message; - channel_post?: Message; - edited_channel_post?: Message; - callback_query?: { message?: Message }; - message_reaction?: { chat?: { id?: number } }; - }; -}): string { - // Handle reaction updates - const reaction = ctx.update?.message_reaction; - if (reaction?.chat?.id) { - return `telegram:${reaction.chat.id}`; - } - const msg = - ctx.message ?? - ctx.channelPost ?? - ctx.editedChannelPost ?? - ctx.update?.message ?? - ctx.update?.edited_message ?? - ctx.update?.channel_post ?? - ctx.update?.edited_channel_post ?? - ctx.update?.callback_query?.message; - const chatId = msg?.chat?.id ?? ctx.chat?.id; - const rawText = msg?.text ?? msg?.caption; - const botUsername = ctx.me?.username; - if (isAbortRequestText(rawText, botUsername ? { botUsername } : undefined)) { - if (typeof chatId === "number") { - return `telegram:${chatId}:control`; - } - return "telegram:control"; - } - const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup"; - const messageThreadId = msg?.message_thread_id; - const isForum = msg?.chat?.is_forum; - const threadId = isGroup - ? resolveTelegramForumThreadId({ isForum, messageThreadId }) - : messageThreadId; - if (typeof chatId === "number") { - return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`; - } - return "telegram:unknown"; -} +export { getTelegramSequentialKey }; export function createTelegramBot(opts: TelegramBotOptions) { const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); diff --git a/src/telegram/sequential-key.test.ts b/src/telegram/sequential-key.test.ts new file mode 100644 index 00000000000..7dc09af2596 --- /dev/null +++ b/src/telegram/sequential-key.test.ts @@ -0,0 +1,92 @@ +import type { Chat, Message } from "@grammyjs/types"; +import { describe, expect, it } from "vitest"; +import { getTelegramSequentialKey } from "./sequential-key.js"; + +const mockChat = (chat: Pick & Partial>): Chat => + chat as Chat; +const mockMessage = (message: Pick & Partial): Message => + ({ + message_id: 1, + date: 0, + ...message, + }) as Message; + +describe("getTelegramSequentialKey", () => { + it.each([ + [{ message: mockMessage({ chat: mockChat({ id: 123 }) }) }, "telegram:123"], + [ + { + message: mockMessage({ + chat: mockChat({ id: 123, type: "private" }), + message_thread_id: 9, + }), + }, + "telegram:123:topic:9", + ], + [ + { + message: mockMessage({ + chat: mockChat({ id: 123, type: "supergroup" }), + message_thread_id: 9, + }), + }, + "telegram:123", + ], + [ + { + message: mockMessage({ + chat: mockChat({ id: 123, type: "supergroup", is_forum: true }), + }), + }, + "telegram:123:topic:1", + ], + [{ update: { message: mockMessage({ chat: mockChat({ id: 555 }) }) } }, "telegram:555"], + [ + { + channelPost: mockMessage({ chat: mockChat({ id: -100777111222, type: "channel" }) }), + }, + "telegram:-100777111222", + ], + [ + { + update: { + channel_post: mockMessage({ chat: mockChat({ id: -100777111223, type: "channel" }) }), + }, + }, + "telegram:-100777111223", + ], + [ + { message: mockMessage({ chat: mockChat({ id: 123 }), text: "/stop" }) }, + "telegram:123:control", + ], + [{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/status" }) }, "telegram:123"], + [ + { message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop" }) }, + "telegram:123:control", + ], + [ + { message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop please" }) }, + "telegram:123:control", + ], + [ + { message: mockMessage({ chat: mockChat({ id: 123 }), text: "do not do that" }) }, + "telegram:123:control", + ], + [ + { message: mockMessage({ chat: mockChat({ id: 123 }), text: "остановись" }) }, + "telegram:123:control", + ], + [ + { message: mockMessage({ chat: mockChat({ id: 123 }), text: "halt" }) }, + "telegram:123:control", + ], + [{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort" }) }, "telegram:123"], + [{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort now" }) }, "telegram:123"], + [ + { message: mockMessage({ chat: mockChat({ id: 123 }), text: "please do not do that" }) }, + "telegram:123", + ], + ])("resolves key %#", (input, expected) => { + expect(getTelegramSequentialKey(input)).toBe(expected); + }); +}); diff --git a/src/telegram/sequential-key.ts b/src/telegram/sequential-key.ts new file mode 100644 index 00000000000..3e787055e0d --- /dev/null +++ b/src/telegram/sequential-key.ts @@ -0,0 +1,54 @@ +import { type Message, type UserFromGetMe } from "@grammyjs/types"; +import { isAbortRequestText } from "../auto-reply/reply/abort.js"; +import { resolveTelegramForumThreadId } from "./bot/helpers.js"; + +export type TelegramSequentialKeyContext = { + chat?: { id?: number }; + me?: UserFromGetMe; + message?: Message; + channelPost?: Message; + editedChannelPost?: Message; + update?: { + message?: Message; + edited_message?: Message; + channel_post?: Message; + edited_channel_post?: Message; + callback_query?: { message?: Message }; + message_reaction?: { chat?: { id?: number } }; + }; +}; + +export function getTelegramSequentialKey(ctx: TelegramSequentialKeyContext): string { + const reaction = ctx.update?.message_reaction; + if (reaction?.chat?.id) { + return `telegram:${reaction.chat.id}`; + } + const msg = + ctx.message ?? + ctx.channelPost ?? + ctx.editedChannelPost ?? + ctx.update?.message ?? + ctx.update?.edited_message ?? + ctx.update?.channel_post ?? + ctx.update?.edited_channel_post ?? + ctx.update?.callback_query?.message; + const chatId = msg?.chat?.id ?? ctx.chat?.id; + const rawText = msg?.text ?? msg?.caption; + const botUsername = ctx.me?.username; + if (isAbortRequestText(rawText, botUsername ? { botUsername } : undefined)) { + if (typeof chatId === "number") { + return `telegram:${chatId}:control`; + } + return "telegram:control"; + } + const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup"; + const messageThreadId = msg?.message_thread_id; + const isForum = msg?.chat?.is_forum; + const threadId = isGroup + ? resolveTelegramForumThreadId({ isForum, messageThreadId }) + : messageThreadId; + if (typeof chatId === "number") { + return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`; + } + return "telegram:unknown"; +}