From 08d926d3a5b359f0771b04d56bbdb6dc695d3791 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 20 Aug 2026 15:56:49 +0530 Subject: [PATCH] fix(telegram): recognize current-source message targets (#126625) Recognize raw, provider-qualified, and topic-qualified Telegram targets as the same current source when their semantic identities match. Prevent successful final message-tool sends from continuing into duplicate replies. Co-authored-by: Ayaan Zaidi --- extensions/telegram/src/channel.ts | 12 +- extensions/telegram/src/normalize.ts | 42 +++++-- .../src/threading-tool-context.test.ts | 44 ++++++++ ...sage-action-execution.source-reply.test.ts | 104 ++++++++++++++++++ 4 files changed, 192 insertions(+), 10 deletions(-) diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index bac3ec34790f..e132384bf4ad 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -75,7 +75,11 @@ import { } from "./group-policy.js"; import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js"; import * as monitorModule from "./monitor.js"; -import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js"; +import { + looksLikeTelegramTargetId, + normalizeTelegramMessagingTarget, + telegramMessagingTargetsMatch, +} from "./normalize.js"; import { createTelegramOutboundAdapter } from "./outbound-adapter.js"; import { parseTelegramThreadId } from "./outbound-params.js"; import { releaseStoppedTelegramPollingLease } from "./polling-lease.js"; @@ -1280,6 +1284,12 @@ export const telegramPlugin = createChatChannelPlugin({ }, security: telegramSecurityAdapter, threading: { + matchesToolContextTarget: ({ target, toolContext }) => { + return [toolContext.currentMessagingTarget, toolContext.currentChannelId].some( + (currentTarget) => + currentTarget != null && telegramMessagingTargetsMatch(target, currentTarget), + ); + }, resolveReplyToMode: ({ cfg, accountId }) => resolveTelegramConfigAccessorAccount({ cfg, accountId }).config.replyToMode ?? "off", buildToolContext: (params) => buildTelegramThreadingToolContext(params), diff --git a/extensions/telegram/src/normalize.ts b/extensions/telegram/src/normalize.ts index 14d584943b03..622cf43a0c71 100644 --- a/extensions/telegram/src/normalize.ts +++ b/extensions/telegram/src/normalize.ts @@ -15,27 +15,39 @@ function normalizeTelegramTargetBody(raw: string): string | undefined { return undefined; } - const parsed = parseTelegramTarget(trimmed); - const normalizedChatId = normalizeTelegramLookupTarget(parsed.chatId); - if (!normalizedChatId) { + const identity = resolveTelegramTargetIdentity(trimmed); + if (!identity) { return undefined; } const keepLegacyGroupPrefix = /^group:/i.test(prefixStripped); const hasTopicSuffix = /:topic:\d+$/i.test(prefixStripped); - const chatSegment = keepLegacyGroupPrefix ? `group:${normalizedChatId}` : normalizedChatId; - if (parsed.directMessagesTopicId != null) { - return `${chatSegment}:direct-topic:${parsed.directMessagesTopicId}`; + const chatSegment = keepLegacyGroupPrefix ? `group:${identity.chatId}` : identity.chatId; + if (identity.directMessagesTopicId != null) { + return `${chatSegment}:direct-topic:${identity.directMessagesTopicId}`; } - if (parsed.messageThreadId == null) { + if (identity.messageThreadId == null) { return chatSegment; } const threadSuffix = hasTopicSuffix - ? `:topic:${parsed.messageThreadId}` - : `:${parsed.messageThreadId}`; + ? `:topic:${identity.messageThreadId}` + : `:${identity.messageThreadId}`; return `${chatSegment}${threadSuffix}`; } +function resolveTelegramTargetIdentity(raw: string) { + const parsed = parseTelegramTarget(raw); + const chatId = normalizeTelegramLookupTarget(parsed.chatId); + if (!chatId) { + return undefined; + } + return { + chatId: normalizeLowercaseStringOrEmpty(chatId), + messageThreadId: parsed.messageThreadId, + directMessagesTopicId: parsed.directMessagesTopicId, + }; +} + export function normalizeTelegramMessagingTarget(raw: string): string | undefined { const normalizedBody = normalizeTelegramTargetBody(raw); if (!normalizedBody) { @@ -47,3 +59,15 @@ export function normalizeTelegramMessagingTarget(raw: string): string | undefine export function looksLikeTelegramTargetId(raw: string): boolean { return normalizeTelegramTargetBody(raw) !== undefined; } + +export function telegramMessagingTargetsMatch(target: string, currentTarget: string): boolean { + const targetIdentity = resolveTelegramTargetIdentity(target); + const currentIdentity = resolveTelegramTargetIdentity(currentTarget); + return ( + targetIdentity !== undefined && + currentIdentity !== undefined && + targetIdentity.chatId === currentIdentity.chatId && + targetIdentity.messageThreadId === currentIdentity.messageThreadId && + targetIdentity.directMessagesTopicId === currentIdentity.directMessagesTopicId + ); +} diff --git a/extensions/telegram/src/threading-tool-context.test.ts b/extensions/telegram/src/threading-tool-context.test.ts index d0f6b01411ca..594715651fc8 100644 --- a/extensions/telegram/src/threading-tool-context.test.ts +++ b/extensions/telegram/src/threading-tool-context.test.ts @@ -15,6 +15,14 @@ vi.mock("openclaw/plugin-sdk/secret-file-runtime", async (importOriginal) => ({ tryReadSecretFileSync: tryReadSecretFileSyncMock, })); +function requireTelegramToolContextTargetMatcher() { + const matchesToolContextTarget = telegramPlugin.threading?.matchesToolContextTarget; + if (!matchesToolContextTarget) { + throw new Error("Telegram tool context target matcher is unavailable"); + } + return matchesToolContextTarget; +} + describe("telegramPlugin reply threading", () => { it.each([ { @@ -60,6 +68,42 @@ describe("telegramPlugin reply threading", () => { expect(resolveReplyToMode({ cfg, accountId: "sut" })).toBe(expected); expect(tryReadSecretFileSyncMock).not.toHaveBeenCalled(); }); + + it.each([ + { target: "-100123", currentChannelId: "telegram:-100123", expected: true }, + { target: "telegram:-100123", currentChannelId: "-100123", expected: true }, + { + target: "-100123:topic:77", + currentChannelId: "telegram:-100123:77", + expected: true, + }, + { + target: "-100123:direct-topic:77", + currentChannelId: "telegram:-100123:direct-topic:77", + expected: true, + }, + { + target: "-100123:topic:77", + currentChannelId: "telegram:-100123:direct-topic:77", + expected: false, + }, + { + target: "-100123:topic:77", + currentChannelId: "telegram:-100123:topic:78", + expected: false, + }, + { target: "-100456", currentChannelId: "telegram:-100123", expected: false }, + ])( + "matches canonical target $target against current channel $currentChannelId", + ({ target, currentChannelId, expected }) => { + expect( + requireTelegramToolContextTargetMatcher()({ + target, + toolContext: { currentChannelId }, + }), + ).toBe(expected); + }, + ); }); describe("buildTelegramThreadingToolContext", () => { diff --git a/src/infra/outbound/message-action-execution.source-reply.test.ts b/src/infra/outbound/message-action-execution.source-reply.test.ts index e56ed0f4ce93..68f4529f8c93 100644 --- a/src/infra/outbound/message-action-execution.source-reply.test.ts +++ b/src/infra/outbound/message-action-execution.source-reply.test.ts @@ -1,6 +1,7 @@ // Covers core message-action send fallback, TTS application, and durable send // policy after plugin preparation is absent. import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPlugin } from "../../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; @@ -22,6 +23,14 @@ const slackConfig = { }, } as OpenClawConfig; +const telegramConfig = { + channels: { + telegram: { + enabled: true, + }, + }, +} as OpenClawConfig; + function registerSlackTextPlugin(accountIds: string[] = ["default"]) { const sendText = vi.fn().mockResolvedValue({ channel: "slack", @@ -54,6 +63,41 @@ function registerSlackTextPlugin(accountIds: string[] = ["default"]) { return sendText; } +function registerTelegramTextPlugin( + matchesToolContextTarget: NonNullable< + NonNullable["matchesToolContextTarget"] + >, +) { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: { + ...createOutboundTestPlugin({ + id: "telegram", + messaging: { targetResolver: { looksLikeId: () => true } }, + outbound: { + deliveryMode: "direct", + sendText: vi.fn().mockResolvedValue({ + channel: "telegram", + messageId: "m1", + chatId: "-100123", + }), + }, + }), + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true }), + isConfigured: () => true, + }, + threading: { matchesToolContextTarget }, + }, + }, + ]), + ); +} + describe("runMessageAction core send routing", () => { afterEach(() => { setActivePluginRegistry(createTestRegistry([])); @@ -123,6 +167,66 @@ describe("runMessageAction core send routing", () => { expect(result.payload).toMatchObject({ sourceReplyRoute: "current-source" }); }); + it.each([ + { + name: "an equivalent raw current-chat target", + target: "-100123", + currentChannelId: "telegram:-100123", + matcherResult: true, + expectedRoute: "current-source", + }, + { + name: "a different chat", + target: "-100456", + currentChannelId: "telegram:-100123", + matcherResult: false, + expectedRoute: undefined, + }, + { + name: "a different topic", + target: "-100123:topic:78", + currentChannelId: "telegram:-100123:topic:77", + matcherResult: false, + expectedRoute: undefined, + }, + ])("uses the Telegram target matcher for $name", async (testCase) => { + const matchesToolContextTarget = vi.fn(() => testCase.matcherResult); + registerTelegramTextPlugin(matchesToolContextTarget); + + const toolContext = { + currentChannelProvider: "telegram", + currentChannelId: testCase.currentChannelId, + currentSourceTurnId: "source-turn-1", + }; + const result = await runMessageAction({ + cfg: telegramConfig, + action: "send", + params: { + channel: "telegram", + target: testCase.target, + message: "visible source reply", + }, + toolContext, + messageActionAuthorization: { + requesterAccountId: "default", + toolContext, + }, + sessionKey: `agent:main:telegram:group:${testCase.currentChannelId}`, + defaultAccountId: "default", + sourceReplyDeliveryMode: "message_tool_only", + dryRun: false, + }); + + expect(result.kind).toBe("send"); + expect((result.payload as { sourceReplyRoute?: unknown }).sourceReplyRoute).toBe( + testCase.expectedRoute, + ); + expect(matchesToolContextTarget).toHaveBeenCalledWith({ + target: testCase.target, + toolContext, + }); + }); + it("does not mark a message-scoped reply that enters a new thread as current-source", async () => { registerSlackTextPlugin();