mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +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
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue