mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
feat: add Discord role mention support to routes
This commit is contained in:
parent
869d84d78c
commit
76ba2c0a89
17 changed files with 201 additions and 14 deletions
|
|
@ -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)
|
- 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 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`
|
- 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)
|
- Format 28 event types as platform-neutral messages (Discord embeds + Telegram HTML)
|
||||||
- Route messages to Discord channels/threads and Telegram chats/topics via REST
|
- 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)
|
- Edit already-sent messages in place for `workflow_run` progress (stable `updateKey`, KV `msg:*` tracking)
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -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.
|
`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.
|
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`)
|
### Web UI (`/admin`)
|
||||||
|
|
|
||||||
14
README.zh.md
14
README.zh.md
|
|
@ -90,6 +90,20 @@ npx wrangler dev # 启动本地开发服务器
|
||||||
|
|
||||||
`target.platform` 选择推送目标:`discord`(默认)或 `telegram`。Discord 目标需 `target.channelId`(可选 `threadId` 指向子区);Telegram 目标需 `target.chatId`(可选 `topicId` 指向话题)。旧的单数 `target` 字段仍会被自动迁移。不存在默认频道回退。
|
`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`。
|
路由隶属于**分组**(KV `config:groups`),分组用于限定管理权限,并可限制哪些组织/用户的事件流入。完整模式见 `config.example.yaml` 与 `docs/zh/guide/configuration.md`。
|
||||||
|
|
||||||
### Web 控制台(`/admin`)
|
### Web 控制台(`/admin`)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
>
|
>
|
||||||
<span v-if="route.fallback" class="badge fallback">{{ t("route.fallback") }}</span>
|
<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.stop" class="badge stop">{{ t("route.stop") }}</span>
|
||||||
|
<span v-if="route.discordRoleIds?.length" class="badge lang">@roles</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,18 @@
|
||||||
<span class="lbl-note">{{ t("routeEditor.stopHint") }}</span></span
|
<span class="lbl-note">{{ t("routeEditor.stopHint") }}</span></span
|
||||||
>
|
>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label
|
<label
|
||||||
>{{ t("routeEditor.filters") }}
|
>{{ t("routeEditor.filters") }}
|
||||||
|
|
@ -203,6 +215,7 @@ const form = reactive({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
fallback: false,
|
fallback: false,
|
||||||
stop: false,
|
stop: false,
|
||||||
|
discordRolesText: "",
|
||||||
targets: [] as TargetForm[],
|
targets: [] as TargetForm[],
|
||||||
filters: [] as FilterForm[],
|
filters: [] as FilterForm[],
|
||||||
});
|
});
|
||||||
|
|
@ -254,6 +267,7 @@ watch(
|
||||||
form.enabled = r?.enabled ?? true;
|
form.enabled = r?.enabled ?? true;
|
||||||
form.fallback = r?.fallback ?? false;
|
form.fallback = r?.fallback ?? false;
|
||||||
form.stop = r?.stop ?? false;
|
form.stop = r?.stop ?? false;
|
||||||
|
form.discordRolesText = r?.discordRoleIds?.length ? r.discordRoleIds.join(", ") : "";
|
||||||
form.targets =
|
form.targets =
|
||||||
r && r.targets.length
|
r && r.targets.length
|
||||||
? r.targets.map((tg) => ({ ...blankTarget(), ...tg }))
|
? r.targets.map((tg) => ({ ...blankTarget(), ...tg }))
|
||||||
|
|
@ -315,6 +329,11 @@ function collect(): Route | null {
|
||||||
}
|
}
|
||||||
targetError.value = "";
|
targetError.value = "";
|
||||||
|
|
||||||
|
const discordRoles = form.discordRolesText
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: form.id.trim(),
|
id: form.id.trim(),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
|
|
@ -322,6 +341,7 @@ function collect(): Route | null {
|
||||||
fallback: form.fallback || undefined,
|
fallback: form.fallback || undefined,
|
||||||
stop: form.stop || undefined,
|
stop: form.stop || undefined,
|
||||||
lang: form.lang.trim() || undefined,
|
lang: form.lang.trim() || undefined,
|
||||||
|
discordRoleIds: discordRoles.length ? discordRoles : undefined,
|
||||||
filters,
|
filters,
|
||||||
targets,
|
targets,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,11 @@ const en: Dict = {
|
||||||
"routeEditor.matchPlaceholder": "match value",
|
"routeEditor.matchPlaceholder": "match value",
|
||||||
"routeEditor.not": "NOT",
|
"routeEditor.not": "NOT",
|
||||||
"routeEditor.addFilter": "+ Add filter",
|
"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.channel": "Channel ID",
|
||||||
"routeEditor.channelPlaceholder": "Discord channel ID",
|
"routeEditor.channelPlaceholder": "Discord channel ID",
|
||||||
"routeEditor.thread": "Thread ID",
|
"routeEditor.thread": "Thread ID",
|
||||||
|
|
@ -243,6 +248,10 @@ const zh: Dict = {
|
||||||
"routeEditor.matchPlaceholder": "匹配值",
|
"routeEditor.matchPlaceholder": "匹配值",
|
||||||
"routeEditor.not": "取反",
|
"routeEditor.not": "取反",
|
||||||
"routeEditor.addFilter": "+ 添加过滤器",
|
"routeEditor.addFilter": "+ 添加过滤器",
|
||||||
|
"routeEditor.discordRoles": "Discord 身份组提醒",
|
||||||
|
"routeEditor.discordRolesNote": "(可选 · 逗号分隔)",
|
||||||
|
"routeEditor.discordRolesPlaceholder": "123456789012345678, 987654321098765432",
|
||||||
|
"routeEditor.discordRolesHint": "当该路由触发时,会在 Discord 目标中提醒(@)这些身份组。",
|
||||||
"routeEditor.channel": "频道 ID",
|
"routeEditor.channel": "频道 ID",
|
||||||
"routeEditor.channelPlaceholder": "Discord 频道 ID",
|
"routeEditor.channelPlaceholder": "Discord 频道 ID",
|
||||||
"routeEditor.thread": "子区 ID",
|
"routeEditor.thread": "子区 ID",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export interface Route {
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
fallback?: boolean;
|
fallback?: boolean;
|
||||||
stop?: boolean;
|
stop?: boolean;
|
||||||
|
discordRoleIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,22 @@ routes:
|
||||||
# - platform: discord
|
# - platform: discord
|
||||||
# channelId: "CHANNEL_ID_HERE"
|
# 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.
|
# Fallback routes only fire when no other (non-fallback) route matched.
|
||||||
# - id: fallback-all
|
# - id: fallback-all
|
||||||
# name: "Fallback: everything else"
|
# name: "Fallback: everything else"
|
||||||
|
|
|
||||||
|
|
@ -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` — Serves the config console HTML
|
||||||
- `GET /admin/api/routes` — Returns `{ "routes": Route[] }`
|
- `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
|
## Health Check
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ There are **no default routes** — each route must define its own target. If no
|
||||||
"groupId": "my-group",
|
"groupId": "my-group",
|
||||||
"fallback": false,
|
"fallback": false,
|
||||||
"stop": false,
|
"stop": false,
|
||||||
|
"discordRoleIds": ["111111111111111111"],
|
||||||
"filters": [
|
"filters": [
|
||||||
{ "type": "event", "match": "push" },
|
{ "type": "event", "match": "push" },
|
||||||
{ "type": "repo", "match": "org/repo", "exclude": false }
|
{ "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.
|
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:
|
Other route fields:
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ---------- | ------- | -------- | ----------------------------------------------------------------------------------------------- |
|
| ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------- |
|
||||||
| `groupId` | string | Yes | Id of the [group](#groups) this route belongs to |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
### Custom Route Example
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ https://your-worker.workers.dev
|
||||||
|
|
||||||
- `GET /admin` — 提供配置控制台 HTML
|
- `GET /admin` — 提供配置控制台 HTML
|
||||||
- `GET /admin/api/routes` — 返回 `{ "routes": Route[] }`
|
- `GET /admin/api/routes` — 返回 `{ "routes": Route[] }`
|
||||||
- `PUT /admin/api/routes` — 请求体为 `{ "routes": Route[] }`;校验每条路由(id 格式、唯一 id、name、enabled、groupId、过滤器、平台感知的 targets:Discord 需 `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 字符串列表)、平台感知的 targets:Discord 需 `target.channelId`,Telegram 需 `target.chatId`)并持久化到 KV `config:routes`。返回 `200 { ok, count }` 或 `400 { error }` / `401 { error }` / `403 { error }`。
|
||||||
|
|
||||||
## 健康检查
|
## 健康检查
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
|
||||||
"groupId": "my-group",
|
"groupId": "my-group",
|
||||||
"fallback": false,
|
"fallback": false,
|
||||||
"stop": false,
|
"stop": false,
|
||||||
|
"discordRoleIds": ["111111111111111111"],
|
||||||
"filters": [
|
"filters": [
|
||||||
{ "type": "event", "match": "push" },
|
{ "type": "event", "match": "push" },
|
||||||
{ "type": "repo", "match": "org/repo", "exclude": false }
|
{ "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 的子区)可选。不存在默认频道回退。
|
`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 |
|
| `groupId` | string | 是 | 该路由所属[分组](#分组)的 id |
|
||||||
| `fallback` | boolean | 否 | 为 `true` 时,仅当没有其它路由匹配该事件时才发送,其自身过滤器会被忽略 |
|
| `fallback` | boolean | 否 | 为 `true` 时,仅当没有其它路由匹配该事件时才发送,其自身过滤器会被忽略 |
|
||||||
| `stop` | boolean | 否 | 为 `true` 且该路由匹配时,停止评估后续路由 |
|
| `stop` | boolean | 否 | 为 `true` 且该路由匹配时,停止评估后续路由 |
|
||||||
| `lang` | string | 否 | 该路由的消息语言覆盖(如 `en`、`zh`),默认跟随全局设置 |
|
| `lang` | string | 否 | 该路由的消息语言覆盖(如 `en`、`zh`),默认跟随全局设置 |
|
||||||
|
| `discordRoleIds` | string[] | 否 | 该路由触发时要在 Discord 目标中 @提醒的身份组 id |
|
||||||
|
|
||||||
### 自定义路由示例
|
### 自定义路由示例
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||||
import { sendMessage } from "../drivers/discord/rest";
|
import { sendMessage } from "../drivers/discord/rest";
|
||||||
|
import { renderNeutralMessage } from "../drivers/discord/render";
|
||||||
import { dispatchEvent } from "../core/dispatch";
|
import { dispatchEvent } from "../core/dispatch";
|
||||||
import type { Env, Route } from "../types";
|
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", () => {
|
describe("dispatchEvent fallback routing", () => {
|
||||||
function createMockKV(): KVNamespace {
|
function createMockKV(): KVNamespace {
|
||||||
const store = new Map<string, string>();
|
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("/111/"))).toHaveLength(0);
|
||||||
expect(sent.filter((u) => u.includes("/222/"))).toHaveLength(1);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,9 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
const group = route.groupId ? groupById.get(route.groupId) : undefined;
|
const group = route.groupId ? groupById.get(route.groupId) : undefined;
|
||||||
const showEmoji = group?.emoji !== false;
|
const showEmoji = group?.emoji !== false;
|
||||||
const message = formatEvent(route, event, tr, showEmoji);
|
const message = formatEvent(route, event, tr, showEmoji);
|
||||||
|
if (route.discordRoleIds?.length) {
|
||||||
|
message.mentionRoleIds = route.discordRoleIds;
|
||||||
|
}
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const targetStr =
|
const targetStr =
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,11 @@ function toStyle(style: NeutralActionStyle): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderNeutralMessage(message: NeutralMessage): FormattedMessage {
|
export function renderNeutralMessage(message: NeutralMessage): FormattedMessage {
|
||||||
|
const content = message.mentionRoleIds?.length
|
||||||
|
? message.mentionRoleIds.map((id) => `<@&${id}>`).join(" ")
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
|
content,
|
||||||
embeds: [
|
embeds: [
|
||||||
{
|
{
|
||||||
title: message.title,
|
title: message.title,
|
||||||
|
|
|
||||||
11
src/types.ts
11
src/types.ts
|
|
@ -64,6 +64,11 @@ export interface Route {
|
||||||
* fallthrough to subsequent routes.
|
* fallthrough to subsequent routes.
|
||||||
*/
|
*/
|
||||||
stop?: boolean;
|
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 {
|
export interface Group {
|
||||||
|
|
@ -136,9 +141,15 @@ export interface NeutralMessage {
|
||||||
* previously sent message instead of sending a new one.
|
* previously sent message instead of sending a new one.
|
||||||
*/
|
*/
|
||||||
updateKey?: string;
|
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 {
|
export interface FormattedMessage {
|
||||||
|
content?: string;
|
||||||
embeds?: Array<{
|
embeds?: Array<{
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,13 @@ function validateRoutes(
|
||||||
if (r.stop !== undefined && typeof r.stop !== "boolean") {
|
if (r.stop !== undefined && typeof r.stop !== "boolean") {
|
||||||
return { ok: false, error: `route "${r.id}".stop must be a 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)) {
|
if (!Array.isArray(r.filters)) {
|
||||||
return { ok: false, error: `route "${r.id}".filters must be an array` };
|
return { ok: false, error: `route "${r.id}".filters must be an array` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue