From da9efec952286a63df432808ceb6323fd67fc751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Alcal=C3=A1=20Rub=C3=AD?= Date: Sat, 18 Jul 2026 04:24:33 -0300 Subject: [PATCH] fix(api): make sendMessage failures actionable on empty errmsg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `sendMessage` fails with a non-zero `ret` the server often returns an empty `errmsg` (commonly `ret=-2`), so the thrown error was `sendMessage ret=-2 errmsg=(none)` — impossible to act on. The most common cause on agent-initiated outbound delivery (cron reminders, scheduled digests) is an expired or missing `context_token`: it is only refreshed by a recent inbound message from the recipient, so after a long idle period the bot cannot send until the user messages it again. Add `describeSendMessageFailure`, which passes through a real server errmsg but, when it is empty, reports the likely cause and whether a `context_token` was included in the request. No behavior change on success or when the server provides an error detail. Refreshing the token automatically is not possible from the bot side (it depends on inbound user activity), so this focuses on diagnosability. Fixes #225 --- src/api/api.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/api/api.ts | 29 ++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/api/api.test.ts b/src/api/api.test.ts index 831289f..5bbacf5 100644 --- a/src/api/api.test.ts +++ b/src/api/api.test.ts @@ -32,6 +32,7 @@ import { getUpdates, getUploadUrl, sendMessage, + describeSendMessageFailure, getConfig, sendTyping, sanitizeBotAgent, @@ -188,6 +189,42 @@ describe("sendMessage", () => { sendMessage({ baseUrl: "https://api.example.com/", body: { msg: {} } }), ).rejects.toThrow("sendMessage 403"); }); + + it("throws an actionable error when ret is non-zero with empty errmsg and a context_token was sent", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ ret: -2, errmsg: "" })); + await expect( + sendMessage({ + baseUrl: "https://api.example.com/", + body: { msg: { to_user_id: "u", context_token: "tok" } }, + }), + ).rejects.toThrow(/ret=-2.*context_token.*expired/); + }); + + it("explains the missing context_token when none was sent", async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ ret: -2 })); + await expect( + sendMessage({ baseUrl: "https://api.example.com/", body: { msg: { to_user_id: "u" } } }), + ).rejects.toThrow(/agent-initiated outbound requires one/); + }); +}); + +describe("describeSendMessageFailure", () => { + it("passes through a non-empty server errmsg", () => { + expect( + describeSendMessageFailure({ ret: -2, errmsg: "rate limited" }, { msg: {} }), + ).toBe("sendMessage ret=-2 errmsg=rate limited"); + }); + + it("flags a likely expired context_token when one was present", () => { + const msg = describeSendMessageFailure({ ret: -2, errmsg: "" }, { msg: { context_token: "tok" } }); + expect(msg).toContain("ret=-2"); + expect(msg).toContain("expired"); + }); + + it("flags a missing context_token when none was present", () => { + const msg = describeSendMessageFailure({ ret: -2 }, { msg: {} }); + expect(msg).toContain("agent-initiated outbound requires one"); + }); }); describe("getConfig", () => { diff --git a/src/api/api.ts b/src/api/api.ts index 6a152b4..9b1c419 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -500,6 +500,31 @@ export async function getUploadUrl( } /** Send a single message downstream. */ +/** + * Build an actionable error string for a non-zero `sendMessage` `ret`. + * + * The server frequently reports a business-level failure with an empty + * `errmsg` (commonly `ret=-2`), which is hard to diagnose. The most common + * cause on agent-initiated outbound delivery is an expired or missing + * `context_token`: it is only refreshed by a recent inbound message from the + * recipient, so a bot that sends after a long idle period (cron reminders, + * scheduled digests) fails until the user messages it again. Surface that + * likely cause instead of an opaque empty message. + */ +export function describeSendMessageFailure( + resp: SendMessageResp, + body: SendMessageReq, +): string { + const errmsg = resp.errmsg?.trim(); + if (errmsg) { + return `sendMessage ret=${resp.ret} errmsg=${errmsg}`; + } + const hint = body.msg?.context_token + ? "context_token was included but the server returned no detail; it has most likely expired — a fresh inbound message from the recipient refreshes it" + : "no context_token was included; agent-initiated outbound requires one, refreshed by a recent inbound message from the recipient"; + return `sendMessage ret=${resp.ret} errmsg=(empty); ${hint}`; +} + export async function sendMessage( params: WeixinApiOptions & { body: SendMessageReq }, ): Promise { @@ -513,9 +538,7 @@ export async function sendMessage( }); const resp: SendMessageResp = JSON.parse(rawText); if (resp.ret && resp.ret !== 0) { - throw new Error( - `sendMessage ret=${resp.ret} errmsg=${resp.errmsg ?? "(none)"}`, - ); + throw new Error(describeSendMessageFailure(resp, params.body)); } }