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:
RhenCloud 2026-08-13 08:36:46 +08:00
parent b35c2c2f90
commit 0b078d938b
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
17 changed files with 526 additions and 35 deletions

View file

@ -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
- 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)
- 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
## Architecture
@ -30,7 +31,7 @@ src/
├── 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 + /
├── 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
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword regex filtering
├── 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
- 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)
- 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 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)

View file

@ -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
- 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
- **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)
- **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

View file

@ -11,6 +11,7 @@ GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare W
- 富消息:颜色编码、作者头像、字段、时间戳——渲染为 Discord embed 与 Telegram HTML
- 路由到 Discord 频道/子区与 Telegram 群组/话题(一条路由可多目标)
- `workflow_run` / `check_run` 进度**原地编辑**同一条消息(运行推进时更新),两个平台均支持
- **分组级 Webhook 日志频道** —— 为分组指定一个 Discord 频道/子区或 Telegram 群组/话题,该分组路由每次分发 webhook 都会向其中发送摘要(每条「路由 × 目标」一行,✅/❌ 结果)
- GitHub OAuth 用户授权(评论、编辑评论、删除评论、合并、关闭、反应)
- **Web 配置控制台**`/admin`)— 通过 GitHub OAuth + 管理员白名单管理路由与分组、查看发送日志
- **Discord Interactions Endpoint**Ed25519 验签)支持 `/gh` 斜杠命令、消息右键菜单命令、PR 合并/关闭按钮与评论 modal

View file

@ -689,6 +689,29 @@ main {
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 {
font-size: 11px;
color: var(--faint);

View file

@ -106,6 +106,42 @@
</div>
<div class="hint">{{ t("groupEditor.providersHint") }}</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>
</form>
<div class="editor-foot">
@ -149,6 +185,11 @@ const form = reactive({
providers: [] as ("github" | "gitea")[],
emoji: true,
lang: "",
logPlatform: "" as "" | "discord" | "telegram",
logChannelId: "",
logThreadId: "",
logChatId: "",
logTopicId: "",
});
function splitList(text: string): string[] {
@ -170,6 +211,7 @@ watch(
(open) => {
if (!open) return;
const g = props.group;
const lt = g?.logTarget;
form.id = g?.id ?? "";
form.name = g?.name ?? "";
form.owners = (g?.owners ?? []).join(", ");
@ -178,6 +220,11 @@ watch(
);
form.emoji = g?.emoji ?? true;
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 = "";
},
);
@ -198,6 +245,29 @@ function save(): void {
formError.value = t("groupEditor.errName");
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 members = props.group?.members
? props.group.members.map((m) => ({ ...m }))
@ -213,6 +283,7 @@ function save(): void {
providers: form.providers.length ? form.providers : undefined,
emoji: form.emoji,
lang: form.lang.trim() || undefined,
logTarget,
});
}
</script>

View file

@ -168,6 +168,13 @@ const en: Dict = {
"groupEditor.providersHint":
"Only webhook events from the checked forges (GitHub / Gitea) enter this group's routes.",
"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.save": "Save group",
"groupEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
@ -384,6 +391,13 @@ const zh: Dict = {
"groupEditor.providersHint":
"只有来自所勾选 forgeGitHub / Gitea的 webhook 事件才会进入本分组的路由。",
"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.save": "保存分组",
"groupEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",

View file

@ -40,6 +40,7 @@ export interface Group {
providers?: ("github" | "gitea" | "gitlab")[];
emoji?: boolean;
lang?: string;
logTarget?: RouteTarget;
}
export interface GroupInvite {

View file

@ -14,6 +14,16 @@ groups:
# providers: ["github", "gitea"] # restrict to source platforms (optional; empty = all)
# emoji: true # include emoji in messages (default true)
# 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:
- id: all-push

View file

@ -181,12 +181,13 @@ Routes belong to groups. Groups scope admin access and can restrict which events
{ "login": "reader", "role": "viewer" }
],
"owners": ["myorg"],
"providers": ["github", "gitea"]
"providers": ["github", "gitea"],
"logTarget": { "platform": "discord", "channelId": "123456789", "threadId": "987654321" }
}
```
| Field | Type | Required | Description |
| ----------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| ----------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId` |
| `name` | string | Yes | Human-readable group name |
| `members` | object[] | No | `{ login, role }` entries; role is `owner`, `admin`, or `viewer` |
@ -195,6 +196,7 @@ Routes belong to groups. Groups scope admin access and can restrict which events
| `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`) |
| `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
@ -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 `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
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}`.

View file

@ -181,12 +181,13 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
{ "login": "reader", "role": "viewer" }
],
"owners": ["myorg"],
"providers": ["github", "gitea"]
"providers": ["github", "gitea"],
"logTarget": { "platform": "discord", "channelId": "123456789", "threadId": "987654321" }
}
```
| 字段 | 类型 | 必需 | 说明 |
| ----------- | -------- | ---- | ------------------------------------------------------------------------------------ |
| ----------- | -------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | 是 | 小写 id`a-z0-9``-`),由每条路由的 `groupId` 引用 |
| `name` | string | 是 | 可读的分组名称 |
| `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner``admin``viewer` |
@ -195,6 +196,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github``gitea`);为空表示全部 |
| `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji默认 `true` |
| `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` 列表限定哪些事件参与者(发送者登录名)的事件会被该分组的路由投递。
- `providers` 列表限定哪个 forge`github``gitea`)的事件会被该分组的路由投递。即使组织/用户同名,也可以借此将 GitHub 与 Gitea 分组区分开。
### Webhook 日志频道
分组可以设置 `logTarget` 指向一个 Discord 频道/子区或 Telegram 群组/话题。每当该分组的路由分发dispatch一个 webhook就会向那里发送一条摘要消息事件类型/动作、仓库、投递 ID以及每条「路由 × 目标」一行的 ✅/❌ 结果(失败时附带错误信息)。全部成功时消息为绿色,任一失败则为红色。摘要使用分组的消息语言。日志消息尽力发送,本身不会被记入 D1 发送日志。
### 邀请
owner及超级管理员可在分组的「成员」面板创建一次性邀请链接7 天内有效。接受邀请后用户以邀请角色(`admin``viewer`,绝不授予 `owner`)加入;已有的 `viewer` 会被升级为 `admin`。邀请存储在 KV `invite:{token}`

View file

@ -8,6 +8,7 @@ import {
clearAdminCookie,
} from "../web/session";
import { groupAcceptsProvider } from "../web/groups";
import { validateGroups } from "../web/admin-routes";
import { loadRoutes, saveRoutes, loadConfig } from "../config";
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", () => {
it("saves and loads routes from KV", async () => {
const kv = createMockKV();

View file

@ -260,6 +260,153 @@ describe("dispatchEvent fallback routing", () => {
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 () => {
const bodies: string[] = [];
mockFetch((url, init) => {

View file

@ -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 { matchRoute, eventOwners } from "../events/match";
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 { loadGroups, groupAcceptsOwners, groupAcceptsProvider } from "../web/groups";
import { getDriver } from "../drivers";
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> {
const groups = await loadGroups(env.KV);
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 attempts: DispatchAttempt[] = [];
const tasks: Promise<void>[] = [];
for (const route of config.routes) {
if (!accepted(route)) continue;
@ -51,6 +62,73 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
}
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> {
const targets = route.targets && route.targets.length > 0 ? route.targets : [];
if (targets.length === 0) return;
@ -106,6 +184,13 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
if (existingId) {
result = await driver.edit(message, target, env, existingId);
if (result.ok) {
attempts.push({
groupId: route.groupId,
routeId: route.id,
routeName: route.name,
target: targetStr,
ok: true,
});
await recordSend(env.DB, {
...base,
ok: true,
@ -119,6 +204,13 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
continue;
}
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, {
...base,
ok: true,
@ -142,6 +234,13 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
}
const durationMs = Date.now() - started;
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, {
...base,
ok: true,
@ -154,10 +253,19 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
});
} catch (err) {
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, {
...base,
ok: false,
error: err instanceof Error ? err.message : String(err),
error,
durationMs,
});
log.error({ routeId: route.id, target: targetStr, err }, "Route failed");

View file

@ -195,4 +195,11 @@ export const en = {
title: "{repo}: {event}{action}",
},
},
log: {
title: "{repo}: {event}{action}",
routes: "Routes",
delivery: "Delivery",
route_ok: "✅ {route} → {target}",
route_fail: "❌ {route} → {target}: {error}",
},
};

View file

@ -195,4 +195,11 @@ export const zh = {
title: "{repo}: {event}{action}",
},
},
log: {
title: "{repo}: {event}{action}",
routes: "路由",
delivery: "投递",
route_ok: "✅ {route} → {target}",
route_fail: "❌ {route} → {target}: {error}",
},
};

View file

@ -124,6 +124,11 @@ export interface Group {
* via KV i18n:<lang>). Defaults to "en" when omitted.
*/
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 {

View file

@ -123,7 +123,7 @@ function validateRoutes(
const rawTarget = r.target as Record<string, unknown> | undefined;
const rawTargets = r.targets as unknown;
if (rawTargets === undefined && rawTarget && typeof rawTarget === "object") {
const legacy = validateTarget(r, rawTarget);
const legacy = validateTarget(`route "${r.id}"`, rawTarget);
if (!legacy.ok) return legacy;
(r as Record<string, unknown>).targets = [legacy.target];
delete (r as Record<string, unknown>).target;
@ -137,7 +137,7 @@ function validateRoutes(
if (!t || typeof t !== "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;
normalized.push(result.target);
}
@ -150,24 +150,24 @@ function validateRoutes(
}
function validateTarget(
r: Record<string, unknown>,
label: string,
target: Record<string, unknown>,
): { ok: true; target: Route["targets"][number] } | { ok: false; error: string } {
const platform = target.platform === undefined ? "discord" : target.platform;
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 (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") {
return { ok: false, error: `route "${r.id}".target.topicId must be a string` };
return { ok: false, error: `${label}.topicId must be a string` };
}
} else {
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") {
return { ok: false, error: `route "${r.id}".target.threadId must be a string` };
return { ok: false, error: `${label}.threadId must be a string` };
}
}
return {
@ -234,7 +234,7 @@ function validateMembers(
return { ok: true, members };
}
function validateGroups(
export function validateGroups(
groups: unknown,
): { ok: true; groups: Group[] } | { ok: false; error: string } {
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") {
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[] };
}
@ -447,6 +460,8 @@ export function createAdminRoutes(): Hono<AuthEnv> {
const fields: string[] = [];
if (prev.name !== g.name) fields.push("name");
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.owners ?? [], g.owners ?? [])) fields.push("owners");
if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members))