From fe0223f7b8234831cf57bd5b00392db0c41b7029 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Mon, 8 Jun 2026 22:37:28 +0800 Subject: [PATCH] fix(discord): de-dupe allowed_mentions roles/users to avoid silent 400 When two plans share one Discord role (e.g. Claude Standard + Premium both mapped to @Claude), the billing-opened notice built allowed_mentions.roles with the role id twice. Discord treats roles/users as unique sets and 400s on duplicate snowflakes; createChannelMessage swallows non-2xx as null, so the notice silently never sent. De-dupe both arrays via Set. Also hardens the overdue users array against the same latent bug. Adds regression tests. --- .../worker/src/adapters/discord/notify.ts | 8 +++--- .../test/adapters/discord-notify.test.ts | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/adapters/discord/notify.ts b/packages/worker/src/adapters/discord/notify.ts index d4eb91f..851d88c 100644 --- a/packages/worker/src/adapters/discord/notify.ts +++ b/packages/worker/src/adapters/discord/notify.ts @@ -13,8 +13,9 @@ export const discordNotifier: Notifier = { const total = lines.reduce((s, l) => s + l.amount, 0); const content = renderTemplate(template, { period, plans, total: total.toLocaleString() }); // Pin mentions to exactly the plan role ids — so nothing else in the (admin-authored) - // template content can be coerced into a ping. - const roles = lines.map((l) => l.role_id).filter((r): r is string => !!r); + // template content can be coerced into a ping. De-dupe: two plans can share one role + // (e.g. Standard + Premium both → @Claude), and Discord 400s on duplicate snowflakes. + const roles = [...new Set(lines.map((l) => l.role_id).filter((r): r is string => !!r))]; await createChannelMessage(env.DISCORD_BOT_TOKEN ?? "", channelId, { content, components: [payButtonRow()], @@ -32,7 +33,8 @@ export const discordNotifier: Notifier = { .join("\n"); const content = renderTemplate(template, { period, count: String(people.length), list }); // Pin mentions to exactly the overdue members' ids — template/display-name text can't ping. - const users = people.map((p) => p.discord_id).filter((d): d is string => !!d); + // De-dupe defensively (same Discord duplicate-snowflake 400 risk as roles above). + const users = [...new Set(people.map((p) => p.discord_id).filter((d): d is string => !!d))]; await createChannelMessage(env.DISCORD_BOT_TOKEN ?? "", channelId, { content, allowed_mentions: { parse: [], users }, diff --git a/packages/worker/test/adapters/discord-notify.test.ts b/packages/worker/test/adapters/discord-notify.test.ts index a032162..237c314 100644 --- a/packages/worker/test/adapters/discord-notify.test.ts +++ b/packages/worker/test/adapters/discord-notify.test.ts @@ -40,4 +40,31 @@ describe("discordNotifier rendering", () => { expect(c).toContain("共 315"); expect(sent[0].components).toBeTruthy(); // pay button row present }); + + it("de-dupes allowed_mentions.roles when two plans share a role (Discord rejects duplicate snowflakes)", async () => { + const sent = captureFetch(); + // Standard + Premium both mapped to the same "Claude" role id. + const lines: PlanOpenLine[] = [ + { plan_id: 1, plan_name: "Claude Standard", amount: 251, role_id: "claude" }, + { plan_id: 2, plan_name: "Claude Premium", amount: 1258, role_id: "claude" }, + ]; + await discordNotifier.sendBillingOpened(env, "chan", "2026-06", lines, "{period}\n{plans}"); + vi.unstubAllGlobals(); + const roles = sent[0].allowed_mentions.roles as string[]; + expect(roles).toEqual(["claude"]); // unique — no duplicate that would 400 + // Both plan lines still render their own mention in the content (display is per-plan). + const c = sent[0].content as string; + expect(c.match(/<@&claude>/g)?.length).toBe(2); + }); + + it("de-dupes allowed_mentions.users when a member appears twice in the overdue list", async () => { + const sent = captureFetch(); + const people: OverduePerson[] = [ + { user_id: 1, discord_id: "d1", user_name: "小明", lines: [{ plan_name: "ChatGPT", amount: 315 }], total: 315 }, + { user_id: 1, discord_id: "d1", user_name: "小明", lines: [{ plan_name: "Claude", amount: 251 }], total: 251 }, + ]; + await discordNotifier.sendOverdue(env, "chan", "2026-06", people, "{period}\n{list}"); + vi.unstubAllGlobals(); + expect(sent[0].allowed_mentions.users as string[]).toEqual(["d1"]); + }); });