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 <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi
2026-08-20 15:56:49 +05:30
committed by GitHub
parent f20c6dacc3
commit 08d926d3a5
4 changed files with 192 additions and 10 deletions
+11 -1
View File
@@ -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),
+33 -9
View File
@@ -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
);
}
@@ -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", () => {
@@ -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<ChannelPlugin["threading"]>["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();