mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(groups): per-group webhook log channel (logTarget)
Add an optional Discord channel/thread or Telegram chat/topic (Group.logTarget) that receives a summary message per webhook the group's routes dispatched: event/action, repo, delivery id and per route×target OK/FAIL lines with green/red embed color. Validated by the admin API and editable in the group editor; docs and example config updated.
This commit is contained in:
parent
b35c2c2f90
commit
0b078d938b
17 changed files with 526 additions and 35 deletions
|
|
@ -19,6 +19,7 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord
|
||||||
- Access control: every group has role-based members (`owner` / `admin` / `viewer`); super admins bypass; legacy `adminIds` are read as owners (backward compatible); owners manage members + invites; `owners` field stays super-only
|
- Access control: every group has role-based members (`owner` / `admin` / `viewer`); super admins bypass; legacy `adminIds` are read as owners (backward compatible); owners manage members + invites; `owners` field stays super-only
|
||||||
- Invites: single-use 7-day links (`invite:{token}`) for joining a group as admin/viewer; `ALLOW_SELF_SIGNUP=1` gives access-less users a personal group on first login (self-service SaaS entry)
|
- Invites: single-use 7-day links (`invite:{token}`) for joining a group as admin/viewer; `ALLOW_SELF_SIGNUP=1` gives access-less users a personal group on first login (self-service SaaS entry)
|
||||||
- Audit log: every admin operation (logins, group/route/member/invite changes) recorded in D1 `audit_logs`; pruned by the scheduled trigger after `AUDIT_RETENTION_DAYS` (default 90)
|
- Audit log: every admin operation (logins, group/route/member/invite changes) recorded in D1 `audit_logs`; pruned by the scheduled trigger after `AUDIT_RETENTION_DAYS` (default 90)
|
||||||
|
- Group webhook log channel: optional `Group.logTarget` (Discord channel/thread or Telegram chat/topic) receives one summary message per webhook the group's routes dispatched (event, repo, delivery id, per route×target ✅/❌ outcome, green/red color); best-effort, not recorded in `send_logs`
|
||||||
- Local dev: wrangler + Miniflare
|
- Local dev: wrangler + Miniflare
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
@ -30,7 +31,7 @@ src/
|
||||||
├── config.ts # loadRoutes/saveRoutes (KV config:routes, cache w/ 60s TTL), loadConfig from env
|
├── config.ts # loadRoutes/saveRoutes (KV config:routes, cache w/ 60s TTL), loadConfig from env
|
||||||
├── server.ts # Hono app: /health, /webhook, /discord/interactions, /telegram/webhook, mounts /auth, /admin + /
|
├── server.ts # Hono app: /health, /webhook, /discord/interactions, /telegram/webhook, mounts /auth, /admin + /
|
||||||
├── core/
|
├── core/
|
||||||
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (recordSend + group filter)
|
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (recordSend + group filter + per-group webhook log)
|
||||||
├── events/ # Provider-agnostic route matching
|
├── events/ # Provider-agnostic route matching
|
||||||
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword regex filtering
|
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword regex filtering
|
||||||
├── providers/ # Forge webhook providers (verify + parse/normalize to GitHub-shaped events)
|
├── providers/ # Forge webhook providers (verify + parse/normalize to GitHub-shaped events)
|
||||||
|
|
@ -106,6 +107,7 @@ src/__tests__/ # bun test unit tests (webhook, formatter, discord, te
|
||||||
- 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` / `check_run` progress (stable `updateKey`, KV `msg:*` tracking)
|
- Edit already-sent messages in place for `workflow_run` / `check_run` progress (stable `updateKey`, KV `msg:*` tracking)
|
||||||
- Record every dispatch attempt to D1 `send_logs` (route id, event, target, ok/error, duration, error code)
|
- Record every dispatch attempt to D1 `send_logs` (route id, event, target, ok/error, duration, error code)
|
||||||
|
- Send a per-event summary (event, repo, delivery id, per route×target ✅/❌ outcome) to the group's `logTarget` when configured
|
||||||
- Serve `/gh` slash commands + message context-menu commands + PR merge/close buttons + comment modals
|
- Serve `/gh` slash commands + message context-menu commands + PR merge/close buttons + comment modals
|
||||||
- Serve Telegram `/gh` commands (login/logout/comment/merge/close) via reply-message parsing
|
- Serve Telegram `/gh` commands (login/logout/comment/merge/close) via reply-message parsing
|
||||||
- Sync application commands from the scheduled trigger (global ~1h propagation + per-guild instant)
|
- Sync application commands from the scheduled trigger (global ~1h propagation + per-guild instant)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook event
|
||||||
- Rich messages with color coding, author avatars, fields, and timestamps — rendered as Discord embeds and Telegram HTML
|
- Rich messages with color coding, author avatars, fields, and timestamps — rendered as Discord embeds and Telegram HTML
|
||||||
- Route to Discord channels/threads and Telegram chats/topics (multi-target routes)
|
- Route to Discord channels/threads and Telegram chats/topics (multi-target routes)
|
||||||
- `workflow_run` / `check_run` progress is edited **in place** (single message updated as the run advances) on both platforms
|
- `workflow_run` / `check_run` progress is edited **in place** (single message updated as the run advances) on both platforms
|
||||||
|
- **Per-group webhook log channel** — point a group at a Discord channel/thread or Telegram chat/topic and every webhook the group's routes dispatch is summarized there (✅/❌ per route × target)
|
||||||
- GitHub OAuth for user actions (comment, edit comment, delete comment, merge, close, react)
|
- GitHub OAuth for user actions (comment, edit comment, delete comment, merge, close, react)
|
||||||
- **Web UI config console** (`/admin`) — manage routes and groups with GitHub OAuth + admin whitelist, view send logs
|
- **Web UI config console** (`/admin`) — manage routes and groups with GitHub OAuth + admin whitelist, view send logs
|
||||||
- **Discord Interactions Endpoint** (Ed25519-verified) for `/gh` slash commands, message context-menu commands, PR merge/close buttons, and comment modals
|
- **Discord Interactions Endpoint** (Ed25519-verified) for `/gh` slash commands, message context-menu commands, PR merge/close buttons, and comment modals
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare W
|
||||||
- 富消息:颜色编码、作者头像、字段、时间戳——渲染为 Discord embed 与 Telegram HTML
|
- 富消息:颜色编码、作者头像、字段、时间戳——渲染为 Discord embed 与 Telegram HTML
|
||||||
- 路由到 Discord 频道/子区与 Telegram 群组/话题(一条路由可多目标)
|
- 路由到 Discord 频道/子区与 Telegram 群组/话题(一条路由可多目标)
|
||||||
- `workflow_run` / `check_run` 进度**原地编辑**同一条消息(运行推进时更新),两个平台均支持
|
- `workflow_run` / `check_run` 进度**原地编辑**同一条消息(运行推进时更新),两个平台均支持
|
||||||
|
- **分组级 Webhook 日志频道** —— 为分组指定一个 Discord 频道/子区或 Telegram 群组/话题,该分组路由每次分发 webhook 都会向其中发送摘要(每条「路由 × 目标」一行,✅/❌ 结果)
|
||||||
- GitHub OAuth 用户授权(评论、编辑评论、删除评论、合并、关闭、反应)
|
- GitHub OAuth 用户授权(评论、编辑评论、删除评论、合并、关闭、反应)
|
||||||
- **Web 配置控制台**(`/admin`)— 通过 GitHub OAuth + 管理员白名单管理路由与分组、查看发送日志
|
- **Web 配置控制台**(`/admin`)— 通过 GitHub OAuth + 管理员白名单管理路由与分组、查看发送日志
|
||||||
- **Discord Interactions Endpoint**(Ed25519 验签)支持 `/gh` 斜杠命令、消息右键菜单命令、PR 合并/关闭按钮与评论 modal
|
- **Discord Interactions Endpoint**(Ed25519 验签)支持 `/gh` 斜杠命令、消息右键菜单命令、PR 合并/关闭按钮与评论 modal
|
||||||
|
|
|
||||||
|
|
@ -689,6 +689,29 @@ main {
|
||||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--ui);
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select + input[type="text"],
|
||||||
|
.field input[type="text"] + input[type="text"] {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.field .hint {
|
.field .hint {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,42 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">{{ t("groupEditor.providersHint") }}</div>
|
<div class="hint">{{ t("groupEditor.providersHint") }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label
|
||||||
|
>{{ t("groupEditor.logTarget") }}
|
||||||
|
<span class="lbl-note">{{ t("groupEditor.logTargetNote") }}</span></label
|
||||||
|
>
|
||||||
|
<select v-model="form.logPlatform">
|
||||||
|
<option value="">{{ t("groupEditor.logDisabled") }}</option>
|
||||||
|
<option value="discord">Discord</option>
|
||||||
|
<option value="telegram">Telegram</option>
|
||||||
|
</select>
|
||||||
|
<template v-if="form.logPlatform === 'discord'">
|
||||||
|
<input
|
||||||
|
v-model="form.logChannelId"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('routeEditor.channelPlaceholder')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="form.logThreadId"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('routeEditor.threadPlaceholder')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="form.logPlatform === 'telegram'">
|
||||||
|
<input
|
||||||
|
v-model="form.logChatId"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('routeEditor.chatPlaceholder')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="form.logTopicId"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('routeEditor.topicPlaceholder')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div class="hint">{{ t("groupEditor.logTargetHint") }}</div>
|
||||||
|
</div>
|
||||||
<div class="err">{{ formError }}</div>
|
<div class="err">{{ formError }}</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="editor-foot">
|
<div class="editor-foot">
|
||||||
|
|
@ -149,6 +185,11 @@ const form = reactive({
|
||||||
providers: [] as ("github" | "gitea")[],
|
providers: [] as ("github" | "gitea")[],
|
||||||
emoji: true,
|
emoji: true,
|
||||||
lang: "",
|
lang: "",
|
||||||
|
logPlatform: "" as "" | "discord" | "telegram",
|
||||||
|
logChannelId: "",
|
||||||
|
logThreadId: "",
|
||||||
|
logChatId: "",
|
||||||
|
logTopicId: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
function splitList(text: string): string[] {
|
function splitList(text: string): string[] {
|
||||||
|
|
@ -170,6 +211,7 @@ watch(
|
||||||
(open) => {
|
(open) => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const g = props.group;
|
const g = props.group;
|
||||||
|
const lt = g?.logTarget;
|
||||||
form.id = g?.id ?? "";
|
form.id = g?.id ?? "";
|
||||||
form.name = g?.name ?? "";
|
form.name = g?.name ?? "";
|
||||||
form.owners = (g?.owners ?? []).join(", ");
|
form.owners = (g?.owners ?? []).join(", ");
|
||||||
|
|
@ -178,6 +220,11 @@ watch(
|
||||||
);
|
);
|
||||||
form.emoji = g?.emoji ?? true;
|
form.emoji = g?.emoji ?? true;
|
||||||
form.lang = g?.lang ?? "";
|
form.lang = g?.lang ?? "";
|
||||||
|
form.logPlatform = lt?.platform ?? "";
|
||||||
|
form.logChannelId = lt?.channelId ?? "";
|
||||||
|
form.logThreadId = lt?.threadId ?? "";
|
||||||
|
form.logChatId = lt?.chatId ?? "";
|
||||||
|
form.logTopicId = lt?.topicId ?? "";
|
||||||
formError.value = "";
|
formError.value = "";
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -198,6 +245,29 @@ function save(): void {
|
||||||
formError.value = t("groupEditor.errName");
|
formError.value = t("groupEditor.errName");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let logTarget:
|
||||||
|
| { platform: "discord"; channelId: string; threadId?: string }
|
||||||
|
| {
|
||||||
|
platform: "telegram";
|
||||||
|
chatId: string;
|
||||||
|
topicId?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (form.logPlatform === "discord") {
|
||||||
|
const channelId = form.logChannelId.trim();
|
||||||
|
if (!channelId) {
|
||||||
|
formError.value = t("groupEditor.errLogChannel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logTarget = { platform: "discord", channelId, threadId: form.logThreadId.trim() || undefined };
|
||||||
|
} else if (form.logPlatform === "telegram") {
|
||||||
|
const chatId = form.logChatId.trim();
|
||||||
|
if (!chatId) {
|
||||||
|
formError.value = t("groupEditor.errLogChat");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logTarget = { platform: "telegram", chatId, topicId: form.logTopicId.trim() || undefined };
|
||||||
|
}
|
||||||
const owners = splitList(form.owners);
|
const owners = splitList(form.owners);
|
||||||
const members = props.group?.members
|
const members = props.group?.members
|
||||||
? props.group.members.map((m) => ({ ...m }))
|
? props.group.members.map((m) => ({ ...m }))
|
||||||
|
|
@ -213,6 +283,7 @@ function save(): void {
|
||||||
providers: form.providers.length ? form.providers : undefined,
|
providers: form.providers.length ? form.providers : undefined,
|
||||||
emoji: form.emoji,
|
emoji: form.emoji,
|
||||||
lang: form.lang.trim() || undefined,
|
lang: form.lang.trim() || undefined,
|
||||||
|
logTarget,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,13 @@ const en: Dict = {
|
||||||
"groupEditor.providersHint":
|
"groupEditor.providersHint":
|
||||||
"Only webhook events from the checked forges (GitHub / Gitea) enter this group's routes.",
|
"Only webhook events from the checked forges (GitHub / Gitea) enter this group's routes.",
|
||||||
"groupEditor.emoji": "Show emojis in messages",
|
"groupEditor.emoji": "Show emojis in messages",
|
||||||
|
"groupEditor.logTarget": "Webhook log channel",
|
||||||
|
"groupEditor.logTargetNote": "(optional)",
|
||||||
|
"groupEditor.logDisabled": "— Disabled —",
|
||||||
|
"groupEditor.logTargetHint":
|
||||||
|
"A Discord channel/thread or Telegram chat/topic that receives a summary of every webhook this group's routes dispatch.",
|
||||||
|
"groupEditor.errLogChannel": "Log channel ID is required when Discord is selected",
|
||||||
|
"groupEditor.errLogChat": "Log chat ID is required when Telegram is selected",
|
||||||
"groupEditor.cancel": "Cancel",
|
"groupEditor.cancel": "Cancel",
|
||||||
"groupEditor.save": "Save group",
|
"groupEditor.save": "Save group",
|
||||||
"groupEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
|
"groupEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
|
||||||
|
|
@ -384,6 +391,13 @@ const zh: Dict = {
|
||||||
"groupEditor.providersHint":
|
"groupEditor.providersHint":
|
||||||
"只有来自所勾选 forge(GitHub / Gitea)的 webhook 事件才会进入本分组的路由。",
|
"只有来自所勾选 forge(GitHub / Gitea)的 webhook 事件才会进入本分组的路由。",
|
||||||
"groupEditor.emoji": "消息中显示表情符号",
|
"groupEditor.emoji": "消息中显示表情符号",
|
||||||
|
"groupEditor.logTarget": "Webhook 日志频道",
|
||||||
|
"groupEditor.logTargetNote": "(可选)",
|
||||||
|
"groupEditor.logDisabled": "— 未启用 —",
|
||||||
|
"groupEditor.logTargetHint":
|
||||||
|
"一个 Discord 频道/子区或 Telegram 群组/话题,本分组路由分发(dispatch)的每个 webhook 都会向其发送摘要。",
|
||||||
|
"groupEditor.errLogChannel": "选择 Discord 时必须填写日志频道 ID",
|
||||||
|
"groupEditor.errLogChat": "选择 Telegram 时必须填写日志群组 ID",
|
||||||
"groupEditor.cancel": "取消",
|
"groupEditor.cancel": "取消",
|
||||||
"groupEditor.save": "保存分组",
|
"groupEditor.save": "保存分组",
|
||||||
"groupEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
|
"groupEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export interface Group {
|
||||||
providers?: ("github" | "gitea" | "gitlab")[];
|
providers?: ("github" | "gitea" | "gitlab")[];
|
||||||
emoji?: boolean;
|
emoji?: boolean;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
|
logTarget?: RouteTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupInvite {
|
export interface GroupInvite {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,16 @@ groups:
|
||||||
# providers: ["github", "gitea"] # restrict to source platforms (optional; empty = all)
|
# providers: ["github", "gitea"] # restrict to source platforms (optional; empty = all)
|
||||||
# emoji: true # include emoji in messages (default true)
|
# emoji: true # include emoji in messages (default true)
|
||||||
# lang: "en" # message language for this group's routes (en/zh, default en)
|
# lang: "en" # message language for this group's routes (en/zh, default en)
|
||||||
|
# Webhook log channel: a Discord channel/thread or Telegram chat/topic that
|
||||||
|
# receives a summary of every webhook this group's routes dispatch.
|
||||||
|
# logTarget:
|
||||||
|
# platform: discord
|
||||||
|
# channelId: "CHANNEL_ID_HERE"
|
||||||
|
# threadId: "" # optional
|
||||||
|
# logTarget:
|
||||||
|
# platform: telegram
|
||||||
|
# chatId: "-1001234567890"
|
||||||
|
# topicId: "" # optional
|
||||||
|
|
||||||
routes:
|
routes:
|
||||||
- id: all-push
|
- id: all-push
|
||||||
|
|
|
||||||
|
|
@ -181,20 +181,22 @@ Routes belong to groups. Groups scope admin access and can restrict which events
|
||||||
{ "login": "reader", "role": "viewer" }
|
{ "login": "reader", "role": "viewer" }
|
||||||
],
|
],
|
||||||
"owners": ["myorg"],
|
"owners": ["myorg"],
|
||||||
"providers": ["github", "gitea"]
|
"providers": ["github", "gitea"],
|
||||||
|
"logTarget": { "platform": "discord", "channelId": "123456789", "threadId": "987654321" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ----------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
| ----------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId` |
|
| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId` |
|
||||||
| `name` | string | Yes | Human-readable group name |
|
| `name` | string | Yes | Human-readable group name |
|
||||||
| `members` | object[] | No | `{ login, role }` entries; role is `owner`, `admin`, or `viewer` |
|
| `members` | object[] | No | `{ login, role }` entries; role is `owner`, `admin`, or `viewer` |
|
||||||
| `adminIds` | string[] | No | Deprecated legacy field; treated as `members` with role `owner` when present |
|
| `adminIds` | string[] | No | Deprecated legacy field; treated as `members` with role `owner` when present |
|
||||||
| `owners` | string[] | No | Org/user logins whose events are accepted into this group; empty = all |
|
| `owners` | string[] | No | Org/user logins whose events are accepted into this group; empty = all |
|
||||||
| `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all |
|
| `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all |
|
||||||
| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) |
|
| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) |
|
||||||
| `lang` | string | No | Message language for every route in this group (e.g. `en`, `zh`; custom via KV `i18n:<lang>`) — defaults to `en` |
|
| `lang` | string | No | Message language for every route in this group (e.g. `en`, `zh`; custom via KV `i18n:<lang>`) — defaults to `en` |
|
||||||
|
| `logTarget` | object | No | Webhook log channel: a Discord `{ platform, channelId, threadId? }` or Telegram `{ platform, chatId, topicId? }` target that receives a summary of every webhook the group's routes dispatch |
|
||||||
|
|
||||||
### Roles
|
### Roles
|
||||||
|
|
||||||
|
|
@ -215,6 +217,10 @@ Every group member has one of three roles. Super admins (`ADMIN_USER_IDS`) alway
|
||||||
- The `owners` list restricts which event actors (sender logins) the group's routes will dispatch at all.
|
- The `owners` list restricts which event actors (sender logins) the group's routes will dispatch at all.
|
||||||
- The `providers` list restricts which forge's events (`github`, `gitea`) the group's routes will dispatch. This lets you keep GitHub and Gitea groups separate even when org/user names collide.
|
- The `providers` list restricts which forge's events (`github`, `gitea`) the group's routes will dispatch. This lets you keep GitHub and Gitea groups separate even when org/user names collide.
|
||||||
|
|
||||||
|
### Webhook Log Channel
|
||||||
|
|
||||||
|
A group may set `logTarget` to a Discord channel/thread or Telegram chat/topic. Whenever the group's routes dispatch a webhook, a single summary message is sent there: the event type/action, the repo, the delivery id, and one line per route×target with an ✅/❌ outcome (including the error for failed sends). The message is green when every dispatch succeeded and red when any failed. The summary uses the group's message language. Log messages are sent best-effort and are not themselves recorded in the D1 send log.
|
||||||
|
|
||||||
### Invites
|
### Invites
|
||||||
|
|
||||||
Owners (and super admins) can create single-use invite links valid for 7 days from the group's _Members_ panel. Accepting an invite adds the user with the invited role (`admin` or `viewer` — never `owner`); an existing `viewer` is upgraded to `admin`. Invites are stored in KV as `invite:{token}`.
|
Owners (and super admins) can create single-use invite links valid for 7 days from the group's _Members_ panel. Accepting an invite adds the user with the invited role (`admin` or `viewer` — never `owner`); an existing `viewer` is upgraded to `admin`. Invites are stored in KV as `invite:{token}`.
|
||||||
|
|
|
||||||
|
|
@ -181,20 +181,22 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
|
||||||
{ "login": "reader", "role": "viewer" }
|
{ "login": "reader", "role": "viewer" }
|
||||||
],
|
],
|
||||||
"owners": ["myorg"],
|
"owners": ["myorg"],
|
||||||
"providers": ["github", "gitea"]
|
"providers": ["github", "gitea"],
|
||||||
|
"logTarget": { "platform": "discord", "channelId": "123456789", "threadId": "987654321" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 字段 | 类型 | 必需 | 说明 |
|
| 字段 | 类型 | 必需 | 说明 |
|
||||||
| ----------- | -------- | ---- | ------------------------------------------------------------------------------------ |
|
| ----------- | -------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `id` | string | 是 | 小写 id(`a-z0-9`、`-`),由每条路由的 `groupId` 引用 |
|
| `id` | string | 是 | 小写 id(`a-z0-9`、`-`),由每条路由的 `groupId` 引用 |
|
||||||
| `name` | string | 是 | 可读的分组名称 |
|
| `name` | string | 是 | 可读的分组名称 |
|
||||||
| `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner`、`admin` 或 `viewer` |
|
| `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner`、`admin` 或 `viewer` |
|
||||||
| `adminIds` | string[] | 否 | 已废弃的旧字段;存在时按 role 为 `owner` 的成员处理 |
|
| `adminIds` | string[] | 否 | 已废弃的旧字段;存在时按 role 为 `owner` 的成员处理 |
|
||||||
| `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 |
|
| `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 |
|
||||||
| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);为空表示全部 |
|
| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);为空表示全部 |
|
||||||
| `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji(默认 `true`) |
|
| `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji(默认 `true`) |
|
||||||
| `lang` | string | 否 | 该分组所有路由的消息语言(如 `en`、`zh`;可通过 KV `i18n:<lang>` 自定义)——默认 `en` |
|
| `lang` | string | 否 | 该分组所有路由的消息语言(如 `en`、`zh`;可通过 KV `i18n:<lang>` 自定义)——默认 `en` |
|
||||||
|
| `logTarget` | object | 否 | Webhook 日志频道:Discord 目标 `{ platform, channelId, threadId? }` 或 Telegram 目标 `{ platform, chatId, topicId? }`,本分组路由每次投递 webhook 时都会向其发送摘要 |
|
||||||
|
|
||||||
### 角色
|
### 角色
|
||||||
|
|
||||||
|
|
@ -215,6 +217,10 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
|
||||||
- `owners` 列表限定哪些事件参与者(发送者登录名)的事件会被该分组的路由投递。
|
- `owners` 列表限定哪些事件参与者(发送者登录名)的事件会被该分组的路由投递。
|
||||||
- `providers` 列表限定哪个 forge(`github`、`gitea`)的事件会被该分组的路由投递。即使组织/用户同名,也可以借此将 GitHub 与 Gitea 分组区分开。
|
- `providers` 列表限定哪个 forge(`github`、`gitea`)的事件会被该分组的路由投递。即使组织/用户同名,也可以借此将 GitHub 与 Gitea 分组区分开。
|
||||||
|
|
||||||
|
### Webhook 日志频道
|
||||||
|
|
||||||
|
分组可以设置 `logTarget` 指向一个 Discord 频道/子区或 Telegram 群组/话题。每当该分组的路由分发(dispatch)一个 webhook,就会向那里发送一条摘要消息:事件类型/动作、仓库、投递 ID,以及每条「路由 × 目标」一行的 ✅/❌ 结果(失败时附带错误信息)。全部成功时消息为绿色,任一失败则为红色。摘要使用分组的消息语言。日志消息尽力发送,本身不会被记入 D1 发送日志。
|
||||||
|
|
||||||
### 邀请
|
### 邀请
|
||||||
|
|
||||||
owner(及超级管理员)可在分组的「成员」面板创建一次性邀请链接,7 天内有效。接受邀请后用户以邀请角色(`admin` 或 `viewer`,绝不授予 `owner`)加入;已有的 `viewer` 会被升级为 `admin`。邀请存储在 KV `invite:{token}`。
|
owner(及超级管理员)可在分组的「成员」面板创建一次性邀请链接,7 天内有效。接受邀请后用户以邀请角色(`admin` 或 `viewer`,绝不授予 `owner`)加入;已有的 `viewer` 会被升级为 `admin`。邀请存储在 KV `invite:{token}`。
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
clearAdminCookie,
|
clearAdminCookie,
|
||||||
} from "../web/session";
|
} from "../web/session";
|
||||||
import { groupAcceptsProvider } from "../web/groups";
|
import { groupAcceptsProvider } from "../web/groups";
|
||||||
|
import { validateGroups } from "../web/admin-routes";
|
||||||
import { loadRoutes, saveRoutes, loadConfig } from "../config";
|
import { loadRoutes, saveRoutes, loadConfig } from "../config";
|
||||||
import type { Env, Route, Group } from "../types";
|
import type { Env, Route, Group } from "../types";
|
||||||
|
|
||||||
|
|
@ -132,6 +133,72 @@ describe("groupAcceptsProvider", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("validateGroups logTarget", () => {
|
||||||
|
const baseGroup = {
|
||||||
|
id: "g",
|
||||||
|
name: "G",
|
||||||
|
members: [{ login: "boss", role: "owner" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts and normalizes a discord log target", () => {
|
||||||
|
const res = validateGroups([
|
||||||
|
{
|
||||||
|
...baseGroup,
|
||||||
|
logTarget: { platform: "discord", channelId: "111", threadId: "222" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (res.ok) {
|
||||||
|
expect(res.groups[0]!.logTarget).toEqual({
|
||||||
|
platform: "discord",
|
||||||
|
channelId: "111",
|
||||||
|
threadId: "222",
|
||||||
|
chatId: undefined,
|
||||||
|
topicId: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a telegram log target", () => {
|
||||||
|
const res = validateGroups([
|
||||||
|
{
|
||||||
|
...baseGroup,
|
||||||
|
logTarget: { platform: "telegram", chatId: "-100123", topicId: "999" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (res.ok) {
|
||||||
|
expect(res.groups[0]!.logTarget).toEqual({
|
||||||
|
platform: "telegram",
|
||||||
|
channelId: undefined,
|
||||||
|
threadId: undefined,
|
||||||
|
chatId: "-100123",
|
||||||
|
topicId: "999",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a log target without a channel id", () => {
|
||||||
|
const res = validateGroups([
|
||||||
|
{ ...baseGroup, logTarget: { platform: "discord", channelId: "" } },
|
||||||
|
]);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toContain("logTarget.channelId");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a log target with an unknown platform", () => {
|
||||||
|
const res = validateGroups([{ ...baseGroup, logTarget: { platform: "slack" } }]);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toContain("logTarget.platform");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a null log target", () => {
|
||||||
|
const res = validateGroups([{ ...baseGroup, logTarget: null }]);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (res.ok) expect(res.groups[0]!.logTarget).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("config routes persistence", () => {
|
describe("config routes persistence", () => {
|
||||||
it("saves and loads routes from KV", async () => {
|
it("saves and loads routes from KV", async () => {
|
||||||
const kv = createMockKV();
|
const kv = createMockKV();
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,153 @@ describe("dispatchEvent fallback routing", () => {
|
||||||
expect(sent.filter((u) => u.includes("/111/"))).toHaveLength(0);
|
expect(sent.filter((u) => u.includes("/111/"))).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends a summary of dispatched webhooks to the group log target", async () => {
|
||||||
|
const sent: Array<{ url: string; body: string }> = [];
|
||||||
|
mockFetch((url, init) => {
|
||||||
|
sent.push({ url, body: String(init?.body ?? "") });
|
||||||
|
return new Response("{}", { status: 200 });
|
||||||
|
});
|
||||||
|
const kv = createMockKV();
|
||||||
|
await kv.put(
|
||||||
|
"config:groups",
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "gh",
|
||||||
|
name: "GH",
|
||||||
|
adminIds: [],
|
||||||
|
logTarget: { platform: "discord", channelId: "777" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const env = createEnv({ KV: kv, DB: createMockDB() });
|
||||||
|
const routes: Route[] = [
|
||||||
|
{
|
||||||
|
id: "push-route",
|
||||||
|
name: "Push Route",
|
||||||
|
enabled: true,
|
||||||
|
groupId: "gh",
|
||||||
|
filters: [{ type: "event", match: "push" }],
|
||||||
|
targets: [{ channelId: "111" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await dispatchEvent(
|
||||||
|
{ ...baseConfig, routes },
|
||||||
|
{
|
||||||
|
event: "push",
|
||||||
|
deliveryId: "deliv-1",
|
||||||
|
payload: {
|
||||||
|
repository: { full_name: "owner/repo" },
|
||||||
|
ref: "refs/heads/main",
|
||||||
|
commits: [{ id: "abc", message: "fix", author: { name: "a" } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sent).toHaveLength(2);
|
||||||
|
expect(sent[1]!.url).toContain("/777/");
|
||||||
|
const logBody = JSON.parse(sent[1]!.body) as {
|
||||||
|
embeds?: Array<{ title?: string; color?: number; fields?: Array<{ value: string }> }>;
|
||||||
|
};
|
||||||
|
expect(logBody.embeds?.[0]?.title).toBe("owner/repo: push");
|
||||||
|
expect(logBody.embeds?.[0]?.color).toBe(0x3fb950);
|
||||||
|
expect(logBody.embeds?.[0]?.fields?.[0]?.value).toContain("✅ Push Route → 111");
|
||||||
|
expect(logBody.embeds?.[0]?.fields?.[1]?.value).toBe("deliv-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports failed dispatches in the group log", async () => {
|
||||||
|
const sent: Array<{ url: string; body: string }> = [];
|
||||||
|
mockFetch((url, init) => {
|
||||||
|
if (url.includes("/111/")) return new Response("Missing Permissions", { status: 403 });
|
||||||
|
sent.push({ url, body: String(init?.body ?? "") });
|
||||||
|
return new Response("{}", { status: 200 });
|
||||||
|
});
|
||||||
|
const kv = createMockKV();
|
||||||
|
await kv.put(
|
||||||
|
"config:groups",
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "gh",
|
||||||
|
name: "GH",
|
||||||
|
adminIds: [],
|
||||||
|
logTarget: { platform: "discord", channelId: "777" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const env = createEnv({ KV: kv, DB: createMockDB() });
|
||||||
|
const routes: Route[] = [
|
||||||
|
{
|
||||||
|
id: "push-route",
|
||||||
|
name: "Push Route",
|
||||||
|
enabled: true,
|
||||||
|
groupId: "gh",
|
||||||
|
filters: [{ type: "event", match: "push" }],
|
||||||
|
targets: [{ channelId: "111" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await dispatchEvent(
|
||||||
|
{ ...baseConfig, routes },
|
||||||
|
{
|
||||||
|
event: "push",
|
||||||
|
payload: {
|
||||||
|
repository: { full_name: "owner/repo" },
|
||||||
|
ref: "refs/heads/main",
|
||||||
|
commits: [{ id: "abc", message: "fix", author: { name: "a" } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
expect(sent[0]!.url).toContain("/777/");
|
||||||
|
const logBody = JSON.parse(sent[0]!.body) as {
|
||||||
|
embeds?: Array<{ color?: number; fields?: Array<{ value: string }> }>;
|
||||||
|
};
|
||||||
|
expect(logBody.embeds?.[0]?.color).toBe(0xf85149);
|
||||||
|
expect(logBody.embeds?.[0]?.fields?.[0]?.value).toContain("❌ Push Route → 111");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends no group log when no route matched the event", async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
mockFetch((url, init) => {
|
||||||
|
sent.push(String(init?.body ?? ""));
|
||||||
|
return new Response("{}", { status: 200 });
|
||||||
|
});
|
||||||
|
const kv = createMockKV();
|
||||||
|
await kv.put(
|
||||||
|
"config:groups",
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "gh",
|
||||||
|
name: "GH",
|
||||||
|
adminIds: [],
|
||||||
|
logTarget: { platform: "discord", channelId: "777" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const env = createEnv({ KV: kv, DB: createMockDB() });
|
||||||
|
const routes: Route[] = [
|
||||||
|
{
|
||||||
|
id: "push-route",
|
||||||
|
name: "Push Route",
|
||||||
|
enabled: true,
|
||||||
|
groupId: "gh",
|
||||||
|
filters: [{ type: "event", match: "push" }],
|
||||||
|
targets: [{ channelId: "111" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await dispatchEvent(
|
||||||
|
{ ...baseConfig, routes },
|
||||||
|
{ event: "issues", payload: { action: "opened", repository: { full_name: "o/r" } } },
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sent).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the group's message language (not the route's)", async () => {
|
it("uses the group's message language (not the route's)", async () => {
|
||||||
const bodies: string[] = [];
|
const bodies: string[] = [];
|
||||||
mockFetch((url, init) => {
|
mockFetch((url, init) => {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
import type { Config, WebhookEvent, Env, Route } from "../types";
|
import type { Config, WebhookEvent, Env, Route, NeutralMessage } from "../types";
|
||||||
import { formatEvent } from "../formatters";
|
import { formatEvent } from "../formatters";
|
||||||
import { matchRoute, eventOwners } from "../events/match";
|
import { matchRoute, eventOwners } from "../events/match";
|
||||||
import { log } from "../lib/log";
|
import { log } from "../lib/log";
|
||||||
import { loadTranslations, type Translations } from "../lib/i18n";
|
import { loadTranslations, t as translate, type Translations } from "../lib/i18n";
|
||||||
import { recordSend } from "../lib/send-log";
|
import { recordSend } from "../lib/send-log";
|
||||||
import { loadGroups, groupAcceptsOwners, groupAcceptsProvider } from "../web/groups";
|
import { loadGroups, groupAcceptsOwners, groupAcceptsProvider } from "../web/groups";
|
||||||
import { getDriver } from "../drivers";
|
import { getDriver } from "../drivers";
|
||||||
import type { SendResult } from "../drivers/types";
|
import type { SendResult } from "../drivers/types";
|
||||||
|
|
||||||
|
/** One dispatch attempt (route × target), collected for the group webhook log. */
|
||||||
|
interface DispatchAttempt {
|
||||||
|
groupId?: string;
|
||||||
|
routeId: string;
|
||||||
|
routeName: string;
|
||||||
|
target: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise<void> {
|
export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise<void> {
|
||||||
const groups = await loadGroups(env.KV);
|
const groups = await loadGroups(env.KV);
|
||||||
const groupById = new Map(groups.map((g) => [g.id, g]));
|
const groupById = new Map(groups.map((g) => [g.id, g]));
|
||||||
|
|
@ -35,6 +45,7 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
);
|
);
|
||||||
const anyRegularMatched = matched.length > 0;
|
const anyRegularMatched = matched.length > 0;
|
||||||
|
|
||||||
|
const attempts: DispatchAttempt[] = [];
|
||||||
const tasks: Promise<void>[] = [];
|
const tasks: Promise<void>[] = [];
|
||||||
for (const route of config.routes) {
|
for (const route of config.routes) {
|
||||||
if (!accepted(route)) continue;
|
if (!accepted(route)) continue;
|
||||||
|
|
@ -51,6 +62,73 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
}
|
}
|
||||||
await Promise.allSettled(tasks);
|
await Promise.allSettled(tasks);
|
||||||
|
|
||||||
|
await sendGroupLogs(attempts);
|
||||||
|
|
||||||
|
async function sendGroupLogs(list: DispatchAttempt[]): Promise<void> {
|
||||||
|
const byGroup = new Map<string, DispatchAttempt[]>();
|
||||||
|
for (const a of list) {
|
||||||
|
if (!a.groupId) continue;
|
||||||
|
const bucket = byGroup.get(a.groupId);
|
||||||
|
if (bucket) bucket.push(a);
|
||||||
|
else byGroup.set(a.groupId, [a]);
|
||||||
|
}
|
||||||
|
for (const [groupId, entries] of byGroup) {
|
||||||
|
const group = groupById.get(groupId);
|
||||||
|
const target = group?.logTarget;
|
||||||
|
if (!group || !target) continue;
|
||||||
|
const tr = trMap.get(group.lang ?? "en")!;
|
||||||
|
try {
|
||||||
|
const allOk = entries.every((a) => a.ok);
|
||||||
|
const routeLines = entries
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((a) =>
|
||||||
|
a.ok
|
||||||
|
? translate("log.route_ok", { route: a.routeName, target: a.target }, undefined, tr)
|
||||||
|
: translate(
|
||||||
|
"log.route_fail",
|
||||||
|
{ route: a.routeName, target: a.target, error: a.error ?? "?" },
|
||||||
|
undefined,
|
||||||
|
tr,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (entries.length > 10) routeLines.push(`… +${entries.length - 10}`);
|
||||||
|
const message: NeutralMessage = {
|
||||||
|
title: translate(
|
||||||
|
"log.title",
|
||||||
|
{
|
||||||
|
repo:
|
||||||
|
(event.payload.repository as { full_name?: string } | undefined)?.full_name ?? "-",
|
||||||
|
event: event.event,
|
||||||
|
action: event.payload.action ? `: ${String(event.payload.action)}` : "",
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
tr,
|
||||||
|
),
|
||||||
|
color: allOk ? 0x3fb950 : 0xf85149,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: translate("log.routes", {}, undefined, tr),
|
||||||
|
value: routeLines.join("\n"),
|
||||||
|
inline: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: translate("log.delivery", {}, undefined, tr),
|
||||||
|
value: event.deliveryId ?? "-",
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const result = await getDriver(target).send(message, target, env);
|
||||||
|
if (!result.ok) {
|
||||||
|
log.warn({ groupId, error: result.error }, "Failed to send group webhook log");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ groupId, err }, "Group webhook log send failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function processRoute(route: Route): Promise<void> {
|
async function processRoute(route: Route): Promise<void> {
|
||||||
const targets = route.targets && route.targets.length > 0 ? route.targets : [];
|
const targets = route.targets && route.targets.length > 0 ? route.targets : [];
|
||||||
if (targets.length === 0) return;
|
if (targets.length === 0) return;
|
||||||
|
|
@ -106,6 +184,13 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
if (existingId) {
|
if (existingId) {
|
||||||
result = await driver.edit(message, target, env, existingId);
|
result = await driver.edit(message, target, env, existingId);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
attempts.push({
|
||||||
|
groupId: route.groupId,
|
||||||
|
routeId: route.id,
|
||||||
|
routeName: route.name,
|
||||||
|
target: targetStr,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
await recordSend(env.DB, {
|
await recordSend(env.DB, {
|
||||||
...base,
|
...base,
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -119,6 +204,13 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (/not modified/i.test(result.error ?? "")) {
|
if (/not modified/i.test(result.error ?? "")) {
|
||||||
|
attempts.push({
|
||||||
|
groupId: route.groupId,
|
||||||
|
routeId: route.id,
|
||||||
|
routeName: route.name,
|
||||||
|
target: targetStr,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
await recordSend(env.DB, {
|
await recordSend(env.DB, {
|
||||||
...base,
|
...base,
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -142,6 +234,13 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
}
|
}
|
||||||
const durationMs = Date.now() - started;
|
const durationMs = Date.now() - started;
|
||||||
if (!result.ok) throw new Error(result.error ?? "Send failed");
|
if (!result.ok) throw new Error(result.error ?? "Send failed");
|
||||||
|
attempts.push({
|
||||||
|
groupId: route.groupId,
|
||||||
|
routeId: route.id,
|
||||||
|
routeName: route.name,
|
||||||
|
target: targetStr,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
await recordSend(env.DB, {
|
await recordSend(env.DB, {
|
||||||
...base,
|
...base,
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -154,10 +253,19 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const durationMs = Date.now() - started;
|
const durationMs = Date.now() - started;
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
attempts.push({
|
||||||
|
groupId: route.groupId,
|
||||||
|
routeId: route.id,
|
||||||
|
routeName: route.name,
|
||||||
|
target: targetStr,
|
||||||
|
ok: false,
|
||||||
|
error,
|
||||||
|
});
|
||||||
await recordSend(env.DB, {
|
await recordSend(env.DB, {
|
||||||
...base,
|
...base,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error,
|
||||||
durationMs,
|
durationMs,
|
||||||
});
|
});
|
||||||
log.error({ routeId: route.id, target: targetStr, err }, "Route failed");
|
log.error({ routeId: route.id, target: targetStr, err }, "Route failed");
|
||||||
|
|
|
||||||
|
|
@ -195,4 +195,11 @@ export const en = {
|
||||||
title: "{repo}: {event}{action}",
|
title: "{repo}: {event}{action}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
log: {
|
||||||
|
title: "{repo}: {event}{action}",
|
||||||
|
routes: "Routes",
|
||||||
|
delivery: "Delivery",
|
||||||
|
route_ok: "✅ {route} → {target}",
|
||||||
|
route_fail: "❌ {route} → {target}: {error}",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -195,4 +195,11 @@ export const zh = {
|
||||||
title: "{repo}: {event}{action}",
|
title: "{repo}: {event}{action}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
log: {
|
||||||
|
title: "{repo}: {event}{action}",
|
||||||
|
routes: "路由",
|
||||||
|
delivery: "投递",
|
||||||
|
route_ok: "✅ {route} → {target}",
|
||||||
|
route_fail: "❌ {route} → {target}: {error}",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,11 @@ export interface Group {
|
||||||
* via KV i18n:<lang>). Defaults to "en" when omitted.
|
* via KV i18n:<lang>). Defaults to "en" when omitted.
|
||||||
*/
|
*/
|
||||||
lang?: string;
|
lang?: string;
|
||||||
|
/**
|
||||||
|
* Discord channel/thread or Telegram chat/topic that receives a summary of
|
||||||
|
* every webhook this group's routes dispatch (the group's webhook log).
|
||||||
|
*/
|
||||||
|
logTarget?: RouteTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ function validateRoutes(
|
||||||
const rawTarget = r.target as Record<string, unknown> | undefined;
|
const rawTarget = r.target as Record<string, unknown> | undefined;
|
||||||
const rawTargets = r.targets as unknown;
|
const rawTargets = r.targets as unknown;
|
||||||
if (rawTargets === undefined && rawTarget && typeof rawTarget === "object") {
|
if (rawTargets === undefined && rawTarget && typeof rawTarget === "object") {
|
||||||
const legacy = validateTarget(r, rawTarget);
|
const legacy = validateTarget(`route "${r.id}"`, rawTarget);
|
||||||
if (!legacy.ok) return legacy;
|
if (!legacy.ok) return legacy;
|
||||||
(r as Record<string, unknown>).targets = [legacy.target];
|
(r as Record<string, unknown>).targets = [legacy.target];
|
||||||
delete (r as Record<string, unknown>).target;
|
delete (r as Record<string, unknown>).target;
|
||||||
|
|
@ -137,7 +137,7 @@ function validateRoutes(
|
||||||
if (!t || typeof t !== "object") {
|
if (!t || typeof t !== "object") {
|
||||||
return { ok: false, error: `route "${r.id}".targets[${j}] is not an object` };
|
return { ok: false, error: `route "${r.id}".targets[${j}] is not an object` };
|
||||||
}
|
}
|
||||||
const result = validateTarget(r, t);
|
const result = validateTarget(`route "${r.id}".targets[${j}]`, t);
|
||||||
if (!result.ok) return result;
|
if (!result.ok) return result;
|
||||||
normalized.push(result.target);
|
normalized.push(result.target);
|
||||||
}
|
}
|
||||||
|
|
@ -150,24 +150,24 @@ function validateRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateTarget(
|
function validateTarget(
|
||||||
r: Record<string, unknown>,
|
label: string,
|
||||||
target: Record<string, unknown>,
|
target: Record<string, unknown>,
|
||||||
): { ok: true; target: Route["targets"][number] } | { ok: false; error: string } {
|
): { ok: true; target: Route["targets"][number] } | { ok: false; error: string } {
|
||||||
const platform = target.platform === undefined ? "discord" : target.platform;
|
const platform = target.platform === undefined ? "discord" : target.platform;
|
||||||
if (platform !== "discord" && platform !== "telegram") {
|
if (platform !== "discord" && platform !== "telegram") {
|
||||||
return { ok: false, error: `route "${r.id}".target.platform must be "discord" or "telegram"` };
|
return { ok: false, error: `${label}.platform must be "discord" or "telegram"` };
|
||||||
}
|
}
|
||||||
if (platform === "telegram") {
|
if (platform === "telegram") {
|
||||||
if (typeof target.chatId !== "string" || target.chatId.trim().length === 0)
|
if (typeof target.chatId !== "string" || target.chatId.trim().length === 0)
|
||||||
return { ok: false, error: `route "${r.id}".target.chatId is required` };
|
return { ok: false, error: `${label}.chatId is required` };
|
||||||
if (target.topicId !== undefined && typeof target.topicId !== "string") {
|
if (target.topicId !== undefined && typeof target.topicId !== "string") {
|
||||||
return { ok: false, error: `route "${r.id}".target.topicId must be a string` };
|
return { ok: false, error: `${label}.topicId must be a string` };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (typeof target.channelId !== "string" || target.channelId.trim().length === 0)
|
if (typeof target.channelId !== "string" || target.channelId.trim().length === 0)
|
||||||
return { ok: false, error: `route "${r.id}".target.channelId is required` };
|
return { ok: false, error: `${label}.channelId is required` };
|
||||||
if (target.threadId !== undefined && typeof target.threadId !== "string") {
|
if (target.threadId !== undefined && typeof target.threadId !== "string") {
|
||||||
return { ok: false, error: `route "${r.id}".target.threadId must be a string` };
|
return { ok: false, error: `${label}.threadId must be a string` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -234,7 +234,7 @@ function validateMembers(
|
||||||
return { ok: true, members };
|
return { ok: true, members };
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateGroups(
|
export function validateGroups(
|
||||||
groups: unknown,
|
groups: unknown,
|
||||||
): { ok: true; groups: Group[] } | { ok: false; error: string } {
|
): { ok: true; groups: Group[] } | { ok: false; error: string } {
|
||||||
if (!Array.isArray(groups)) return { ok: false, error: "groups must be an array" };
|
if (!Array.isArray(groups)) return { ok: false, error: "groups must be an array" };
|
||||||
|
|
@ -283,6 +283,19 @@ function validateGroups(
|
||||||
if (g.lang !== undefined && typeof g.lang !== "string") {
|
if (g.lang !== undefined && typeof g.lang !== "string") {
|
||||||
return { ok: false, error: `group "${g.id}".lang must be a string` };
|
return { ok: false, error: `group "${g.id}".lang must be a string` };
|
||||||
}
|
}
|
||||||
|
if (g.logTarget !== undefined && g.logTarget !== null) {
|
||||||
|
if (typeof g.logTarget !== "object" || Array.isArray(g.logTarget)) {
|
||||||
|
return { ok: false, error: `group "${g.id}".logTarget must be an object` };
|
||||||
|
}
|
||||||
|
const tgt = validateTarget(
|
||||||
|
`group "${g.id}".logTarget`,
|
||||||
|
g.logTarget as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
if (!tgt.ok) return tgt;
|
||||||
|
g.logTarget = tgt.target;
|
||||||
|
} else {
|
||||||
|
delete g.logTarget;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { ok: true, groups: groups as Group[] };
|
return { ok: true, groups: groups as Group[] };
|
||||||
}
|
}
|
||||||
|
|
@ -447,6 +460,8 @@ export function createAdminRoutes(): Hono<AuthEnv> {
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
if (prev.name !== g.name) fields.push("name");
|
if (prev.name !== g.name) fields.push("name");
|
||||||
if (prev.emoji !== g.emoji) fields.push("emoji");
|
if (prev.emoji !== g.emoji) fields.push("emoji");
|
||||||
|
if (prev.lang !== g.lang) fields.push("lang");
|
||||||
|
if (!deepEqual(prev.logTarget, g.logTarget)) fields.push("logTarget");
|
||||||
if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers");
|
if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers");
|
||||||
if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners");
|
if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners");
|
||||||
if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members))
|
if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue