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

@ -81,6 +81,7 @@ src/__tests__/ # bun test unit tests (webhook, formatter, discord, te
- Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured)
- Filter events by: event type, repo name, actor, action, branch, keyword (regex supported)
- Filter routes by group owner restriction (`Group.owners`) and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true`
- Mention Discord roles on route trigger: route-level `discordRoleIds` are rendered as `<@&id>` into the Discord message `content` (Telegram targets ignore the field)
- Format 28 event types as platform-neutral messages (Discord embeds + Telegram HTML)
- Route messages to Discord channels/threads and Telegram chats/topics via REST
- Edit already-sent messages in place for `workflow_run` progress (stable `updateKey`, KV `msg:*` tracking)

View file

@ -90,6 +90,20 @@ Routes are stored in KV (`config:routes` as JSON). There are **no default routes
`target.platform` selects the push target: `discord` (default) or `telegram`. Discord targets require `target.channelId` (optional `threadId` for a thread); Telegram targets require `target.chatId` (optional `topicId` for a topic). The legacy singular `target` field is still migrated automatically. There is no fallback to a default channel.
Set `discordRoleIds` on a route to ping Discord roles (身份组) whenever it fires:
```json
{
"id": "release-notify",
"name": "Notify on Release",
"enabled": true,
"groupId": "default",
"discordRoleIds": ["111111111111111111"],
"filters": [{ "type": "event", "match": "release" }],
"targets": [{ "platform": "discord", "channelId": "CHANNEL_ID" }]
}
```
Routes belong to **groups** (KV `config:groups`) that scope admin access and can restrict which org/user events flow in. See `config.example.yaml` and `docs/guide/configuration.md` for the full schema.
### Web UI (`/admin`)

View file

@ -90,6 +90,20 @@ npx wrangler dev # 启动本地开发服务器
`target.platform` 选择推送目标:`discord`(默认)或 `telegram`。Discord 目标需 `target.channelId`(可选 `threadId` 指向子区Telegram 目标需 `target.chatId`(可选 `topicId` 指向话题)。旧的单数 `target` 字段仍会被自动迁移。不存在默认频道回退。
在路由上设置 `discordRoleIds`,可在该路由触发时 @提醒 Discord 身份组:
```json
{
"id": "release-notify",
"name": "发布时提醒",
"enabled": true,
"groupId": "default",
"discordRoleIds": ["111111111111111111"],
"filters": [{ "type": "event", "match": "release" }],
"targets": [{ "platform": "discord", "channelId": "频道ID" }]
}
```
路由隶属于**分组**KV `config:groups`),分组用于限定管理权限,并可限制哪些组织/用户的事件流入。完整模式见 `config.example.yaml``docs/zh/guide/configuration.md`
### Web 控制台(`/admin`

View file

@ -18,6 +18,7 @@
>
<span v-if="route.fallback" class="badge fallback">{{ t("route.fallback") }}</span>
<span v-if="route.stop" class="badge stop">{{ t("route.stop") }}</span>
<span v-if="route.discordRoleIds?.length" class="badge lang">@roles</span>
</div>
<div class="card-actions">
<button

View file

@ -71,6 +71,18 @@
<span class="lbl-note">{{ t("routeEditor.stopHint") }}</span></span
>
</div>
<div class="field">
<label
>{{ t("routeEditor.discordRoles") }}
<span class="lbl-note">{{ t("routeEditor.discordRolesNote") }}</span></label
>
<input
v-model="form.discordRolesText"
type="text"
:placeholder="t('routeEditor.discordRolesPlaceholder')"
/>
<div class="hint">{{ t("routeEditor.discordRolesHint") }}</div>
</div>
<div class="field">
<label
>{{ t("routeEditor.filters") }}
@ -203,6 +215,7 @@ const form = reactive({
enabled: true,
fallback: false,
stop: false,
discordRolesText: "",
targets: [] as TargetForm[],
filters: [] as FilterForm[],
});
@ -254,6 +267,7 @@ watch(
form.enabled = r?.enabled ?? true;
form.fallback = r?.fallback ?? false;
form.stop = r?.stop ?? false;
form.discordRolesText = r?.discordRoleIds?.length ? r.discordRoleIds.join(", ") : "";
form.targets =
r && r.targets.length
? r.targets.map((tg) => ({ ...blankTarget(), ...tg }))
@ -315,6 +329,11 @@ function collect(): Route | null {
}
targetError.value = "";
const discordRoles = form.discordRolesText
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return {
id: form.id.trim(),
name: form.name.trim(),
@ -322,6 +341,7 @@ function collect(): Route | null {
fallback: form.fallback || undefined,
stop: form.stop || undefined,
lang: form.lang.trim() || undefined,
discordRoleIds: discordRoles.length ? discordRoles : undefined,
filters,
targets,
};

View file

@ -81,6 +81,11 @@ const en: Dict = {
"routeEditor.matchPlaceholder": "match value",
"routeEditor.not": "NOT",
"routeEditor.addFilter": "+ Add filter",
"routeEditor.discordRoles": "Discord role mentions",
"routeEditor.discordRolesNote": "(optional · comma-separated)",
"routeEditor.discordRolesPlaceholder": "123456789012345678, 987654321098765432",
"routeEditor.discordRolesHint":
"When this route fires, the listed roles are pinged in Discord targets.",
"routeEditor.channel": "Channel ID",
"routeEditor.channelPlaceholder": "Discord channel ID",
"routeEditor.thread": "Thread ID",
@ -243,6 +248,10 @@ const zh: Dict = {
"routeEditor.matchPlaceholder": "匹配值",
"routeEditor.not": "取反",
"routeEditor.addFilter": "+ 添加过滤器",
"routeEditor.discordRoles": "Discord 身份组提醒",
"routeEditor.discordRolesNote": "(可选 · 逗号分隔)",
"routeEditor.discordRolesPlaceholder": "123456789012345678, 987654321098765432",
"routeEditor.discordRolesHint": "当该路由触发时,会在 Discord 目标中提醒(@)这些身份组。",
"routeEditor.channel": "频道 ID",
"routeEditor.channelPlaceholder": "Discord 频道 ID",
"routeEditor.thread": "子区 ID",

View file

@ -22,6 +22,7 @@ export interface Route {
groupId?: string;
fallback?: boolean;
stop?: boolean;
discordRoleIds?: string[];
}
export interface Group {

View file

@ -51,6 +51,22 @@ routes:
# - platform: discord
# channelId: "CHANNEL_ID_HERE"
# Discord role mentions: when this route fires, the listed role ids are
# pinged in every Discord target (Telegram targets ignore this field).
# - id: mention-on-release
# name: "Mention Roles on Release"
# enabled: true
# groupId: default
# discordRoleIds: ["111111111111111111", "222222222222222222"]
# filters:
# - type: event
# match: release
# - type: action
# match: published
# targets:
# - platform: discord
# channelId: "CHANNEL_ID_HERE"
# Fallback routes only fire when no other (non-fallback) route matched.
# - id: fallback-all
# name: "Fallback: everything else"

View file

@ -41,7 +41,7 @@ See [Configuration → Web UI](../guide/configuration.md#web-ui) for setup. Admi
- `GET /admin` — Serves the config console HTML
- `GET /admin/api/routes` — Returns `{ "routes": Route[] }`
- `PUT /admin/api/routes` — Body `{ "routes": Route[] }`; validates each route (id pattern, unique id, name, enabled, `groupId`, filters — empty only allowed for `fallback` routes — and platform-aware targets: `target.channelId` for Discord, `target.chatId` for Telegram) and persists to KV `config:routes`. Returns `200 { ok, count }` or `400 { error }` / `401 { error }` / `403 { error }`.
- `PUT /admin/api/routes` — Body `{ "routes": Route[] }`; validates each route (id pattern, unique id, name, enabled, `groupId`, filters — empty only allowed for `fallback` routes — optional `discordRoleIds` (list of role id strings), and platform-aware targets: `target.channelId` for Discord, `target.chatId` for Telegram) and persists to KV `config:routes`. Returns `200 { ok, count }` or `400 { error }` / `401 { error }` / `403 { error }`.
## Health Check

View file

@ -75,6 +75,7 @@ There are **no default routes** — each route must define its own target. If no
"groupId": "my-group",
"fallback": false,
"stop": false,
"discordRoleIds": ["111111111111111111"],
"filters": [
{ "type": "event", "match": "push" },
{ "type": "repo", "match": "org/repo", "exclude": false }
@ -91,14 +92,33 @@ There are **no default routes** — each route must define its own target. If no
Each entry of `targets` is a push destination, so one route can forward to several channels at once (e.g. a Discord channel **and** a Telegram group). `target.platform` selects the platform: `discord` (default) or `telegram`. For **Discord**, `target.channelId` is required (a thread in `target.threadId` is optional). For **Telegram**, `target.chatId` (the group/supergroup chat id, e.g. `-1001234567890`) is required and `target.topicId` (the `message_thread_id` of a topic, equivalent of a Discord thread) is optional. There is no fallback to a default channel.
### Discord Role Mentions
Set `discordRoleIds` on a route to ping one or more Discord roles (身份组) whenever that route fires. The mention (`<@&roleId>`) is prepended to the message content of every **Discord** target of the route; Telegram targets ignore this field. Mentions only trigger notifications when the bot has the `Mention Everyone` permission (or the role is marked mentionable), and the bot must be able to see the role.
```json
{
"id": "release-notify",
"name": "Notify on Release",
"enabled": true,
"groupId": "default",
"discordRoleIds": ["111111111111111111", "222222222222222222"],
"filters": [{ "type": "event", "match": "release" }],
"targets": [{ "platform": "discord", "channelId": "REQUIRED_CHANNEL_ID" }]
}
```
You can add role ids in the admin console under _Discord role mentions_.
Other route fields:
| Field | Type | Required | Description |
| ---------- | ------- | -------- | ----------------------------------------------------------------------------------------------- |
| `groupId` | string | Yes | Id of the [group](#groups) this route belongs to |
| `fallback` | boolean | No | When `true`, fires only if no non-fallback route matched the event; its own filters are ignored |
| `stop` | boolean | No | When `true` and this route matches, no further routes are evaluated for this event |
| `lang` | string | No | Message language override for this route (e.g. `en`, `zh`); defaults to the global setting |
| Field | Type | Required | Description |
| ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------- |
| `groupId` | string | Yes | Id of the [group](#groups) this route belongs to |
| `fallback` | boolean | No | When `true`, fires only if no non-fallback route matched the event; its own filters are ignored |
| `stop` | boolean | No | When `true` and this route matches, no further routes are evaluated for this event |
| `lang` | string | No | Message language override for this route (e.g. `en`, `zh`); defaults to the global setting |
| `discordRoleIds` | string[] | No | Discord role ids to ping when this route fires; applied to Discord targets only |
### Custom Route Example

View file

@ -41,7 +41,7 @@ https://your-worker.workers.dev
- `GET /admin` — 提供配置控制台 HTML
- `GET /admin/api/routes` — 返回 `{ "routes": Route[] }`
- `PUT /admin/api/routes` — 请求体为 `{ "routes": Route[] }`校验每条路由id 格式、唯一 id、name、enabled、groupId、过滤器、平台感知的 targetsDiscord 需 `target.channelId`Telegram 需 `target.chatId`)并持久化到 KV `config:routes`。返回 `200 { ok, count }``400 { error }` / `401 { error }` / `403 { error }`
- `PUT /admin/api/routes` — 请求体为 `{ "routes": Route[] }`校验每条路由id 格式、唯一 id、name、enabled、groupId、过滤器、可选的 `discordRoleIds`(身份组 id 字符串列表)、平台感知的 targetsDiscord 需 `target.channelId`Telegram 需 `target.chatId`)并持久化到 KV `config:routes`。返回 `200 { ok, count }``400 { error }` / `401 { error }` / `403 { error }`
## 健康检查

View file

@ -75,6 +75,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
"groupId": "my-group",
"fallback": false,
"stop": false,
"discordRoleIds": ["111111111111111111"],
"filters": [
{ "type": "event", "match": "push" },
{ "type": "repo", "match": "org/repo", "exclude": false }
@ -91,14 +92,33 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
`targets` 数组的每一项是一个推送目标,因此一条路由可同时转发到多个频道(例如同时发到 Discord 频道 **和** Telegram 群组)。`target.platform` 选择推送目标:`discord`(默认)或 `telegram`。**Discord** 需 `target.channelId``target.threadId` 可选的子区);**Telegram** 需 `target.chatId`(群组/超级群组聊天 id`-1001234567890``target.topicId`(话题的 `message_thread_id`,相当于 Discord 的子区)可选。不存在默认频道回退。
### Discord 身份组提醒
在路由上设置 `discordRoleIds`,当该路由触发时会 @提醒ping一个或多个 Discord 身份组。`<@&roleId>` 形式的提醒会拼接到该路由所有 **Discord** 目标的消息正文开头Telegram 目标会忽略此字段。只有在机器人拥有 `Mention Everyone` 权限(或该身份组被标记为可被提及 mentionable且机器人能看到该身份组时提醒才会真正触发通知。
```json
{
"id": "release-notify",
"name": "发布时提醒",
"enabled": true,
"groupId": "default",
"discordRoleIds": ["111111111111111111", "222222222222222222"],
"filters": [{ "type": "event", "match": "release" }],
"targets": [{ "platform": "discord", "channelId": "必填频道ID" }]
}
```
也可以在管理控制台的“Discord 身份组提醒”中配置。
其他路由字段:
| 字段 | 类型 | 必需 | 说明 |
| ---------- | ------- | ---- | ---------------------------------------------------------------------- |
| `groupId` | string | 是 | 该路由所属[分组](#分组)的 id |
| `fallback` | boolean | 否 | 为 `true` 时,仅当没有其它路由匹配该事件时才发送,其自身过滤器会被忽略 |
| `stop` | boolean | 否 | 为 `true` 且该路由匹配时,停止评估后续路由 |
| `lang` | string | 否 | 该路由的消息语言覆盖(如 `en``zh`),默认跟随全局设置 |
| 字段 | 类型 | 必需 | 说明 |
| ---------------- | -------- | ---- | ---------------------------------------------------------------------- |
| `groupId` | string | 是 | 该路由所属[分组](#分组)的 id |
| `fallback` | boolean | 否 | 为 `true` 时,仅当没有其它路由匹配该事件时才发送,其自身过滤器会被忽略 |
| `stop` | boolean | 否 | 为 `true` 且该路由匹配时,停止评估后续路由 |
| `lang` | string | 否 | 该路由的消息语言覆盖(如 `en``zh`),默认跟随全局设置 |
| `discordRoleIds` | string[] | 否 | 该路由触发时要在 Discord 目标中 @提醒的身份组 id |
### 自定义路由示例

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` };
}