Skip to content
Merged
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
8 changes: 5 additions & 3 deletions packages/worker/src/adapters/discord/notify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand All @@ -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 },
Expand Down
27 changes: 27 additions & 0 deletions packages/worker/test/adapters/discord-notify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});