From ac2e72a8e6550d799132e5ad33cb128065ea98c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A8=B1=E5=85=83=E8=B1=AA?= <146086744+edenfunf@users.noreply.github.com> Date: Thu, 14 May 2026 13:57:47 +0800 Subject: [PATCH] fix(cron): refuse LINE session-key recipient fallback (#81628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LINE chat ids are case-sensitive (push requires capital C/U/R) but the session key holds the peer id lowercased for canonical routing. When cron-tool runs without currentDeliveryContext (delivery-recovery, queue replay after reply-token expiry), inferDeliveryFromSessionKey was lifting the lowercased fragment straight into delivery.to, producing a value LINE rejects with HTTP 400 — the job retried five times silently and the dashboard reported "delivered" while the LINE group received nothing. Refuse the session-key fallback for channel === "line" so the missing target surfaces explicitly instead of scheduling an undeliverable job. --- src/agents/tools/cron-tool.test.ts | 48 ++++++++++++++++++++++++++++++ src/agents/tools/cron-tool.ts | 10 +++++++ 2 files changed, 58 insertions(+) diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index d54faed1339..77e29a5646f 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -12,6 +12,7 @@ vi.mock("../agent-scope.js", async () => { }; }); +import { buildAgentPeerSessionKey } from "../../routing/session-key.js"; import { createCronTool } from "./cron-tool.js"; describe("cron tool", () => { @@ -823,6 +824,53 @@ describe("cron tool", () => { }); }); + it("does not surface lowercased LINE recipients when current delivery context is unavailable (#81628)", async () => { + // Reproduces openclaw/openclaw#81628. LINE chat IDs are case-sensitive — push + // requires capital C/U/R; lowercased recipients return HTTP 400. The runtime + // already lowercases LINE peer IDs when canonicalizing the session key, and + // when the delivery-recovery / post-reply-token-expiry push path is missing + // currentDeliveryContext, inferDeliveryFromSessionKey lifts the lowercased + // fragment straight into delivery.to. + const sessionKey = buildAgentPeerSessionKey({ + agentId: "main", + channel: "line", + peerKind: "group", + peerId: "Cabcdef0123456789abcdef0123456789", + }); + expect(sessionKey).toBe("agent:main:line:group:cabcdef0123456789abcdef0123456789"); + + const delivery = await executeAddAndReadDelivery({ + callId: "call-line-group-no-context-81628", + agentSessionKey: sessionKey, + // Intentionally no currentDeliveryContext — emulates the delivery-recovery + // boundary that reloads queued entries from disk after the reply token has + // expired. + }); + + expect(delivery?.to).toBeUndefined(); + }); + + it("does not surface lowercased LINE DM recipients with per-account-channel-peer scope (#81628)", async () => { + const sessionKey = buildAgentPeerSessionKey({ + agentId: "main", + channel: "line", + peerKind: "direct", + accountId: "primary", + dmScope: "per-account-channel-peer", + peerId: "Uabcdef0123456789abcdef0123456789", + }); + expect(sessionKey).toBe( + "agent:main:line:primary:direct:uabcdef0123456789abcdef0123456789", + ); + + const delivery = await executeAddAndReadDelivery({ + callId: "call-line-direct-no-context-81628", + agentSessionKey: sessionKey, + }); + + expect(delivery?.to).toBeUndefined(); + }); + it("does not let current delivery context override explicit delivery targets", async () => { expect( await executeAddAndReadDelivery({ diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 726b83f1574..f7e186941a2 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -581,6 +581,16 @@ function inferDeliveryFromSessionKey(agentSessionKey?: string): CronDelivery | n channel = normalizeOptionalLowercaseString(parts[0]) as CronMessageChannel | undefined; } + // LINE chat ids are case-sensitive (push requires capital C/U/R) but the + // session key holds the peer id lowercased for canonical routing. Rebuilding + // `to` from the session-key fragment would yield a value LINE rejects with + // HTTP 400, so refuse the fallback for LINE and let the caller surface the + // missing target instead of silently scheduling an undeliverable job. + // openclaw/openclaw#81628 + if (channel === "line") { + return null; + } + const marker = parts[markerIndex]; const delivery: CronDelivery = { mode: "announce", to: peerId }; if (channel) {