mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user