diff --git a/CHANGELOG.md b/CHANGELOG.md index 815a53b2f46..cad5ac3ea7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - Memory/session indexing: keep full reindexes from skipping session transcripts when sync is triggered by `session-start` or `watch`, so restart-driven reindexes preserve session memory (#39732) thanks @upupc - Telegram/retries: keep non-idempotent sends on the strict safe-send path, retry wrapped pre-connect failures, and preserve `429` / `retry_after` backoff for safe delivery retries. (#51895) Thanks @chinar-amrutkar - Agents/Anthropic: preserve thinking blocks and signatures across replay, cache-control patching, and context pruning so compacted Anthropic sessions continue working instead of failing on later turns. (#58916) Thanks @obviyus +- Telegram/exec approvals: route topic-aware exec approval followups through Telegram-owned threading and approval-target parsing, so forum-topic approvals stay in the originating topic instead of falling back to the root chat. (#58783) - Agents/failover: unify structured and raw provider error classification so provider-specific `400`/`422` payloads no longer get forced into generic format failures before retry, billing, or compaction logic can inspect them. (#58856) Thanks @aaron-he-zhu. - Auth profiles/store: coerce misplaced SecretRef objects out of plaintext `key` and `token` fields during store load so agents without ACP runtime stop crashing on `.trim()` after upgrade. (#58923) Thanks @openperf. diff --git a/extensions/telegram/src/approval-native.test.ts b/extensions/telegram/src/approval-native.test.ts index 7db1d97e8dc..e45b74daa7c 100644 --- a/extensions/telegram/src/approval-native.test.ts +++ b/extensions/telegram/src/approval-native.test.ts @@ -57,6 +57,31 @@ describe("telegram native approval adapter", () => { }); }); + it("parses topic-scoped turn-source targets in the extension", async () => { + const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({ + cfg: buildConfig(), + accountId: "default", + approvalKind: "exec", + request: { + id: "req-topic-1", + request: { + command: "echo hi", + turnSourceChannel: "telegram", + turnSourceTo: "telegram:-1003841603622:topic:928", + turnSourceAccountId: "default", + sessionKey: "agent:main:telegram:group:-1003841603622:topic:928", + }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }); + + expect(target).toEqual({ + to: "-1003841603622", + threadId: 928, + }); + }); + it("falls back to the session-bound origin target for plugin approvals", async () => { writeStore({ "agent:main:telegram:group:-1003841603622:topic:928": { diff --git a/extensions/telegram/src/approval-native.ts b/extensions/telegram/src/approval-native.ts index 0bc7f9e63aa..9966c4cf502 100644 --- a/extensions/telegram/src/approval-native.ts +++ b/extensions/telegram/src/approval-native.ts @@ -12,7 +12,7 @@ import { isTelegramExecApprovalClientEnabled, resolveTelegramExecApprovalTarget, } from "./exec-approvals.js"; -import { normalizeTelegramChatId } from "./targets.js"; +import { normalizeTelegramChatId, parseTelegramTarget } from "./targets.js"; type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; type TelegramOriginTarget = { to: string; threadId?: number }; @@ -22,15 +22,18 @@ function resolveTurnSourceTelegramOriginTarget( ): TelegramOriginTarget | null { const turnSourceChannel = request.request.turnSourceChannel?.trim().toLowerCase() || ""; const rawTurnSourceTo = request.request.turnSourceTo?.trim() || ""; - const turnSourceTo = normalizeTelegramChatId(rawTurnSourceTo) ?? rawTurnSourceTo; + const parsedTurnSourceTarget = rawTurnSourceTo ? parseTelegramTarget(rawTurnSourceTo) : null; + const turnSourceTo = normalizeTelegramChatId(parsedTurnSourceTarget?.chatId ?? rawTurnSourceTo); if (turnSourceChannel !== "telegram" || !turnSourceTo) { return null; } + const rawThreadId = + request.request.turnSourceThreadId ?? parsedTurnSourceTarget?.messageThreadId ?? undefined; const threadId = - typeof request.request.turnSourceThreadId === "number" - ? request.request.turnSourceThreadId - : typeof request.request.turnSourceThreadId === "string" - ? Number.parseInt(request.request.turnSourceThreadId, 10) + typeof rawThreadId === "number" + ? rawThreadId + : typeof rawThreadId === "string" + ? Number.parseInt(rawThreadId, 10) : undefined; return { to: turnSourceTo, diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 8c7f7d9309b..84ee403253a 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -220,6 +220,42 @@ describe("telegramPlugin messaging", () => { }); }); +describe("telegramPlugin threading", () => { + it("keeps topic thread state in plugin-owned tool context", () => { + expect( + telegramPlugin.threading?.buildToolContext?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + context: { + To: "telegram:-1001:topic:77", + MessageThreadId: 77, + CurrentMessageId: "msg-1", + }, + hasRepliedRef: { value: false }, + }), + ).toMatchObject({ + currentChannelId: "telegram:-1001:topic:77", + currentThreadTs: "77", + }); + }); + + it("parses topic thread state from target grammar when MessageThreadId is absent", () => { + expect( + telegramPlugin.threading?.buildToolContext?.({ + cfg: {} as OpenClawConfig, + accountId: "default", + context: { + To: "telegram:-1001:topic:77", + CurrentMessageId: "msg-1", + }, + }), + ).toMatchObject({ + currentChannelId: "telegram:-1001:topic:77", + currentThreadTs: "77", + }); + }); +}); + describe("telegramPlugin duplicate token guard", () => { it("marks secondary account as not configured when token is shared", async () => { const cfg = createCfg(); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 8b3e648b524..acd148d88de 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -88,6 +88,7 @@ import { setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey, } from "./thread-bindings.js"; +import { buildTelegramThreadingToolContext } from "./threading-tool-context.js"; import { resolveTelegramToken } from "./token.js"; type TelegramSendFn = typeof sendMessageTelegram; @@ -825,6 +826,7 @@ export const telegramPlugin = createChatChannelPlugin({ }, threading: { topLevelReplyToMode: "telegram", + buildToolContext: (params) => buildTelegramThreadingToolContext(params), resolveAutoThreadId: ({ to, toolContext, replyToId }) => replyToId ? undefined : resolveTelegramAutoThreadId({ to, toolContext }), }, diff --git a/extensions/telegram/src/threading-tool-context.ts b/extensions/telegram/src/threading-tool-context.ts new file mode 100644 index 00000000000..1358dd4bf73 --- /dev/null +++ b/extensions/telegram/src/threading-tool-context.ts @@ -0,0 +1,34 @@ +import type { + ChannelThreadingContext, + ChannelThreadingToolContext, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { parseTelegramTarget } from "./targets.js"; + +function resolveTelegramToolContextThreadId(context: ChannelThreadingContext): string | undefined { + if (context.MessageThreadId != null) { + return String(context.MessageThreadId); + } + const currentChannelId = context.To?.trim(); + if (!currentChannelId) { + return undefined; + } + const parsedTarget = parseTelegramTarget(currentChannelId); + return parsedTarget.messageThreadId != null ? String(parsedTarget.messageThreadId) : undefined; +} + +export function buildTelegramThreadingToolContext(params: { + cfg: OpenClawConfig; + accountId?: string | null; + context: ChannelThreadingContext; + hasRepliedRef?: { value: boolean }; +}): ChannelThreadingToolContext { + void params.cfg; + void params.accountId; + + return { + currentChannelId: params.context.To?.trim() || undefined, + currentThreadTs: resolveTelegramToolContextThreadId(params.context), + hasRepliedRef: params.hasRepliedRef, + }; +} diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index a12f2d9c4c0..2ab06633a88 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -3,7 +3,8 @@ import type { FollowupRun } from "./queue.js"; const hoisted = vi.hoisted(() => { const resolveRunModelFallbacksOverrideMock = vi.fn(); - return { resolveRunModelFallbacksOverrideMock }; + const getChannelPluginMock = vi.fn(); + return { resolveRunModelFallbacksOverrideMock, getChannelPluginMock }; }); vi.mock("../../agents/agent-scope.js", () => ({ @@ -11,6 +12,10 @@ vi.mock("../../agents/agent-scope.js", () => ({ hoisted.resolveRunModelFallbacksOverrideMock(...args), })); +vi.mock("../../channels/plugins/index.js", () => ({ + getChannelPlugin: (...args: unknown[]) => hoisted.getChannelPluginMock(...args), +})); + const { buildThreadingToolContext, buildEmbeddedRunBaseParams, @@ -46,6 +51,7 @@ function makeRun(overrides: Partial = {}): FollowupRun["run" describe("agent-runner-utils", () => { beforeEach(() => { hoisted.resolveRunModelFallbacksOverrideMock.mockClear(); + hoisted.getChannelPluginMock.mockReset(); }); it("resolves model fallback options from run context", () => { @@ -175,7 +181,24 @@ describe("agent-runner-utils", () => { expect(resolved.embeddedContext.messageTo).toBe("268300329"); }); - it("uses OriginatingTo for telegram native command tool context without implicit thread state", () => { + it("uses telegram plugin threading context for native commands", () => { + hoisted.getChannelPluginMock.mockReturnValue({ + threading: { + buildToolContext: ({ + context, + hasRepliedRef, + }: { + context: { To?: string; MessageThreadId?: string | number }; + hasRepliedRef?: { value: boolean }; + }) => ({ + currentChannelId: context.To?.trim() || undefined, + currentThreadTs: + context.MessageThreadId != null ? String(context.MessageThreadId) : undefined, + hasRepliedRef, + }), + }, + }); + const context = buildThreadingToolContext({ sessionCtx: { Provider: "telegram", @@ -191,9 +214,9 @@ describe("agent-runner-utils", () => { expect(context).toMatchObject({ currentChannelId: "telegram:-1003841603622", + currentThreadTs: "928", currentMessageId: "2284", }); - expect(context.currentThreadTs).toBeUndefined(); }); it("uses OriginatingTo for threading tool context on discord native commands", () => { diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 596b6473408..a91a22e8fe3 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -798,6 +798,25 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", expect(resolved.threadId).toBe(1122); }); + it("keeps Telegram topic thread routing when turnSourceTo uses the plugin-owned topic target", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-forum-topic-scoped", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "telegram:-1001234567890:topic:1122", + lastThreadId: 1122, + }, + requestedChannel: "last", + turnSourceChannel: "telegram", + turnSourceTo: "telegram:-1001234567890:topic:1122", + }); + + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("telegram:-1001234567890:topic:1122"); + expect(resolved.threadId).toBe(1122); + }); + it("does not fall back to session lastThreadId when turnSourceChannel differs from session channel", () => { const resolved = resolveSessionDeliveryTarget({ entry: {