mirror of https://github.com/openclaw/openclaw.git
fix: preserve telegram exec approval topic routing
This commit is contained in:
parent
f55b6b1acf
commit
ab3c646bb1
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue