From d2825c70a5168c19e98ec75babf27476a3c15669 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 11 Aug 2026 23:48:16 +0530 Subject: [PATCH] refactor(telegram): consolidate bot handlers into named modules (#122174) The bot-handlers family was 22 files behind a 22-line fan-out: six files holding one callback switch, four holding one inbound pipeline, every interface a derived ReturnType, processMessage taking 8 positional args, and its params type declared inside bot-native-commands.ts. Three named coordinators (inbound pipeline, callback router, event bindings) now sit behind the same registerTelegramHandlers entry with hand-written leaf contracts; the factory graph, 16 slice/barrel files, and the type shim are deleted. Behavior-neutral: bot.test.ts and the ingress e2e byte-identical to main (SHA-256-pinned) and green; +185 production LOC accepted as the written-contract tradeoff. Co-authored-by: Ayaan Zaidi --- extensions/telegram/src/bot-core.ts | 21 +- ...t-handlers.authorization-groups.runtime.ts | 118 ----- ...me.ts => bot-handlers.callback-actions.ts} | 64 ++- ...bot-handlers.callback-approvals.runtime.ts | 261 ----------- .../bot-handlers.callback-errors.runtime.ts | 33 -- .../bot-handlers.callback-model.runtime.ts | 355 -------------- ...andlers.callback-questions.runtime.test.ts | 2 +- ...bot-handlers.callback-questions.runtime.ts | 36 -- ... bot-handlers.callback-router-controls.ts} | 303 +++++++++++- ...ime.ts => bot-handlers.callback-router.ts} | 366 ++++++++++++++- .../src/bot-handlers.event-bindings.ts | 437 ++++++++++++++++++ ... => bot-handlers.inbound-authorization.ts} | 197 +++++++- ...time.ts => bot-handlers.inbound-buffer.ts} | 220 ++++++++- ...ntime.ts => bot-handlers.inbound-media.ts} | 25 +- .../src/bot-handlers.inbound-pipeline.test.ts | 57 +++ ...me.ts => bot-handlers.inbound-pipeline.ts} | 133 ++++-- ....ts => bot-handlers.inbound-processing.ts} | 138 +++--- .../src/bot-handlers.inbound-text.runtime.ts | 217 --------- ...t-handlers.message-context.runtime.test.ts | 4 +- ...ime.ts => bot-handlers.message-context.ts} | 250 +++++++++- .../bot-handlers.message-lifecycle.runtime.ts | 200 -------- .../bot-handlers.message-lifecycle.test.ts | 18 +- ...me.ts => bot-handlers.message-pipeline.ts} | 230 +++++++-- ...t-handlers.message-session.runtime.test.ts | 2 +- .../bot-handlers.message-session.runtime.ts | 151 ------ .../src/bot-handlers.migration.runtime.ts | 73 --- .../src/bot-handlers.poll-answer.runtime.ts | 171 ------- .../src/bot-handlers.reaction.runtime.test.ts | 19 +- .../src/bot-handlers.reaction.runtime.ts | 216 --------- .../telegram/src/bot-handlers.runtime.test.ts | 92 ++-- .../telegram/src/bot-handlers.runtime.ts | 45 +- extensions/telegram/src/bot-handlers.types.ts | 124 +++++ ...bot-message-context.prompt-context.test.ts | 2 +- extensions/telegram/src/bot-message.test.ts | 5 +- extensions/telegram/src/bot-message.ts | 18 +- .../bot-native-commands.session-meta.test.ts | 2 +- .../telegram/src/bot-native-commands.ts | 65 +-- ....response-body-timeout.integration.test.ts | 112 +++-- .../telegram/src/interactive-fallback.test.ts | 2 +- .../telegram/src/transport-payload.test.ts | 2 +- 40 files changed, 2526 insertions(+), 2260 deletions(-) delete mode 100644 extensions/telegram/src/bot-handlers.authorization-groups.runtime.ts rename extensions/telegram/src/{bot-handlers.callback-actions.runtime.ts => bot-handlers.callback-actions.ts} (60%) delete mode 100644 extensions/telegram/src/bot-handlers.callback-approvals.runtime.ts delete mode 100644 extensions/telegram/src/bot-handlers.callback-errors.runtime.ts delete mode 100644 extensions/telegram/src/bot-handlers.callback-model.runtime.ts delete mode 100644 extensions/telegram/src/bot-handlers.callback-questions.runtime.ts rename extensions/telegram/src/{bot-handlers.callback-interactions.runtime.ts => bot-handlers.callback-router-controls.ts} (54%) rename extensions/telegram/src/{bot-handlers.callback.runtime.ts => bot-handlers.callback-router.ts} (50%) create mode 100644 extensions/telegram/src/bot-handlers.event-bindings.ts rename extensions/telegram/src/{bot-handlers.authorization.runtime.ts => bot-handlers.inbound-authorization.ts} (72%) rename extensions/telegram/src/{bot-handlers.inbound-debounce.runtime.ts => bot-handlers.inbound-buffer.ts} (51%) rename extensions/telegram/src/{bot-handlers.inbound-media-group.runtime.ts => bot-handlers.inbound-media.ts} (96%) create mode 100644 extensions/telegram/src/bot-handlers.inbound-pipeline.test.ts rename extensions/telegram/src/{bot-handlers.message-events.runtime.ts => bot-handlers.inbound-pipeline.ts} (75%) rename extensions/telegram/src/{bot-handlers.inbound.runtime.ts => bot-handlers.inbound-processing.ts} (80%) delete mode 100644 extensions/telegram/src/bot-handlers.inbound-text.runtime.ts rename extensions/telegram/src/{bot-handlers.message-context.runtime.ts => bot-handlers.message-context.ts} (58%) delete mode 100644 extensions/telegram/src/bot-handlers.message-lifecycle.runtime.ts rename extensions/telegram/src/{bot-handlers.message.runtime.ts => bot-handlers.message-pipeline.ts} (69%) delete mode 100644 extensions/telegram/src/bot-handlers.message-session.runtime.ts delete mode 100644 extensions/telegram/src/bot-handlers.migration.runtime.ts delete mode 100644 extensions/telegram/src/bot-handlers.poll-answer.runtime.ts delete mode 100644 extensions/telegram/src/bot-handlers.reaction.runtime.ts create mode 100644 extensions/telegram/src/bot-handlers.types.ts diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index 2bb0333b7f43..5b5e9866bedd 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -431,7 +431,26 @@ export function createTelegramBotCore( resolveGroupRequireMention, resolveTelegramGroupConfig, shouldSkipUpdate, - processMessage, + processMessage: async ({ + ctx, + allMedia, + storeAllowFrom, + turnContext, + options, + replyMedia, + replyChain, + promptContext, + }) => + await processMessage( + ctx, + allMedia, + storeAllowFrom, + turnContext, + options, + replyMedia, + replyChain, + promptContext, + ), logger, telegramDeps, nativeCommandCallbackDispatcher, diff --git a/extensions/telegram/src/bot-handlers.authorization-groups.runtime.ts b/extensions/telegram/src/bot-handlers.authorization-groups.runtime.ts deleted file mode 100644 index 17bd01473636..000000000000 --- a/extensions/telegram/src/bot-handlers.authorization-groups.runtime.ts +++ /dev/null @@ -1,118 +0,0 @@ -// Telegram group policy checks shared by message-like and callback events. -import type { - OpenClawConfig, - TelegramAccountConfig, - TelegramGroupConfig, - TelegramTopicConfig, -} from "openclaw/plugin-sdk/config-contracts"; -import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import type { NormalizedAllowFrom } from "./bot-access.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; -import { - evaluateTelegramGroupBaseAccess, - evaluateTelegramGroupPolicyAccess, -} from "./group-access.js"; - -export function shouldSkipTelegramGroupMessage( - params: { - isGroup: boolean; - chatId: string | number; - chatTitle?: string; - resolvedThreadId?: number; - senderId: string; - senderUsername: string; - effectiveGroupAllow: NormalizedAllowFrom; - hasGroupAllowOverride: boolean; - groupConfig?: TelegramGroupConfig; - topicConfig?: TelegramTopicConfig; - cfg: OpenClawConfig; - telegramCfg: TelegramAccountConfig; - }, - runtime: Pick, -): boolean { - const { - isGroup, - chatId, - chatTitle, - resolvedThreadId, - senderId, - senderUsername, - effectiveGroupAllow, - hasGroupAllowOverride, - groupConfig, - topicConfig, - cfg, - telegramCfg, - } = params; - const baseAccess = evaluateTelegramGroupBaseAccess({ - isGroup, - groupConfig, - topicConfig, - hasGroupAllowOverride, - effectiveGroupAllow, - senderId, - senderUsername, - enforceAllowOverride: true, - requireSenderForAllowOverride: true, - }); - if (!baseAccess.allowed) { - if (baseAccess.reason === "group-disabled") { - logVerbose(`Blocked telegram group ${chatId} (group disabled)`); - return true; - } - if (baseAccess.reason === "topic-disabled") { - logVerbose( - `Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, - ); - return true; - } - logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`); - return true; - } - if (!isGroup) { - return false; - } - const policyAccess = evaluateTelegramGroupPolicyAccess({ - isGroup, - chatId, - cfg, - telegramCfg, - topicConfig, - groupConfig, - effectiveGroupAllow, - senderId, - senderUsername, - resolveGroupPolicy: runtime.resolveGroupPolicy, - enforcePolicy: true, - enforceAllowlistAuthorization: true, - allowEmptyAllowlistEntries: false, - requireSenderForAllowlistAuthorization: true, - checkChatAllowlist: true, - }); - if (policyAccess.allowed) { - return false; - } - if (policyAccess.reason === "group-policy-disabled") { - logVerbose("Blocked telegram group message (groupPolicy: disabled)"); - return true; - } - if (policyAccess.reason === "group-policy-allowlist-no-sender") { - logVerbose("Blocked telegram group message (no sender ID, groupPolicy: allowlist)"); - return true; - } - if (policyAccess.reason === "group-policy-allowlist-empty") { - logVerbose( - "Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)", - ); - return true; - } - if (policyAccess.reason === "group-policy-allowlist-unauthorized") { - logVerbose(`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`); - return true; - } - runtime.logger.info( - { chatId, title: chatTitle, reason: "not-allowed" }, - "skipping group message", - ); - return true; -} diff --git a/extensions/telegram/src/bot-handlers.callback-actions.runtime.ts b/extensions/telegram/src/bot-handlers.callback-actions.ts similarity index 60% rename from extensions/telegram/src/bot-handlers.callback-actions.runtime.ts rename to extensions/telegram/src/bot-handlers.callback-actions.ts index c73002dc9e36..35a55f9481bd 100644 --- a/extensions/telegram/src/bot-handlers.callback-actions.runtime.ts +++ b/extensions/telegram/src/bot-handlers.callback-actions.ts @@ -1,6 +1,8 @@ import type { Message } from "grammy/types"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; +import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; import { buildTelegramThreadParams, resolveTelegramMessageThreadSpec } from "./bot/helpers.js"; +import type { TelegramQuestionCallback } from "./question-callback-data.js"; import { buildInlineKeyboard } from "./send.js"; export type TelegramCallbackButton = { @@ -25,6 +27,11 @@ export interface TelegramCallbackMessageActions { editCallbackButtons: ( buttons: TelegramCallbackButton[][], ) => ReturnType; + editCallbackMessageWithButtons: ( + text: string, + buttons: TelegramCallbackButton[][], + extra?: { parse_mode?: "HTML" | "Markdown" | "MarkdownV2" }, + ) => Promise; deleteCallbackMessage: () => ReturnType< RegisterTelegramHandlerParams["bot"]["api"]["deleteMessage"] >; @@ -92,11 +99,66 @@ export function createTelegramCallbackMessageActions(params: { return await bot.api.sendMessage(callbackMessage.chat.id, text, mergedParams); }; + const editCallbackMessageWithButtons = async ( + text: string, + buttons: TelegramCallbackButton[][], + extra?: { parse_mode?: "HTML" | "Markdown" | "MarkdownV2" }, + ) => { + const keyboard = buildInlineKeyboard(buttons); + const editParams = keyboard ? { reply_markup: keyboard, ...extra } : extra; + try { + await editCallbackMessage(text, editParams); + } catch (editErr) { + const errStr = String(editErr); + if (errStr.includes("no text in the message")) { + try { + await deleteCallbackMessage(); + } catch {} + await replyToCallbackChat(text, keyboard ? { reply_markup: keyboard, ...extra } : extra); + } else if (!errStr.includes("message is not modified")) { + throw editErr; + } + } + }; + return { editCallbackMessage, clearCallbackButtons, editCallbackButtons, + editCallbackMessageWithButtons, deleteCallbackMessage, replyToCallbackChat, }; } +type ResolveQuestionParams = Parameters[0]; +type QuestionResolver = ( + params: ResolveQuestionParams, +) => ReturnType; + +export async function handleTelegramQuestionCallback(params: { + callback: TelegramQuestionCallback; + cfg: ResolveQuestionParams["cfg"]; + senderId: string; + feedback: (text: string, terminal: boolean) => Promise; + resolveQuestion?: QuestionResolver; +}): Promise { + let result: Awaited>; + try { + result = await (params.resolveQuestion ?? questionGatewayRuntime.resolveOption)({ + cfg: params.cfg, + questionId: params.callback.questionId, + optionIndex: params.callback.optionIndex, + senderId: params.senderId, + clientDisplayName: "Telegram question", + }); + } catch (error) { + await params.feedback("Could not submit this answer.", false).catch(() => {}); + throw error; + } + await params + .feedback( + result.status === "answered" ? "Answer submitted." : "This question was already answered.", + true, + ) + .catch(() => {}); +} diff --git a/extensions/telegram/src/bot-handlers.callback-approvals.runtime.ts b/extensions/telegram/src/bot-handlers.callback-approvals.runtime.ts deleted file mode 100644 index cd3237ac9d79..000000000000 --- a/extensions/telegram/src/bot-handlers.callback-approvals.runtime.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { - resolveApprovalOverGateway, - type ApprovalResolveResult, -} from "openclaw/plugin-sdk/approval-gateway-runtime"; -import type { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime"; -import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import type { TelegramApprovalCallback } from "./approval-callback-data.js"; -import { - buildTelegramCanonicalApprovalTerminalText, - buildTelegramInvalidApprovalTerminalText, - buildTelegramLegacyApprovalTerminalText, -} from "./approval-terminal.js"; -import type { TelegramCallbackMessageActions } from "./bot-handlers.callback-actions.runtime.js"; -import { - isApprovalAlreadyResolvedError, - TelegramRetryableCallbackError, -} from "./bot-handlers.callback-errors.runtime.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; -import { - isTelegramExecApprovalApprover, - isTelegramExecApprovalAuthorizedSender, -} from "./exec-approvals.js"; - -type LegacyApprovalCallback = NonNullable>; - -export function createTelegramCallbackApprovalRuntime(params: { - accountId: RegisterTelegramHandlerParams["accountId"]; - telegramDeps: RegisterTelegramHandlerParams["telegramDeps"]; - runtimeCfg: OpenClawConfig; - senderId: string; - actions: TelegramCallbackMessageActions; -}) { - const { accountId, telegramDeps, runtimeCfg, senderId, actions } = params; - const { clearCallbackButtons, editCallbackMessage, replyToCallbackChat } = actions; - - const resolveApprovalAuthorizations = () => { - const pluginApprovalAuthorizedSender = isTelegramExecApprovalApprover({ - cfg: runtimeCfg, - accountId, - senderId, - }); - const execApprovalAuthorizedSender = isTelegramExecApprovalAuthorizedSender({ - cfg: runtimeCfg, - accountId, - senderId, - }); - return { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender }; - }; - - const clearTerminalApprovalButtons = async () => { - try { - // First-answer-wins returns applied:false to losing surfaces. Their controls - // are stale too, so cleanup follows canonical terminal truth, not local authorship. - await clearCallbackButtons(); - } catch (editErr) { - const errStr = String(editErr); - if ( - errStr.includes("message is not modified") || - errStr.includes("there is no text in the message to edit") - ) { - return; - } - logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`); - } - }; - - const terminalizeApprovalMessage = async (text: string) => { - try { - await editCallbackMessage(text, { reply_markup: { inline_keyboard: [] } }); - return; - } catch (editErr) { - const errStr = String(editErr); - const alreadyTerminal = errStr.includes("message is not modified"); - if (!alreadyTerminal) { - logVerbose(`telegram: failed to render terminal approval receipt: ${errStr}`); - } - // Preserve the terminal state even when Telegram no longer permits a text edit. - await clearTerminalApprovalButtons(); - if (alreadyTerminal) { - return; - } - } - try { - await replyToCallbackChat(text); - } catch (sendErr) { - logVerbose(`telegram: failed to send terminal approval receipt: ${String(sendErr)}`); - } - }; - - const resolveApproval = telegramDeps.resolveApproval ?? resolveApprovalOverGateway; - - const resolveCanonicalApproval = async ( - approvalCallback: TelegramApprovalCallback, - ): Promise => - (await resolveApproval({ - cfg: runtimeCfg, - approvalId: approvalCallback.approvalId, - approvalKind: approvalCallback.approvalKind, - decision: approvalCallback.decision, - channel: "telegram", - accountId, - senderId, - })) as ApprovalResolveResult; - - const terminalizeCanonicalApproval = async ( - approvalCallback: TelegramApprovalCallback, - result: Awaited>, - ) => - await terminalizeApprovalMessage( - buildTelegramCanonicalApprovalTerminalText({ - result, - fallbackApprovalId: approvalCallback.approvalId, - }), - ); - - const handleCanonical = async (approvalCallback: TelegramApprovalCallback): Promise => { - const { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender } = - resolveApprovalAuthorizations(); - const authorizedApprovalSender = - approvalCallback.approvalKind === "plugin" - ? pluginApprovalAuthorizedSender - : execApprovalAuthorizedSender || pluginApprovalAuthorizedSender; - if (!authorizedApprovalSender) { - logVerbose( - `Blocked telegram approval callback from ${senderId || "unknown"} (not authorized)`, - ); - return; - } - try { - const result = await resolveCanonicalApproval(approvalCallback); - if (!result.applied) { - logVerbose( - `telegram: approval callback already resolved ${approvalCallback.approvalId} ` + - `status=${result.approval.status}`, - ); - } - await terminalizeCanonicalApproval(approvalCallback, result); - } catch (resolveErr) { - logVerbose( - `telegram: failed to resolve approval callback ${approvalCallback.approvalId}: ${String(resolveErr)}`, - ); - if (isApprovalNotFoundError(resolveErr) || isApprovalAlreadyResolvedError(resolveErr)) { - await terminalizeApprovalMessage( - buildTelegramLegacyApprovalTerminalText({ - approvalId: approvalCallback.approvalId, - outcome: "no-longer-pending", - }), - ); - return; - } - throw new TelegramRetryableCallbackError(resolveErr); - } - }; - - const handleMalformedReserved = async (): Promise => { - const { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender } = - resolveApprovalAuthorizations(); - if (!execApprovalAuthorizedSender && !pluginApprovalAuthorizedSender) { - logVerbose( - `Blocked malformed telegram approval callback from ${senderId || "unknown"} (not authorized)`, - ); - return; - } - logVerbose(`telegram: consumed malformed reserved approval callback from ${senderId}`); - await terminalizeApprovalMessage(buildTelegramInvalidApprovalTerminalText()); - }; - - const handleLegacy = async (approvalCallback: LegacyApprovalCallback): Promise => { - const { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender } = - resolveApprovalAuthorizations(); - const approvalKinds: Array<"exec" | "plugin"> = []; - if (execApprovalAuthorizedSender || pluginApprovalAuthorizedSender) { - approvalKinds.push("exec"); - } - if (pluginApprovalAuthorizedSender) { - approvalKinds.push("plugin"); - } - if (approvalKinds.length === 0) { - logVerbose( - `Blocked telegram approval callback from ${senderId || "unknown"} (not authorized)`, - ); - return; - } - - for (const approvalKind of approvalKinds) { - const canonicalCallback: TelegramApprovalCallback = { - type: "approval", - approvalId: approvalCallback.approvalId, - approvalKind, - decision: approvalCallback.decision, - }; - try { - // Legacy callbacks lack an owner. Probe only adapters this sender may use. - await resolveApproval({ - cfg: runtimeCfg, - approvalId: approvalCallback.approvalId, - decision: approvalCallback.decision, - channel: "telegram", - accountId, - senderId, - resolveMethod: approvalKind, - }); - await terminalizeApprovalMessage( - buildTelegramLegacyApprovalTerminalText({ - approvalId: approvalCallback.approvalId, - decision: approvalCallback.decision, - outcome: "resolved-here", - }), - ); - return; - } catch (resolveErr) { - if (isApprovalNotFoundError(resolveErr)) { - continue; - } - if (isApprovalAlreadyResolvedError(resolveErr)) { - try { - const result = await resolveCanonicalApproval(canonicalCallback); - await terminalizeCanonicalApproval(canonicalCallback, result); - } catch (canonicalError) { - if ( - !isApprovalNotFoundError(canonicalError) && - !isApprovalAlreadyResolvedError(canonicalError) - ) { - throw new TelegramRetryableCallbackError(canonicalError); - } - logVerbose( - `telegram: canonical approval lookup failed after stale legacy callback ` + - `${approvalCallback.approvalId}: ${String(canonicalError)}`, - ); - await terminalizeApprovalMessage( - buildTelegramLegacyApprovalTerminalText({ - approvalId: approvalCallback.approvalId, - outcome: "no-longer-pending", - }), - ); - } - return; - } - logVerbose( - `telegram: failed to resolve approval callback ${approvalCallback.approvalId}: ${String(resolveErr)}`, - ); - throw new TelegramRetryableCallbackError(resolveErr); - } - } - - logVerbose(`telegram: approval callback not found ${approvalCallback.approvalId}`); - if (!pluginApprovalAuthorizedSender) { - return; - } - await terminalizeApprovalMessage( - buildTelegramLegacyApprovalTerminalText({ - approvalId: approvalCallback.approvalId, - outcome: "no-longer-pending", - }), - ); - }; - - return { handleCanonical, handleMalformedReserved, handleLegacy }; -} diff --git a/extensions/telegram/src/bot-handlers.callback-errors.runtime.ts b/extensions/telegram/src/bot-handlers.callback-errors.runtime.ts deleted file mode 100644 index 3e67d38396fe..000000000000 --- a/extensions/telegram/src/bot-handlers.callback-errors.runtime.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - isTelegramEditTargetMissingError, - isTelegramMessageHasNoTextError, -} from "./network-errors.js"; - -export class TelegramRetryableCallbackError extends Error { - public override readonly cause: unknown; - - constructor(cause: unknown) { - super(String(cause)); - this.cause = cause; - this.name = "TelegramRetryableCallbackError"; - } -} - -export const isPermanentTelegramCallbackEditError = (err: unknown): boolean => - isTelegramEditTargetMissingError(err) || isTelegramMessageHasNoTextError(err); - -export function isApprovalAlreadyResolvedError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - const record = error as { - gatewayCode?: unknown; - details?: { reason?: unknown } | null; - }; - const reason = record.details?.reason; - return ( - record.gatewayCode === "APPROVAL_ALREADY_RESOLVED" || - (record.gatewayCode === "INVALID_REQUEST" && reason === "APPROVAL_ALREADY_RESOLVED") || - /approval already resolved/i.test(error.message) - ); -} diff --git a/extensions/telegram/src/bot-handlers.callback-model.runtime.ts b/extensions/telegram/src/bot-handlers.callback-model.runtime.ts deleted file mode 100644 index 1449a0d23db9..000000000000 --- a/extensions/telegram/src/bot-handlers.callback-model.runtime.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/command-status"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { applySessionModelSelection } from "openclaw/plugin-sdk/model-session-runtime"; -import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/models-provider-runtime"; -import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; -import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; -import { - resolveAgentDir, - resolveDefaultAgentId, - resolveDefaultModelForAgent, -} from "./bot-handlers.agent.runtime.js"; -import type { TelegramCallbackMessageActions } from "./bot-handlers.callback-actions.runtime.js"; -import { TelegramRetryableCallbackError } from "./bot-handlers.callback-errors.runtime.js"; -import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; -import { resolveTelegramBotHasTopicsEnabled } from "./bot/helpers.js"; -import type { TelegramContext } from "./bot/types.js"; -import { buildCommandsPaginationKeyboard, buildTelegramModelsMenuButtons } from "./command-ui.js"; -import { - buildModelsKeyboard, - buildProviderKeyboard, - calculateTotalPages, - getModelsPageSize, - parseModelCallbackData, - resolveModelSelection, - type ProviderInfo, -} from "./model-buttons.js"; -import { buildInlineKeyboard } from "./send.js"; - -export async function handleTelegramModelCallback(params: { - data: string; - ctx: Pick; - chatId: number; - isGroup: boolean; - isForum: boolean; - messageThreadId?: number; - resolvedThreadId?: number; - senderId: string; - runtimeCfg: OpenClawConfig; - telegramDeps: RegisterTelegramHandlerParams["telegramDeps"]; - actions: TelegramCallbackMessageActions; - messageRuntime: TelegramHandlerMessageRuntime; - authorizeCallback: () => Promise; -}): Promise { - const { - data, - ctx, - chatId, - isGroup, - isForum, - messageThreadId, - resolvedThreadId, - senderId, - runtimeCfg, - telegramDeps, - actions, - messageRuntime, - authorizeCallback, - } = params; - const { editCallbackMessage, deleteCallbackMessage, replyToCallbackChat } = actions; - - const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/); - if (paginationMatch) { - const pageValue = paginationMatch[1]; - if (pageValue === "noop") { - return true; - } - const page = parseStrictPositiveInteger(pageValue); - if (page === undefined) { - return true; - } - const agentId = paginationMatch[2]?.trim() || resolveDefaultAgentId(runtimeCfg); - let result: ReturnType; - try { - const skillCommands = telegramDeps.listSkillCommandsForAgents({ - cfg: runtimeCfg, - agentIds: [agentId], - }); - result = buildCommandsMessagePaginated(runtimeCfg, skillCommands, { - page, - forcePaginatedList: true, - surface: "telegram", - }); - } catch (err) { - throw new TelegramRetryableCallbackError(err); - } - const keyboard = - result.totalPages > 1 - ? buildInlineKeyboard( - buildCommandsPaginationKeyboard(result.currentPage, result.totalPages, agentId), - ) - : undefined; - try { - await editCallbackMessage(result.text, keyboard ? { reply_markup: keyboard } : undefined); - } catch (editErr) { - if (!String(editErr).includes("message is not modified")) { - throw new TelegramRetryableCallbackError(editErr); - } - } - return true; - } - - const modelCallback = parseModelCallbackData(data); - if (!modelCallback) { - return false; - } - if (!(await authorizeCallback())) { - logVerbose( - `Blocked telegram model callback from ${senderId || "unknown"} (not authorized for /models)`, - ); - return true; - } - - let sessionState: ReturnType; - let modelData: Awaited>; - try { - sessionState = messageRuntime.resolveTelegramSessionState({ - chatId, - isGroup, - isForum, - messageThreadId, - resolvedThreadId, - botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(ctx.me), - senderId, - runtimeCfg, - }); - modelData = await telegramDeps.buildModelsProviderData(runtimeCfg, sessionState.agentId); - } catch (err) { - throw new TelegramRetryableCallbackError(err); - } - const { byProvider, providers, modelNames, resolvedDefault: activeResolvedDefault } = modelData; - - const editMessageWithButtons = async ( - text: string, - buttons: ReturnType, - extra?: { parse_mode?: "HTML" | "Markdown" | "MarkdownV2" }, - ) => { - const keyboard = buildInlineKeyboard(buttons); - const editParams = keyboard ? { reply_markup: keyboard, ...extra } : extra; - try { - await editCallbackMessage(text, editParams); - } catch (editErr) { - const errStr = String(editErr); - if (errStr.includes("no text in the message")) { - try { - await deleteCallbackMessage(); - } catch {} - await replyToCallbackChat(text, keyboard ? { reply_markup: keyboard, ...extra } : extra); - } else if (!errStr.includes("message is not modified")) { - throw editErr; - } - } - }; - - if (modelCallback.type === "providers" || modelCallback.type === "back") { - if (providers.length === 0) { - try { - await editMessageWithButtons("No providers available.", []); - } catch (err) { - throw new TelegramRetryableCallbackError(err); - } - return true; - } - const providerInfos: ProviderInfo[] = providers.map((provider) => ({ - id: provider, - count: byProvider.get(provider)?.size ?? 0, - })); - try { - await editMessageWithButtons( - "Select a provider:", - buildTelegramModelsMenuButtons({ providers: providerInfos }), - ); - } catch (err) { - throw new TelegramRetryableCallbackError(err); - } - return true; - } - - if (modelCallback.type === "list") { - const { provider, page } = modelCallback; - const modelSet = byProvider.get(provider); - if (!modelSet || modelSet.size === 0) { - const providerInfos: ProviderInfo[] = providers.map((providerId) => ({ - id: providerId, - count: byProvider.get(providerId)?.size ?? 0, - })); - try { - await editMessageWithButtons( - `Unknown provider: ${provider}\n\nSelect a provider:`, - buildTelegramModelsMenuButtons({ providers: providerInfos }), - ); - } catch (err) { - throw new TelegramRetryableCallbackError(err); - } - return true; - } - const models = [...modelSet].toSorted((left, right) => left.localeCompare(right)); - const pageSize = getModelsPageSize(); - const totalPages = calculateTotalPages(models.length, pageSize); - const safePage = Math.max(1, Math.min(page, totalPages)); - const currentModel = - sessionState.model || `${activeResolvedDefault.provider}/${activeResolvedDefault.model}`; - const buttons = buildModelsKeyboard({ - provider, - models, - currentModel, - currentPage: safePage, - totalPages, - pageSize, - modelNames, - }); - const text = formatModelsAvailableHeader({ - provider, - total: models.length, - cfg: runtimeCfg, - agentDir: resolveAgentDir(runtimeCfg, sessionState.agentId), - sessionEntry: sessionState.sessionEntry, - }); - try { - await editMessageWithButtons(text, buttons); - } catch (err) { - throw new TelegramRetryableCallbackError(err); - } - return true; - } - - if (modelCallback.type !== "select") { - return true; - } - const selection = resolveModelSelection({ callback: modelCallback, providers, byProvider }); - if (selection.kind !== "resolved") { - const providerInfos: ProviderInfo[] = providers.map((provider) => ({ - id: provider, - count: byProvider.get(provider)?.size ?? 0, - })); - try { - await editMessageWithButtons( - `Could not resolve model "${selection.model}".\n\nSelect a provider:`, - buildTelegramModelsMenuButtons({ providers: providerInfos }), - ); - } catch (err) { - throw new TelegramRetryableCallbackError(err); - } - return true; - } - if (!byProvider.get(selection.provider)?.has(selection.model)) { - try { - await editMessageWithButtons( - `❌ Model "${selection.provider}/${selection.model}" is not allowed.`, - [], - ); - } catch (err) { - throw new TelegramRetryableCallbackError(err); - } - return true; - } - - try { - const storePath = telegramDeps.resolveStorePath(runtimeCfg.session?.store, { - agentId: sessionState.agentId, - }); - const resolvedDefault = resolveDefaultModelForAgent({ - cfg: runtimeCfg, - agentId: sessionState.agentId, - }); - const isDefaultSelection = - selection.provider === resolvedDefault.provider && selection.model === resolvedDefault.model; - const persistedSessionEntry = - sessionState.sessionEntry ?? - telegramDeps.getSessionEntry?.({ storePath, sessionKey: sessionState.sessionKey }) ?? - getSessionEntry({ storePath, sessionKey: sessionState.sessionKey }); - const sessionEntryMissing = persistedSessionEntry === undefined; - const sessionEntry = persistedSessionEntry ?? { - sessionId: randomUUID(), - updatedAt: Date.now(), - }; - const previousAuthProfileId = sessionEntry.authProfileOverride?.trim(); - const sessionStore = { [sessionState.sessionKey]: sessionEntry }; - const modelCatalog = [...byProvider.entries()].flatMap(([provider, models]) => - [...models].map((model) => ({ provider, id: model, name: model })), - ); - const currentModelRef = sessionState.model?.trim(); - const currentModelSeparator = currentModelRef?.indexOf("/") ?? -1; - const currentProvider = - currentModelRef && currentModelSeparator > 0 - ? currentModelRef.slice(0, currentModelSeparator) - : resolvedDefault.provider; - const currentModel = - currentModelRef && currentModelSeparator > 0 - ? currentModelRef.slice(currentModelSeparator + 1) - : resolvedDefault.model; - let applied: Awaited>; - try { - applied = await applySessionModelSelection({ - cfg: runtimeCfg, - agentId: sessionState.agentId, - sessionKey: sessionState.sessionKey, - storePath, - sessionEntry, - sessionStore, - allowCreate: sessionEntryMissing, - defaultProvider: resolvedDefault.provider, - defaultModel: resolvedDefault.model, - currentProvider, - currentModel, - allowedModelKeys: new Set(modelCatalog.map((entry) => `${entry.provider}/${entry.id}`)), - modelCatalog, - canPersistStickyModelSelection: false, - request: { - provider: selection.provider, - model: selection.model, - isDefault: isDefaultSelection, - runtime: { kind: "unchanged" }, - }, - markLiveSwitchPending: true, - }); - } catch (err) { - throw new TelegramRetryableCallbackError(err); - } - if (applied.status !== "applied") { - await editMessageWithButtons(`❌ ${applied.message}`, []); - return true; - } - const defaultAuthProfileNotice = - isDefaultSelection && previousAuthProfileId - ? sessionStore[sessionState.sessionKey]?.authProfileOverride?.trim() === - previousAuthProfileId - ? "Compatible auth profile retained." - : "Incompatible auth profile cleared." - : undefined; - const escapeHtml = (text: string) => - text.replace(/&/g, "&").replace(//g, ">"); - const actionText = isDefaultSelection - ? "reset to default" - : `changed to ${escapeHtml(selection.provider)}/${escapeHtml(selection.model)}`; - const runtimeText = - applied.runtimeChange?.kind === "clear" - ? "Runtime reset to configured policy." - : "Runtime unchanged."; - const scopeText = isDefaultSelection - ? `Session model selection cleared.${defaultAuthProfileNotice ? ` ${defaultAuthProfileNotice}` : ""} ${runtimeText} New replies use the agent's configured default.` - : `Session-only model selection. ${runtimeText} Use /model ${escapeHtml(selection.provider)}/${escapeHtml(selection.model)} --runtime <runtime> -s to switch harnesses. The agent default in openclaw.json is unchanged. This chat keeps the model selection across /new and /reset; use /model default -s to clear the session model selection.`; - await editMessageWithButtons(`✅ Model ${actionText}\n\n${scopeText}`, [], { - parse_mode: "HTML", - }); - } catch (err) { - if (err instanceof TelegramRetryableCallbackError) { - throw err; - } - await editMessageWithButtons(`❌ Failed to change model: ${String(err)}`, []); - } - return true; -} diff --git a/extensions/telegram/src/bot-handlers.callback-questions.runtime.test.ts b/extensions/telegram/src/bot-handlers.callback-questions.runtime.test.ts index d79278428034..39a25f338c95 100644 --- a/extensions/telegram/src/bot-handlers.callback-questions.runtime.test.ts +++ b/extensions/telegram/src/bot-handlers.callback-questions.runtime.test.ts @@ -1,6 +1,6 @@ // Telegram question callback feedback tests. import { describe, expect, it, vi } from "vitest"; -import { handleTelegramQuestionCallback } from "./bot-handlers.callback-questions.runtime.js"; +import { handleTelegramQuestionCallback } from "./bot-handlers.callback-actions.js"; const callback = { questionId: "ask_0123456789abcdef0123456789abcdef", diff --git a/extensions/telegram/src/bot-handlers.callback-questions.runtime.ts b/extensions/telegram/src/bot-handlers.callback-questions.runtime.ts deleted file mode 100644 index 806f0878cc11..000000000000 --- a/extensions/telegram/src/bot-handlers.callback-questions.runtime.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Telegram ask_user callback resolution and toast feedback. -import { questionGatewayRuntime } from "openclaw/plugin-sdk/question-gateway-runtime"; -import type { TelegramQuestionCallback } from "./question-callback-data.js"; - -type ResolveQuestionParams = Parameters[0]; -type QuestionResolver = ( - params: ResolveQuestionParams, -) => ReturnType; - -export async function handleTelegramQuestionCallback(params: { - callback: TelegramQuestionCallback; - cfg: ResolveQuestionParams["cfg"]; - senderId: string; - feedback: (text: string, terminal: boolean) => Promise; - resolveQuestion?: QuestionResolver; -}): Promise { - let result: Awaited>; - try { - result = await (params.resolveQuestion ?? questionGatewayRuntime.resolveOption)({ - cfg: params.cfg, - questionId: params.callback.questionId, - optionIndex: params.callback.optionIndex, - senderId: params.senderId, - clientDisplayName: "Telegram question", - }); - } catch (error) { - await params.feedback("Could not submit this answer.", false).catch(() => {}); - throw error; - } - await params - .feedback( - result.status === "answered" ? "Answer submitted." : "This question was already answered.", - true, - ) - .catch(() => {}); -} diff --git a/extensions/telegram/src/bot-handlers.callback-interactions.runtime.ts b/extensions/telegram/src/bot-handlers.callback-router-controls.ts similarity index 54% rename from extensions/telegram/src/bot-handlers.callback-interactions.runtime.ts rename to extensions/telegram/src/bot-handlers.callback-router-controls.ts index 99dfd732f4ea..df36b7430907 100644 --- a/extensions/telegram/src/bot-handlers.callback-interactions.runtime.ts +++ b/extensions/telegram/src/bot-handlers.callback-router-controls.ts @@ -1,17 +1,29 @@ import type { CallbackQuery, Message } from "grammy/types"; +import { + resolveApprovalOverGateway, + type ApprovalResolveResult, +} from "openclaw/plugin-sdk/approval-gateway-runtime"; +import type { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { buildPluginBindingResolvedText, parsePluginBindingApprovalCustomId, resolvePluginConversationBindingApproval, } from "openclaw/plugin-sdk/conversation-runtime"; +import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime"; import { logVerbose, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; +import type { TelegramApprovalCallback } from "./approval-callback-data.js"; +import { + buildTelegramCanonicalApprovalTerminalText, + buildTelegramInvalidApprovalTerminalText, + buildTelegramLegacyApprovalTerminalText, +} from "./approval-terminal.js"; import type { TelegramCallbackButton, TelegramCallbackMessageActions, -} from "./bot-handlers.callback-actions.runtime.js"; -import { TelegramRetryableCallbackError } from "./bot-handlers.callback-errors.runtime.js"; -import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; +} from "./bot-handlers.callback-actions.js"; +import type { TelegramMessagePipeline } from "./bot-handlers.message-pipeline.js"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; import { createTelegramSpooledReplayDeferredParticipant, getTelegramSpooledReplayDeferredParticipant, @@ -20,9 +32,286 @@ import { } from "./bot-processing-outcome.js"; import { withResolvedTelegramForumFlag } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; +import { + isTelegramExecApprovalApprover, + isTelegramExecApprovalAuthorizedSender, +} from "./exec-approvals.js"; import { dispatchTelegramPluginInteractiveHandler } from "./interactive-dispatch.js"; +import { + isTelegramEditTargetMissingError, + isTelegramMessageHasNoTextError, +} from "./network-errors.js"; import { buildInlineKeyboard } from "./send.js"; +export type TelegramCallbackMessageRuntime = Pick< + TelegramMessagePipeline, + | "buildSyntheticTextMessage" + | "buildSyntheticContext" + | "buildFailedProcessingResult" + | "processMessageWithReplyChain" + | "resolveTelegramSessionState" +>; + +export class TelegramRetryableCallbackError extends Error { + public override readonly cause: unknown; + + constructor(cause: unknown) { + super(String(cause)); + this.cause = cause; + this.name = "TelegramRetryableCallbackError"; + } +} + +export const isPermanentTelegramCallbackEditError = (err: unknown): boolean => + isTelegramEditTargetMissingError(err) || isTelegramMessageHasNoTextError(err); + +function isApprovalAlreadyResolvedError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const record = error as { + gatewayCode?: unknown; + details?: { reason?: unknown } | null; + }; + const reason = record.details?.reason; + return ( + record.gatewayCode === "APPROVAL_ALREADY_RESOLVED" || + (record.gatewayCode === "INVALID_REQUEST" && reason === "APPROVAL_ALREADY_RESOLVED") || + /approval already resolved/i.test(error.message) + ); +} + +type LegacyApprovalCallback = NonNullable>; + +export function createTelegramCallbackApprovalRuntime(params: { + accountId: RegisterTelegramHandlerParams["accountId"]; + telegramDeps: RegisterTelegramHandlerParams["telegramDeps"]; + runtimeCfg: OpenClawConfig; + senderId: string; + actions: TelegramCallbackMessageActions; +}) { + const { accountId, telegramDeps, runtimeCfg, senderId, actions } = params; + const { clearCallbackButtons, editCallbackMessage, replyToCallbackChat } = actions; + + const resolveApprovalAuthorizations = () => { + const pluginApprovalAuthorizedSender = isTelegramExecApprovalApprover({ + cfg: runtimeCfg, + accountId, + senderId, + }); + const execApprovalAuthorizedSender = isTelegramExecApprovalAuthorizedSender({ + cfg: runtimeCfg, + accountId, + senderId, + }); + return { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender }; + }; + + const clearTerminalApprovalButtons = async () => { + try { + // First-answer-wins returns applied:false to losing surfaces. Their controls + // are stale too, so cleanup follows canonical terminal truth, not local authorship. + await clearCallbackButtons(); + } catch (editErr) { + const errStr = String(editErr); + if ( + errStr.includes("message is not modified") || + errStr.includes("there is no text in the message to edit") + ) { + return; + } + logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`); + } + }; + + const terminalizeApprovalMessage = async (text: string) => { + try { + await editCallbackMessage(text, { reply_markup: { inline_keyboard: [] } }); + return; + } catch (editErr) { + const errStr = String(editErr); + const alreadyTerminal = errStr.includes("message is not modified"); + if (!alreadyTerminal) { + logVerbose(`telegram: failed to render terminal approval receipt: ${errStr}`); + } + // Preserve the terminal state even when Telegram no longer permits a text edit. + await clearTerminalApprovalButtons(); + if (alreadyTerminal) { + return; + } + } + try { + await replyToCallbackChat(text); + } catch (sendErr) { + logVerbose(`telegram: failed to send terminal approval receipt: ${String(sendErr)}`); + } + }; + const terminalizeLegacyApproval = async ( + receipt: Parameters[0], + ) => await terminalizeApprovalMessage(buildTelegramLegacyApprovalTerminalText(receipt)); + + const resolveApproval = telegramDeps.resolveApproval ?? resolveApprovalOverGateway; + + const resolveCanonicalApproval = async ( + approvalCallback: TelegramApprovalCallback, + ): Promise => + (await resolveApproval({ + cfg: runtimeCfg, + approvalId: approvalCallback.approvalId, + approvalKind: approvalCallback.approvalKind, + decision: approvalCallback.decision, + channel: "telegram", + accountId, + senderId, + })) as ApprovalResolveResult; + + const terminalizeCanonicalApproval = async ( + approvalCallback: TelegramApprovalCallback, + result: Awaited>, + ) => + await terminalizeApprovalMessage( + buildTelegramCanonicalApprovalTerminalText({ + result, + fallbackApprovalId: approvalCallback.approvalId, + }), + ); + + const handleCanonical = async (approvalCallback: TelegramApprovalCallback): Promise => { + const { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender } = + resolveApprovalAuthorizations(); + const authorizedApprovalSender = + approvalCallback.approvalKind === "plugin" + ? pluginApprovalAuthorizedSender + : execApprovalAuthorizedSender || pluginApprovalAuthorizedSender; + if (!authorizedApprovalSender) { + logVerbose( + `Blocked telegram approval callback from ${senderId || "unknown"} (not authorized)`, + ); + return; + } + try { + const result = await resolveCanonicalApproval(approvalCallback); + if (!result.applied) { + logVerbose( + `telegram: approval callback already resolved ${approvalCallback.approvalId} ` + + `status=${result.approval.status}`, + ); + } + await terminalizeCanonicalApproval(approvalCallback, result); + } catch (resolveErr) { + logVerbose( + `telegram: failed to resolve approval callback ${approvalCallback.approvalId}: ${String(resolveErr)}`, + ); + if (isApprovalNotFoundError(resolveErr) || isApprovalAlreadyResolvedError(resolveErr)) { + await terminalizeLegacyApproval({ + approvalId: approvalCallback.approvalId, + outcome: "no-longer-pending", + }); + return; + } + throw new TelegramRetryableCallbackError(resolveErr); + } + }; + + const handleMalformedReserved = async (): Promise => { + const { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender } = + resolveApprovalAuthorizations(); + if (!execApprovalAuthorizedSender && !pluginApprovalAuthorizedSender) { + logVerbose( + `Blocked malformed telegram approval callback from ${senderId || "unknown"} (not authorized)`, + ); + return; + } + logVerbose(`telegram: consumed malformed reserved approval callback from ${senderId}`); + await terminalizeApprovalMessage(buildTelegramInvalidApprovalTerminalText()); + }; + + const handleLegacy = async (approvalCallback: LegacyApprovalCallback): Promise => { + const { execApprovalAuthorizedSender, pluginApprovalAuthorizedSender } = + resolveApprovalAuthorizations(); + const approvalKinds: Array<"exec" | "plugin"> = []; + if (execApprovalAuthorizedSender || pluginApprovalAuthorizedSender) { + approvalKinds.push("exec"); + } + if (pluginApprovalAuthorizedSender) { + approvalKinds.push("plugin"); + } + if (approvalKinds.length === 0) { + logVerbose( + `Blocked telegram approval callback from ${senderId || "unknown"} (not authorized)`, + ); + return; + } + + for (const approvalKind of approvalKinds) { + const canonicalCallback: TelegramApprovalCallback = { + type: "approval", + approvalId: approvalCallback.approvalId, + approvalKind, + decision: approvalCallback.decision, + }; + try { + // Legacy callbacks lack an owner. Probe only adapters this sender may use. + await resolveApproval({ + cfg: runtimeCfg, + approvalId: approvalCallback.approvalId, + decision: approvalCallback.decision, + channel: "telegram", + accountId, + senderId, + resolveMethod: approvalKind, + }); + await terminalizeLegacyApproval({ + approvalId: approvalCallback.approvalId, + decision: approvalCallback.decision, + outcome: "resolved-here", + }); + return; + } catch (resolveErr) { + if (isApprovalNotFoundError(resolveErr)) { + continue; + } + if (isApprovalAlreadyResolvedError(resolveErr)) { + try { + const result = await resolveCanonicalApproval(canonicalCallback); + await terminalizeCanonicalApproval(canonicalCallback, result); + } catch (canonicalError) { + if ( + !isApprovalNotFoundError(canonicalError) && + !isApprovalAlreadyResolvedError(canonicalError) + ) { + throw new TelegramRetryableCallbackError(canonicalError); + } + logVerbose( + `telegram: canonical approval lookup failed after stale legacy callback ` + + `${approvalCallback.approvalId}: ${String(canonicalError)}`, + ); + await terminalizeLegacyApproval({ + approvalId: approvalCallback.approvalId, + outcome: "no-longer-pending", + }); + } + return; + } + logVerbose( + `telegram: failed to resolve approval callback ${approvalCallback.approvalId}: ${String(resolveErr)}`, + ); + throw new TelegramRetryableCallbackError(resolveErr); + } + } + + logVerbose(`telegram: approval callback not found ${approvalCallback.approvalId}`); + if (!pluginApprovalAuthorizedSender) { + return; + } + await terminalizeLegacyApproval({ + approvalId: approvalCallback.approvalId, + outcome: "no-longer-pending", + }); + }; + + return { handleCanonical, handleMalformedReserved, handleLegacy }; +} const MULTI_SELECT_PREFIX = "OC_MULTI|"; const MULTI_SELECT_TOGGLE_PREFIX = `${MULTI_SELECT_PREFIX}toggle|`; const SELECT_PREFIX = "OC_SELECT|"; @@ -157,11 +446,9 @@ export async function handleTelegramInteractiveCallback(params: { senderUsername: string; isGroup: boolean; isForum: boolean; - storeAllowFrom: Parameters< - TelegramHandlerMessageRuntime["processMessageWithReplyChain"] - >[0]["storeAllowFrom"]; + storeAllowFrom: string[]; actions: TelegramCallbackMessageActions; - messageRuntime: TelegramHandlerMessageRuntime; + messageRuntime: TelegramCallbackMessageRuntime; authorizeCallback: () => Promise; }): Promise { const { diff --git a/extensions/telegram/src/bot-handlers.callback.runtime.ts b/extensions/telegram/src/bot-handlers.callback-router.ts similarity index 50% rename from extensions/telegram/src/bot-handlers.callback.runtime.ts rename to extensions/telegram/src/bot-handlers.callback-router.ts index e9670c45d455..cc40fc2303cf 100644 --- a/extensions/telegram/src/bot-handlers.callback.runtime.ts +++ b/extensions/telegram/src/bot-handlers.callback-router.ts @@ -1,39 +1,66 @@ -// Telegram callback-query routing across approvals, plugin actions, selects, commands, and models. +import { randomUUID } from "node:crypto"; +import type { Context } from "grammy"; import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime"; +import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/command-status"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { applySessionModelSelection } from "openclaw/plugin-sdk/model-session-runtime"; +import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/models-provider-runtime"; +import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { hasTelegramApprovalCallbackPrefix, parseTelegramApprovalCallbackData, } from "./approval-callback-data.js"; +import { + resolveAgentDir, + resolveDefaultAgentId, + resolveDefaultModelForAgent, +} from "./bot-handlers.agent.runtime.js"; +import { + createTelegramCallbackMessageActions, + handleTelegramQuestionCallback, + type TelegramCallbackMessageActions, +} from "./bot-handlers.callback-actions.js"; +import { + createTelegramCallbackApprovalRuntime, + handleTelegramInteractiveCallback, + isPermanentTelegramCallbackEditError, + type TelegramCallbackMessageRuntime, + TelegramRetryableCallbackError, +} from "./bot-handlers.callback-router-controls.js"; import type { TelegramEventAuthorizationMode, - TelegramHandlerAuthorizationRuntime, -} from "./bot-handlers.authorization.runtime.js"; -import { createTelegramCallbackMessageActions } from "./bot-handlers.callback-actions.runtime.js"; -import { createTelegramCallbackApprovalRuntime } from "./bot-handlers.callback-approvals.runtime.js"; -import { - isPermanentTelegramCallbackEditError, - TelegramRetryableCallbackError, -} from "./bot-handlers.callback-errors.runtime.js"; -import { handleTelegramInteractiveCallback } from "./bot-handlers.callback-interactions.runtime.js"; -import { handleTelegramModelCallback } from "./bot-handlers.callback-model.runtime.js"; -import { handleTelegramQuestionCallback } from "./bot-handlers.callback-questions.runtime.js"; -import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js"; + TelegramHandlerAuthorization, +} from "./bot-handlers.inbound-authorization.js"; +import type { + RegisterTelegramHandlerParams, + TelegramCallbackRouter, +} from "./bot-handlers.types.js"; import { parseTelegramNativeCommandCallbackData } from "./bot-native-commands.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; import { isTelegramSpooledReplayUpdate, recordTelegramMessageProcessingResult, } from "./bot-processing-outcome.js"; import { resolveTelegramForumFlag, + resolveTelegramBotHasTopicsEnabled, resolveTelegramMessageThreadSpec, withResolvedTelegramForumFlag, } from "./bot/helpers.js"; -import type { TelegramGetChat } from "./bot/types.js"; +import type { TelegramContext, TelegramGetChat } from "./bot/types.js"; import { getTelegramCallbackQueryAnswerPromise } from "./callback-query-answer-state.js"; +import { buildCommandsPaginationKeyboard, buildTelegramModelsMenuButtons } from "./command-ui.js"; import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js"; +import { + buildModelsKeyboard, + calculateTotalPages, + getModelsPageSize, + parseModelCallbackData, + resolveModelSelection, + type ProviderInfo, +} from "./model-buttons.js"; import { hasTelegramOpaqueCallbackPrefix, parseTelegramOpaqueCallbackData, @@ -43,19 +70,24 @@ import { hasTelegramQuestionCallbackPrefix, parseTelegramQuestionCallbackData, } from "./question-callback-data.js"; +import { buildInlineKeyboard } from "./send.js"; -export function registerTelegramCallbackQueryHandler( - { +export function createTelegramCallbackRouter({ + params: { accountId, bot, runtime, telegramDeps, shouldSkipUpdate, nativeCommandCallbackDispatcher, - }: RegisterTelegramHandlerParams, - messageRuntime: TelegramHandlerMessageRuntime, - authorizationRuntime: TelegramHandlerAuthorizationRuntime, -) { + }, + message: messageRuntime, + authorization: authorizationRuntime, +}: { + params: RegisterTelegramHandlerParams; + message: TelegramCallbackMessageRuntime; + authorization: TelegramHandlerAuthorization; +}): TelegramCallbackRouter { const { buildSyntheticTextMessage, buildSyntheticContext, processMessageWithReplyChain } = messageRuntime; const { @@ -65,14 +97,13 @@ export function registerTelegramCallbackQueryHandler( } = authorizationRuntime; const getChat: TelegramGetChat = bot.api.getChat.bind(bot.api); - bot.on("callback_query", async (ctx) => { + const handleCallback = async (ctx: Context) => { const callback = ctx.callbackQuery; if (!callback) { return; } let callbackAnswered = false; const answerCallbackQuery = async (text?: string) => { - // Callback answers prevent Telegram retries while the routed action runs. await withTelegramApiErrorLogging({ operation: "answerCallbackQuery", runtime, @@ -371,5 +402,292 @@ export function registerTelegramCallbackQueryHandler( await answerCallbackQuery(); } } - }); + }; + + return { + route: async (ctx) => { + if (!ctx.callbackQuery) { + return { kind: "ignored" }; + } + await handleCallback(ctx); + return { kind: "handled" }; + }, + }; +} + +async function handleTelegramModelCallback(params: { + data: string; + ctx: Pick; + chatId: number; + isGroup: boolean; + isForum: boolean; + messageThreadId?: number; + resolvedThreadId?: number; + senderId: string; + runtimeCfg: OpenClawConfig; + telegramDeps: RegisterTelegramHandlerParams["telegramDeps"]; + actions: TelegramCallbackMessageActions; + messageRuntime: TelegramCallbackMessageRuntime; + authorizeCallback: () => Promise; +}): Promise { + const { + data, + ctx, + chatId, + isGroup, + isForum, + messageThreadId, + resolvedThreadId, + senderId, + runtimeCfg, + telegramDeps, + actions, + messageRuntime, + authorizeCallback, + } = params; + const { editCallbackMessage, editCallbackMessageWithButtons: editMessageWithButtons } = actions; + const retryModelAction = async (action: () => Promise): Promise => { + try { + return await action(); + } catch (error) { + throw new TelegramRetryableCallbackError(error); + } + }; + + const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/); + if (paginationMatch) { + const pageValue = paginationMatch[1]; + if (pageValue === "noop") { + return true; + } + const page = parseStrictPositiveInteger(pageValue); + if (page === undefined) { + return true; + } + const agentId = paginationMatch[2]?.trim() || resolveDefaultAgentId(runtimeCfg); + const result = await retryModelAction(async () => { + const skillCommands = telegramDeps.listSkillCommandsForAgents({ + cfg: runtimeCfg, + agentIds: [agentId], + }); + return buildCommandsMessagePaginated(runtimeCfg, skillCommands, { + page, + forcePaginatedList: true, + surface: "telegram", + }); + }); + const keyboard = + result.totalPages > 1 + ? buildInlineKeyboard( + buildCommandsPaginationKeyboard(result.currentPage, result.totalPages, agentId), + ) + : undefined; + try { + await editCallbackMessage(result.text, keyboard ? { reply_markup: keyboard } : undefined); + } catch (editErr) { + if (!String(editErr).includes("message is not modified")) { + throw new TelegramRetryableCallbackError(editErr); + } + } + return true; + } + + const modelCallback = parseModelCallbackData(data); + if (!modelCallback) { + return false; + } + if (!(await authorizeCallback())) { + logVerbose( + `Blocked telegram model callback from ${senderId || "unknown"} (not authorized for /models)`, + ); + return true; + } + + const { sessionState, modelData } = await retryModelAction(async () => { + const session = messageRuntime.resolveTelegramSessionState({ + chatId, + isGroup, + isForum, + messageThreadId, + resolvedThreadId, + botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(ctx.me), + senderId, + runtimeCfg, + }); + const providerData = await telegramDeps.buildModelsProviderData(runtimeCfg, session.agentId); + return { sessionState: session, modelData: providerData }; + }); + const { byProvider, providers, modelNames, resolvedDefault: activeResolvedDefault } = modelData; + const providerInfos: ProviderInfo[] = providers.map((provider) => ({ + id: provider, + count: byProvider.get(provider)?.size ?? 0, + })); + + if (modelCallback.type === "providers" || modelCallback.type === "back") { + if (providers.length === 0) { + await retryModelAction(() => editMessageWithButtons("No providers available.", [])); + return true; + } + await retryModelAction(() => + editMessageWithButtons( + "Select a provider:", + buildTelegramModelsMenuButtons({ providers: providerInfos }), + ), + ); + return true; + } + + if (modelCallback.type === "list") { + const { provider, page } = modelCallback; + const modelSet = byProvider.get(provider); + if (!modelSet || modelSet.size === 0) { + await retryModelAction(() => + editMessageWithButtons( + `Unknown provider: ${provider}\n\nSelect a provider:`, + buildTelegramModelsMenuButtons({ providers: providerInfos }), + ), + ); + return true; + } + const models = [...modelSet].toSorted((left, right) => left.localeCompare(right)); + const pageSize = getModelsPageSize(); + const totalPages = calculateTotalPages(models.length, pageSize); + const safePage = Math.max(1, Math.min(page, totalPages)); + const currentModel = + sessionState.model || `${activeResolvedDefault.provider}/${activeResolvedDefault.model}`; + const buttons = buildModelsKeyboard({ + provider, + models, + currentModel, + currentPage: safePage, + totalPages, + pageSize, + modelNames, + }); + const text = formatModelsAvailableHeader({ + provider, + total: models.length, + cfg: runtimeCfg, + agentDir: resolveAgentDir(runtimeCfg, sessionState.agentId), + sessionEntry: sessionState.sessionEntry, + }); + await retryModelAction(() => editMessageWithButtons(text, buttons)); + return true; + } + + if (modelCallback.type !== "select") { + return true; + } + const selection = resolveModelSelection({ callback: modelCallback, providers, byProvider }); + if (selection.kind !== "resolved") { + await retryModelAction(() => + editMessageWithButtons( + `Could not resolve model "${selection.model}".\n\nSelect a provider:`, + buildTelegramModelsMenuButtons({ providers: providerInfos }), + ), + ); + return true; + } + if (!byProvider.get(selection.provider)?.has(selection.model)) { + await retryModelAction(() => + editMessageWithButtons( + `❌ Model "${selection.provider}/${selection.model}" is not allowed.`, + [], + ), + ); + return true; + } + + try { + const storePath = telegramDeps.resolveStorePath(runtimeCfg.session?.store, { + agentId: sessionState.agentId, + }); + const resolvedDefault = resolveDefaultModelForAgent({ + cfg: runtimeCfg, + agentId: sessionState.agentId, + }); + const isDefaultSelection = + selection.provider === resolvedDefault.provider && selection.model === resolvedDefault.model; + const persistedSessionEntry = + sessionState.sessionEntry ?? + telegramDeps.getSessionEntry?.({ storePath, sessionKey: sessionState.sessionKey }) ?? + getSessionEntry({ storePath, sessionKey: sessionState.sessionKey }); + const sessionEntryMissing = persistedSessionEntry === undefined; + const sessionEntry = persistedSessionEntry ?? { + sessionId: randomUUID(), + updatedAt: Date.now(), + }; + const previousAuthProfileId = sessionEntry.authProfileOverride?.trim(); + const sessionStore = { [sessionState.sessionKey]: sessionEntry }; + const modelCatalog = [...byProvider.entries()].flatMap(([provider, models]) => + [...models].map((model) => ({ provider, id: model, name: model })), + ); + const currentModelRef = sessionState.model?.trim(); + const currentModelSeparator = currentModelRef?.indexOf("/") ?? -1; + const currentProvider = + currentModelRef && currentModelSeparator > 0 + ? currentModelRef.slice(0, currentModelSeparator) + : resolvedDefault.provider; + const currentModel = + currentModelRef && currentModelSeparator > 0 + ? currentModelRef.slice(currentModelSeparator + 1) + : resolvedDefault.model; + const applied = await retryModelAction(() => + applySessionModelSelection({ + cfg: runtimeCfg, + agentId: sessionState.agentId, + sessionKey: sessionState.sessionKey, + storePath, + sessionEntry, + sessionStore, + allowCreate: sessionEntryMissing, + defaultProvider: resolvedDefault.provider, + defaultModel: resolvedDefault.model, + currentProvider, + currentModel, + allowedModelKeys: new Set(modelCatalog.map((entry) => `${entry.provider}/${entry.id}`)), + modelCatalog, + canPersistStickyModelSelection: false, + request: { + provider: selection.provider, + model: selection.model, + isDefault: isDefaultSelection, + runtime: { kind: "unchanged" }, + }, + markLiveSwitchPending: true, + }), + ); + if (applied.status !== "applied") { + await editMessageWithButtons(`❌ ${applied.message}`, []); + return true; + } + const defaultAuthProfileNotice = + isDefaultSelection && previousAuthProfileId + ? sessionStore[sessionState.sessionKey]?.authProfileOverride?.trim() === + previousAuthProfileId + ? "Compatible auth profile retained." + : "Incompatible auth profile cleared." + : undefined; + const escapeHtml = (text: string) => + text.replace(/&/g, "&").replace(//g, ">"); + const actionText = isDefaultSelection + ? "reset to default" + : `changed to ${escapeHtml(selection.provider)}/${escapeHtml(selection.model)}`; + const runtimeText = + applied.runtimeChange?.kind === "clear" + ? "Runtime reset to configured policy." + : "Runtime unchanged."; + const scopeText = isDefaultSelection + ? `Session model selection cleared.${defaultAuthProfileNotice ? ` ${defaultAuthProfileNotice}` : ""} ${runtimeText} New replies use the agent's configured default.` + : `Session-only model selection. ${runtimeText} Use /model ${escapeHtml(selection.provider)}/${escapeHtml(selection.model)} --runtime <runtime> -s to switch harnesses. The agent default in openclaw.json is unchanged. This chat keeps the model selection across /new and /reset; use /model default -s to clear the session model selection.`; + await editMessageWithButtons(`✅ Model ${actionText}\n\n${scopeText}`, [], { + parse_mode: "HTML", + }); + } catch (err) { + if (err instanceof TelegramRetryableCallbackError) { + throw err; + } + await editMessageWithButtons(`❌ Failed to change model: ${String(err)}`, []); + } + return true; } diff --git a/extensions/telegram/src/bot-handlers.event-bindings.ts b/extensions/telegram/src/bot-handlers.event-bindings.ts new file mode 100644 index 000000000000..40620f6c91cd --- /dev/null +++ b/extensions/telegram/src/bot-handlers.event-bindings.ts @@ -0,0 +1,437 @@ +import type { ChatMember, ReactionTypeEmoji } from "grammy/types"; +import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers"; +import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; +import { resolveTelegramAccount } from "./accounts.js"; +import type { TelegramHandlerAuthorization } from "./bot-handlers.inbound-authorization.js"; +import type { TelegramMessagePipeline } from "./bot-handlers.message-pipeline.js"; +import type { RegisterTelegramHandlerParams, TelegramEventBindings } from "./bot-handlers.types.js"; +import { + isTelegramSpooledReplayUpdate, + recordTelegramMessageProcessingResult, +} from "./bot-processing-outcome.js"; +import { + buildTelegramGroupPeerId, + buildTelegramParentPeer, + resolveTelegramThreadSpec, + type TelegramThreadSpec, +} from "./bot/helpers.js"; +import { resolveTelegramConversationRoute } from "./conversation-route.js"; +import { migrateTelegramGroupConfig } from "./group-migration.js"; +import { getPreparedTelegramPollAnswer } from "./poll-answer-context.js"; +import { findTelegramPollRegistryEntry, retireTelegramPollRegistryEntry } from "./poll-registry.js"; + +/** Stable operator-facing reason for a scoped reaction dropped without a known topic. */ +const TELEGRAM_REACTION_THREAD_UNRESOLVED_REASON = "thread-context-unavailable"; + +type TelegramEventMessageDependencies = Pick< + TelegramMessagePipeline, + | "resolveCachedMessageThreadSpec" + | "buildSyntheticTextMessage" + | "buildSyntheticContext" + | "processMessageWithReplyChain" +>; + +type CreateTelegramEventBindingsOptions = { + params: RegisterTelegramHandlerParams; + message: TelegramEventMessageDependencies; + authorization: Pick< + TelegramHandlerAuthorization, + "resolveTelegramEventAuthorizationContext" | "authorizeTelegramEventSender" + >; + registerMessages: () => void; +}; + +function isCurrentTelegramChatMember(member: ChatMember): boolean { + return ( + member.status === "creator" || + member.status === "administrator" || + member.status === "member" || + (member.status === "restricted" && member.is_member) + ); +} + +export function createTelegramEventBindings({ + params, + message, + authorization, + registerMessages, +}: CreateTelegramEventBindingsOptions): TelegramEventBindings { + const { accountId, bot, cfg, runtime, shouldSkipUpdate, telegramDeps } = params; + const { authorizeTelegramEventSender, resolveTelegramEventAuthorizationContext } = authorization; + const { + buildSyntheticContext, + buildSyntheticTextMessage, + processMessageWithReplyChain, + resolveCachedMessageThreadSpec, + } = message; + + const registerReaction = () => { + bot.on("message_reaction", async (ctx) => { + try { + const reaction = ctx.messageReaction; + if (!reaction || shouldSkipUpdate(ctx)) { + return; + } + + const chatId = reaction.chat.id; + const messageId = reaction.message_id; + const user = reaction.user; + const senderId = user?.id != null ? String(user.id) : ""; + const senderUsername = user?.username ?? ""; + const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup"; + const isDirectMessagesChat = reaction.chat.is_direct_messages === true; + const isForum = !isDirectMessagesChat && reaction.chat.is_forum === true; + const authorizationCfg = telegramDeps.getRuntimeConfig(); + const authorizationTelegramCfg = resolveTelegramAccount({ + cfg: authorizationCfg, + accountId, + }).config; + + const reactionMode = authorizationTelegramCfg.reactionNotifications ?? "own"; + if (reactionMode === "off" || user?.is_bot) { + return; + } + if ( + reactionMode === "own" && + !telegramDeps.wasSentByBot(chatId, messageId, authorizationCfg) + ) { + logVerbose( + `telegram: skipped reaction on msg ${messageId} in chat ${chatId} (own mode, not sent by bot)`, + ); + return; + } + + // Detect additions before topic recovery so no-op reactions avoid cache work and warnings. + const oldEmojis = new Set( + reaction.old_reaction + .filter((item): item is ReactionTypeEmoji => item.type === "emoji") + .map((item) => item.emoji), + ); + const addedReactions = reaction.new_reaction + .filter((item): item is ReactionTypeEmoji => item.type === "emoji") + .filter((item) => !oldEmojis.has(item.emoji)); + if (addedReactions.length === 0) { + return; + } + + // Reaction updates omit every topic field. Scoped reactions require the bounded cache. + let recoveredThreadSpec: TelegramThreadSpec | undefined; + const requiredScope = isDirectMessagesChat + ? "direct-messages" + : isForum + ? "forum" + : undefined; + if (requiredScope) { + recoveredThreadSpec = await resolveCachedMessageThreadSpec({ chatId, messageId }); + if ( + recoveredThreadSpec?.scope !== requiredScope || + recoveredThreadSpec.id === undefined + ) { + runtime.log?.( + warn( + `telegram: skipped scoped reaction account=${accountId} chat=${chatId} message=${messageId} reason=${TELEGRAM_REACTION_THREAD_UNRESOLVED_REASON}`, + ), + ); + return; + } + } + + const eventAuthContext = await resolveTelegramEventAuthorizationContext({ + cfg: authorizationCfg, + chatId, + isGroup, + senderId, + threadSpec: + recoveredThreadSpec ?? + resolveTelegramThreadSpec({ + isGroup, + isForum, + }), + }); + const senderAuthorization = await authorizeTelegramEventSender({ + chatId, + chatTitle: reaction.chat.title, + isGroup, + senderId, + senderUsername, + mode: "reaction", + context: eventAuthContext, + }); + if (!senderAuthorization) { + return; + } + + // DM reactions cannot prove topic membership because Telegram omits the thread id. + if (!isGroup) { + const requireTopic = + eventAuthContext.groupConfig && "requireTopic" in eventAuthContext.groupConfig + ? eventAuthContext.groupConfig.requireTopic + : undefined; + if (requireTopic === true) { + logVerbose( + `Blocked telegram reaction in DM ${chatId}: requireTopic=true but topic unknown for reactions`, + ); + return; + } + } + + const resolvedThreadId = eventAuthContext.resolvedThreadId; + let sessionKey: string; + if (recoveredThreadSpec) { + // Scoped topics must retain topic agents and conversation bindings. + sessionKey = resolveTelegramConversationRoute({ + cfg: eventAuthContext.cfg, + accountId, + chatId, + isGroup, + resolvedThreadId, + replyThreadId: recoveredThreadSpec.id, + senderId, + topicAgentId: eventAuthContext.topicConfig?.agentId, + }).route.sessionKey; + } else { + // Direct chats and non-forum groups retain their established peer route. + const peerId = isGroup + ? buildTelegramGroupPeerId(chatId, resolvedThreadId) + : String(chatId); + const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); + sessionKey = resolveAgentRoute({ + cfg: eventAuthContext.cfg, + channel: "telegram", + accountId, + peer: { kind: isGroup ? "group" : "direct", id: peerId }, + parentPeer, + }).sessionKey; + } + + const senderName = user + ? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username + : undefined; + const senderUsernameLabel = user?.username ? `@${user.username}` : undefined; + let senderLabel = senderName; + if (senderName && senderUsernameLabel) { + senderLabel = `${senderName} (${senderUsernameLabel})`; + } else if (!senderName && senderUsernameLabel) { + senderLabel = senderUsernameLabel; + } + if (!senderLabel && user?.id) { + senderLabel = `id:${user.id}`; + } + senderLabel = senderLabel || "unknown"; + + for (const addedReaction of addedReactions) { + const emoji = addedReaction.emoji; + const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`; + telegramDeps.enqueueSystemEvent(text, { + sessionKey, + contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`, + }); + logVerbose(`telegram: reaction event enqueued: ${text}`); + } + } catch (err) { + runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`)); + throw err; + } + }); + }; + + const registerPolls = () => { + bot.on("poll", async (ctx) => { + try { + const poll = ctx.poll; + if (!poll?.is_closed || shouldSkipUpdate(ctx)) { + return; + } + await retireTelegramPollRegistryEntry({ accountId, pollId: poll.id }); + } catch (err) { + runtime.error?.(danger(`telegram poll handler failed: ${String(err)}`)); + if (isTelegramSpooledReplayUpdate(ctx.update)) { + recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: err }); + return; + } + throw err; + } + }); + + // Public poll answers omit chat/thread data. The send path persists that origin. + bot.on("poll_answer", async (ctx) => { + try { + const pollAnswer = ctx.pollAnswer; + if (!pollAnswer || shouldSkipUpdate(ctx)) { + return; + } + const optionIds = pollAnswer.option_ids ?? []; + const user = pollAnswer.user; + // Retractions and voters without a usable user identity cannot pass authorization. + if (optionIds.length === 0 || !user || user.is_bot) { + return; + } + + // Store failures replay durable ingress; only a true miss is a safe no-op. + const pollId = pollAnswer.poll_id; + const prepared = getPreparedTelegramPollAnswer(ctx.update); + const entry = prepared + ? prepared.entry + : await findTelegramPollRegistryEntry({ pollId, accountId }); + if (!entry) { + logVerbose(`telegram: poll_answer for poll ${pollId} has no registry entry; skipping`); + return; + } + + const chatId = entry.chat.id; + const isGroup = entry.chat.type === "group" || entry.chat.type === "supergroup"; + const senderId = String(user.id); + const senderUsername = user.username ?? ""; + if (!isGroup && user.id !== chatId) { + logVerbose(`Blocked forwarded telegram poll_answer for DM ${chatId} from ${senderId}`); + return; + } + if (isGroup && !isCurrentTelegramChatMember(await bot.api.getChatMember(chatId, user.id))) { + logVerbose( + `Blocked forwarded telegram poll_answer for group ${chatId} from non-member ${senderId}`, + ); + return; + } + const authorizationCfg = telegramDeps.getRuntimeConfig(); + const eventAuthContext = await resolveTelegramEventAuthorizationContext({ + cfg: authorizationCfg, + chatId, + isGroup, + senderId, + threadSpec: entry.threadSpec, + }); + const senderAuthorization = await authorizeTelegramEventSender({ + chatId, + chatTitle: "title" in entry.chat ? entry.chat.title : undefined, + isGroup, + senderId, + senderUsername, + mode: "reaction", + context: eventAuthContext, + }); + if (!senderAuthorization) { + return; + } + + // A DM poll without persisted topic context must not wake the base DM session. + const requireTopic = + eventAuthContext.groupConfig && "requireTopic" in eventAuthContext.groupConfig + ? eventAuthContext.groupConfig.requireTopic + : undefined; + if (!isGroup && requireTopic === true) { + if (eventAuthContext.dmThreadId == null) { + logVerbose( + `Blocked telegram poll_answer in DM ${chatId}: requireTopic=true but topic unknown`, + ); + return; + } + } + + const optionLabels = optionIds.map((index) => entry.options[index] ?? `option ${index}`); + const text = `Poll response to "${entry.question}": ${optionLabels.join(", ")}`; + const messageThreadId = "id" in entry.threadSpec ? entry.threadSpec.id : undefined; + const syntheticMessage = buildSyntheticTextMessage({ + base: { + message_id: entry.messageId, + date: Math.floor(Date.now() / 1000), + chat: entry.chat, + ...(messageThreadId == null + ? {} + : { + message_thread_id: messageThreadId, + is_topic_message: true, + }), + }, + from: user, + text, + }); + const result = await processMessageWithReplyChain({ + ctx: buildSyntheticContext(ctx, syntheticMessage), + msg: syntheticMessage, + allMedia: [], + storeAllowFrom: eventAuthContext.storeAllowFrom, + options: { + forceWasMentioned: true, + messageIdOverride: + typeof ctx.update.update_id === "number" + ? String(ctx.update.update_id) + : `poll:${pollId}:${user.id}:${optionIds.join("-")}`, + }, + }); + recordTelegramMessageProcessingResult(result); + logVerbose(`telegram: poll_answer dispatched for poll ${pollId} by ${senderId}`); + } catch (err) { + runtime.error?.(danger(`telegram poll_answer handler failed: ${String(err)}`)); + if (isTelegramSpooledReplayUpdate(ctx.update)) { + recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: err }); + return; + } + throw err; + } + }); + }; + + const registerMigration = () => { + bot.on("message:migrate_to_chat_id", async (ctx) => { + try { + const msg = ctx.message; + if (!msg?.migrate_to_chat_id || shouldSkipUpdate(ctx)) { + return; + } + + const oldChatId = String(msg.chat.id); + const newChatId = String(msg.migrate_to_chat_id); + const chatTitle = msg.chat.title ?? "Unknown"; + runtime.log?.( + warn(`[telegram] Group migrated: "${chatTitle}" ${oldChatId} → ${newChatId}`), + ); + + if (!resolveChannelConfigWrites({ cfg, channelId: "telegram", accountId })) { + runtime.log?.( + warn("[telegram] Config writes disabled; skipping group config migration."), + ); + return; + } + + const currentConfig = telegramDeps.getRuntimeConfig(); + const migration = migrateTelegramGroupConfig({ + cfg: currentConfig, + accountId, + oldChatId, + newChatId, + }); + + if (migration.migrated) { + runtime.log?.( + warn(`[telegram] Migrating group config from ${oldChatId} to ${newChatId}`), + ); + migrateTelegramGroupConfig({ cfg, accountId, oldChatId, newChatId }); + await mutateConfigFile({ + afterWrite: { mode: "auto" }, + mutate: (draft) => { + migrateTelegramGroupConfig({ cfg: draft, accountId, oldChatId, newChatId }); + }, + }); + runtime.log?.(warn("[telegram] Group config migrated and saved successfully")); + } else if (migration.skippedExisting) { + runtime.log?.( + warn( + `[telegram] Group config already exists for ${newChatId}; leaving ${oldChatId} unchanged`, + ), + ); + } else { + runtime.log?.( + warn(`[telegram] No config found for old group ID ${oldChatId}, migration logged only`), + ); + } + } catch (err) { + runtime.error?.(danger(`[telegram] Group migration handler failed: ${String(err)}`)); + throw err; + } + }); + }; + + return { registerReaction, registerPolls, registerMigration, registerMessages }; +} diff --git a/extensions/telegram/src/bot-handlers.authorization.runtime.ts b/extensions/telegram/src/bot-handlers.inbound-authorization.ts similarity index 72% rename from extensions/telegram/src/bot-handlers.authorization.runtime.ts rename to extensions/telegram/src/bot-handlers.inbound-authorization.ts index 05153bb7c973..84d038b9e2f4 100644 --- a/extensions/telegram/src/bot-handlers.authorization.runtime.ts +++ b/extensions/telegram/src/bot-handlers.inbound-authorization.ts @@ -1,9 +1,11 @@ -// Telegram sender authorization shared by message, reaction, and callback handlers. import type { Message } from "grammy/types"; import type { DmPolicy, OpenClawConfig, TelegramAccountConfig, + TelegramDirectConfig, + TelegramGroupConfig, + TelegramTopicConfig, } from "openclaw/plugin-sdk/config-contracts"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; @@ -13,9 +15,8 @@ import { resolveTelegramEffectiveDmPolicy, type NormalizedAllowFrom, } from "./bot-access.js"; -import { shouldSkipTelegramGroupMessage } from "./bot-handlers.authorization-groups.runtime.js"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; import { resolveTelegramMessageTurnSettings } from "./bot-message.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; import { isTelegramCommandsAllowFromConfigured, resolveTelegramCommandAuthorization, @@ -24,6 +25,10 @@ import { type TelegramThreadSpec, } from "./bot/helpers.js"; import { enforceTelegramDmAccess, isTelegramDmAccessAllowed } from "./dm-access.js"; +import { + evaluateTelegramGroupBaseAccess, + evaluateTelegramGroupPolicyAccess, +} from "./group-access.js"; import { resolveTelegramCommandIngressAuthorization, resolveTelegramEventIngressAuthorization, @@ -35,7 +40,43 @@ export type TelegramEventAuthorizationMode = | "callback-allowlist" | "callback-runtime-allowlist"; -export function createTelegramHandlerAuthorizationRuntime({ +export interface TelegramHandlerAuthorization { + resolveTelegramEventAuthorizationContext: (params: { + cfg: OpenClawConfig; + chatId: number; + isGroup: boolean; + senderId?: string; + threadSpec: TelegramThreadSpec; + }) => Promise; + authorizeTelegramEventSender: (params: { + chatId: number; + chatTitle?: string; + isGroup: boolean; + senderId: string; + senderUsername: string; + mode: TelegramEventAuthorizationMode; + context: TelegramEventAuthorizationContext; + }) => Promise; + isTelegramModelCallbackAuthorized: (params: { + chatId: number; + isGroup: boolean; + senderId: string; + senderUsername: string; + context: TelegramEventAuthorizationContext; + }) => Promise; + authorizeInboundMessage: (params: { + msg: Message; + chatId: number; + isGroup: boolean; + isForum: boolean; + senderId: string; + senderUsername: string; + requireConfiguredGroup: boolean; + dmAccess: "challenge" | "silent"; + }) => Promise; +} + +export function createTelegramHandlerAuthorization({ accountId, bot, opts, @@ -43,17 +84,11 @@ export function createTelegramHandlerAuthorizationRuntime({ telegramDeps, resolveGroupPolicy, resolveTelegramGroupConfig, -}: RegisterTelegramHandlerParams) { +}: RegisterTelegramHandlerParams): TelegramHandlerAuthorization { const shouldSkipGroupMessage = (params: Parameters[0]) => shouldSkipTelegramGroupMessage(params, { logger, resolveGroupPolicy }); - type TelegramGroupAllowContext = Awaited>; - type TelegramEventAuthorizationContextValue = TelegramGroupAllowContext & { - cfg: OpenClawConfig; - telegramCfg: TelegramAccountConfig; - allowFrom: ReturnType["allowFrom"]; - dmPolicy: DmPolicy; - }; + type TelegramEventAuthorizationContextValue = TelegramEventAuthorizationContext; const TELEGRAM_EVENT_AUTH_RULES: Record< TelegramEventAuthorizationMode, { @@ -294,14 +329,6 @@ export function createTelegramHandlerAuthorizationRuntime({ }) ).authorized; }; - type TelegramInboundGate = - | { allowed: false } - | { - allowed: true; - context: TelegramEventAuthorizationContextValue; - effectiveDmAllow: NormalizedAllowFrom; - }; - // Single authorization gate for every message-like update that can reach the // reply-chain cache or dispatch: fresh messages, edits, channel posts. Must run // before any cache/dedupe side effect so blocked content is never recorded. @@ -418,6 +445,130 @@ export function createTelegramHandlerAuthorizationRuntime({ }; } -export type TelegramHandlerAuthorizationRuntime = ReturnType< - typeof createTelegramHandlerAuthorizationRuntime ->; +type TelegramEventAuthorizationContext = { + cfg: OpenClawConfig; + telegramCfg: TelegramAccountConfig; + allowFrom?: Array; + dmPolicy: DmPolicy; + threadSpec: TelegramThreadSpec; + resolvedThreadId?: number; + dmThreadId?: number; + storeAllowFrom: string[]; + groupConfig?: TelegramGroupConfig | TelegramDirectConfig; + topicConfig?: TelegramTopicConfig; + groupAllowOverride?: Array; + effectiveGroupAllow: NormalizedAllowFrom; + hasGroupAllowOverride: boolean; +}; + +type TelegramInboundGate = + | { allowed: false } + | { + allowed: true; + context: TelegramEventAuthorizationContext; + effectiveDmAllow: NormalizedAllowFrom; + }; + +function shouldSkipTelegramGroupMessage( + params: { + isGroup: boolean; + chatId: string | number; + chatTitle?: string; + resolvedThreadId?: number; + senderId: string; + senderUsername: string; + effectiveGroupAllow: NormalizedAllowFrom; + hasGroupAllowOverride: boolean; + groupConfig?: TelegramGroupConfig; + topicConfig?: TelegramTopicConfig; + cfg: OpenClawConfig; + telegramCfg: TelegramAccountConfig; + }, + runtime: Pick, +): boolean { + const { + isGroup, + chatId, + chatTitle, + resolvedThreadId, + senderId, + senderUsername, + effectiveGroupAllow, + hasGroupAllowOverride, + groupConfig, + topicConfig, + cfg, + telegramCfg, + } = params; + const baseAccess = evaluateTelegramGroupBaseAccess({ + isGroup, + groupConfig, + topicConfig, + hasGroupAllowOverride, + effectiveGroupAllow, + senderId, + senderUsername, + enforceAllowOverride: true, + requireSenderForAllowOverride: true, + }); + if (!baseAccess.allowed) { + if (baseAccess.reason === "group-disabled") { + logVerbose(`Blocked telegram group ${chatId} (group disabled)`); + return true; + } + if (baseAccess.reason === "topic-disabled") { + logVerbose( + `Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, + ); + return true; + } + logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`); + return true; + } + if (!isGroup) { + return false; + } + const policyAccess = evaluateTelegramGroupPolicyAccess({ + isGroup, + chatId, + cfg, + telegramCfg, + topicConfig, + groupConfig, + effectiveGroupAllow, + senderId, + senderUsername, + resolveGroupPolicy: runtime.resolveGroupPolicy, + enforcePolicy: true, + enforceAllowlistAuthorization: true, + allowEmptyAllowlistEntries: false, + requireSenderForAllowlistAuthorization: true, + checkChatAllowlist: true, + }); + if (policyAccess.allowed) { + return false; + } + if (policyAccess.reason === "group-policy-disabled") { + logVerbose("Blocked telegram group message (groupPolicy: disabled)"); + return true; + } + if (policyAccess.reason === "group-policy-allowlist-no-sender") { + logVerbose("Blocked telegram group message (no sender ID, groupPolicy: allowlist)"); + return true; + } + if (policyAccess.reason === "group-policy-allowlist-empty") { + logVerbose( + "Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)", + ); + return true; + } + if (policyAccess.reason === "group-policy-allowlist-unauthorized") { + logVerbose(`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`); + return true; + } + runtime.logger.info( + { chatId, title: chatTitle, reason: "not-allowed" }, + "skipping group message", + ); + return true; +} diff --git a/extensions/telegram/src/bot-handlers.inbound-debounce.runtime.ts b/extensions/telegram/src/bot-handlers.inbound-buffer.ts similarity index 51% rename from extensions/telegram/src/bot-handlers.inbound-debounce.runtime.ts rename to extensions/telegram/src/bot-handlers.inbound-buffer.ts index e73e2a72d4ba..97a5dc1db6aa 100644 --- a/extensions/telegram/src/bot-handlers.inbound-debounce.runtime.ts +++ b/extensions/telegram/src/bot-handlers.inbound-buffer.ts @@ -1,4 +1,3 @@ -// Telegram inbound debounce lanes and batch flushing. import type { Message } from "grammy/types"; import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-inbound"; import { @@ -6,11 +5,12 @@ import { resolveInboundDebounceMs, } from "openclaw/plugin-sdk/channel-inbound-debounce"; import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; +import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js"; +import type { TelegramMessagePipeline } from "./bot-handlers.message-pipeline.js"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; import type { TelegramMediaRef } from "./bot-message-context.js"; import type { TelegramAmbientTranscriptWatermark } from "./bot-message-context.types.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; import type { TelegramSpooledReplayDeferredParticipant } from "./bot-processing-outcome.js"; import { buildTelegramThreadParams, @@ -39,10 +39,51 @@ export type TelegramDebounceEntry = { spooledReplayParticipant?: TelegramSpooledReplayDeferredParticipant; }; -export function createTelegramInboundDebounceRuntime( - { cfg, bot, runtime }: Pick, - messageRuntime: TelegramHandlerMessageRuntime, -) { +type TextFragmentEntry = { + key: string; + storeAllowFrom: string[]; + messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>; + promptContextMinTimestampMs?: number; + promptContextAmbientWatermark?: TelegramAmbientTranscriptWatermark; + dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[]; + spooledReplayParticipants: TelegramSpooledReplayDeferredParticipant[]; + timer: ReturnType; +}; + +type TelegramTextFragmentInput = { + ctx: TelegramContext; + msg: Message; + chatId: number; + resolvedThreadId?: number; + dmThreadId?: number; + storeAllowFrom: string[]; + isAbortControlMessage: boolean; + isAuthorizedAbortControlMessage: () => Promise; + promptContextMinTimestampMs?: number; + promptContextAmbientWatermark?: TelegramAmbientTranscriptWatermark; + dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[]; +}; + +interface TelegramInboundBuffers { + inboundDebouncer: { + enqueue: (entry: TelegramDebounceEntry) => Promise; + flushKey: (key: string) => Promise; + cancelKey: (key: string) => boolean; + drain: () => Promise; + }; + resolveTelegramDebounceEntryMs: (entry: TelegramDebounceEntry) => number; + shouldDebounceTelegramEntry: (entry: TelegramDebounceEntry) => boolean; + resolveTelegramDebounceLane: (msg: Message) => TelegramDebounceLane; + handleTextFragment: (params: TelegramTextFragmentInput) => Promise; +} + +export function createTelegramInboundBuffers({ + params: { cfg, bot, runtime, opts }, + message, +}: { + params: Pick; + message: TelegramMessagePipeline; +}): TelegramInboundBuffers { const { promptContextBoundaryOptions, latestPromptContextMinTimestampMs, @@ -51,12 +92,13 @@ export function createTelegramInboundDebounceRuntime( releaseDispatchDedupeClaims, buildFailedProcessingResult, settleSpooledReplayParticipants, + createSpooledReplayParticipantForBufferedWork, spooledReplayOptions, buildSyntheticTextMessage, buildSyntheticContext, formatTelegramAmbientTranscriptBody, processMessageWithReplyChain, - } = messageRuntime; + } = message; const debounceMs = resolveInboundDebounceMs({ cfg, channel: "telegram" }); const FORWARD_BURST_DEBOUNCE_MS = 80; const resolveTelegramDebounceEntryMs = (entry: TelegramDebounceEntry): number => @@ -141,7 +183,6 @@ export function createTelegramInboundDebounceRuntime( settleSpooledReplayParticipants(participants, { kind: "skipped" }); return; } - // Single entries return above with their original message and structured forward metadata. const first = expectDefined(entries.at(0), "multi-entry Telegram debounce batch"); const syntheticMessage = { ...buildSyntheticTextMessage({ @@ -235,10 +276,171 @@ export function createTelegramInboundDebounceRuntime( }, }); + const maxGapMs = + typeof opts.testTimings?.textFragmentGapMs === "number" && + Number.isFinite(opts.testTimings.textFragmentGapMs) + ? Math.max(10, Math.floor(opts.testTimings.textFragmentGapMs)) + : 1500; + const textBuffer = new Map(); + const textQueue = new KeyedAsyncQueue(); + + const flushTextFragments = async (entry: TextFragmentEntry) => { + try { + entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id); + const first = entry.messages[0]; + const last = entry.messages.at(-1); + if (!first || !last) { + releaseDispatchDedupeClaims(entry.dispatchDedupeClaims); + settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" }); + return; + } + const combinedTextParts = joinTelegramTextParts( + entry.messages.map((bufferedMessage) => bufferedMessage.msg), + "", + ); + const combinedText = combinedTextParts.text; + if (!combinedText.trim()) { + releaseDispatchDedupeClaims(entry.dispatchDedupeClaims); + settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" }); + return; + } + const syntheticMessage = buildSyntheticTextMessage({ + base: first.msg, + text: combinedText, + entities: combinedTextParts.entities, + date: last.msg.date ?? first.msg.date, + }); + const result = await processMessageWithReplyChain({ + ctx: buildSyntheticContext(first.ctx, syntheticMessage), + msg: syntheticMessage, + allMedia: [], + storeAllowFrom: entry.storeAllowFrom, + options: { + messageIdOverride: String(last.msg.message_id), + ambientTranscriptBody: formatTelegramAmbientTranscriptBody( + entry.messages.map((bufferedMessage) => bufferedMessage.msg), + ), + receivedAtMs: first.receivedAtMs, + ingressBuffer: "text-fragment", + ...promptContextBoundaryOptions( + entry.promptContextMinTimestampMs, + entry.promptContextAmbientWatermark, + ), + ...spooledReplayOptions(entry.spooledReplayParticipants), + }, + dispatchDedupeClaims: entry.dispatchDedupeClaims, + spooledReplayParticipants: entry.spooledReplayParticipants, + }); + settleSpooledReplayParticipants(entry.spooledReplayParticipants, result); + } catch (error) { + releaseDispatchDedupeClaims(entry.dispatchDedupeClaims, error); + settleSpooledReplayParticipants( + entry.spooledReplayParticipants, + buildFailedProcessingResult(error), + ); + runtime.error?.(danger(`text fragment handler failed: ${String(error)}`)); + } + }; + const queueTextFlush = async (entry: TextFragmentEntry) => { + await textQueue.enqueue(entry.key, async () => { + await flushTextFragments(entry).catch(() => undefined); + }); + }; + const runTextFlush = async (entry: TextFragmentEntry) => { + textBuffer.delete(entry.key); + await queueTextFlush(entry); + }; + const scheduleTextFlush = (entry: TextFragmentEntry) => { + clearTimeout(entry.timer); + entry.timer = setTimeout(() => void runTextFlush(entry), maxGapMs); + }; + const handleTextFragment = async (params: TelegramTextFragmentInput): Promise => { + const text = typeof params.msg.text === "string" ? params.msg.text : undefined; + const isCommandLike = (text ?? "").trim().startsWith("/"); + const senderId = params.msg.from?.id != null ? String(params.msg.from.id) : "unknown"; + const threadId = params.resolvedThreadId ?? params.dmThreadId; + const key = `text:${params.chatId}:${threadId ?? "main"}:${senderId}`; + if (text && !isCommandLike && !params.isAbortControlMessage) { + const nowMs = Date.now(); + const existing = textBuffer.get(key); + if (existing) { + const last = existing.messages.at(-1); + const idGap = last ? params.msg.message_id - last.msg.message_id : Infinity; + const timeGapMs = nowMs - (last?.receivedAtMs ?? nowMs); + const canAppend = idGap > 0 && idGap <= 1 && timeGapMs >= 0 && timeGapMs <= maxGapMs; + const nextTotalChars = + existing.messages.reduce( + (sum, bufferedMessage) => sum + (bufferedMessage.msg.text?.length ?? 0), + 0, + ) + text.length; + if (canAppend && existing.messages.length < 12 && nextTotalChars <= 50_000) { + const participant = createSpooledReplayParticipantForBufferedWork( + `text-fragment:${key}:${params.msg.message_id}`, + ); + if (participant) { + existing.spooledReplayParticipants.push(participant); + } + existing.messages.push({ msg: params.msg, ctx: params.ctx, receivedAtMs: nowMs }); + existing.promptContextMinTimestampMs = latestPromptContextMinTimestampMs( + existing.promptContextMinTimestampMs, + params.promptContextMinTimestampMs, + ); + existing.promptContextAmbientWatermark = latestPromptContextAmbientWatermark( + existing.promptContextAmbientWatermark, + params.promptContextAmbientWatermark, + ); + existing.dispatchDedupeClaims = mergeDispatchDedupeClaims( + existing.dispatchDedupeClaims, + params.dispatchDedupeClaims, + ); + scheduleTextFlush(existing); + return true; + } + clearTimeout(existing.timer); + textBuffer.delete(key); + await queueTextFlush(existing); + } + if (text.length >= 4000) { + const participant = createSpooledReplayParticipantForBufferedWork( + `text-fragment:${key}:${params.msg.message_id}`, + ); + const entry: TextFragmentEntry = { + key, + storeAllowFrom: params.storeAllowFrom, + messages: [{ msg: params.msg, ctx: params.ctx, receivedAtMs: nowMs }], + dispatchDedupeClaims: params.dispatchDedupeClaims, + spooledReplayParticipants: participant ? [participant] : [], + ...promptContextBoundaryOptions( + params.promptContextMinTimestampMs, + params.promptContextAmbientWatermark, + ), + timer: setTimeout(() => {}, maxGapMs), + }; + textBuffer.set(key, entry); + scheduleTextFlush(entry); + return true; + } + } else if ( + text && + params.isAbortControlMessage && + (await params.isAuthorizedAbortControlMessage()) + ) { + const existing = textBuffer.get(key); + if (existing) { + clearTimeout(existing.timer); + textBuffer.delete(key); + releaseDispatchDedupeClaims(existing.dispatchDedupeClaims); + settleSpooledReplayParticipants(existing.spooledReplayParticipants, { kind: "skipped" }); + } + } + return false; + }; + return { inboundDebouncer, resolveTelegramDebounceEntryMs, shouldDebounceTelegramEntry, resolveTelegramDebounceLane, + handleTextFragment, }; } diff --git a/extensions/telegram/src/bot-handlers.inbound-media-group.runtime.ts b/extensions/telegram/src/bot-handlers.inbound-media.ts similarity index 96% rename from extensions/telegram/src/bot-handlers.inbound-media-group.runtime.ts rename to extensions/telegram/src/bot-handlers.inbound-media.ts index 712bf9bb52ef..f00a354e7cc2 100644 --- a/extensions/telegram/src/bot-handlers.inbound-media-group.runtime.ts +++ b/extensions/telegram/src/bot-handlers.inbound-media.ts @@ -1,4 +1,3 @@ -// Telegram media-group buffering and mention-aware album dispatch. import type { Message } from "grammy/types"; import { buildMentionRegexes, @@ -21,10 +20,10 @@ import { isDurablyRetryableInboundMediaError, isRecoverableMediaGroupError, } from "./bot-handlers.media.js"; -import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js"; +import type { TelegramMessagePipeline } from "./bot-handlers.message-pipeline.js"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; import type { TelegramMediaRef } from "./bot-message-context.js"; import type { TelegramAmbientTranscriptWatermark } from "./bot-message-context.types.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; import type { TelegramSpooledReplayDeferredParticipant } from "./bot-processing-outcome.js"; import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js"; import { resolveMedia } from "./bot/delivery.resolve-media.js"; @@ -72,7 +71,17 @@ type BufferedMediaGroupEntry = MediaGroupEntry & type TelegramGroupMediaDisposition = "process" | "skip" | "silent-ingest"; -export function createTelegramInboundMediaGroupRuntime( +interface TelegramInboundMedia { + handleMediaGroup: (input: TelegramMediaGroupInput) => boolean; + resolveUnaddressedGroupMediaDisposition: ( + authorization: MediaAuthorization & { ctx: TelegramContext; msg: Message }, + ) => Promise; +} + +export function createTelegramInboundMedia({ + params, + message, +}: { params: Pick< RegisterTelegramHandlerParams, | "accountId" @@ -83,9 +92,9 @@ export function createTelegramInboundMediaGroupRuntime( | "logger" | "resolveGroupActivation" | "resolveGroupRequireMention" - >, - messageRuntime: TelegramHandlerMessageRuntime, -) { + >; + message: TelegramMessagePipeline; +}): TelegramInboundMedia { const { accountId, bot, @@ -110,7 +119,7 @@ export function createTelegramInboundMediaGroupRuntime( spooledReplayOptions, resolveTelegramSessionState, processMessageWithReplyChain, - } = messageRuntime; + } = message; const timeoutMs = typeof opts.testTimings?.mediaGroupFlushMs === "number" && Number.isFinite(opts.testTimings.mediaGroupFlushMs) diff --git a/extensions/telegram/src/bot-handlers.inbound-pipeline.test.ts b/extensions/telegram/src/bot-handlers.inbound-pipeline.test.ts new file mode 100644 index 000000000000..43d2ec2fd596 --- /dev/null +++ b/extensions/telegram/src/bot-handlers.inbound-pipeline.test.ts @@ -0,0 +1,57 @@ +// Telegram tests cover inbound buffering identity. +import { describe, expect, it } from "vitest"; +import { + buildTelegramInboundDebounceConversationKey, + buildTelegramInboundDebounceKey, +} from "./bot-handlers.debounce-key.js"; + +describe("buildTelegramInboundDebounceKey", () => { + it("uses the resolved account id instead of literal default when provided", () => { + expect( + buildTelegramInboundDebounceKey({ + accountId: "work", + conversationKey: "12345", + senderId: "67890", + debounceLane: "default", + }), + ).toBe("telegram:work:12345:67890:default"); + }); + + it("falls back to literal default only when account id is actually absent", () => { + expect( + buildTelegramInboundDebounceKey({ + accountId: undefined, + conversationKey: "12345", + senderId: "67890", + debounceLane: "forward", + }), + ).toBe("telegram:default:12345:67890:forward"); + }); + + it("keeps direct topic thread ids in the conversation key", () => { + const topic100 = buildTelegramInboundDebounceConversationKey({ chatId: 7, threadId: 100 }); + const topic200 = buildTelegramInboundDebounceConversationKey({ chatId: 7, threadId: 200 }); + + expect(topic100).toBe("7:topic:100"); + expect(topic200).toBe("7:topic:200"); + expect( + buildTelegramInboundDebounceKey({ + accountId: "default", + conversationKey: topic100, + senderId: "42", + debounceLane: "default", + }), + ).not.toBe( + buildTelegramInboundDebounceKey({ + accountId: "default", + conversationKey: topic200, + senderId: "42", + debounceLane: "default", + }), + ); + }); + + it("uses the chat id as the conversation key when no thread is present", () => { + expect(buildTelegramInboundDebounceConversationKey({ chatId: 7 })).toBe("7"); + }); +}); diff --git a/extensions/telegram/src/bot-handlers.message-events.runtime.ts b/extensions/telegram/src/bot-handlers.inbound-pipeline.ts similarity index 75% rename from extensions/telegram/src/bot-handlers.message-events.runtime.ts rename to extensions/telegram/src/bot-handlers.inbound-pipeline.ts index bea018f30461..37d6975b5cbe 100644 --- a/extensions/telegram/src/bot-handlers.message-events.runtime.ts +++ b/extensions/telegram/src/bot-handlers.inbound-pipeline.ts @@ -1,12 +1,17 @@ -// Telegram message-like update registration and cache/dispatch ordering. +import type { Context } from "grammy"; import type { Message } from "grammy/types"; import type { TelegramGroupConfig } from "openclaw/plugin-sdk/config-contracts"; import { danger } from "openclaw/plugin-sdk/runtime-env"; import { withTelegramApiErrorLogging } from "./api-logging.js"; -import type { TelegramHandlerAuthorizationRuntime } from "./bot-handlers.authorization.runtime.js"; -import type { TelegramHandlerInboundRuntime } from "./bot-handlers.inbound.runtime.js"; -import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; +import type { TelegramHandlerAuthorization } from "./bot-handlers.inbound-authorization.js"; +import { createTelegramInboundProcessing } from "./bot-handlers.inbound-processing.js"; +import type { TelegramInboundProcessing } from "./bot-handlers.inbound-processing.js"; +import type { TelegramMessagePipeline } from "./bot-handlers.message-pipeline.js"; +import type { + RegisterTelegramHandlerParams, + TelegramInboundDisposition, + TelegramInboundPipeline, +} from "./bot-handlers.types.js"; import { isTelegramSpooledReplayUpdate, recordTelegramMessageProcessingResult, @@ -30,7 +35,7 @@ type TelegramMessageHandlerParams = Pick< }; type TelegramMessageHandlerRuntime = Pick< - TelegramHandlerMessageRuntime, + TelegramMessagePipeline, | "normalizePromptContextMinTimestampMs" | "promptContextBoundaryOptions" | "releaseDispatchDedupeClaims" @@ -40,16 +45,23 @@ type TelegramMessageHandlerRuntime = Pick< | "resolvePromptContextAmbientWatermark" > & { recordMessageForReplyChain: ( - ...args: Parameters + ...args: Parameters ) => Promise; }; -export function registerTelegramMessageHandlers( +interface TelegramInboundHandlers { + handleMessage: (ctx: Context) => Promise; + handleEditedMessage: (ctx: Context) => Promise; + handleChannelPost: (ctx: Context) => Promise; + handleEditedChannelPost: (ctx: Context) => Promise; +} + +function createTelegramInboundHandlers( { bot, opts, runtime, shouldSkipUpdate }: TelegramMessageHandlerParams, messageRuntime: TelegramMessageHandlerRuntime, - authorizationRuntime: Pick, - inboundRuntime: Pick, -) { + authorizationRuntime: Pick, + inboundRuntime: Pick, +): TelegramInboundHandlers { const { normalizePromptContextMinTimestampMs, promptContextBoundaryOptions, @@ -147,11 +159,13 @@ export function registerTelegramMessageHandlers( await recordMessageForReplyChain(normalizedMsg, gate.context.threadSpec, params.botUserId); }; - const handleInboundMessageLike = async (event: InboundTelegramEvent) => { + const handleInboundMessageLike = async ( + event: InboundTelegramEvent, + ): Promise => { let dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[] = []; try { if (shouldSkipUpdate(event.ctxForDedupe)) { - return; + return { kind: "ignored" }; } const gate = await authorizeInboundMessage({ msg: event.msg, @@ -164,7 +178,7 @@ export function registerTelegramMessageHandlers( dmAccess: "challenge", }); if (!gate.allowed) { - return; + return { kind: "ignored" }; } const { effectiveDmAllow } = gate; const { @@ -200,11 +214,11 @@ export function registerTelegramMessageHandlers( const dispatchDedupe = await claimMessageDispatchDedupe(event.msg, event.botUserId); if (!dispatchDedupe.process) { - return; + return { kind: "ignored" }; } dispatchDedupeClaims = dispatchDedupe.claims; await recordMessageForReplyChain(event.msg, gate.context.threadSpec, event.botUserId); - await processInboundMessage({ + return await processInboundMessage({ authorizationCfg: gate.context.cfg, ctx: event.ctx, msg: event.msg, @@ -234,7 +248,7 @@ export function registerTelegramMessageHandlers( // Spooled replays are durably retried; live updates get one apology // because they are acked without replay. if (spooledReplay) { - return; + return { kind: "ignored" }; } await withTelegramApiErrorLogging({ operation: "sendMessage", @@ -252,13 +266,14 @@ export function registerTelegramMessageHandlers( ), }).catch(() => {}); } + return { kind: "ignored" }; } }; - bot.on("message", async (ctx) => { + const handleMessage = async (ctx: Context): Promise => { const msg = ctx.message; if (!msg) { - return; + return { kind: "ignored" }; } const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; const isForum = await resolveTelegramForumFlag({ @@ -274,9 +289,9 @@ export function registerTelegramMessageHandlers( // Bot-authored message updates can be echoed back by Telegram. Skip them here // and rely on the dedicated channel_post handler for channel-originated posts. if (normalizedMsg.from?.id != null && normalizedMsg.from.id === botUserId) { - return; + return { kind: "ignored" }; } - await handleInboundMessageLike({ + return await handleInboundMessageLike({ ctxForDedupe: ctx, ctx: buildSyntheticContext(ctx, normalizedMsg), botUserId, @@ -292,12 +307,12 @@ export function registerTelegramMessageHandlers( oversizeLogMessage: "media exceeds size limit", errorMessage: "handler failed", }); - }); + }; - bot.on("edited_message", async (ctx) => { + const handleEditedMessage = async (ctx: Context): Promise => { const msg = ctx.editedMessage; if (!msg) { - return; + return { kind: "ignored" }; } await recordEditedMessageForReplyChain({ ctxForDedupe: ctx, @@ -305,21 +320,19 @@ export function registerTelegramMessageHandlers( requireConfiguredGroup: false, botUserId: resolveBotUserId(ctx), }); - }); + return { kind: "recorded" }; + }; - // Handle channel posts — enables bot-to-bot communication via Telegram channels. - // Telegram bots cannot see other bot messages in groups, but CAN in channels. - // This handler normalizes channel_post updates into the standard message pipeline. - bot.on("channel_post", async (ctx) => { + const handleChannelPost = async (ctx: Context): Promise => { const post = ctx.channelPost; if (!post) { - return; + return { kind: "ignored" }; } const chatId = post.chat.id; const syntheticMsg = normalizeChannelPostMessage(post); - await handleInboundMessageLike({ + return await handleInboundMessageLike({ ctxForDedupe: ctx, ctx: buildSyntheticContext(ctx, syntheticMsg), botUserId: resolveBotUserId(ctx), @@ -339,12 +352,12 @@ export function registerTelegramMessageHandlers( oversizeLogMessage: "channel post media exceeds size limit", errorMessage: "channel_post handler failed", }); - }); + }; - bot.on("edited_channel_post", async (ctx) => { + const handleEditedChannelPost = async (ctx: Context): Promise => { const post = ctx.editedChannelPost; if (!post) { - return; + return { kind: "ignored" }; } await recordEditedMessageForReplyChain({ ctxForDedupe: ctx, @@ -352,5 +365,55 @@ export function registerTelegramMessageHandlers( requireConfiguredGroup: true, botUserId: resolveBotUserId(ctx), }); - }); + return { kind: "recorded" }; + }; + + return { handleMessage, handleEditedMessage, handleChannelPost, handleEditedChannelPost }; +} + +export function createTelegramInboundPipeline({ + params, + message, + authorization, +}: { + params: RegisterTelegramHandlerParams; + message: TelegramMessagePipeline; + authorization: TelegramHandlerAuthorization; +}): TelegramInboundPipeline { + const handlers = createTelegramInboundHandlers( + params, + message, + authorization, + createTelegramInboundProcessing({ params, message }), + ); + return { + handle: async (ctx) => { + if (ctx.message) { + return await handlers.handleMessage(ctx); + } + if (ctx.editedMessage) { + return await handlers.handleEditedMessage(ctx); + } + if (ctx.channelPost) { + return await handlers.handleChannelPost(ctx); + } + if (ctx.editedChannelPost) { + return await handlers.handleEditedChannelPost(ctx); + } + return { kind: "ignored" }; + }, + }; +} + +export function registerTelegramInboundHandlers({ + bot, + pipeline, +}: { + bot: RegisterTelegramHandlerParams["bot"]; + pipeline: TelegramInboundPipeline; +}): void { + bot.on("message", pipeline.handle); + bot.on("edited_message", pipeline.handle); + bot.on("channel_post", pipeline.handle); + bot.on("edited_channel_post", pipeline.handle); } diff --git a/extensions/telegram/src/bot-handlers.inbound.runtime.ts b/extensions/telegram/src/bot-handlers.inbound-processing.ts similarity index 80% rename from extensions/telegram/src/bot-handlers.inbound.runtime.ts rename to extensions/telegram/src/bot-handlers.inbound-processing.ts index 278f3318b578..236b0bf6d438 100644 --- a/extensions/telegram/src/bot-handlers.inbound.runtime.ts +++ b/extensions/telegram/src/bot-handlers.inbound-processing.ts @@ -1,4 +1,3 @@ -// Telegram inbound buffering, media resolution, and message dispatch. import type { Message } from "grammy/types"; import { isAbortRequestText } from "openclaw/plugin-sdk/command-primitives-runtime"; import type { @@ -14,19 +13,21 @@ import { buildTelegramInboundDebounceKey, } from "./bot-handlers.debounce-key.js"; import { - createTelegramInboundDebounceRuntime, + createTelegramInboundBuffers, type TelegramDebounceEntry, -} from "./bot-handlers.inbound-debounce.runtime.js"; -import { createTelegramInboundMediaGroupRuntime } from "./bot-handlers.inbound-media-group.runtime.js"; -import { createTelegramInboundTextRuntime } from "./bot-handlers.inbound-text.runtime.js"; +} from "./bot-handlers.inbound-buffer.js"; +import { createTelegramInboundMedia } from "./bot-handlers.inbound-media.js"; import { isDurablyRetryableInboundMediaError, isMediaSizeLimitError, TelegramBotApiFileTooLargeError, } from "./bot-handlers.media.js"; -import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js"; +import type { TelegramMessagePipeline } from "./bot-handlers.message-pipeline.js"; +import type { + RegisterTelegramHandlerParams, + TelegramInboundDisposition, +} from "./bot-handlers.types.js"; import type { TelegramAmbientTranscriptWatermark } from "./bot-message-context.types.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; import { isTelegramSpooledReplayUpdate, recordTelegramMessageProcessingResult, @@ -42,8 +43,35 @@ import type { TelegramContext } from "./bot/types.js"; import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; import type { TelegramMessageDispatchReplayClaim } from "./message-dispatch-dedupe.js"; -export function createTelegramHandlerInboundRuntime( - { +export interface TelegramInboundProcessing { + processInboundMessage: (params: TelegramInboundMessage) => Promise; +} + +type TelegramInboundMessage = { + authorizationCfg: OpenClawConfig; + ctx: TelegramContext; + msg: Message; + chatId: number; + isGroup: boolean; + isForum: boolean; + resolvedThreadId?: number; + dmThreadId?: number; + dmPolicy: DmPolicy; + storeAllowFrom: string[]; + senderId: string; + effectiveGroupAllow: NormalizedAllowFrom; + effectiveDmAllow: NormalizedAllowFrom; + groupConfig?: TelegramGroupConfig; + topicConfig?: TelegramTopicConfig; + sendOversizeWarning: boolean; + oversizeLogMessage: string; + promptContextMinTimestampMs?: number; + promptContextAmbientWatermark?: TelegramAmbientTranscriptWatermark; + dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[]; +}; + +export function createTelegramInboundProcessing({ + params: { cfg, accountId, bot, @@ -53,64 +81,43 @@ export function createTelegramHandlerInboundRuntime( logger, resolveGroupActivation, resolveGroupRequireMention, - }: RegisterTelegramHandlerParams, - messageRuntime: TelegramHandlerMessageRuntime, -) { + }, + message, +}: { + params: RegisterTelegramHandlerParams; + message: TelegramMessagePipeline; +}): TelegramInboundProcessing { const { resolveMediaRuntime, recordMessageResolvedMedia, promptContextBoundaryOptions, releaseDispatchDedupeClaims, createSpooledReplayParticipantForBufferedWork, - } = messageRuntime; + } = message; const { inboundDebouncer, resolveTelegramDebounceEntryMs, shouldDebounceTelegramEntry, resolveTelegramDebounceLane, - } = createTelegramInboundDebounceRuntime({ cfg, bot, runtime }, messageRuntime); + handleTextFragment, + } = createTelegramInboundBuffers({ params: { cfg, bot, runtime, opts }, message }); - const { handleMediaGroup, resolveUnaddressedGroupMediaDisposition } = - createTelegramInboundMediaGroupRuntime( - { - accountId, - bot, - opts, - runtime, - mediaMaxBytes, - logger, - resolveGroupActivation, - resolveGroupRequireMention, - }, - messageRuntime, - ); - - const { handleTextFragment } = createTelegramInboundTextRuntime( - { opts, runtime }, - messageRuntime, - ); - const processInboundMessage = async (params: { - authorizationCfg: OpenClawConfig; - ctx: TelegramContext; - msg: Message; - chatId: number; - isGroup: boolean; - isForum: boolean; - resolvedThreadId?: number; - dmThreadId?: number; - dmPolicy: DmPolicy; - storeAllowFrom: string[]; - senderId: string; - effectiveGroupAllow: NormalizedAllowFrom; - effectiveDmAllow: NormalizedAllowFrom; - groupConfig?: TelegramGroupConfig; - topicConfig?: TelegramTopicConfig; - sendOversizeWarning: boolean; - oversizeLogMessage: string; - promptContextMinTimestampMs?: number; - promptContextAmbientWatermark?: TelegramAmbientTranscriptWatermark; - dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[]; - }) => { + const { handleMediaGroup, resolveUnaddressedGroupMediaDisposition } = createTelegramInboundMedia({ + params: { + accountId, + bot, + opts, + runtime, + mediaMaxBytes, + logger, + resolveGroupActivation, + resolveGroupRequireMention, + }, + message, + }); + const processInboundMessage = async ( + params: TelegramInboundMessage, + ): Promise => { const { authorizationCfg, ctx, @@ -177,10 +184,9 @@ export function createTelegramHandlerInboundRuntime( dispatchDedupeClaims, }) ) { - return; + return { kind: "buffered", buffer: "text-fragment" }; } - // Media group handling - buffer multi-image messages if ( handleMediaGroup({ authorizationCfg, @@ -202,7 +208,7 @@ export function createTelegramHandlerInboundRuntime( dispatchDedupeClaims, }) ) { - return; + return { kind: "buffered", buffer: "media-group" }; } const mediaDisposition = await resolveUnaddressedGroupMediaDisposition({ @@ -222,7 +228,7 @@ export function createTelegramHandlerInboundRuntime( }); if (mediaDisposition === "skip") { releaseDispatchDedupeClaims(dispatchDedupeClaims); - return; + return { kind: "ignored" }; } const nativeMedia = resolveTelegramPrimaryMedia(msg); @@ -248,7 +254,7 @@ export function createTelegramHandlerInboundRuntime( // drop the message during shutdown or deadline cancellation. recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: mediaErr }); releaseDispatchDedupeClaims(dispatchDedupeClaims, mediaErr); - return; + return { kind: "ignored" }; } if (isMediaSizeLimitError(mediaErr)) { if (sendOversizeWarning && mediaDisposition !== "silent-ingest") { @@ -276,7 +282,7 @@ export function createTelegramHandlerInboundRuntime( if (retryable && replayingSpooledUpdate) { recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: mediaErr }); releaseDispatchDedupeClaims(dispatchDedupeClaims, mediaErr); - return; + return { kind: "ignored" }; } if (mediaDisposition !== "silent-ingest") { await withTelegramApiErrorLogging({ @@ -344,19 +350,19 @@ export function createTelegramHandlerInboundRuntime( ...promptContextBoundaryOptions(promptContextMinTimestampMs, promptContextAmbientWatermark), dispatchDedupeClaims, }; - if ( + const shouldBufferDebounce = Boolean( debounceEntry.debounceKey && resolveTelegramDebounceEntryMs(debounceEntry) > 0 && - shouldDebounceTelegramEntry(debounceEntry) - ) { + shouldDebounceTelegramEntry(debounceEntry), + ); + if (shouldBufferDebounce) { debounceEntry.spooledReplayParticipant = createSpooledReplayParticipantForBufferedWork( `inbound-debounce:${debounceEntry.debounceKey}`, ); } await inboundDebouncer.enqueue(debounceEntry); + return shouldBufferDebounce ? { kind: "buffered", buffer: "debounce" } : { kind: "processed" }; }; return { processInboundMessage }; } - -export type TelegramHandlerInboundRuntime = ReturnType; diff --git a/extensions/telegram/src/bot-handlers.inbound-text.runtime.ts b/extensions/telegram/src/bot-handlers.inbound-text.runtime.ts deleted file mode 100644 index cfe94432ec47..000000000000 --- a/extensions/telegram/src/bot-handlers.inbound-text.runtime.ts +++ /dev/null @@ -1,217 +0,0 @@ -// Telegram long-text fragment buffering and ordered flush. -import type { Message } from "grammy/types"; -import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; -import { danger } from "openclaw/plugin-sdk/runtime-env"; -import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js"; -import type { TelegramAmbientTranscriptWatermark } from "./bot-message-context.types.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; -import type { TelegramSpooledReplayDeferredParticipant } from "./bot-processing-outcome.js"; -import { joinTelegramTextParts } from "./bot/helpers.js"; -import type { TelegramContext } from "./bot/types.js"; -import type { TelegramMessageDispatchReplayClaim } from "./message-dispatch-dedupe.js"; - -type TextFragmentEntry = { - key: string; - storeAllowFrom: string[]; - messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>; - promptContextMinTimestampMs?: number; - promptContextAmbientWatermark?: TelegramAmbientTranscriptWatermark; - dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[]; - spooledReplayParticipants: TelegramSpooledReplayDeferredParticipant[]; - timer: ReturnType; -}; - -type TelegramTextFragmentInput = { - ctx: TelegramContext; - msg: Message; - chatId: number; - resolvedThreadId?: number; - dmThreadId?: number; - storeAllowFrom: string[]; - isAbortControlMessage: boolean; - isAuthorizedAbortControlMessage: () => Promise; - promptContextMinTimestampMs?: number; - promptContextAmbientWatermark?: TelegramAmbientTranscriptWatermark; - dispatchDedupeClaims: TelegramMessageDispatchReplayClaim[]; -}; - -export function createTelegramInboundTextRuntime( - { opts, runtime }: Pick, - messageRuntime: TelegramHandlerMessageRuntime, -) { - const { - promptContextBoundaryOptions, - latestPromptContextMinTimestampMs, - latestPromptContextAmbientWatermark, - mergeDispatchDedupeClaims, - releaseDispatchDedupeClaims, - buildFailedProcessingResult, - settleSpooledReplayParticipants, - createSpooledReplayParticipantForBufferedWork, - spooledReplayOptions, - buildSyntheticTextMessage, - buildSyntheticContext, - formatTelegramAmbientTranscriptBody, - processMessageWithReplyChain, - } = messageRuntime; - const maxGapMs = - typeof opts.testTimings?.textFragmentGapMs === "number" && - Number.isFinite(opts.testTimings.textFragmentGapMs) - ? Math.max(10, Math.floor(opts.testTimings.textFragmentGapMs)) - : 1500; - const buffer = new Map(); - const queue = new KeyedAsyncQueue(); - - const flush = async (entry: TextFragmentEntry) => { - try { - entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id); - const first = entry.messages[0]; - const last = entry.messages.at(-1); - if (!first || !last) { - releaseDispatchDedupeClaims(entry.dispatchDedupeClaims); - settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" }); - return; - } - const combinedTextParts = joinTelegramTextParts( - entry.messages.map((message) => message.msg), - "", - ); - const combinedText = combinedTextParts.text; - if (!combinedText.trim()) { - releaseDispatchDedupeClaims(entry.dispatchDedupeClaims); - settleSpooledReplayParticipants(entry.spooledReplayParticipants, { kind: "skipped" }); - return; - } - const syntheticMessage = buildSyntheticTextMessage({ - base: first.msg, - text: combinedText, - entities: combinedTextParts.entities, - date: last.msg.date ?? first.msg.date, - }); - const result = await processMessageWithReplyChain({ - ctx: buildSyntheticContext(first.ctx, syntheticMessage), - msg: syntheticMessage, - allMedia: [], - storeAllowFrom: entry.storeAllowFrom, - options: { - messageIdOverride: String(last.msg.message_id), - ambientTranscriptBody: formatTelegramAmbientTranscriptBody( - entry.messages.map((message) => message.msg), - ), - receivedAtMs: first.receivedAtMs, - ingressBuffer: "text-fragment", - ...promptContextBoundaryOptions( - entry.promptContextMinTimestampMs, - entry.promptContextAmbientWatermark, - ), - ...spooledReplayOptions(entry.spooledReplayParticipants), - }, - dispatchDedupeClaims: entry.dispatchDedupeClaims, - spooledReplayParticipants: entry.spooledReplayParticipants, - }); - settleSpooledReplayParticipants(entry.spooledReplayParticipants, result); - } catch (error) { - releaseDispatchDedupeClaims(entry.dispatchDedupeClaims, error); - settleSpooledReplayParticipants( - entry.spooledReplayParticipants, - buildFailedProcessingResult(error), - ); - runtime.error?.(danger(`text fragment handler failed: ${String(error)}`)); - } - }; - const queueFlush = async (entry: TextFragmentEntry) => { - await queue.enqueue(entry.key, async () => { - await flush(entry).catch(() => undefined); - }); - }; - const runFlush = async (entry: TextFragmentEntry) => { - buffer.delete(entry.key); - await queueFlush(entry); - }; - const scheduleFlush = (entry: TextFragmentEntry) => { - clearTimeout(entry.timer); - entry.timer = setTimeout(() => void runFlush(entry), maxGapMs); - }; - - const handleTextFragment = async (params: TelegramTextFragmentInput): Promise => { - const text = typeof params.msg.text === "string" ? params.msg.text : undefined; - const isCommandLike = (text ?? "").trim().startsWith("/"); - const senderId = params.msg.from?.id != null ? String(params.msg.from.id) : "unknown"; - const threadId = params.resolvedThreadId ?? params.dmThreadId; - const key = `text:${params.chatId}:${threadId ?? "main"}:${senderId}`; - if (text && !isCommandLike && !params.isAbortControlMessage) { - const nowMs = Date.now(); - const existing = buffer.get(key); - if (existing) { - const last = existing.messages.at(-1); - const idGap = last ? params.msg.message_id - last.msg.message_id : Infinity; - const timeGapMs = nowMs - (last?.receivedAtMs ?? nowMs); - const canAppend = idGap > 0 && idGap <= 1 && timeGapMs >= 0 && timeGapMs <= maxGapMs; - const nextTotalChars = - existing.messages.reduce((sum, message) => sum + (message.msg.text?.length ?? 0), 0) + - text.length; - if (canAppend && existing.messages.length < 12 && nextTotalChars <= 50_000) { - const participant = createSpooledReplayParticipantForBufferedWork( - `text-fragment:${key}:${params.msg.message_id}`, - ); - if (participant) { - existing.spooledReplayParticipants.push(participant); - } - existing.messages.push({ msg: params.msg, ctx: params.ctx, receivedAtMs: nowMs }); - existing.promptContextMinTimestampMs = latestPromptContextMinTimestampMs( - existing.promptContextMinTimestampMs, - params.promptContextMinTimestampMs, - ); - existing.promptContextAmbientWatermark = latestPromptContextAmbientWatermark( - existing.promptContextAmbientWatermark, - params.promptContextAmbientWatermark, - ); - existing.dispatchDedupeClaims = mergeDispatchDedupeClaims( - existing.dispatchDedupeClaims, - params.dispatchDedupeClaims, - ); - scheduleFlush(existing); - return true; - } - clearTimeout(existing.timer); - buffer.delete(key); - await queueFlush(existing); - } - if (text.length >= 4000) { - const participant = createSpooledReplayParticipantForBufferedWork( - `text-fragment:${key}:${params.msg.message_id}`, - ); - const entry: TextFragmentEntry = { - key, - storeAllowFrom: params.storeAllowFrom, - messages: [{ msg: params.msg, ctx: params.ctx, receivedAtMs: nowMs }], - dispatchDedupeClaims: params.dispatchDedupeClaims, - spooledReplayParticipants: participant ? [participant] : [], - ...promptContextBoundaryOptions( - params.promptContextMinTimestampMs, - params.promptContextAmbientWatermark, - ), - timer: setTimeout(() => {}, maxGapMs), - }; - buffer.set(key, entry); - scheduleFlush(entry); - return true; - } - } else if ( - text && - params.isAbortControlMessage && - (await params.isAuthorizedAbortControlMessage()) - ) { - const existing = buffer.get(key); - if (existing) { - clearTimeout(existing.timer); - buffer.delete(key); - releaseDispatchDedupeClaims(existing.dispatchDedupeClaims); - settleSpooledReplayParticipants(existing.spooledReplayParticipants, { kind: "skipped" }); - } - } - return false; - }; - - return { handleTextFragment }; -} diff --git a/extensions/telegram/src/bot-handlers.message-context.runtime.test.ts b/extensions/telegram/src/bot-handlers.message-context.runtime.test.ts index 9fc44943684d..70098dfeb8c1 100644 --- a/extensions/telegram/src/bot-handlers.message-context.runtime.test.ts +++ b/extensions/telegram/src/bot-handlers.message-context.runtime.test.ts @@ -2,8 +2,8 @@ import type { Message } from "grammy/types"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { beforeEach, describe, expect, it } from "vitest"; -import { createTelegramMessageContextRuntime } from "./bot-handlers.message-context.runtime.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; +import { createTelegramMessageContextRuntime } from "./bot-handlers.message-context.js"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; import { resetTelegramMessageCacheForTest } from "./runtime.test-support.js"; const CHAT_ID = 5678; diff --git a/extensions/telegram/src/bot-handlers.message-context.runtime.ts b/extensions/telegram/src/bot-handlers.message-context.ts similarity index 58% rename from extensions/telegram/src/bot-handlers.message-context.runtime.ts rename to extensions/telegram/src/bot-handlers.message-context.ts index 624171042a37..6692675aa08f 100644 --- a/extensions/telegram/src/bot-handlers.message-context.runtime.ts +++ b/extensions/telegram/src/bot-handlers.message-context.ts @@ -1,17 +1,38 @@ -// Telegram reply-chain cache and prompt-context projection. import type { Message } from "grammy/types"; import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; +import { formatMediaPlaceholderText } from "openclaw/plugin-sdk/channel-inbound"; +import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history"; +import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +import { + getSessionEntry, + readAmbientTranscriptWatermark, + resolveAmbientTranscriptWatermarkKey, + type SessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-chunking"; +import { resolveDefaultModelForAgent } from "./bot-handlers.agent.runtime.js"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; import type { TelegramMediaRef } from "./bot-message-context.js"; import type { + TelegramAmbientTranscriptWatermark, TelegramMessageContextOptions, TelegramPromptContextEntry, } from "./bot-message-context.types.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; -import type { TelegramThreadSpec } from "./bot/helpers.js"; +import { + buildSenderName, + getTelegramTextParts, + resolveTelegramPrimaryMedia, + resolveTelegramForumThreadId, + shouldUseTelegramDmThreadSession, + type TelegramThreadSpec, +} from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; +import { + resolveTelegramConversationBaseSessionKey, + resolveTelegramConversationRoute, +} from "./conversation-route.js"; import { resolveTelegramDmHistoryLimit } from "./dm-history.js"; import { buildTelegramSelfSenderName, @@ -51,6 +72,229 @@ function legacyAssistantTextKey(node: TelegramCachedMessageNode, botUserId?: num export type TelegramPromptContextMessageSelection = ReadonlyMap; +export type TelegramSessionState = { + agentId: string; + sessionEntry: SessionEntry | undefined; + sessionKey: string; + storePath: string; + model: string | undefined; +}; + +export type ResolveTelegramSessionStateParams = { + chatId: number | string; + isGroup: boolean; + isForum: boolean; + messageThreadId?: number; + resolvedThreadId?: number; + botHasTopicsEnabled?: boolean; + senderId?: string | number; + runtimeCfg: OpenClawConfig; +}; + +export type ResolvePromptContextAmbientWatermarkParams = { + chatId: number | string; + isGroup: boolean; + resolvedThreadId?: number; + sessionKey: string; + storePath: string; +}; + +export const normalizePromptContextMinTimestampMs = (timestampMs?: number) => + typeof timestampMs === "number" && Number.isFinite(timestampMs) ? timestampMs : undefined; + +export function promptContextBoundaryOptions( + timestampMs?: number, + ambientWatermark?: TelegramAmbientTranscriptWatermark, +): Pick< + TelegramMessageContextOptions, + "promptContextMinTimestampMs" | "promptContextAmbientWatermark" +> { + const promptContextMinTimestampMs = normalizePromptContextMinTimestampMs(timestampMs); + return { + ...(promptContextMinTimestampMs === undefined ? {} : { promptContextMinTimestampMs }), + ...(ambientWatermark === undefined ? {} : { promptContextAmbientWatermark: ambientWatermark }), + }; +} + +export function latestPromptContextMinTimestampMs( + ...timestamps: Array +): number | undefined { + let latest: number | undefined; + for (const timestampMs of timestamps) { + const normalized = normalizePromptContextMinTimestampMs(timestampMs); + if (normalized !== undefined) { + latest = latest === undefined ? normalized : Math.max(latest, normalized); + } + } + return latest; +} + +export const latestPromptContextAmbientWatermark = ( + ...watermarks: Array +): TelegramAmbientTranscriptWatermark | undefined => + watermarks.findLast((watermark) => watermark !== undefined); + +export function buildSyntheticTextMessage(params: { + base: Message.ServiceMessage; + text: string; + entities?: Message["entities"]; + date?: number; + from?: Message["from"]; +}): Message { + return { + ...params.base, + ...(params.from ? { from: params.from } : {}), + text: params.text, + caption: undefined, + caption_entities: undefined, + entities: params.entities?.length ? params.entities : undefined, + ...(params.date != null ? { date: params.date } : {}), + }; +} + +export const buildSyntheticContext = ( + ctx: Pick, + message: Message, +): TelegramContext => ({ message, me: ctx.me, getFile: ctx.getFile.bind(ctx) }); + +export function formatTelegramAmbientTranscriptBody( + messages: readonly Message[], +): string | undefined { + const lines = messages.map((msg) => { + const text = getTelegramTextParts(msg).text.trim(); + const media = resolveTelegramPrimaryMedia(msg); + const body = text || formatMediaPlaceholderText(media ? [{ kind: media.kind }] : [{}]); + const messageId = msg.message_id ? `#${msg.message_id}` : undefined; + const sender = buildSenderName(msg); + const prefix = [messageId, sender].filter(Boolean).join(" "); + return prefix ? `${prefix}: ${body}` : body; + }); + return lines.length > 0 ? lines.join("\n") : undefined; +} + +export function createTelegramMessageSessionRuntime({ + accountId, + resolveTelegramGroupConfig, + telegramDeps, +}: Pick< + RegisterTelegramHandlerParams, + "accountId" | "resolveTelegramGroupConfig" | "telegramDeps" +>) { + const loadSessionEntry = telegramDeps.getSessionEntry ?? getSessionEntry; + const resolveTelegramSessionState = ( + params: ResolveTelegramSessionStateParams, + ): TelegramSessionState => { + const resolvedThreadId = + params.resolvedThreadId ?? + resolveTelegramForumThreadId({ + isForum: params.isForum, + messageThreadId: params.messageThreadId, + }); + const dmThreadId = !params.isGroup ? params.messageThreadId : undefined; + const topicThreadId = resolvedThreadId ?? dmThreadId; + const { topicConfig } = resolveTelegramGroupConfig( + params.chatId, + topicThreadId, + params.runtimeCfg, + ); + const { route } = resolveTelegramConversationRoute({ + cfg: params.runtimeCfg, + accountId, + chatId: params.chatId, + isGroup: params.isGroup, + resolvedThreadId, + replyThreadId: topicThreadId, + senderId: params.senderId, + topicAgentId: topicConfig?.agentId, + }); + const baseSessionKey = resolveTelegramConversationBaseSessionKey({ + cfg: params.runtimeCfg, + route, + chatId: params.chatId, + isGroup: params.isGroup, + senderId: params.senderId, + }); + const threadKeys = + shouldUseTelegramDmThreadSession({ + dmThreadId, + botHasTopicsEnabled: params.botHasTopicsEnabled, + }) && dmThreadId != null + ? resolveThreadSessionKeys({ + baseSessionKey, + threadId: `${params.chatId}:${dmThreadId}`, + }) + : null; + const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; + const storePath = telegramDeps.resolveStorePath(params.runtimeCfg.session?.store, { + agentId: route.agentId, + }); + const entry = loadSessionEntry({ storePath, sessionKey }); + const storedOverride = resolveStoredModelOverride({ + sessionEntry: entry, + loadSessionEntry: (parentSessionKey) => + loadSessionEntry({ storePath, sessionKey: parentSessionKey }), + sessionKey, + defaultProvider: resolveDefaultModelForAgent({ + cfg: params.runtimeCfg, + agentId: route.agentId, + }).provider, + }); + if (storedOverride) { + return { + agentId: route.agentId, + sessionEntry: entry, + sessionKey, + storePath, + model: storedOverride.provider + ? `${storedOverride.provider}/${storedOverride.model}` + : storedOverride.model, + }; + } + const provider = entry?.modelProvider?.trim(); + const model = entry?.model?.trim(); + if (provider && model) { + return { + agentId: route.agentId, + sessionEntry: entry, + sessionKey, + storePath, + model: `${provider}/${model}`, + }; + } + const modelCfg = params.runtimeCfg.agents?.defaults?.model; + return { + agentId: route.agentId, + sessionEntry: entry, + sessionKey, + storePath, + model: typeof modelCfg === "string" ? modelCfg : modelCfg?.primary, + }; + }; + + const resolvePromptContextAmbientWatermark = ( + params: ResolvePromptContextAmbientWatermarkParams, + ): TelegramAmbientTranscriptWatermark | undefined => { + if (!params.isGroup) { + return undefined; + } + const key = ( + telegramDeps.resolveAmbientTranscriptWatermarkKey ?? resolveAmbientTranscriptWatermarkKey + )({ + channel: "telegram", + accountId, + conversationId: String(params.chatId), + ...(params.resolvedThreadId !== undefined ? { threadId: params.resolvedThreadId } : {}), + }); + return (telegramDeps.readAmbientTranscriptWatermark ?? readAmbientTranscriptWatermark)({ + storePath: params.storePath, + sessionKey: params.sessionKey, + key, + }); + }; + + return { resolveTelegramSessionState, resolvePromptContextAmbientWatermark }; +} + export function createTelegramMessageContextRuntime({ cfg, accountId, diff --git a/extensions/telegram/src/bot-handlers.message-lifecycle.runtime.ts b/extensions/telegram/src/bot-handlers.message-lifecycle.runtime.ts deleted file mode 100644 index a518ec6cb7ac..000000000000 --- a/extensions/telegram/src/bot-handlers.message-lifecycle.runtime.ts +++ /dev/null @@ -1,200 +0,0 @@ -// Telegram dispatch dedupe, replay settlement, and synthetic-message helpers. -import type { Message } from "grammy/types"; -import { formatMediaPlaceholderText } from "openclaw/plugin-sdk/channel-inbound"; -import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import type { - TelegramAmbientTranscriptWatermark, - TelegramMessageContextOptions, -} from "./bot-message-context.types.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; -import { - createTelegramSpooledReplayDeferredParticipant, - type TelegramMessageProcessingResult, - type TelegramSpooledReplayDeferredParticipant, - type TelegramSpooledReplaySettlementHold, -} from "./bot-processing-outcome.js"; -import { - buildSenderName, - getTelegramTextParts, - resolveTelegramPrimaryMedia, -} from "./bot/helpers.js"; -import type { TelegramContext } from "./bot/types.js"; -import { - claimTelegramMessageDispatchReplay, - commitTelegramMessageDispatchReplay, - createTelegramMessageDispatchReplayGuard, - releaseTelegramMessageDispatchReplay, - type TelegramMessageDispatchReplayClaim, -} from "./message-dispatch-dedupe.js"; - -export function createTelegramMessageLifecycleRuntime({ - accountId, - runtime, -}: Pick) { - const replayGuard = createTelegramMessageDispatchReplayGuard({ - onDiskError: (error) => { - runtime.error?.(danger(`[telegram] message dispatch dedupe store failed: ${String(error)}`)); - }, - }); - const normalizePromptContextMinTimestampMs = (timestampMs?: number) => - typeof timestampMs === "number" && Number.isFinite(timestampMs) ? timestampMs : undefined; - const promptContextBoundaryOptions = ( - timestampMs?: number, - ambientWatermark?: TelegramAmbientTranscriptWatermark, - ): Pick< - TelegramMessageContextOptions, - "promptContextMinTimestampMs" | "promptContextAmbientWatermark" - > => { - const promptContextMinTimestampMs = normalizePromptContextMinTimestampMs(timestampMs); - return { - ...(promptContextMinTimestampMs === undefined ? {} : { promptContextMinTimestampMs }), - ...(ambientWatermark === undefined - ? {} - : { promptContextAmbientWatermark: ambientWatermark }), - }; - }; - const latestPromptContextMinTimestampMs = ( - ...timestamps: Array - ): number | undefined => { - let latest: number | undefined; - for (const timestampMs of timestamps) { - const normalized = normalizePromptContextMinTimestampMs(timestampMs); - if (normalized !== undefined) { - latest = latest === undefined ? normalized : Math.max(latest, normalized); - } - } - return latest; - }; - const latestPromptContextAmbientWatermark = ( - ...watermarks: Array - ): TelegramAmbientTranscriptWatermark | undefined => - watermarks.findLast((watermark) => watermark !== undefined); - const mergeDispatchDedupeClaims = ( - ...groups: Array - ) => [...new Set(groups.flatMap((group) => group ?? []))]; - const releaseDispatchDedupeClaims = ( - claims: readonly TelegramMessageDispatchReplayClaim[], - error?: unknown, - ) => { - releaseTelegramMessageDispatchReplay({ claims, error }); - }; - const commitDispatchDedupeClaims = async ( - claims: readonly TelegramMessageDispatchReplayClaim[], - options: { requirePersistent?: boolean } = {}, - ) => { - await commitTelegramMessageDispatchReplay({ guard: replayGuard, claims, ...options }); - }; - const buildFailedProcessingResult = (error: unknown): TelegramMessageProcessingResult => ({ - kind: "failed-retryable", - error, - }); - const settleSpooledReplayParticipants = ( - participants: readonly TelegramSpooledReplayDeferredParticipant[], - result: TelegramMessageProcessingResult, - ) => { - for (const participant of new Set(participants)) { - participant.settle(result); - } - }; - const beginSpooledReplaySettlementHolds = ( - participants: readonly TelegramSpooledReplayDeferredParticipant[], - ) => { - const holds: TelegramSpooledReplaySettlementHold[] = []; - for (const participant of new Set(participants)) { - const hold = participant.beginSettlementHold(); - if (!hold) { - for (const acquired of holds) { - acquired.release("replay-pending"); - } - const reason = participant.abortSignal.reason; - throw reason instanceof Error - ? reason - : new Error( - `telegram spooled replay participant ${participant.key} settled before durable adoption`, - ); - } - holds.push(hold); - } - return (mode: Parameters[0]) => { - for (const hold of holds) { - hold.release(mode); - } - }; - }; - const createSpooledReplayParticipantForBufferedWork = (key: string) => - createTelegramSpooledReplayDeferredParticipant(key) ?? undefined; - const spooledReplayOptions = ( - participants: readonly TelegramSpooledReplayDeferredParticipant[], - ): Pick => - participants.length > 0 ? { spooledReplay: true } : {}; - const claimMessageDispatchDedupe = async ( - msg: Message, - botUserId: number, - ): Promise< - { process: true; claims: TelegramMessageDispatchReplayClaim[] } | { process: false } - > => { - const claim = await claimTelegramMessageDispatchReplay({ - guard: replayGuard, - accountId, - botUserId, - msg, - }); - if (claim.kind === "duplicate") { - logVerbose(`telegram dispatch dedupe: skipped message ${msg.chat.id}:${msg.message_id}`); - return { process: false }; - } - return { process: true, claims: claim.kind === "claimed" ? [claim.handle] : [] }; - }; - const buildSyntheticTextMessage = (params: { - base: Message.ServiceMessage; - text: string; - entities?: Message["entities"]; - date?: number; - from?: Message["from"]; - }): Message => ({ - ...params.base, - ...(params.from ? { from: params.from } : {}), - text: params.text, - caption: undefined, - caption_entities: undefined, - entities: params.entities?.length ? params.entities : undefined, - ...(params.date != null ? { date: params.date } : {}), - }); - const buildSyntheticContext = ( - ctx: Pick, - message: Message, - ): TelegramContext => ({ message, me: ctx.me, getFile: ctx.getFile.bind(ctx) }); - const formatTelegramAmbientTranscriptBody = ( - messages: readonly Message[], - ): string | undefined => { - const lines = messages.map((msg) => { - const text = getTelegramTextParts(msg).text.trim(); - const media = resolveTelegramPrimaryMedia(msg); - const body = text || formatMediaPlaceholderText(media ? [{ kind: media.kind }] : [{}]); - const messageId = msg.message_id ? `#${msg.message_id}` : undefined; - const sender = buildSenderName(msg); - const prefix = [messageId, sender].filter(Boolean).join(" "); - return prefix ? `${prefix}: ${body}` : body; - }); - return lines.length > 0 ? lines.join("\n") : undefined; - }; - - return { - normalizePromptContextMinTimestampMs, - promptContextBoundaryOptions, - latestPromptContextMinTimestampMs, - latestPromptContextAmbientWatermark, - mergeDispatchDedupeClaims, - releaseDispatchDedupeClaims, - commitDispatchDedupeClaims, - buildFailedProcessingResult, - settleSpooledReplayParticipants, - beginSpooledReplaySettlementHolds, - createSpooledReplayParticipantForBufferedWork, - spooledReplayOptions, - claimMessageDispatchDedupe, - buildSyntheticTextMessage, - buildSyntheticContext, - formatTelegramAmbientTranscriptBody, - }; -} diff --git a/extensions/telegram/src/bot-handlers.message-lifecycle.test.ts b/extensions/telegram/src/bot-handlers.message-lifecycle.test.ts index 0b6a3fbe501e..059bca408be0 100644 --- a/extensions/telegram/src/bot-handlers.message-lifecycle.test.ts +++ b/extensions/telegram/src/bot-handlers.message-lifecycle.test.ts @@ -1,6 +1,9 @@ import type { Message } from "grammy/types"; import { describe, expect, it } from "vitest"; -import { createTelegramMessageLifecycleRuntime } from "./bot-handlers.message-lifecycle.runtime.js"; +import { + buildSyntheticTextMessage, + formatTelegramAmbientTranscriptBody, +} from "./bot-handlers.message-context.js"; function message(fields: Record): Message { return { @@ -13,13 +16,8 @@ function message(fields: Record): Message { } describe("Telegram ambient transcript media text", () => { - const runtime = createTelegramMessageLifecycleRuntime({ - accountId: "default", - runtime: { log: () => {}, error: () => {}, exit: () => {} } as never, - }); - it("renders native media kinds for captionless transcript lines", () => { - const body = runtime.formatTelegramAmbientTranscriptBody([ + const body = formatTelegramAmbientTranscriptBody([ message({ message_id: 7, photo: [{ file_id: "photo-1", file_unique_id: "photo-u1", width: 1, height: 1 }], @@ -30,7 +28,7 @@ describe("Telegram ambient transcript media text", () => { }); it("preserves captions instead of appending media text", () => { - const body = runtime.formatTelegramAmbientTranscriptBody([ + const body = formatTelegramAmbientTranscriptBody([ message({ message_id: 8, caption: "diagram", document: { file_id: "doc-1" } }), ]); @@ -38,14 +36,14 @@ describe("Telegram ambient transcript media text", () => { }); it("uses the formatter attachment fallback for media-less empty messages", () => { - const body = runtime.formatTelegramAmbientTranscriptBody([message({ message_id: 9 })]); + const body = formatTelegramAmbientTranscriptBody([message({ message_id: 9 })]); expect(body).toBe("#9 Ada: "); }); it("preserves combined formatting entities when building synthetic text messages", () => { const entities = [{ type: "bold" as const, offset: 3, length: 4 }]; - const synthetic = runtime.buildSyntheticTextMessage({ + const synthetic = buildSyntheticTextMessage({ base: message({ caption: "old caption", caption_entities: entities }), text: "😀 bold", entities, diff --git a/extensions/telegram/src/bot-handlers.message.runtime.ts b/extensions/telegram/src/bot-handlers.message-pipeline.ts similarity index 69% rename from extensions/telegram/src/bot-handlers.message.runtime.ts rename to extensions/telegram/src/bot-handlers.message-pipeline.ts index 780a365a7c83..493cf88e3ff2 100644 --- a/extensions/telegram/src/bot-handlers.message.runtime.ts +++ b/extensions/telegram/src/bot-handlers.message-pipeline.ts @@ -1,21 +1,37 @@ -// Telegram message/session/prompt pipeline shared by bot handler registrars. import type { Message } from "grammy/types"; import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime"; import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; +import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime"; import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; -import { resolveTelegramAccount, resolveTelegramMediaRuntimeOptions } from "./accounts.js"; +import { + resolveTelegramAccount, + resolveTelegramMediaRuntimeOptions, + type TelegramMediaRuntimeOptions, +} from "./accounts.js"; import { firstDefined, isSenderAllowed, normalizeAllowFrom } from "./bot-access.js"; import { hasInboundMedia, resolveInboundMediaFileId } from "./bot-handlers.media.js"; import { + buildSyntheticContext, + buildSyntheticTextMessage, createTelegramMessageContextRuntime, + createTelegramMessageSessionRuntime, + formatTelegramAmbientTranscriptBody, + latestPromptContextAmbientWatermark, + latestPromptContextMinTimestampMs, + normalizePromptContextMinTimestampMs, + promptContextBoundaryOptions, + type ResolvePromptContextAmbientWatermarkParams, + type ResolveTelegramSessionStateParams, type TelegramPromptContextMessageSelection, -} from "./bot-handlers.message-context.runtime.js"; -import { createTelegramMessageLifecycleRuntime } from "./bot-handlers.message-lifecycle.runtime.js"; -import { createTelegramMessageSessionRuntime } from "./bot-handlers.message-session.runtime.js"; + type TelegramSessionState, +} from "./bot-handlers.message-context.js"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; import type { TelegramMediaRef } from "./bot-message-context.js"; -import type { TelegramMessageContextOptions } from "./bot-message-context.types.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; +import type { + TelegramAmbientTranscriptWatermark, + TelegramMessageContextOptions, +} from "./bot-message-context.types.js"; import { createTelegramSpooledReplayDeferredParticipant, createTelegramSpooledReplayParticipant, @@ -25,14 +41,21 @@ import { recordTelegramMessageProcessingResult, type TelegramMessageProcessingResult, type TelegramSpooledReplayDeferredParticipant, + type TelegramSpooledReplaySettlementHold, } from "./bot-processing-outcome.js"; import { resolveMedia } from "./bot/delivery.resolve-media.js"; -import { resolveTelegramMessageThreadSpec } from "./bot/helpers.js"; +import { resolveTelegramMessageThreadSpec, type TelegramThreadSpec } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { resolveTelegramScopedGroupConfig } from "./group-config-helpers.js"; import type { TelegramResolvedMedia } from "./message-cache-persistence.js"; import type { TelegramCachedMessageNode, TelegramReplyChainEntry } from "./message-cache.js"; -import type { TelegramMessageDispatchReplayClaim } from "./message-dispatch-dedupe.js"; +import { + claimTelegramMessageDispatchReplay, + commitTelegramMessageDispatchReplay, + createTelegramMessageDispatchReplayGuard, + releaseTelegramMessageDispatchReplay, + type TelegramMessageDispatchReplayClaim, +} from "./message-dispatch-dedupe.js"; import { resolveTelegramInboundMediaUri, resolveTelegramPromptMediaPath, @@ -40,6 +63,76 @@ import { const HOUR_MS = 60 * 60_000; +type TelegramProcessMessageWithReplyChainOptions = { + ctx: TelegramContext; + msg: Message; + allMedia: TelegramMediaRef[]; + promptContextMessageSelection?: TelegramPromptContextMessageSelection; + storeAllowFrom: string[]; + options?: TelegramMessageContextOptions; + dispatchDedupeClaims?: TelegramMessageDispatchReplayClaim[]; + spooledReplayParticipants?: readonly TelegramSpooledReplayDeferredParticipant[]; + spooledReplayAbortSignal?: AbortSignal; +}; + +export interface TelegramMessagePipeline { + resolveMediaRuntime: ( + ...explicitSignals: AbortSignal[] + ) => TelegramMediaRuntimeOptions & { abortSignal: AbortSignal | undefined }; + normalizePromptContextMinTimestampMs: typeof normalizePromptContextMinTimestampMs; + promptContextBoundaryOptions: typeof promptContextBoundaryOptions; + latestPromptContextMinTimestampMs: typeof latestPromptContextMinTimestampMs; + latestPromptContextAmbientWatermark: typeof latestPromptContextAmbientWatermark; + mergeDispatchDedupeClaims: ( + ...groups: Array + ) => TelegramMessageDispatchReplayClaim[]; + releaseDispatchDedupeClaims: ( + claims: readonly TelegramMessageDispatchReplayClaim[], + error?: unknown, + ) => void; + buildFailedProcessingResult: (error: unknown) => TelegramMessageProcessingResult; + settleSpooledReplayParticipants: ( + participants: readonly TelegramSpooledReplayDeferredParticipant[], + result: TelegramMessageProcessingResult, + ) => void; + createSpooledReplayParticipantForBufferedWork: ( + key: string, + ) => TelegramSpooledReplayDeferredParticipant | undefined; + spooledReplayOptions: ( + participants: readonly TelegramSpooledReplayDeferredParticipant[], + ) => Pick; + claimMessageDispatchDedupe: ( + msg: Message, + botUserId: number, + ) => Promise< + { process: true; claims: TelegramMessageDispatchReplayClaim[] } | { process: false } + >; + buildSyntheticTextMessage: typeof buildSyntheticTextMessage; + buildSyntheticContext: typeof buildSyntheticContext; + formatTelegramAmbientTranscriptBody: typeof formatTelegramAmbientTranscriptBody; + resolveTelegramSessionState: (params: ResolveTelegramSessionStateParams) => TelegramSessionState; + resolvePromptContextAmbientWatermark: ( + params: ResolvePromptContextAmbientWatermarkParams, + ) => TelegramAmbientTranscriptWatermark | undefined; + recordMessageForReplyChain: ( + msg: Message, + providerObservedThread?: TelegramThreadSpec, + botUserId?: number, + ) => Promise; + recordMessageResolvedMedia: (params: { + msg: Message; + media: TelegramResolvedMedia; + botUserId?: number; + }) => Promise; + resolveCachedMessageThreadSpec: (params: { + chatId: number | string; + messageId: number | string; + }) => Promise; + processMessageWithReplyChain: ( + params: TelegramProcessMessageWithReplyChainOptions, + ) => Promise; +} + function resolveRetainedTelegramMedia(params: { media?: TelegramResolvedMedia; maxBytes: number; @@ -65,7 +158,7 @@ function resolveRetainedTelegramMedia(params: { : undefined; } -export function createTelegramHandlerMessageRuntime({ +export function createTelegramMessagePipeline({ cfg, accountId, bot, @@ -78,7 +171,7 @@ export function createTelegramHandlerMessageRuntime({ processMessage, logger, telegramDeps, -}: RegisterTelegramHandlerParams) { +}: RegisterTelegramHandlerParams): TelegramMessagePipeline { const { token } = opts; const mediaRuntimeOptions = resolveTelegramMediaRuntimeOptions({ cfg, @@ -121,24 +214,87 @@ export function createTelegramHandlerMessageRuntime({ telegramCfg, telegramDeps, }); - const { - normalizePromptContextMinTimestampMs, - promptContextBoundaryOptions, - latestPromptContextMinTimestampMs, - latestPromptContextAmbientWatermark, - mergeDispatchDedupeClaims, - releaseDispatchDedupeClaims, - commitDispatchDedupeClaims, - buildFailedProcessingResult, - settleSpooledReplayParticipants, - beginSpooledReplaySettlementHolds, - createSpooledReplayParticipantForBufferedWork, - spooledReplayOptions, - claimMessageDispatchDedupe, - buildSyntheticTextMessage, - buildSyntheticContext, - formatTelegramAmbientTranscriptBody, - } = createTelegramMessageLifecycleRuntime({ accountId, runtime }); + const replayGuard = createTelegramMessageDispatchReplayGuard({ + onDiskError: (error) => { + runtime.error?.(danger(`[telegram] message dispatch dedupe store failed: ${String(error)}`)); + }, + }); + const mergeDispatchDedupeClaims = ( + ...groups: Array + ) => [...new Set(groups.flatMap((group) => group ?? []))]; + const releaseDispatchDedupeClaims = ( + claims: readonly TelegramMessageDispatchReplayClaim[], + error?: unknown, + ) => { + releaseTelegramMessageDispatchReplay({ claims, error }); + }; + const commitDispatchDedupeClaims = async ( + claims: readonly TelegramMessageDispatchReplayClaim[], + options: { requirePersistent?: boolean } = {}, + ) => { + await commitTelegramMessageDispatchReplay({ guard: replayGuard, claims, ...options }); + }; + const buildFailedProcessingResult = (error: unknown): TelegramMessageProcessingResult => ({ + kind: "failed-retryable", + error, + }); + const settleSpooledReplayParticipants = ( + participants: readonly TelegramSpooledReplayDeferredParticipant[], + result: TelegramMessageProcessingResult, + ) => { + for (const participant of new Set(participants)) { + participant.settle(result); + } + }; + const beginSpooledReplaySettlementHolds = ( + participants: readonly TelegramSpooledReplayDeferredParticipant[], + ) => { + const holds: TelegramSpooledReplaySettlementHold[] = []; + for (const participant of new Set(participants)) { + const hold = participant.beginSettlementHold(); + if (!hold) { + for (const acquired of holds) { + acquired.release("replay-pending"); + } + const reason = participant.abortSignal.reason; + throw reason instanceof Error + ? reason + : new Error( + `telegram spooled replay participant ${participant.key} settled before durable adoption`, + ); + } + holds.push(hold); + } + return (mode: Parameters[0]) => { + for (const hold of holds) { + hold.release(mode); + } + }; + }; + const createSpooledReplayParticipantForBufferedWork = (key: string) => + createTelegramSpooledReplayDeferredParticipant(key) ?? undefined; + const spooledReplayOptions = ( + participants: readonly TelegramSpooledReplayDeferredParticipant[], + ): Pick => + participants.length > 0 ? { spooledReplay: true } : {}; + const claimMessageDispatchDedupe = async ( + msg: Message, + botUserId: number, + ): Promise< + { process: true; claims: TelegramMessageDispatchReplayClaim[] } | { process: false } + > => { + const claim = await claimTelegramMessageDispatchReplay({ + guard: replayGuard, + accountId, + botUserId, + msg, + }); + if (claim.kind === "duplicate") { + logVerbose(`telegram dispatch dedupe: skipped message ${msg.chat.id}:${msg.message_id}`); + return { process: false }; + } + return { process: true, claims: claim.kind === "claimed" ? [claim.handle] : [] }; + }; const resolveReplyMediaForChain = async ( ctx: TelegramContext, @@ -413,11 +569,11 @@ export function createTelegramHandlerMessageRuntime({ promptContextMediaByMessageId, params.promptContextMessageSelection, ); - const result = await processMessage( - params.ctx, - params.allMedia, - params.storeAllowFrom, - { + const result = await processMessage({ + ctx: params.ctx, + allMedia: params.allMedia, + storeAllowFrom: params.storeAllowFrom, + turnContext: { cfg: runtimeCfg, telegramCfg: runtimeTelegramCfg, onDispatchStart: async () => { @@ -433,11 +589,11 @@ export function createTelegramHandlerMessageRuntime({ return await finalizeSpooledReplayResult(completed); }, }, - params.options, + options: params.options, replyMedia, replyChain, promptContext, - ); + }); if (spooledReplay) { return await finalizeSpooledReplayResult(result); } @@ -482,5 +638,3 @@ export function createTelegramHandlerMessageRuntime({ processMessageWithReplyChain, }; } - -export type TelegramHandlerMessageRuntime = ReturnType; diff --git a/extensions/telegram/src/bot-handlers.message-session.runtime.test.ts b/extensions/telegram/src/bot-handlers.message-session.runtime.test.ts index 39507774e2f5..40fa1abe0db7 100644 --- a/extensions/telegram/src/bot-handlers.message-session.runtime.test.ts +++ b/extensions/telegram/src/bot-handlers.message-session.runtime.test.ts @@ -2,7 +2,7 @@ import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { describe, expect, it, vi } from "vitest"; import type { TelegramBotDeps } from "./bot-deps.js"; -import { createTelegramMessageSessionRuntime } from "./bot-handlers.message-session.runtime.js"; +import { createTelegramMessageSessionRuntime } from "./bot-handlers.message-context.js"; describe("createTelegramMessageSessionRuntime", () => { it("inherits a DM topic model override through keyed session loads", () => { diff --git a/extensions/telegram/src/bot-handlers.message-session.runtime.ts b/extensions/telegram/src/bot-handlers.message-session.runtime.ts deleted file mode 100644 index 9e4af90f4d0a..000000000000 --- a/extensions/telegram/src/bot-handlers.message-session.runtime.ts +++ /dev/null @@ -1,151 +0,0 @@ -// Telegram conversation routing and session-state lookup for bot handlers. -import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth-native"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; -import { - getSessionEntry, - readAmbientTranscriptWatermark, - resolveAmbientTranscriptWatermarkKey, -} from "openclaw/plugin-sdk/session-store-runtime"; -import { resolveDefaultModelForAgent } from "./bot-handlers.agent.runtime.js"; -import type { TelegramAmbientTranscriptWatermark } from "./bot-message-context.types.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; -import { resolveTelegramForumThreadId, shouldUseTelegramDmThreadSession } from "./bot/helpers.js"; -import { - resolveTelegramConversationBaseSessionKey, - resolveTelegramConversationRoute, -} from "./conversation-route.js"; - -export function createTelegramMessageSessionRuntime({ - accountId, - resolveTelegramGroupConfig, - telegramDeps, -}: Pick< - RegisterTelegramHandlerParams, - "accountId" | "resolveTelegramGroupConfig" | "telegramDeps" ->) { - const loadSessionEntry = telegramDeps.getSessionEntry ?? getSessionEntry; - const resolveTelegramSessionState = (params: { - chatId: number | string; - isGroup: boolean; - isForum: boolean; - messageThreadId?: number; - resolvedThreadId?: number; - botHasTopicsEnabled?: boolean; - senderId?: string | number; - runtimeCfg: OpenClawConfig; - }) => { - const resolvedThreadId = - params.resolvedThreadId ?? - resolveTelegramForumThreadId({ - isForum: params.isForum, - messageThreadId: params.messageThreadId, - }); - const dmThreadId = !params.isGroup ? params.messageThreadId : undefined; - const topicThreadId = resolvedThreadId ?? dmThreadId; - const { topicConfig } = resolveTelegramGroupConfig( - params.chatId, - topicThreadId, - params.runtimeCfg, - ); - const { route } = resolveTelegramConversationRoute({ - cfg: params.runtimeCfg, - accountId, - chatId: params.chatId, - isGroup: params.isGroup, - resolvedThreadId, - replyThreadId: topicThreadId, - senderId: params.senderId, - topicAgentId: topicConfig?.agentId, - }); - const baseSessionKey = resolveTelegramConversationBaseSessionKey({ - cfg: params.runtimeCfg, - route, - chatId: params.chatId, - isGroup: params.isGroup, - senderId: params.senderId, - }); - const threadKeys = - shouldUseTelegramDmThreadSession({ - dmThreadId, - botHasTopicsEnabled: params.botHasTopicsEnabled, - }) && dmThreadId != null - ? resolveThreadSessionKeys({ - baseSessionKey, - threadId: `${params.chatId}:${dmThreadId}`, - }) - : null; - const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; - const storePath = telegramDeps.resolveStorePath(params.runtimeCfg.session?.store, { - agentId: route.agentId, - }); - const entry = loadSessionEntry({ storePath, sessionKey }); - const storedOverride = resolveStoredModelOverride({ - sessionEntry: entry, - loadSessionEntry: (parentSessionKey) => - loadSessionEntry({ storePath, sessionKey: parentSessionKey }), - sessionKey, - defaultProvider: resolveDefaultModelForAgent({ - cfg: params.runtimeCfg, - agentId: route.agentId, - }).provider, - }); - if (storedOverride) { - return { - agentId: route.agentId, - sessionEntry: entry, - sessionKey, - storePath, - model: storedOverride.provider - ? `${storedOverride.provider}/${storedOverride.model}` - : storedOverride.model, - }; - } - const provider = entry?.modelProvider?.trim(); - const model = entry?.model?.trim(); - if (provider && model) { - return { - agentId: route.agentId, - sessionEntry: entry, - sessionKey, - storePath, - model: `${provider}/${model}`, - }; - } - const modelCfg = params.runtimeCfg.agents?.defaults?.model; - return { - agentId: route.agentId, - sessionEntry: entry, - sessionKey, - storePath, - model: typeof modelCfg === "string" ? modelCfg : modelCfg?.primary, - }; - }; - - const resolvePromptContextAmbientWatermark = (params: { - chatId: number | string; - isGroup: boolean; - resolvedThreadId?: number; - sessionKey: string; - storePath: string; - }): TelegramAmbientTranscriptWatermark | undefined => { - if (!params.isGroup) { - return undefined; - } - const key = ( - telegramDeps.resolveAmbientTranscriptWatermarkKey ?? resolveAmbientTranscriptWatermarkKey - )({ - channel: "telegram", - accountId, - conversationId: String(params.chatId), - ...(params.resolvedThreadId !== undefined ? { threadId: params.resolvedThreadId } : {}), - }); - return (telegramDeps.readAmbientTranscriptWatermark ?? readAmbientTranscriptWatermark)({ - storePath: params.storePath, - sessionKey: params.sessionKey, - key, - }); - }; - - return { resolveTelegramSessionState, resolvePromptContextAmbientWatermark }; -} diff --git a/extensions/telegram/src/bot-handlers.migration.runtime.ts b/extensions/telegram/src/bot-handlers.migration.runtime.ts deleted file mode 100644 index 10d862c756bf..000000000000 --- a/extensions/telegram/src/bot-handlers.migration.runtime.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Telegram group-to-supergroup config migration handler. -import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers"; -import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation"; -import { danger, warn } from "openclaw/plugin-sdk/runtime-env"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; -import { migrateTelegramGroupConfig } from "./group-migration.js"; - -export function registerTelegramMigrationHandler({ - cfg, - accountId, - bot, - runtime, - telegramDeps, - shouldSkipUpdate, -}: RegisterTelegramHandlerParams) { - // Handle group migration to supergroup (chat ID changes) - bot.on("message:migrate_to_chat_id", async (ctx) => { - try { - const msg = ctx.message; - if (!msg?.migrate_to_chat_id) { - return; - } - if (shouldSkipUpdate(ctx)) { - return; - } - - const oldChatId = String(msg.chat.id); - const newChatId = String(msg.migrate_to_chat_id); - const chatTitle = msg.chat.title ?? "Unknown"; - - runtime.log?.(warn(`[telegram] Group migrated: "${chatTitle}" ${oldChatId} → ${newChatId}`)); - - if (!resolveChannelConfigWrites({ cfg, channelId: "telegram", accountId })) { - runtime.log?.(warn("[telegram] Config writes disabled; skipping group config migration.")); - return; - } - - // Check if old chat ID has config and migrate it - const currentConfig = telegramDeps.getRuntimeConfig(); - const migration = migrateTelegramGroupConfig({ - cfg: currentConfig, - accountId, - oldChatId, - newChatId, - }); - - if (migration.migrated) { - runtime.log?.(warn(`[telegram] Migrating group config from ${oldChatId} to ${newChatId}`)); - migrateTelegramGroupConfig({ cfg, accountId, oldChatId, newChatId }); - await mutateConfigFile({ - afterWrite: { mode: "auto" }, - mutate: (draft) => { - migrateTelegramGroupConfig({ cfg: draft, accountId, oldChatId, newChatId }); - }, - }); - runtime.log?.(warn(`[telegram] Group config migrated and saved successfully`)); - } else if (migration.skippedExisting) { - runtime.log?.( - warn( - `[telegram] Group config already exists for ${newChatId}; leaving ${oldChatId} unchanged`, - ), - ); - } else { - runtime.log?.( - warn(`[telegram] No config found for old group ID ${oldChatId}, migration logged only`), - ); - } - } catch (err) { - runtime.error?.(danger(`[telegram] Group migration handler failed: ${String(err)}`)); - throw err; - } - }); -} diff --git a/extensions/telegram/src/bot-handlers.poll-answer.runtime.ts b/extensions/telegram/src/bot-handlers.poll-answer.runtime.ts deleted file mode 100644 index 392f53ad9430..000000000000 --- a/extensions/telegram/src/bot-handlers.poll-answer.runtime.ts +++ /dev/null @@ -1,171 +0,0 @@ -// Telegram public-poll answer handler registration. -import type { ChatMember } from "grammy/types"; -import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import type { TelegramHandlerAuthorizationRuntime } from "./bot-handlers.authorization.runtime.js"; -import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; -import { - isTelegramSpooledReplayUpdate, - recordTelegramMessageProcessingResult, -} from "./bot-processing-outcome.js"; -import { getPreparedTelegramPollAnswer } from "./poll-answer-context.js"; -import { findTelegramPollRegistryEntry, retireTelegramPollRegistryEntry } from "./poll-registry.js"; - -function isCurrentTelegramChatMember(member: ChatMember): boolean { - return ( - member.status === "creator" || - member.status === "administrator" || - member.status === "member" || - (member.status === "restricted" && member.is_member) - ); -} - -export function registerTelegramPollHandlers( - { accountId, bot, runtime, telegramDeps, shouldSkipUpdate }: RegisterTelegramHandlerParams, - messageRuntime: TelegramHandlerMessageRuntime, - authorizationRuntime: TelegramHandlerAuthorizationRuntime, -) { - const { resolveTelegramEventAuthorizationContext, authorizeTelegramEventSender } = - authorizationRuntime; - const { buildSyntheticTextMessage, buildSyntheticContext, processMessageWithReplyChain } = - messageRuntime; - - bot.on("poll", async (ctx) => { - try { - const poll = ctx.poll; - if (!poll?.is_closed || shouldSkipUpdate(ctx)) { - return; - } - await retireTelegramPollRegistryEntry({ accountId, pollId: poll.id }); - } catch (err) { - runtime.error?.(danger(`telegram poll handler failed: ${String(err)}`)); - if (isTelegramSpooledReplayUpdate(ctx.update)) { - recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: err }); - return; - } - throw err; - } - }); - - // Telegram emits poll_answer only for non-anonymous polls, and the update omits - // chat/thread data. The send path records that origin in the keyed plugin store. - bot.on("poll_answer", async (ctx) => { - try { - const pollAnswer = ctx.pollAnswer; - if (!pollAnswer || shouldSkipUpdate(ctx)) { - return; - } - const optionIds = pollAnswer.option_ids ?? []; - const user = pollAnswer.user; - // Retractions have no selection to route. Bot voters and voter_chat-only - // answers have no user identity that can pass the sender authorization gate. - if (optionIds.length === 0 || !user || user.is_bot) { - return; - } - - // A true miss is a safe no-op. Store failures throw so durable ingress can - // release the claim and replay instead of permanently dropping the vote. - const pollId = pollAnswer.poll_id; - const prepared = getPreparedTelegramPollAnswer(ctx.update); - const entry = prepared - ? prepared.entry - : await findTelegramPollRegistryEntry({ pollId, accountId }); - if (!entry) { - logVerbose(`telegram: poll_answer for poll ${pollId} has no registry entry; skipping`); - return; - } - - const chatId = entry.chat.id; - const isGroup = entry.chat.type === "group" || entry.chat.type === "supergroup"; - const senderId = user?.id != null ? String(user.id) : ""; - const senderUsername = user?.username ?? ""; - if (!isGroup && user.id !== chatId) { - logVerbose(`Blocked forwarded telegram poll_answer for DM ${chatId} from ${senderId}`); - return; - } - if (isGroup && !isCurrentTelegramChatMember(await bot.api.getChatMember(chatId, user.id))) { - logVerbose( - `Blocked forwarded telegram poll_answer for group ${chatId} from non-member ${senderId}`, - ); - return; - } - const authorizationCfg = telegramDeps.getRuntimeConfig(); - const eventAuthContext = await resolveTelegramEventAuthorizationContext({ - cfg: authorizationCfg, - chatId, - isGroup, - senderId, - threadSpec: entry.threadSpec, - }); - const senderAuthorization = await authorizeTelegramEventSender({ - chatId, - chatTitle: "title" in entry.chat ? entry.chat.title : undefined, - isGroup, - senderId, - senderUsername, - // Poll votes and reactions are both user-originated updates attached to - // bot-created UI, so they share the reaction authorization boundary. - mode: "reaction", - context: eventAuthContext, - }); - if (!senderAuthorization) { - return; - } - - // poll_answer has no thread id. A DM poll without persisted topic context - // cannot satisfy requireTopic and must not wake the base DM session. - if (!isGroup) { - const requireTopic = ( - eventAuthContext.groupConfig as { requireTopic?: boolean } | undefined - )?.requireTopic; - if (requireTopic === true && eventAuthContext.dmThreadId == null) { - logVerbose( - `Blocked telegram poll_answer in DM ${chatId}: requireTopic=true but topic unknown`, - ); - return; - } - } - - const optionLabels = optionIds.map((index) => entry.options[index] ?? `option ${index}`); - const text = `Poll response to "${entry.question}": ${optionLabels.join(", ")}`; - const messageThreadId = "id" in entry.threadSpec ? entry.threadSpec.id : undefined; - const syntheticMessage = buildSyntheticTextMessage({ - base: { - message_id: entry.messageId, - date: Math.floor(Date.now() / 1000), - chat: entry.chat, - ...(messageThreadId == null - ? {} - : { - message_thread_id: messageThreadId, - is_topic_message: true, - }), - }, - from: user, - text, - }); - const result = await processMessageWithReplyChain({ - ctx: buildSyntheticContext(ctx, syntheticMessage), - msg: syntheticMessage, - allMedia: [], - storeAllowFrom: eventAuthContext.storeAllowFrom, - options: { - forceWasMentioned: true, - messageIdOverride: - typeof ctx.update.update_id === "number" - ? String(ctx.update.update_id) - : `poll:${pollId}:${user.id}:${optionIds.join("-")}`, - }, - }); - recordTelegramMessageProcessingResult(result); - logVerbose(`telegram: poll_answer dispatched for poll ${pollId} by ${senderId}`); - } catch (err) { - runtime.error?.(danger(`telegram poll_answer handler failed: ${String(err)}`)); - if (isTelegramSpooledReplayUpdate(ctx.update)) { - recordTelegramMessageProcessingResult({ kind: "failed-retryable", error: err }); - return; - } - throw err; - } - }); -} diff --git a/extensions/telegram/src/bot-handlers.reaction.runtime.test.ts b/extensions/telegram/src/bot-handlers.reaction.runtime.test.ts index 3800dc5ef11d..d1c456a0a9e2 100644 --- a/extensions/telegram/src/bot-handlers.reaction.runtime.test.ts +++ b/extensions/telegram/src/bot-handlers.reaction.runtime.test.ts @@ -3,9 +3,10 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { defaultTelegramBotDeps } from "./bot-deps.js"; -import { createTelegramHandlerAuthorizationRuntime } from "./bot-handlers.authorization.runtime.js"; -import { registerTelegramReactionHandler } from "./bot-handlers.reaction.runtime.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; +import { createTelegramEventBindings } from "./bot-handlers.event-bindings.js"; +import { createTelegramHandlerAuthorization } from "./bot-handlers.inbound-authorization.js"; +import { createTelegramMessagePipeline } from "./bot-handlers.message-pipeline.js"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; import type { TelegramThreadSpec } from "./bot/helpers.js"; const FIRE_EMOJI = "\u{1F525}"; @@ -106,11 +107,15 @@ function registerHandler(cfg: OpenClawConfig): ReactionHandler { }, }; - registerTelegramReactionHandler( + createTelegramEventBindings({ params, - { resolveCachedMessageThreadSpec }, - createTelegramHandlerAuthorizationRuntime(params), - ); + message: { + ...createTelegramMessagePipeline(params), + resolveCachedMessageThreadSpec, + }, + authorization: createTelegramHandlerAuthorization(params), + registerMessages: () => {}, + }).registerReaction(); const handler = handlers.get("message_reaction"); if (!handler) { throw new Error("expected message_reaction handler"); diff --git a/extensions/telegram/src/bot-handlers.reaction.runtime.ts b/extensions/telegram/src/bot-handlers.reaction.runtime.ts deleted file mode 100644 index da25a633b6d9..000000000000 --- a/extensions/telegram/src/bot-handlers.reaction.runtime.ts +++ /dev/null @@ -1,216 +0,0 @@ -// Telegram reaction handler registration. -import type { ReactionTypeEmoji } from "grammy/types"; -import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; -import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; -import { resolveTelegramAccount } from "./accounts.js"; -import type { TelegramHandlerAuthorizationRuntime } from "./bot-handlers.authorization.runtime.js"; -import type { TelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; -import { - buildTelegramGroupPeerId, - buildTelegramParentPeer, - resolveTelegramThreadSpec, - type TelegramThreadSpec, -} from "./bot/helpers.js"; -import { resolveTelegramConversationRoute } from "./conversation-route.js"; - -/** Stable operator-facing reason for a scoped reaction dropped without a known topic. */ -const TELEGRAM_REACTION_THREAD_UNRESOLVED_REASON = "thread-context-unavailable"; - -/** Only the message-cache lookup this handler needs, so tests can supply it directly. */ -type TelegramReactionThreadRecovery = Pick< - TelegramHandlerMessageRuntime, - "resolveCachedMessageThreadSpec" ->; - -export function registerTelegramReactionHandler( - { accountId, bot, runtime, telegramDeps, shouldSkipUpdate }: RegisterTelegramHandlerParams, - threadRecovery: TelegramReactionThreadRecovery, - authorizationRuntime: TelegramHandlerAuthorizationRuntime, -) { - const { resolveTelegramEventAuthorizationContext, authorizeTelegramEventSender } = - authorizationRuntime; - // Handle emoji reactions to messages. - bot.on("message_reaction", async (ctx) => { - try { - const reaction = ctx.messageReaction; - if (!reaction) { - return; - } - if (shouldSkipUpdate(ctx)) { - return; - } - - const chatId = reaction.chat.id; - const messageId = reaction.message_id; - const user = reaction.user; - const senderId = user?.id != null ? String(user.id) : ""; - const senderUsername = user?.username ?? ""; - const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup"; - const isDirectMessagesChat = reaction.chat.is_direct_messages === true; - const isForum = !isDirectMessagesChat && reaction.chat.is_forum === true; - const authorizationCfg = telegramDeps.getRuntimeConfig(); - const authorizationTelegramCfg = resolveTelegramAccount({ - cfg: authorizationCfg, - accountId, - }).config; - - // Resolve reaction notification mode (default: "own"). - const reactionMode = authorizationTelegramCfg.reactionNotifications ?? "own"; - if (reactionMode === "off") { - return; - } - if (user?.is_bot) { - return; - } - if ( - reactionMode === "own" && - !telegramDeps.wasSentByBot(chatId, messageId, authorizationCfg) - ) { - logVerbose( - `telegram: skipped reaction on msg ${messageId} in chat ${chatId} (own mode, not sent by bot)`, - ); - return; - } - // Detect added reactions. This runs before topic recovery so a reaction that - // enqueues nothing never spends a cache lookup or logs an unresolved-topic warning. - const oldEmojis = new Set( - reaction.old_reaction - .filter((r): r is ReactionTypeEmoji => r.type === "emoji") - .map((r) => r.emoji), - ); - const addedReactions = reaction.new_reaction - .filter((r): r is ReactionTypeEmoji => r.type === "emoji") - .filter((r) => !oldEmojis.has(r.emoji)); - - if (addedReactions.length === 0) { - return; - } - - // `MessageReactionUpdated` omits every topic field. Scoped reactions only have a - // route if the reacted-to message is still in the bounded message cache. - let recoveredThreadSpec: TelegramThreadSpec | undefined; - const requiredScope = isDirectMessagesChat - ? "direct-messages" - : isForum - ? "forum" - : undefined; - if (requiredScope) { - recoveredThreadSpec = await threadRecovery.resolveCachedMessageThreadSpec({ - chatId, - messageId, - }); - if (recoveredThreadSpec?.scope !== requiredScope || recoveredThreadSpec.id === undefined) { - runtime.log?.( - warn( - `telegram: skipped scoped reaction account=${accountId} chat=${chatId} message=${messageId} reason=${TELEGRAM_REACTION_THREAD_UNRESOLVED_REASON}`, - ), - ); - return; - } - } - - const eventAuthContext = await resolveTelegramEventAuthorizationContext({ - cfg: authorizationCfg, - chatId, - isGroup, - senderId, - threadSpec: - recoveredThreadSpec ?? - resolveTelegramThreadSpec({ - isGroup, - isForum, - }), - }); - const senderAuthorization = await authorizeTelegramEventSender({ - chatId, - chatTitle: reaction.chat.title, - isGroup, - senderId, - senderUsername, - mode: "reaction", - context: eventAuthContext, - }); - if (!senderAuthorization) { - return; - } - - // Enforce requireTopic for DM reactions: since Telegram doesn't provide messageThreadId - // for reactions, we cannot determine if the reaction came from a topic, so block all - // reactions if requireTopic is enabled for this DM. - if (!isGroup) { - const requireTopic = ( - eventAuthContext.groupConfig as { requireTopic?: boolean } | undefined - )?.requireTopic; - if (requireTopic === true) { - logVerbose( - `Blocked telegram reaction in DM ${chatId}: requireTopic=true but topic unknown for reactions`, - ); - return; - } - } - - const resolvedThreadId = eventAuthContext.resolvedThreadId; - let sessionKey: string; - if (recoveredThreadSpec) { - // Scoped topics carry topic agents and conversation bindings, so the recovered - // spec goes through the canonical route resolver instead of a bare peer route. - sessionKey = resolveTelegramConversationRoute({ - cfg: eventAuthContext.cfg, - accountId, - chatId, - isGroup, - resolvedThreadId, - replyThreadId: recoveredThreadSpec.id, - senderId, - topicAgentId: eventAuthContext.topicConfig?.agentId, - }).route.sessionKey; - } else { - // Direct chats and non-forum groups have no topic to recover; keep their - // established peer route so reaction sessions stay where they already are. - const peerId = isGroup - ? buildTelegramGroupPeerId(chatId, resolvedThreadId) - : String(chatId); - const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); - // Fresh config for bindings lookup; other routing inputs are payload-derived. - sessionKey = resolveAgentRoute({ - cfg: eventAuthContext.cfg, - channel: "telegram", - accountId, - peer: { kind: isGroup ? "group" : "direct", id: peerId }, - parentPeer, - }).sessionKey; - } - - // Build sender label. - const senderName = user - ? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username - : undefined; - const senderUsernameLabel = user?.username ? `@${user.username}` : undefined; - let senderLabel = senderName; - if (senderName && senderUsernameLabel) { - senderLabel = `${senderName} (${senderUsernameLabel})`; - } else if (!senderName && senderUsernameLabel) { - senderLabel = senderUsernameLabel; - } - if (!senderLabel && user?.id) { - senderLabel = `id:${user.id}`; - } - senderLabel = senderLabel || "unknown"; - - // Enqueue system event for each added reaction. - for (const r of addedReactions) { - const emoji = r.emoji; - const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`; - telegramDeps.enqueueSystemEvent(text, { - sessionKey, - contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`, - }); - logVerbose(`telegram: reaction event enqueued: ${text}`); - } - } catch (err) { - runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`)); - throw err; - } - }); -} diff --git a/extensions/telegram/src/bot-handlers.runtime.test.ts b/extensions/telegram/src/bot-handlers.runtime.test.ts index ad591dd00652..959e01cc61af 100644 --- a/extensions/telegram/src/bot-handlers.runtime.test.ts +++ b/extensions/telegram/src/bot-handlers.runtime.test.ts @@ -1,57 +1,45 @@ -// Telegram tests cover bot handlers plugin behavior. -import { describe, expect, it } from "vitest"; -import { - buildTelegramInboundDebounceConversationKey, - buildTelegramInboundDebounceKey, -} from "./bot-handlers.debounce-key.js"; +// Telegram tests cover bot handler registration behavior. +import { Bot } from "grammy"; +import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; +import { describe, expect, it, vi } from "vitest"; +import { defaultTelegramBotDeps } from "./bot-deps.js"; +import { registerTelegramHandlers } from "./bot-handlers.runtime.js"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; -describe("buildTelegramInboundDebounceKey", () => { - it("uses the resolved account id instead of literal default when provided", () => { - expect( - buildTelegramInboundDebounceKey({ - accountId: "work", - conversationKey: "12345", - senderId: "67890", - debounceLane: "default", - }), - ).toBe("telegram:work:12345:67890:default"); - }); +describe("registerTelegramHandlers", () => { + it("registers middleware in transport order", () => { + const bot = new Bot("123456:handler-registration-test"); + const on = vi.spyOn(bot, "on"); + const params: RegisterTelegramHandlerParams = { + cfg: {}, + accountId: "default", + bot, + mediaMaxBytes: 1, + opts: { token: "tok" }, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + telegramCfg: {}, + telegramDeps: defaultTelegramBotDeps, + resolveGroupPolicy: () => ({ allowlistEnabled: false, allowed: true }), + resolveGroupActivation: () => undefined, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({}), + shouldSkipUpdate: () => false, + processMessage: vi.fn(), + logger: getChildLogger({ module: "telegram/handler-registration-test" }), + }; - it("falls back to literal default only when account id is actually absent", () => { - expect( - buildTelegramInboundDebounceKey({ - accountId: undefined, - conversationKey: "12345", - senderId: "67890", - debounceLane: "forward", - }), - ).toBe("telegram:default:12345:67890:forward"); - }); + registerTelegramHandlers(params); - it("keeps direct topic thread ids in the conversation key", () => { - const topic100 = buildTelegramInboundDebounceConversationKey({ chatId: 7, threadId: 100 }); - const topic200 = buildTelegramInboundDebounceConversationKey({ chatId: 7, threadId: 200 }); - - expect(topic100).toBe("7:topic:100"); - expect(topic200).toBe("7:topic:200"); - expect( - buildTelegramInboundDebounceKey({ - accountId: "default", - conversationKey: topic100, - senderId: "42", - debounceLane: "default", - }), - ).not.toBe( - buildTelegramInboundDebounceKey({ - accountId: "default", - conversationKey: topic200, - senderId: "42", - debounceLane: "default", - }), - ); - }); - - it("uses the chat id as the conversation key when no thread is present", () => { - expect(buildTelegramInboundDebounceConversationKey({ chatId: 7 })).toBe("7"); + expect(on.mock.calls.map(([trigger]) => trigger)).toEqual([ + "message_reaction", + "poll", + "poll_answer", + "callback_query", + "message:migrate_to_chat_id", + "message", + "edited_message", + "channel_post", + "edited_channel_post", + ]); }); }); diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 8e308a5076a8..c88068d2fa62 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -1,22 +1,31 @@ -// Telegram bot handler composition. -import { createTelegramHandlerAuthorizationRuntime } from "./bot-handlers.authorization.runtime.js"; -import { registerTelegramCallbackQueryHandler } from "./bot-handlers.callback.runtime.js"; -import { createTelegramHandlerInboundRuntime } from "./bot-handlers.inbound.runtime.js"; -import { registerTelegramMessageHandlers } from "./bot-handlers.message-events.runtime.js"; -import { createTelegramHandlerMessageRuntime } from "./bot-handlers.message.runtime.js"; -import { registerTelegramMigrationHandler } from "./bot-handlers.migration.runtime.js"; -import { registerTelegramPollHandlers } from "./bot-handlers.poll-answer.runtime.js"; -import { registerTelegramReactionHandler } from "./bot-handlers.reaction.runtime.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; +import { createTelegramCallbackRouter } from "./bot-handlers.callback-router.js"; +import { createTelegramEventBindings } from "./bot-handlers.event-bindings.js"; +import { createTelegramHandlerAuthorization } from "./bot-handlers.inbound-authorization.js"; +import { + createTelegramInboundPipeline, + registerTelegramInboundHandlers, +} from "./bot-handlers.inbound-pipeline.js"; +import { createTelegramMessagePipeline } from "./bot-handlers.message-pipeline.js"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; export const registerTelegramHandlers = (params: RegisterTelegramHandlerParams) => { - const messageRuntime = createTelegramHandlerMessageRuntime(params); - const authorizationRuntime = createTelegramHandlerAuthorizationRuntime(params); - const inboundRuntime = createTelegramHandlerInboundRuntime(params, messageRuntime); + const message = createTelegramMessagePipeline(params); + const authorization = createTelegramHandlerAuthorization(params); + const inboundPipeline = createTelegramInboundPipeline({ params, message, authorization }); + const callbackRouter = createTelegramCallbackRouter({ params, message, authorization }); + const eventBindings = createTelegramEventBindings({ + params, + message, + authorization, + registerMessages: () => + registerTelegramInboundHandlers({ bot: params.bot, pipeline: inboundPipeline }), + }); - registerTelegramReactionHandler(params, messageRuntime, authorizationRuntime); - registerTelegramPollHandlers(params, messageRuntime, authorizationRuntime); - registerTelegramCallbackQueryHandler(params, messageRuntime, authorizationRuntime); - registerTelegramMigrationHandler(params); - registerTelegramMessageHandlers(params, messageRuntime, authorizationRuntime, inboundRuntime); + eventBindings.registerReaction(); + eventBindings.registerPolls(); + params.bot.on("callback_query", async (ctx) => { + await callbackRouter.route(ctx); + }); + eventBindings.registerMigration(); + eventBindings.registerMessages(); }; diff --git a/extensions/telegram/src/bot-handlers.types.ts b/extensions/telegram/src/bot-handlers.types.ts new file mode 100644 index 000000000000..4d5308c72707 --- /dev/null +++ b/extensions/telegram/src/bot-handlers.types.ts @@ -0,0 +1,124 @@ +import type { Bot, Context } from "grammy"; +import type { + ChannelGroupPolicy, + OpenClawConfig, + TelegramAccountConfig, + TelegramDirectConfig, + TelegramGroupConfig, + TelegramTopicConfig, +} from "openclaw/plugin-sdk/config-contracts"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import type { TelegramBotDeps } from "./bot-deps.js"; +import type { + TelegramMediaRef, + TelegramMessageContextOptions, + TelegramPromptContextEntry, +} from "./bot-message-context.types.js"; +import type { + TelegramMessageProcessingResult, + TelegramSpooledReplayDeferredParticipant, +} from "./bot-processing-outcome.js"; +import type { TelegramUpdateKeyContext } from "./bot-updates.js"; +import type { TelegramBotOptions } from "./bot.types.js"; +import type { TelegramContext } from "./bot/types.js"; +import type { TelegramTransport } from "./fetch.js"; +import type { TelegramReplyChainEntry } from "./message-cache.js"; + +export type TelegramMessageProcessorTurnContext = { + cfg: OpenClawConfig; + telegramCfg: TelegramAccountConfig; + onDispatchStart?: () => Promise | void; + spooledReplayAbortSignal?: AbortSignal; + spooledReplayParticipant?: TelegramSpooledReplayDeferredParticipant; + finalizeSpooledReplayResult?: ( + result: TelegramMessageProcessingResult, + phase: "adopted" | "terminal", + ) => Promise; + completeSpooledReplayAfterIrrevocableAdoption?: ( + error: unknown, + ) => Promise | TelegramMessageProcessingResult; +}; + +type ProcessTelegramMessageOptions = { + ctx: TelegramContext; + allMedia: TelegramMediaRef[]; + storeAllowFrom: string[]; + turnContext: TelegramMessageProcessorTurnContext; + options?: TelegramMessageContextOptions; + replyMedia?: TelegramMediaRef[]; + replyChain?: TelegramReplyChainEntry[]; + promptContext?: TelegramPromptContextEntry[]; +}; + +type ProcessTelegramMessage = ( + options: ProcessTelegramMessageOptions, +) => Promise; + +export type TelegramResolvedGroupConfig = { + groupConfig?: TelegramGroupConfig | TelegramDirectConfig; + topicConfig?: TelegramTopicConfig; +}; + +export type TelegramNativeCommandCallbackDispatcher = (params: { + botUser: Context["me"]; + callbackQuery: NonNullable; + commandText: string; +}) => Promise<{ handled: boolean; clearButtons: boolean }>; + +type TelegramHandlerLogger = { + info: (fields: Record, message: string) => void; + warn: (fields: Record, message: string) => void; +}; + +export type RegisterTelegramHandlerParams = { + cfg: OpenClawConfig; + accountId: string; + bot: Bot; + mediaMaxBytes: number; + opts: TelegramBotOptions; + telegramTransport?: TelegramTransport; + runtime: RuntimeEnv; + telegramCfg: TelegramAccountConfig; + telegramDeps: TelegramBotDeps; + resolveGroupPolicy: (chatId: string | number, cfg: OpenClawConfig) => ChannelGroupPolicy; + resolveGroupActivation: (params: { + chatId: string | number; + agentId?: string; + messageThreadId?: number; + sessionKey?: string; + cfg: OpenClawConfig; + }) => boolean | undefined; + resolveGroupRequireMention: (chatId: string | number, cfg: OpenClawConfig) => boolean; + resolveTelegramGroupConfig: ( + chatId: string | number, + messageThreadId: number | undefined, + cfg: OpenClawConfig, + ) => TelegramResolvedGroupConfig; + shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean; + processMessage: ProcessTelegramMessage; + logger: TelegramHandlerLogger; + nativeCommandCallbackDispatcher?: TelegramNativeCommandCallbackDispatcher; +}; + +export type TelegramInboundDisposition = + | { kind: "ignored" } + | { kind: "recorded" } + | { kind: "buffered"; buffer: "text-fragment" | "media-group" | "debounce" } + | { kind: "processed" }; + +export interface TelegramInboundPipeline { + handle: (ctx: Context) => Promise; +} + +type TelegramCallbackRouteOutcome = { kind: "ignored" } | { kind: "handled" }; + +export interface TelegramCallbackRouter { + route(ctx: Context): Promise; +} + +export interface TelegramEventBindings { + registerReaction(): void; + registerPolls(): void; + registerMigration(): void; + registerMessages(): void; +} diff --git a/extensions/telegram/src/bot-message-context.prompt-context.test.ts b/extensions/telegram/src/bot-message-context.prompt-context.test.ts index a6259b6b48d2..420c9906e70a 100644 --- a/extensions/telegram/src/bot-message-context.prompt-context.test.ts +++ b/extensions/telegram/src/bot-message-context.prompt-context.test.ts @@ -9,7 +9,7 @@ import { upsertSessionEntry, } from "openclaw/plugin-sdk/session-store-runtime"; import { afterEach, describe, expect, it } from "vitest"; -import { createTelegramMessageContextRuntime } from "./bot-handlers.message-context.runtime.js"; +import { createTelegramMessageContextRuntime } from "./bot-handlers.message-context.js"; import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; import type { TelegramPromptContextEntry } from "./bot-message-context.types.js"; diff --git a/extensions/telegram/src/bot-message.test.ts b/extensions/telegram/src/bot-message.test.ts index c4c210f7efa4..5e3c09683934 100644 --- a/extensions/telegram/src/bot-message.test.ts +++ b/extensions/telegram/src/bot-message.test.ts @@ -2,6 +2,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { TelegramBotDeps } from "./bot-deps.js"; +import type { TelegramMessageProcessorTurnContext } from "./bot-handlers.types.js"; import type { TelegramMessageProcessingResult } from "./bot-processing-outcome.js"; const buildTelegramMessageContext = vi.hoisted(() => vi.fn()); @@ -69,7 +70,7 @@ describe("telegram bot message processor", () => { const baseTurnContext = { cfg: {}, telegramCfg: {}, - } satisfies import("./bot-message.js").TelegramMessageProcessorTurnContext; + } satisfies TelegramMessageProcessorTurnContext; it("passes the effective per-DM history limit into message context", async () => { buildTelegramMessageContext.mockResolvedValue(null); @@ -122,7 +123,7 @@ describe("telegram bot message processor", () => { async function processSampleMessage( processMessage: ReturnType, - turnContext?: Partial, + turnContext?: Partial, primaryCtxOverrides: Record = {}, options: Parameters[4] = {}, allMedia: Parameters[1] = [], diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts index fd81b1a6359a..56720dbca09a 100644 --- a/extensions/telegram/src/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -11,6 +11,7 @@ import { } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { TelegramBotDeps } from "./bot-deps.js"; +import type { TelegramMessageProcessorTurnContext } from "./bot-handlers.types.js"; import { buildTelegramMessageContext, type BuildTelegramMessageContextParams, @@ -27,7 +28,6 @@ import { isTelegramSpooledReplayUpdate, recordTelegramMessageProcessingResult, type TelegramMessageProcessingResult, - type TelegramSpooledReplayDeferredParticipant, } from "./bot-processing-outcome.js"; import type { TelegramBotOptions } from "./bot.types.js"; import { buildTelegramThreadParams, resolveTelegramStreamMode } from "./bot/helpers.js"; @@ -70,22 +70,6 @@ type TelegramMessageProcessorDeps = Omit< opts: Pick; }; -export type TelegramMessageProcessorTurnContext = { - cfg: OpenClawConfig; - telegramCfg: TelegramAccountConfig; - onDispatchStart?: () => Promise | void; - /** One-way cancellation from an outer spool owner into an isolated retry attempt. */ - spooledReplayAbortSignal?: AbortSignal; - spooledReplayParticipant?: TelegramSpooledReplayDeferredParticipant; - finalizeSpooledReplayResult?: ( - result: TelegramMessageProcessingResult, - phase: "adopted" | "terminal", - ) => Promise; - completeSpooledReplayAfterIrrevocableAdoption?: ( - error: unknown, - ) => Promise | TelegramMessageProcessingResult; -}; - export function resolveTelegramMessageTurnSettings(params: { accountId: string; senderId?: string | number; diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 197997f10e4f..0f351d70b58c 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -14,6 +14,7 @@ import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; // Telegram tests cover bot native commands.session meta plugin behavior. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js"; import { createTelegramGroupCommandContext, @@ -22,7 +23,6 @@ import { createTelegramTopicCommandContext, type NativeCommandTestParams, } from "./bot-native-commands.fixture-test-support.js"; -import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; import { runWithTelegramUpdateProcessingFrame } from "./bot-processing-outcome.js"; // All mocks scoped to this file only — does not affect bot-native-commands.test.ts diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 6b164f28ea8e..3f5dbfd7b861 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -49,7 +49,6 @@ import { codexChannelLoginRuntime } from "openclaw/plugin-sdk/provider-auth-logi import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatSqliteSessionFileMarker, @@ -65,12 +64,11 @@ import { resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { normalizeDmAllowFromWithStore, resolveTelegramEffectiveDmPolicy } from "./bot-access.js"; import type { TelegramBotDeps } from "./bot-deps.js"; -import type { TelegramMediaRef } from "./bot-message-context.js"; -import type { TelegramMessageContextOptions } from "./bot-message-context.types.js"; -import { - resolveTelegramMessageTurnSettings, - type TelegramMessageProcessorTurnContext, -} from "./bot-message.js"; +import type { + TelegramNativeCommandCallbackDispatcher, + TelegramResolvedGroupConfig, +} from "./bot-handlers.types.js"; +import { resolveTelegramMessageTurnSettings } from "./bot-message.js"; import { defaultTelegramNativeCommandDeps, type TelegramNativeCommandDeps, @@ -81,7 +79,6 @@ import { syncTelegramMenuCommands as syncTelegramMenuCommandsRuntime, type TelegramMenuCommand, } from "./bot-native-command-menu.js"; -import type { TelegramMessageProcessingResult } from "./bot-processing-outcome.js"; import type { TelegramUpdateKeyContext } from "./bot-updates.js"; import type { TelegramBotOptions } from "./bot.types.js"; import { @@ -99,7 +96,7 @@ import { resolveTelegramThreadSpec, shouldUseTelegramDmThreadSession, } from "./bot/helpers.js"; -import type { TelegramContext, TelegramGetChat } from "./bot/types.js"; +import type { TelegramGetChat } from "./bot/types.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { normalizeTelegramCommandName, @@ -111,7 +108,6 @@ import { resolveTelegramConversationRoute, } from "./conversation-route.js"; import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; -import type { TelegramTransport } from "./fetch.js"; import { evaluateTelegramGroupBaseAccess, evaluateTelegramGroupPolicyAccess, @@ -148,10 +144,6 @@ type TelegramNativeReplyChannelData = { }; }; type FastModeState = ReturnType; -type TelegramResolvedGroupConfig = { - groupConfig?: TelegramGroupConfig | TelegramDirectConfig; - topicConfig?: TelegramTopicConfig; -}; type TelegramCommandAuthResult = { chatId: number; @@ -612,51 +604,6 @@ async function resolveTelegramNativeCommandThreadContext(params: { }; } -export type RegisterTelegramHandlerParams = { - cfg: OpenClawConfig; - accountId: string; - bot: Bot; - mediaMaxBytes: number; - opts: TelegramBotOptions; - telegramTransport?: TelegramTransport; - runtime: RuntimeEnv; - telegramCfg: TelegramAccountConfig; - telegramDeps: TelegramBotDeps; - resolveGroupPolicy: (chatId: string | number, cfg: OpenClawConfig) => ChannelGroupPolicy; - resolveGroupActivation: (params: { - chatId: string | number; - agentId?: string; - messageThreadId?: number; - sessionKey?: string; - cfg: OpenClawConfig; - }) => boolean | undefined; - resolveGroupRequireMention: (chatId: string | number, cfg: OpenClawConfig) => boolean; - resolveTelegramGroupConfig: ( - chatId: string | number, - messageThreadId: number | undefined, - cfg: OpenClawConfig, - ) => TelegramResolvedGroupConfig; - shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean; - processMessage: ( - ctx: TelegramContext, - allMedia: TelegramMediaRef[], - storeAllowFrom: string[], - turnContext: TelegramMessageProcessorTurnContext, - options?: TelegramMessageContextOptions, - replyMedia?: TelegramMediaRef[], - replyChain?: import("./message-cache.js").TelegramReplyChainEntry[], - promptContext?: import("./bot-message-context.types.js").TelegramPromptContextEntry[], - ) => Promise; - logger: ReturnType; - nativeCommandCallbackDispatcher?: TelegramNativeCommandCallbackDispatcher; -}; - -type TelegramNativeCommandCallbackDispatcher = (params: { - botUser: Context["me"]; - callbackQuery: NonNullable; - commandText: string; -}) => Promise<{ handled: boolean; clearButtons: boolean }>; - function resolveTelegramNativeCommandDisableBlockStreaming( telegramCfg: TelegramAccountConfig, ): boolean | undefined { diff --git a/extensions/telegram/src/bot.forum-ingress.response-body-timeout.integration.test.ts b/extensions/telegram/src/bot.forum-ingress.response-body-timeout.integration.test.ts index b0f357f4260e..b320d1312084 100644 --- a/extensions/telegram/src/bot.forum-ingress.response-body-timeout.integration.test.ts +++ b/extensions/telegram/src/bot.forum-ingress.response-body-timeout.integration.test.ts @@ -2,8 +2,19 @@ import { createServer, type Server } from "node:http"; import type { AddressInfo, Socket } from "node:net"; import { Bot } from "grammy"; +import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { registerTelegramMessageHandlers } from "./bot-handlers.message-events.runtime.js"; +import { defaultTelegramBotDeps } from "./bot-deps.js"; +import { createTelegramHandlerAuthorization } from "./bot-handlers.inbound-authorization.js"; +import { + createTelegramInboundPipeline, + registerTelegramInboundHandlers, +} from "./bot-handlers.inbound-pipeline.js"; +import { + createTelegramMessagePipeline, + type TelegramMessagePipeline, +} from "./bot-handlers.message-pipeline.js"; +import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js"; import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js"; import { asTelegramClientFetch, createTelegramClientFetch } from "./client-fetch.js"; @@ -72,40 +83,60 @@ describe("Telegram supergroup ingress with a stalled Bot API response body", () botInfo, client: { apiRoot, fetch: asTelegramClientFetch(clientFetch) }, }); - const dispatched = vi.fn< - Parameters[3]["processInboundMessage"] - >(async () => undefined); + const dispatched = vi.fn(async () => ({ + kind: "completed", + })); const emptyAllow = { entries: [], hasWildcard: false, hasEntries: false, invalidEntries: [], }; + const params: RegisterTelegramHandlerParams = { + accountId: "default", + bot, + cfg: {}, + mediaMaxBytes: 1, + opts: { token: "123456:integration-token", botInfo }, + runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() }, + telegramCfg: {}, + telegramDeps: defaultTelegramBotDeps, + logger: getChildLogger({ module: "telegram/forum-ingress-test" }), + resolveGroupPolicy: () => ({ allowlistEnabled: false, allowed: true }), + resolveGroupActivation: () => undefined, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({}), + shouldSkipUpdate: () => false, + processMessage: async () => ({ kind: "completed" }), + }; + const authorizeInboundMessage = vi.fn< + ReturnType["authorizeInboundMessage"] + >(async (inbound) => ({ + allowed: true as const, + effectiveDmAllow: emptyAllow, + context: { + cfg: {}, + telegramCfg: {}, + allowFrom: [], + dmPolicy: "open" as const, + threadSpec: inbound.isGroup ? ({ scope: "none" } as const) : ({ scope: "dm" } as const), + storeAllowFrom: [], + effectiveGroupAllow: emptyAllow, + hasGroupAllowOverride: false, + }, + })); const authorization = { - authorizeInboundMessage: vi.fn< - Parameters[2]["authorizeInboundMessage"] - >(async (params) => ({ - allowed: true as const, - effectiveDmAllow: emptyAllow, - context: { - cfg: {}, - telegramCfg: {}, - allowFrom: [], - dmPolicy: "open" as const, - threadSpec: params.isGroup ? ({ scope: "none" } as const) : ({ scope: "dm" } as const), - storeAllowFrom: [], - effectiveGroupAllow: emptyAllow, - hasGroupAllowOverride: false, - }, - })), - } satisfies Parameters[2]; - const messageRuntime = { + ...createTelegramHandlerAuthorization(params), + authorizeInboundMessage, + }; + const message: TelegramMessagePipeline = { + ...createTelegramMessagePipeline(params), normalizePromptContextMinTimestampMs: () => undefined, promptContextBoundaryOptions: () => ({}), releaseDispatchDedupeClaims: () => undefined, claimMessageDispatchDedupe: async () => ({ process: true, claims: [] }), - buildSyntheticContext: (context, message) => ({ - message, + buildSyntheticContext: (context, syntheticMessage) => ({ + message: syntheticMessage, me: context.me, getFile: context.getFile.bind(context), }), @@ -117,20 +148,15 @@ describe("Telegram supergroup ingress with a stalled Bot API response body", () model: undefined, }), resolvePromptContextAmbientWatermark: () => undefined, - recordMessageForReplyChain: async () => undefined, - } satisfies Parameters[1]; - - registerTelegramMessageHandlers( - { - bot, - opts: { botInfo }, - runtime: { error: vi.fn() }, - shouldSkipUpdate: () => false, - } satisfies Parameters[0], - messageRuntime, - authorization, - { processInboundMessage: dispatched }, - ); + recordMessageForReplyChain: async (msg) => ({ + messageId: String(msg.message_id), + sender: "integration sender", + sourceMessage: msg, + }), + processMessageWithReplyChain: dispatched, + }; + const pipeline = createTelegramInboundPipeline({ params, message, authorization }); + registerTelegramInboundHandlers({ bot, pipeline }); const headersReceived = new Promise((resolve) => { resolveGetChatHeaders = resolve; @@ -159,7 +185,12 @@ describe("Telegram supergroup ingress with a stalled Bot API response body", () }, }); expect(dispatched).toHaveBeenCalledTimes(1); - expect(dispatched.mock.calls[0]?.[0]).toMatchObject({ chatId: 222, isGroup: false }); + expect(dispatched.mock.calls[0]?.[0].msg.chat.id).toBe(222); + expect(authorizeInboundMessage.mock.calls[0]?.[0]).toMatchObject({ + chatId: 222, + isGroup: false, + isForum: false, + }); const groupResult = await Promise.race([ groupDelivery.then(() => "dispatched" as const), @@ -170,7 +201,8 @@ describe("Telegram supergroup ingress with a stalled Bot API response body", () expect(groupResult).toBe("dispatched"); expect(dispatched).toHaveBeenCalledTimes(2); - expect(dispatched.mock.calls[1]?.[0]).toMatchObject({ + expect(dispatched.mock.calls[1]?.[0].msg.chat.id).toBe(-100364); + expect(authorizeInboundMessage.mock.calls[1]?.[0]).toMatchObject({ chatId: -100364, isGroup: true, isForum: false, diff --git a/extensions/telegram/src/interactive-fallback.test.ts b/extensions/telegram/src/interactive-fallback.test.ts index 891e623932be..6efd18a717b9 100644 --- a/extensions/telegram/src/interactive-fallback.test.ts +++ b/extensions/telegram/src/interactive-fallback.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { handleTelegramQuestionCallback } from "./bot-handlers.callback-questions.runtime.js"; +import { handleTelegramQuestionCallback } from "./bot-handlers.callback-actions.js"; import { canonicalizeTelegramPresentationPayload } from "./interactive-fallback.js"; import { parseTelegramQuestionCallbackData } from "./question-callback-data.js"; diff --git a/extensions/telegram/src/transport-payload.test.ts b/extensions/telegram/src/transport-payload.test.ts index bd352ed7b3e0..76681b4746e7 100644 --- a/extensions/telegram/src/transport-payload.test.ts +++ b/extensions/telegram/src/transport-payload.test.ts @@ -8,7 +8,7 @@ import { resetPluginStateStoreForTests, } from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createTelegramCallbackMessageActions } from "./bot-handlers.callback-actions.runtime.js"; +import { createTelegramCallbackMessageActions } from "./bot-handlers.callback-actions.js"; import { asTelegramClientFetch } from "./client-fetch.js"; import { createTelegramDraftStream } from "./draft-stream.js"; import { setTelegramRuntime } from "./runtime.js";