mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +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)
|
||||
- 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)
|
||||
|
|
|
|||
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.
|
||||
|
||||
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`)
|
||||
|
|
|
|||
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` 字段仍会被自动迁移。不存在默认频道回退。
|
||||
|
||||
在路由上设置 `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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface Route {
|
|||
groupId?: string;
|
||||
fallback?: boolean;
|
||||
stop?: boolean;
|
||||
discordRoleIds?: string[];
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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、过滤器、平台感知的 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",
|
||||
"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 |
|
||||
|
||||
### 自定义路由示例
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
11
src/types.ts
11
src/types.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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` };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue