chore: auto-fix lint & formatting [skip ci]

This commit is contained in:
github-actions[bot] 2026-08-17 07:51:36 +00:00
parent 9eeafb8c15
commit 7c37f5d8b6
19 changed files with 101 additions and 116 deletions

View file

@ -19,7 +19,7 @@ GitHub / Gitea Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
### Components ### Components
| Component | Role | | Component | Role |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Cloudflare Worker** | HTTP ingress, signature verification, delivery dedup, event parsing, route matching, platform dispatch | | **Cloudflare Worker** | HTTP ingress, signature verification, delivery dedup, event parsing, route matching, platform dispatch |
| **Interactions Endpoint** | Verifies Ed25519 signatures and handles `/gh` interactions (slash commands, context-menu commands, buttons, modals) | | **Interactions Endpoint** | Verifies Ed25519 signatures and handles `/gh` interactions (slash commands, context-menu commands, buttons, modals) |
| **KV** | Token storage (`token:{userId}`), OAuth state (`state:{hex}`), admin sessions, per-group secrets, config cache, delivery dedup/state/message-tracking fallback when D1 is unavailable (`delivery:*`, `delivery-state:*`, `msg:*`), message-update locks (`msg:lock:*`) | | **KV** | Token storage (`token:{userId}`), OAuth state (`state:{hex}`), admin sessions, per-group secrets, config cache, delivery dedup/state/message-tracking fallback when D1 is unavailable (`delivery:*`, `delivery-state:*`, `msg:*`), message-update locks (`msg:lock:*`) |

View file

@ -5,7 +5,7 @@
KV keeps only cache data and short-lived/ephemeral state. High-frequency writes (webhook dedup, delivery state, message tracking) live in D1 and only fall back to KV when D1 is unavailable or not yet migrated (see [Storage decisions](./storage#storage-decisions)). KV keeps only cache data and short-lived/ephemeral state. High-frequency writes (webhook dedup, delivery state, message tracking) live in D1 and only fall back to KV when D1 is unavailable or not yet migrated (see [Storage decisions](./storage#storage-decisions)).
| Key Pattern | Value | TTL | | Key Pattern | Value | TTL |
| ------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------ | | ------------------------------------------ | ----------------------------------------------------------------------------- | ------------------ |
| `config:routes` | Route config cache (D1 `d1_routes` is authoritative) | 1 hour | | `config:routes` | Route config cache (D1 `d1_routes` is authoritative) | 1 hour |
| `config:groups` | Group config cache (D1 `d1_groups` is authoritative) | 1 hour | | `config:groups` | Group config cache (D1 `d1_groups` is authoritative) | 1 hour |
| `session:{id}` | Admin session `{ userId, login }` | 7 days | | `session:{id}` | Admin session `{ userId, login }` | 7 days |
@ -30,7 +30,7 @@ KV keeps only cache data and short-lived/ephemeral state. High-frequency writes
The D1 database (`DB` binding, database `webhooker`) holds the source of truth for configuration, delivery logs and high-frequency ephemeral state: The D1 database (`DB` binding, database `webhooker`) holds the source of truth for configuration, delivery logs and high-frequency ephemeral state:
| Table | Purpose | | Table | Purpose |
| ------------------ | ---------------------------------------------------------------------------------------------------- | | ------------------ | ---------------------------------------------------------------------------------------------- |
| `d1_groups` | Groups (authoritative config, seeded from legacy KV `config:groups`) | | `d1_groups` | Groups (authoritative config, seeded from legacy KV `config:groups`) |
| `d1_routes` | Routes per group (authoritative config, seeded from legacy KV `config:routes`) | | `d1_routes` | Routes per group (authoritative config, seeded from legacy KV `config:routes`) |
| `send_logs` | One row per dispatch attempt (route id, event, target, ok/error, duration, error code, detail) | | `send_logs` | One row per dispatch attempt (route id, event, target, ok/error, duration, error code, detail) |
@ -48,7 +48,7 @@ The D1 database (`DB` binding, database `webhooker`) holds the source of truth f
R2 (`PAYLOAD` binding, bucket `webhooker-payloads`) stores oversized webhook payloads that are too large for a queue message or KV: R2 (`PAYLOAD` binding, bucket `webhooker-payloads`) stores oversized webhook payloads that are too large for a queue message or KV:
| Object Pattern | Purpose | Retention | | Object Pattern | Purpose | Retention |
| ----------------------------- | ---------------------------------------------- | --------- | | --------------------------------- | ----------------------------------------------- | ---------------------- |
| `webhooks/YYYY/MM/DD/<uuid>.json` | Oversized payload parked for the queue consumer | deleted after dispatch | | `webhooks/YYYY/MM/DD/<uuid>.json` | Oversized payload parked for the queue consumer | deleted after dispatch |
When the `PAYLOAD` binding is absent, oversized payloads fall back to the KV key `queue:payload:{provider}:{groupId}:{id}` (1 hour TTL). When the `PAYLOAD` binding is absent, oversized payloads fall back to the KV key `queue:payload:{provider}:{groupId}:{id}` (1 hour TTL).

View file

@ -3,7 +3,7 @@
WebHooker runs four maintenance tasks on the scheduled trigger (`*/5 * * * *`, every 5 minutes). They only run on the deployed worker (Cloudflare cron); local `wrangler dev` runs them when triggered via `wrangler dev --test-scheduled`. WebHooker runs four maintenance tasks on the scheduled trigger (`*/5 * * * *`, every 5 minutes). They only run on the deployed worker (Cloudflare cron); local `wrangler dev` runs them when triggered via `wrangler dev --test-scheduled`.
| Task | Purpose | | Task | Purpose |
| --------------- | ---------------------------------------------------------------------------------------------------------------- | | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `discord-sync` | Registers the Discord slash/context-menu commands: per-guild (instant) and globally (24h dedup, ~1h propagation) | | `discord-sync` | Registers the Discord slash/context-menu commands: per-guild (instant) and globally (24h dedup, ~1h propagation) |
| `telegram-sync` | Calls `setWebhook` to `{BASE_URL}/telegram/webhook` (with `TELEGRAM_WEBHOOK_SECRET` as `secret_token` when set) | | `telegram-sync` | Calls `setWebhook` to `{BASE_URL}/telegram/webhook` (with `TELEGRAM_WEBHOOK_SECRET` as `secret_token` when set) |
| `audit-prune` | Deletes `audit_logs` entries older than `AUDIT_RETENTION_DAYS` (default 90) | | `audit-prune` | Deletes `audit_logs` entries older than `AUDIT_RETENTION_DAYS` (default 90) |

View file

@ -19,7 +19,7 @@ GitHub / Gitea Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
### 组件 ### 组件
| 组件 | 职责 | | 组件 | 职责 |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Cloudflare Worker** | HTTP 入口、签名验证、投递去重、事件解析、路由匹配、平台分发 | | **Cloudflare Worker** | HTTP 入口、签名验证、投递去重、事件解析、路由匹配、平台分发 |
| **Interactions Endpoint** | 验证 Ed25519 签名并处理 `/gh` 交互斜杠命令、右键菜单、按钮、modal | | **Interactions Endpoint** | 验证 Ed25519 签名并处理 `/gh` 交互斜杠命令、右键菜单、按钮、modal |
| **KV** | Token 存储 (`token:{userId}`)、OAuth 状态 (`state:{hex}`)、管理员会话、分组级 secret、配置缓存、投递去重/投递状态/消息更新追踪的回退(`delivery:*``delivery-state:*``msg:*`,仅在 D1 不可用时使用)、消息更新锁 (`msg:lock:*`) | | **KV** | Token 存储 (`token:{userId}`)、OAuth 状态 (`state:{hex}`)、管理员会话、分组级 secret、配置缓存、投递去重/投递状态/消息更新追踪的回退(`delivery:*``delivery-state:*``msg:*`,仅在 D1 不可用时使用)、消息更新锁 (`msg:lock:*`) |

View file

@ -30,7 +30,7 @@ KV 只保留缓存数据和短期/临时状态。高频写入webhook 去重
D1 数据库(`DB` 绑定,数据库 `webhooker`)保存配置、投递日志和高频临时状态的权威数据源: D1 数据库(`DB` 绑定,数据库 `webhooker`)保存配置、投递日志和高频临时状态的权威数据源:
| 表 | 用途 | | 表 | 用途 |
| ------------------ | ---------------------------------------------------------------------- | | ------------------ | ----------------------------------------------------------------------------- |
| `d1_groups` | 分组(权威配置,从旧版 KV `config:groups` 播种) | | `d1_groups` | 分组(权威配置,从旧版 KV `config:groups` 播种) |
| `d1_routes` | 每组分组的路由(权威配置,从旧版 KV `config:routes` 播种) | | `d1_routes` | 每组分组的路由(权威配置,从旧版 KV `config:routes` 播种) |
| `send_logs` | 每次分发尝试一行(路由 id、事件、目标、ok/error、耗时、错误码、详情 | | `send_logs` | 每次分发尝试一行(路由 id、事件、目标、ok/error、耗时、错误码、详情 |
@ -48,7 +48,7 @@ D1 数据库(`DB` 绑定,数据库 `webhooker`)保存配置、投递日志
R2`PAYLOAD` 绑定bucket `webhooker-payloads`)存储对队列消息或 KV 来说过大的 webhook 负载: R2`PAYLOAD` 绑定bucket `webhooker-payloads`)存储对队列消息或 KV 来说过大的 webhook 负载:
| 对象模式 | 用途 | 保留期 | | 对象模式 | 用途 | 保留期 |
| ------------------------------- | ----------------------------------------------- | ------------ | | --------------------------------- | ------------------------------ | ---------- |
| `webhooks/YYYY/MM/DD/<uuid>.json` | 暂存供队列消费者读取的超大负载 | 分发后删除 | | `webhooks/YYYY/MM/DD/<uuid>.json` | 暂存供队列消费者读取的超大负载 | 分发后删除 |
当缺少 `PAYLOAD` 绑定时,超大负载回退到 KV 键 `queue:payload:{provider}:{groupId}:{id}`1 小时 TTL 当缺少 `PAYLOAD` 绑定时,超大负载回退到 KV 键 `queue:payload:{provider}:{groupId}:{id}`1 小时 TTL

View file

@ -1,10 +1,7 @@
import type { SendRecord } from "./send-log"; import type { SendRecord } from "./send-log";
import { log } from "./log"; import { log } from "./log";
export async function recordSendBatch( export async function recordSendBatch(db: D1Database, records: SendRecord[]): Promise<void> {
db: D1Database,
records: SendRecord[],
): Promise<void> {
if (records.length === 0) return; if (records.length === 0) return;
try { try {
const stmts = records.map((r) => const stmts = records.map((r) =>

View file

@ -3,7 +3,7 @@ import { r2PayloadStore } from "../storage/payload";
import { canUseD1 } from "../storage/d1"; import { canUseD1 } from "../storage/d1";
export type DeliveryStatus = export type DeliveryStatus =
| "pending" | "processing" | "delivered" | "retrying" | "failed" | "dead"; "pending" | "processing" | "delivered" | "retrying" | "failed" | "dead";
export interface DeliveryMessage { export interface DeliveryMessage {
deliveryId: string; deliveryId: string;

View file

@ -7,8 +7,5 @@
* while production eagerly routes through D1. * while production eagerly routes through D1.
*/ */
export function canUseD1(db: D1Database | undefined | null): boolean { export function canUseD1(db: D1Database | undefined | null): boolean {
return ( return typeof db?.prepare === "function" && typeof (db as D1Database).batch === "function";
typeof db?.prepare === "function" &&
typeof (db as D1Database).batch === "function"
);
} }

View file

@ -41,9 +41,7 @@ export function r2PayloadStore(env: Env): PayloadStore {
return { return {
async put(payload: string, ttl?: number): Promise<string> { async put(payload: string, ttl?: number): Promise<string> {
const key = generateKey(); const key = generateKey();
const expires = new Date( const expires = new Date(Date.now() + (ttl ?? DEFAULT_TTL_SECONDS) * 1000);
Date.now() + (ttl ?? DEFAULT_TTL_SECONDS) * 1000,
);
await bucket.put(key, payload, { await bucket.put(key, payload, {
httpMetadata: { contentType: "application/json" }, httpMetadata: { contentType: "application/json" },
customMetadata: { expires: expires.toISOString() }, customMetadata: { expires: expires.toISOString() },

View file

@ -24,9 +24,7 @@ export default defineTask({
.bind(now - DELIVERY_STATE_RETENTION_MS) .bind(now - DELIVERY_STATE_RETENTION_MS)
.run(); .run();
removed += delivery.meta?.changes ?? 0; removed += delivery.meta?.changes ?? 0;
const messages = await env.DB.prepare( const messages = await env.DB.prepare("DELETE FROM message_tracking WHERE updated_at < ?")
"DELETE FROM message_tracking WHERE updated_at < ?",
)
.bind(now - MESSAGE_TRACKING_RETENTION_MS) .bind(now - MESSAGE_TRACKING_RETENTION_MS)
.run(); .run();
removed += messages.meta?.changes ?? 0; removed += messages.meta?.changes ?? 0;

View file

@ -9,10 +9,7 @@ import {
kvMessageTracker, kvMessageTracker,
messageTracker, messageTracker,
} from "../server/lib/lib/message-tracker"; } from "../server/lib/lib/message-tracker";
import { import { getDeliveryState, setDeliveryState } from "../server/lib/queue/delivery";
getDeliveryState,
setDeliveryState,
} from "../server/lib/queue/delivery";
import { canUseD1 } from "../server/lib/storage/d1"; import { canUseD1 } from "../server/lib/storage/d1";
import type { Env } from "../server/lib/types"; import type { Env } from "../server/lib/types";
@ -45,10 +42,7 @@ function createMockD1(): {
const messageRows = new Map<string, { messageId: string; updatedAt: number }>(); const messageRows = new Map<string, { messageId: string; updatedAt: number }>();
const deliveryRows = new Map<string, { status: string; updatedAt: number }>(); const deliveryRows = new Map<string, { status: string; updatedAt: number }>();
const run = ( const run = (sql: string, args: unknown[]): { success: boolean; meta: { changes: number } } => {
sql: string,
args: unknown[],
): { success: boolean; meta: { changes: number } } => {
if (sql.includes("INSERT INTO dedup_keys")) { if (sql.includes("INSERT INTO dedup_keys")) {
const [key, claimedAt, expiresAt] = args as [string, number, number]; const [key, claimedAt, expiresAt] = args as [string, number, number];
const existing = dedupKeys.get(key); const existing = dedupKeys.get(key);
@ -63,12 +57,7 @@ function createMockD1(): {
return { success: true, meta: { changes: 0 } }; return { success: true, meta: { changes: 0 } };
} }
if (sql.includes("INSERT INTO message_tracking")) { if (sql.includes("INSERT INTO message_tracking")) {
const [eventId, targetId, messageId, updatedAt] = args as [ const [eventId, targetId, messageId, updatedAt] = args as [string, string, string, number];
string,
string,
string,
number,
];
messageRows.set(`${eventId}\u0000${targetId}`, { messageId, updatedAt }); messageRows.set(`${eventId}\u0000${targetId}`, { messageId, updatedAt });
return { success: true, meta: { changes: 1 } }; return { success: true, meta: { changes: 1 } };
} }
@ -106,14 +95,18 @@ function createMockD1(): {
}; };
const db = { const db = {
prepare: (sql: string): { prepare: (
sql: string,
): {
bind: (...args: unknown[]) => { bind: (...args: unknown[]) => {
run: () => Promise<{ success: boolean; meta: { changes: number } }>; run: () => Promise<{ success: boolean; meta: { changes: number } }>;
all: () => Promise<{ results: unknown[] }>; all: () => Promise<{ results: unknown[] }>;
first: () => Promise<Row | null>; first: () => Promise<Row | null>;
}; };
} => ({ } => ({
bind: (...args: unknown[]): { bind: (
...args: unknown[]
): {
run: () => Promise<{ success: boolean; meta: { changes: number } }>; run: () => Promise<{ success: boolean; meta: { changes: number } }>;
all: () => Promise<{ results: unknown[] }>; all: () => Promise<{ results: unknown[] }>;
first: () => Promise<Row | null>; first: () => Promise<Row | null>;
@ -196,9 +189,7 @@ describe("delivery state backed by D1", () => {
it("returns null when no state exists", async () => { it("returns null when no state exists", async () => {
const { db } = createMockD1(); const { db } = createMockD1();
const env = envWith(db, createMockKV()); const env = envWith(db, createMockKV());
await expect( await expect(getDeliveryState(env, "delivery-state:github:g9:nope")).resolves.toBeNull();
getDeliveryState(env, "delivery-state:github:g9:nope"),
).resolves.toBeNull();
}); });
}); });
@ -211,8 +202,16 @@ describe("factory fallback decision", () => {
it("idempotencyStore falls back to KV semantics without batch", async () => { it("idempotencyStore falls back to KV semantics without batch", async () => {
const db = { const db = {
prepare: (): { bind: () => { run: () => Promise<{ success: boolean }>; all: () => Promise<{ results: unknown[] }> } } => ({ prepare: (): {
bind: (): { run: () => Promise<{ success: boolean }>; all: () => Promise<{ results: unknown[] }> } => ({ bind: () => {
run: () => Promise<{ success: boolean }>;
all: () => Promise<{ results: unknown[] }>;
};
} => ({
bind: (): {
run: () => Promise<{ success: boolean }>;
all: () => Promise<{ results: unknown[] }>;
} => ({
run: async () => ({ success: true }), run: async () => ({ success: true }),
all: async () => ({ results: [] }), all: async () => ({ results: [] }),
}), }),

View file

@ -86,9 +86,7 @@ describe("recordSendBatch", () => {
it("serializes detail and encodes ok/error", async () => { it("serializes detail and encodes ok/error", async () => {
const { db, rows } = createMockDB(); const { db, rows } = createMockDB();
await recordSendBatch(db, [ await recordSendBatch(db, [record({ detail: { a: 1 }, ok: false, error: "x", status: 500 })]);
record({ detail: { a: 1 }, ok: false, error: "x", status: 500 }),
]);
expect(rows[0].detail).toBe('{"a":1}'); expect(rows[0].detail).toBe('{"a":1}');
expect(rows[0].ok).toBe(0); expect(rows[0].ok).toBe(0);
expect(rows[0].status).toBe(500); expect(rows[0].status).toBe(500);
@ -105,8 +103,6 @@ describe("recordSendBatch", () => {
throw new Error("batch failed"); throw new Error("batch failed");
}, },
} as unknown as D1Database; } as unknown as D1Database;
await expect( await expect(recordSendBatch(db, [record()])).resolves.toBeUndefined();
recordSendBatch(db, [record()]),
).resolves.toBeUndefined();
}); });
}); });