feat: add Discord role mention support to routes

This commit is contained in:
RhenCloud 2026-08-11 15:48:44 +08:00
parent 869d84d78c
commit 76ba2c0a89
17 changed files with 201 additions and 14 deletions

View file

@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { sendMessage } from "../drivers/discord/rest";
import { renderNeutralMessage } from "../drivers/discord/render";
import { dispatchEvent } from "../core/dispatch";
import type { Env, Route } from "../types";
@ -68,6 +69,22 @@ describe("discord-rest sendMessage", () => {
});
});
describe("discord role mentions", () => {
it("renders mentionRoleIds into the message content", () => {
const out = renderNeutralMessage({
title: "T",
mentionRoleIds: ["111", "222"],
});
expect(out.content).toBe("<@&111> <@&222>");
expect(out.embeds?.[0]?.title).toBe("T");
});
it("omits content when no roles are mentioned", () => {
const out = renderNeutralMessage({ title: "T" });
expect(out.content).toBeUndefined();
});
});
describe("dispatchEvent fallback routing", () => {
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
@ -169,4 +186,33 @@ describe("dispatchEvent fallback routing", () => {
expect(sent.filter((u) => u.includes("/111/"))).toHaveLength(0);
expect(sent.filter((u) => u.includes("/222/"))).toHaveLength(1);
});
it("prepends role mentions to the Discord message content", async () => {
const bodies: string[] = [];
mockFetch((url, init) => {
bodies.push(String(init?.body ?? ""));
return new Response("{}", { status: 200 });
});
const env = createEnv({ KV: createMockKV(), DB: createMockDB() });
const routes: Route[] = [
{
id: "mention-push",
name: "Mention Push",
enabled: true,
discordRoleIds: ["111", "222"],
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "333" }],
},
];
await dispatchEvent({ ...baseConfig, routes }, { event: "push", payload: {} }, env);
expect(bodies).toHaveLength(1);
const parsed = JSON.parse(bodies[0]!) as {
content?: string;
embeds?: Array<{ title?: string }>;
};
expect(parsed.content).toBe("<@&111> <@&222>");
expect(parsed.embeds?.[0]).toBeDefined();
});
});

View file

@ -55,6 +55,9 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
const group = route.groupId ? groupById.get(route.groupId) : undefined;
const showEmoji = group?.emoji !== false;
const message = formatEvent(route, event, tr, showEmoji);
if (route.discordRoleIds?.length) {
message.mentionRoleIds = route.discordRoleIds;
}
for (const target of targets) {
const targetStr =

View file

@ -12,7 +12,11 @@ function toStyle(style: NeutralActionStyle): number {
}
export function renderNeutralMessage(message: NeutralMessage): FormattedMessage {
const content = message.mentionRoleIds?.length
? message.mentionRoleIds.map((id) => `<@&${id}>`).join(" ")
: undefined;
return {
content,
embeds: [
{
title: message.title,

View file

@ -64,6 +64,11 @@ export interface Route {
* fallthrough to subsequent routes.
*/
stop?: boolean;
/**
* Discord role () ids to mention/notify when this route fires. Roles
* are only mentioned in Discord targets; Telegram targets ignore this field.
*/
discordRoleIds?: string[];
}
export interface Group {
@ -136,9 +141,15 @@ export interface NeutralMessage {
* previously sent message instead of sending a new one.
*/
updateKey?: string;
/**
* Discord role ids to mention in the message content (set by dispatch from
* the route's `discordRoleIds`). Only used by the Discord driver.
*/
mentionRoleIds?: string[];
}
export interface FormattedMessage {
content?: string;
embeds?: Array<{
title?: string;
description?: string;

View file

@ -89,6 +89,13 @@ function validateRoutes(
if (r.stop !== undefined && typeof r.stop !== "boolean") {
return { ok: false, error: `route "${r.id}".stop must be a boolean` };
}
if (
r.discordRoleIds !== undefined &&
(!Array.isArray(r.discordRoleIds) ||
!r.discordRoleIds.every((d) => typeof d === "string" && d.trim().length > 0))
) {
return { ok: false, error: `route "${r.id}".discordRoleIds must be a list of strings` };
}
if (!Array.isArray(r.filters)) {
return { ok: false, error: `route "${r.id}".filters must be an array` };
}