fix: preserve telegram exec approval topic routing

This commit is contained in:
Peter Steinberger 2026-04-01 13:33:56 +01:00
parent f55b6b1acf
commit ab3c646bb1
No known key found for this signature in database
8 changed files with 152 additions and 9 deletions

View File

@ -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.

View File

@ -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": {

View File

@ -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,

View File

@ -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();

View File

@ -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 }),
},

View File

@ -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,
};
}

View File

@ -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"]> = {}): 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", () => {

View File

@ -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: {