fix(line): reject lowercased LINE-shaped recipients before push (#81628)

Defense-in-depth safety net for #81628: even with the cron-tool fix in
place, any other code path that ever produces a 33-char LINE-shaped
recipient missing its leading capital (C/U/R) would otherwise hit the
LINE API and return HTTP 400 with no permanent-error signal, causing
delivery-recovery to retry five times before moving the entry to
failed/.

normalizeTarget now throws "Recipient is not a valid LINE id ..." when
the post-strip value looks like a LINE id but the case was lost. The
message matches the existing /recipient is not a valid/i pattern in
delivery-queue-recovery's PERMANENT_ERROR_PATTERNS, so recovery moves
the entry to failed/ on the first attempt instead of silently retrying.

Short fixtures (length < 33) are left alone so existing tests using
"U123", "line:user:1", etc. keep working.
This commit is contained in:
許元豪
2026-05-14 14:29:08 +08:00
committed by Peter Steinberger
parent ac2e72a8e6
commit f3f2c784c4
2 changed files with 39 additions and 0 deletions

View File

@@ -358,6 +358,33 @@ describe("LINE send helpers", () => {
);
});
it("rejects lowercased LINE-shaped recipients (#81628 safety net)", async () => {
// 33-char value with lowercase leading char — what an upstream session-key
// fragment looked like before the cron-tool fix. LINE rejects with HTTP 400
// anyway; throwing locally keeps the failure permanent so delivery-recovery
// moves the entry to failed/ immediately instead of silently retrying 5×.
await expect(
sendModule.pushMessagesLine(
"cabcdef0123456789abcdef0123456789",
[{ type: "text", text: "hello" }],
{ cfg: LINE_TEST_CFG },
),
).rejects.toThrow(/Recipient is not a valid LINE id/);
expect(pushMessageMock).not.toHaveBeenCalled();
});
it("accepts case-exact LINE recipients with the leading capital preserved", async () => {
await sendModule.pushMessagesLine(
"Cabcdef0123456789abcdef0123456789",
[{ type: "text", text: "hello" }],
{ cfg: LINE_TEST_CFG },
);
expect(pushMessageMock).toHaveBeenCalledWith({
to: "Cabcdef0123456789abcdef0123456789",
messages: [{ type: "text", text: "hello" }],
});
});
it("logs HTTP body when push fails", async () => {
const err = new Error("LINE push failed") as Error & {
status: number;

View File

@@ -68,6 +68,18 @@ function normalizeTarget(to: string): string {
throw new Error("Recipient is required for LINE sends");
}
// Real LINE chat ids are a capital C/U/R followed by 32 lowercase hex chars
// (33 chars total) and are case-sensitive — push returns HTTP 400 otherwise.
// Reject values that match the LINE id shape but lost their leading capital
// so the failure is surfaced as a permanent error (recovery moves the entry
// to failed/ immediately instead of silently retrying 5 times). Short test
// fixtures (e.g. "U123") are left alone. openclaw/openclaw#81628
if (normalized.length >= 33 && !/^[CUR]/.test(normalized)) {
throw new Error(
`Recipient is not a valid LINE id (case-sensitive; expected leading capital C/U/R): ${normalized.slice(0, 4)}`,
);
}
return normalized;
}