Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
getUpdates,
getUploadUrl,
sendMessage,
describeSendMessageFailure,
getConfig,
sendTyping,
sanitizeBotAgent,
Expand Down Expand Up @@ -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", () => {
Expand Down
29 changes: 26 additions & 3 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -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));
}
}

Expand Down