diff --git a/extensions/telegram/src/bot-message-dispatch-draft.ts b/extensions/telegram/src/bot-message-dispatch-draft.ts index 04ba3f7a099b..658007468c32 100644 --- a/extensions/telegram/src/bot-message-dispatch-draft.ts +++ b/extensions/telegram/src/bot-message-dispatch-draft.ts @@ -149,6 +149,10 @@ export function createTelegramDraftController(params: { chatId: params.chatId, message, messageId: message.message_id, + ...(params.threadSpec.id !== undefined + ? { messageThreadId: params.threadSpec.id } + : {}), + successfulSendThread: params.threadSpec, }); }, log: logVerbose, diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts index d4fc50598b94..90bd3e51ddde 100644 --- a/extensions/telegram/src/bot/helpers.ts +++ b/extensions/telegram/src/bot/helpers.ts @@ -62,7 +62,7 @@ export { resolveTelegramPrimaryMedia, }; -const TELEGRAM_GENERAL_TOPIC_ID = 1; +export const TELEGRAM_GENERAL_TOPIC_ID = 1; const TELEGRAM_FORUM_FLAG_CACHE_MAX_CHATS = 1024; const TELEGRAM_FORUM_FLAG_CACHE_TTL_MS = 10 * 60_000; const telegramForumFlagByChatId = new Map(); diff --git a/extensions/telegram/src/channel-actions.contract.test.ts b/extensions/telegram/src/channel-actions.contract.test.ts index 29504761f3d3..b453b7c563fc 100644 --- a/extensions/telegram/src/channel-actions.contract.test.ts +++ b/extensions/telegram/src/channel-actions.contract.test.ts @@ -23,6 +23,15 @@ describe("telegram actions contract", () => { ], }); + it("exposes message resource aliases through the registered adapter", () => { + for (const action of ["react", "edit", "delete"] as const) { + expect(telegramPlugin.actions?.messageActionTargetAliases?.[action]).toEqual({ + aliases: ["messageId"], + deliveryTargetAliases: [], + }); + } + }); + it.each([ { richMessages: undefined as boolean | undefined, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index d6cb10ecfa99..5975b1fbed4b 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -267,6 +267,7 @@ const telegramMessageAdapter = createChannelMessageAdapterFromOutbound getOptionalTelegramRuntime()?.channel?.telegram?.messageActions?.resolveExecutionMode?.(ctx) ?? telegramMessageActionsImpl.resolveExecutionMode?.(ctx) ?? diff --git a/extensions/telegram/src/outbound-message-context.test.ts b/extensions/telegram/src/outbound-message-context.test.ts index bdd30adb73b0..df312f4641ee 100644 --- a/extensions/telegram/src/outbound-message-context.test.ts +++ b/extensions/telegram/src/outbound-message-context.test.ts @@ -119,6 +119,7 @@ describe("recordOutboundMessageForPromptContext", () => { } as const; const callerOnlyThread = await recordAndRead({ ...common, + successfulSendThread: { id: 77, scope: "forum" }, message: { chat: { id: -1001, type: "supergroup", title: "QA" }, date: 1_736_380_700, @@ -144,6 +145,44 @@ describe("recordOutboundMessageForPromptContext", () => { expect(hasProviderObservedTelegramThreadBinding(providerThread, 77)).toBe(true); }); + it("binds a successful General-topic response from trusted send context", async () => { + const cached = await recordAndRead({ + account: { accountId: "default", name: "Configured Agent" }, + chatId: -1001, + message: { + chat: { id: -1001, type: "supergroup", title: "QA" }, + date: 1_736_380_700, + from: { id: 999, is_bot: true, first_name: "OpenClaw" }, + message_id: 702, + text: "Bot replied in General", + }, + messageId: 702, + messageThreadId: 1, + successfulSendThread: { id: 1, scope: "forum" }, + }); + + expect(hasProviderObservedTelegramThreadBinding(cached, 1)).toBe(true); + }); + + it("does not infer a General-topic binding for DM thread context", async () => { + const cached = await recordAndRead({ + account: { accountId: "default", name: "Configured Agent" }, + chatId: 42, + message: { + chat: { id: 42, type: "private" }, + date: 1_736_380_700, + from: { id: 999, is_bot: true, first_name: "OpenClaw" }, + message_id: 703, + text: "Bot replied in a DM topic", + }, + messageId: 703, + messageThreadId: 1, + successfulSendThread: { id: 1, scope: "dm" }, + }); + + expect(hasProviderObservedTelegramThreadBinding(cached, 1)).toBe(false); + }); + it("falls back to the Telegram bot name when no configured name exists", async () => { const cached = await recordAndRead({ account: { accountId: "default", name: "" }, diff --git a/extensions/telegram/src/outbound-message-context.ts b/extensions/telegram/src/outbound-message-context.ts index 41cb09892f51..f03f1bd1f082 100644 --- a/extensions/telegram/src/outbound-message-context.ts +++ b/extensions/telegram/src/outbound-message-context.ts @@ -3,6 +3,7 @@ import type { Message } from "grammy/types"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { TELEGRAM_GENERAL_TOPIC_ID, type TelegramThreadSpec } from "./bot/helpers.js"; import { buildTelegramSelfSenderName } from "./group-history-window.js"; import { createTelegramMessageCache, resolveTelegramMessageCacheScope } from "./message-cache.js"; import type { TelegramPromptContextProjection } from "./prompt-context-projection.js"; @@ -130,11 +131,25 @@ export async function recordOutboundMessageForPromptContext(params: { botUserId?: number; text?: string; messageThreadId?: number; + /** Effective server-owned thread for the successful provider send. */ + successfulSendThread?: TelegramThreadSpec; promptContextTimestampMs?: number; promptContextProjection?: TelegramPromptContextProjection; }): Promise { try { - const cacheMessage = buildOutboundCacheMessage(params); + const providerGeneralTopicId = + params.message.message_thread_id === undefined && + params.message.chat?.type === "supergroup" && + params.successfulSendThread?.scope === "forum" && + params.successfulSendThread.id === TELEGRAM_GENERAL_TOPIC_ID + ? TELEGRAM_GENERAL_TOPIC_ID + : undefined; + const providerObservedThreadId = params.message.message_thread_id ?? providerGeneralTopicId; + const messageThreadId = params.messageThreadId ?? providerGeneralTopicId; + const cacheMessage = buildOutboundCacheMessage({ + ...params, + ...(messageThreadId !== undefined ? { messageThreadId } : {}), + }); const cache = createTelegramMessageCache({ scope: resolveTelegramMessageCacheScope(resolveStorePath(params.cfg.session?.store)), }); @@ -146,17 +161,15 @@ export async function recordOutboundMessageForPromptContext(params: { ...(params.promptContextProjection ? { promptContextProjection: params.promptContextProjection } : {}), - ...(params.message.message_thread_id !== undefined - ? { providerObservedThreadId: params.message.message_thread_id } - : {}), - ...(params.messageThreadId !== undefined ? { threadId: params.messageThreadId } : {}), + ...(providerObservedThreadId !== undefined ? { providerObservedThreadId } : {}), + ...(messageThreadId !== undefined ? { threadId: messageThreadId } : {}), }); const timestamp = resolveOutboundCacheMessageTimestamp(cacheMessage); outboundGroupHistoryRecorders.get(params.account.accountId)?.({ chatId: params.chatId, messageId: params.messageId, text: params.text ?? cacheMessage.text ?? cacheMessage.caption, - ...(params.messageThreadId !== undefined ? { messageThreadId: params.messageThreadId } : {}), + ...(messageThreadId !== undefined ? { messageThreadId } : {}), ...(timestamp !== undefined ? { timestamp } : {}), }); return true; diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index a23e76f9390f..46d42b1e4a20 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -13,6 +13,7 @@ import { markdownToTelegramHtml, telegramHtmlToPlainTextFallback } from "./forma import { buildTelegramConversationContext, createTelegramMessageCache, + hasProviderObservedTelegramThreadBinding, resolveTelegramMessageCacheScope, } from "./message-cache.js"; import { createTelegramPromptContextProjectionCursor } from "./prompt-context-projection.js"; @@ -982,6 +983,35 @@ describe("sendMessageTelegram", () => { ); }); + it("records a successful General-topic send when the response omits the thread id", async () => { + const storePath = `/tmp/openclaw-telegram-general-context-${process.pid}-${Date.now()}.json`; + const chatId = "-1003966283270"; + botApi.sendMessage.mockResolvedValueOnce({ + message_id: 1498, + date: 1_779_394_741, + chat: { id: chatId, type: "supergroup", title: "QA forum" }, + from: { id: 42, is_bot: true, first_name: "OpenClaw" }, + text: "Reply in General", + }); + + await sendMessageTelegram(`${chatId}:topic:1`, "Reply in General", { + cfg: { session: { store: storePath } }, + token: "tok", + }); + + expect(firstMockCall(botApi.sendMessage, "General-topic send")[2]).not.toHaveProperty( + "message_thread_id", + ); + const cached = await createTelegramMessageCache({ + scope: resolveTelegramMessageCacheScope(storePath), + }).get({ + accountId: "default", + chatId, + messageId: "1498", + }); + expect(hasProviderObservedTelegramThreadBinding(cached, 1)).toBe(true); + }); + it("records transcript projection metadata without replacing Telegram time", async () => { const storePath = `/tmp/openclaw-telegram-send-context-override-${process.pid}-${Date.now()}.json`; const cfg = { session: { store: storePath } }; diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 2d323a203561..d78172797b90 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -840,6 +840,11 @@ async function sendMessageTelegramWithContext( verbose: opts.verbose, gatewayClientScopes: opts.gatewayClientScopes, }); + const threadSpec = resolveTelegramSendThreadSpec({ + targetMessageThreadId: target.messageThreadId, + messageThreadId: opts.messageThreadId, + chatType: target.chatType, + }); const reportDelivery = async ( messageId: string | number, deliveredChatId: string | number, @@ -865,6 +870,8 @@ async function sendMessageTelegramWithContext( account, ...(botUserId !== undefined ? { botUserId } : {}), chatId, + ...(threadSpec?.id !== undefined ? { messageThreadId: threadSpec.id } : {}), + ...(threadSpec ? { successfulSendThread: threadSpec } : {}), ...params, promptContextProjection: projection, }); @@ -880,11 +887,6 @@ async function sendMessageTelegramWithContext( (typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb : 100) * 1024 * 1024; const replyMarkup = buildInlineKeyboard(opts.buttons); - const threadSpec = resolveTelegramSendThreadSpec({ - targetMessageThreadId: target.messageThreadId, - messageThreadId: opts.messageThreadId, - chatType: target.chatType, - }); const singleUseReplyTo = opts.replyToIdSource === "implicit" && opts.replyToMode !== undefined && @@ -1659,12 +1661,13 @@ async function sendLocationTelegramWithContext( verbose: opts.verbose, gatewayClientScopes: opts.gatewayClientScopes, }); + const threadSpec = resolveTelegramSendThreadSpec({ + targetMessageThreadId: target.messageThreadId, + messageThreadId: opts.messageThreadId, + chatType: target.chatType, + }); const threadParams = buildTelegramThreadReplyParams({ - thread: resolveTelegramSendThreadSpec({ - targetMessageThreadId: target.messageThreadId, - messageThreadId: opts.messageThreadId, - chatType: target.chatType, - }), + thread: threadSpec, replyToMessageId: opts.replyToMessageId, replyQuoteText: opts.quoteText, useReplyIdAsQuoteSource: true, @@ -1726,6 +1729,8 @@ async function sendLocationTelegramWithContext( message: result, messageId, text: formatLocationText(location), + ...(threadSpec?.id !== undefined ? { messageThreadId: threadSpec.id } : {}), + ...(threadSpec ? { successfulSendThread: threadSpec } : {}), ...(acceptedParams?.message_thread_id !== undefined ? { messageThreadId: acceptedParams.message_thread_id } : {}), diff --git a/src/channels/plugins/message-action-dispatch.ts b/src/channels/plugins/message-action-dispatch.ts index 4acdc41c399d..576402b6eb83 100644 --- a/src/channels/plugins/message-action-dispatch.ts +++ b/src/channels/plugins/message-action-dispatch.ts @@ -24,6 +24,13 @@ const BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES: ReadonlySet = new Set([ "slack", ]); +// Telegram owns exact topic/account binding for message mutations only. Other +// Telegram reads retain the host gate, including targetless sticker cache reads. +const BUNDLED_PROVIDER_READ_GATE_ACTIONS: ReadonlyMap< + string, + ReadonlySet +> = new Map([["telegram", new Set(["react", "edit", "delete"])]]); + declare const serverOwnedConversationReadOrigin: unique symbol; type ServerOwnedConversationReadOrigin = ReturnType< @@ -137,12 +144,14 @@ type MessageActionReadEnforcement = }; function resolveMessageActionReadEnforcement(params: { + action: ChannelMessageActionName; channel: string; pluginOrigin: string | undefined; }): MessageActionReadEnforcement { if ( params.pluginOrigin === "bundled" && - BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES.has(params.channel) + (BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES.has(params.channel) || + BUNDLED_PROVIDER_READ_GATE_ACTIONS.get(params.channel)?.has(params.action) === true) ) { return { kind: "provider-owned" }; } @@ -551,6 +560,7 @@ export async function dispatchChannelMessageAction( origin, actionPolicy, enforcement: resolveMessageActionReadEnforcement({ + action: actionContext.action, channel: actionContext.channel, pluginOrigin: registration.origin, }), diff --git a/src/channels/plugins/message-actions.security.test.ts b/src/channels/plugins/message-actions.security.test.ts index 140b822f2905..d0ea2e463406 100644 --- a/src/channels/plugins/message-actions.security.test.ts +++ b/src/channels/plugins/message-actions.security.test.ts @@ -530,6 +530,52 @@ describe("dispatchChannelMessageAction conversation-read provenance", () => { expect(handleAction).not.toHaveBeenCalled(); }); + it.each(["react", "edit", "delete"] as const)( + "delegates Telegram %s topic binding to the bundled provider", + async (action) => { + setReadPlugin({ channel: "telegram", origin: "bundled" }); + + await dispatchChannelMessageAction({ + channel: "telegram", + action, + cfg: {} as OpenClawConfig, + params: { chatId: "-1001" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "telegram", + currentChannelId: "telegram:-1001:topic:77", + currentThreadTs: "77", + }, + }); + + expect(handleAction).toHaveBeenCalledOnce(); + }, + ); + + it("does not grant Telegram mutation enforcement to an external override", async () => { + setReadPlugin({ channel: "telegram", origin: "workspace" }); + + await expect( + dispatchChannelMessageAction({ + channel: "telegram", + action: "react", + cfg: {} as OpenClawConfig, + params: { chatId: "-1001" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "telegram", + currentChannelId: "telegram:-1001:topic:77", + currentThreadTs: "77", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + it("uses bundled provider target normalization for equivalent exact-current forms", async () => { const normalizeTarget = vi.fn((raw: string) => { const room = raw