refactor: modularize into core/events/formatters/drivers

This commit is contained in:
RhenCloud 2026-08-03 03:36:36 +08:00
parent 7b341ff9f4
commit 0af6b9a4b8
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
62 changed files with 2430 additions and 2015 deletions

View file

@ -22,22 +22,49 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord
```text
src/
├── index.ts # CF Workers entry (fetch + scheduled), scheduled = command sync
├── types.ts # Env, Config, Route, Filter, WebhookEvent, FormattedMessage
├── types.ts # Env, Config, Route, Filter, WebhookEvent, NeutralMessage
├── config.ts # loadRoutes/saveRoutes (KV config:routes, cache w/ 60s TTL), loadConfig from env
├── server.ts # Hono app: /health, /webhook, /discord/interactions, mounts /auth, /admin + /
├── webhook.ts # HMAC verify (Web Crypto), parseEvent, extractBranch, matchRoute
├── discord.ts # Dispatch to Discord via REST (sendMessage)
├── discord-rest.ts # Discord REST sendMessage with retry + rate-limit handling
├── discord-interactions.ts # Ed25519 verify + interaction handlers (/gh, buttons, modals) + command registration
├── formatter.ts # 24 event formatters + generic fallback (~1570 lines)
├── github-oauth.ts # OAuth URL, callback token exchange, getUserOctokit
├── oauth-routes.ts # GET /auth/github, callback (sets admin session if redirect=/admin), DELETE /token/:userId
├── action-routes.ts # POST /api/comment|merge|react (Bearer token auth via KV lookup)
├── admin-routes.ts # /admin UI + GET/PUT /admin/api/routes (session + ADMIN_USER_IDS auth, validation)
├── admin-session.ts # Session CRUD (KV session:{id}), isAdminUser, cookie helpers
├── admin-ui.ts # ADMIN_HTML: single-file config console (vanilla HTML/CSS/JS)
├── token-store.ts # KV-based token CRUD with findUserIdByToken reverse lookup
└── log.ts # JSON console logger (info/warn/error/fatal)
├── core/
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send (recordSend + group filter)
├── events/ # GitHub webhook pipeline (was webhook.ts)
│ ├── verify.ts # HMAC signature verify (Web Crypto, timing-safe)
│ ├── parse.ts # parseEvent (headers + body → WebhookEvent)
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword regex filtering
├── formatters/ # Platform-neutral message formatters (was formatter.ts)
│ ├── index.ts # formatEvent: 24-event switch → NeutralMessage + re-exports
│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI
│ ├── helpers.ts # emojiPrefix, T, buildMessage
│ └── *.ts # push, pull-request, issues, comments, workflow, release, create,
│ # repo, check, review, commit-comment, deployment, member, label,
│ # milestone, discussion, repository, security, generic
├── drivers/ # Platform drivers (pluggable push targets)
│ ├── types.ts # PlatformDriver interface + SendResult
│ ├── index.ts # getDriver() registry (discord default + telegram stub)
│ ├── discord/
│ │ ├── index.ts # DiscordDriver: send → renderNeutralMessage + rest.sendMessage
│ │ ├── render.ts # renderNeutralMessage: NeutralMessage → Discord FormattedMessage
│ │ ├── rest.ts # Discord REST sendMessage with retry + rate-limit handling
│ │ ├── interactions.ts # Ed25519 verify + interaction handlers (/gh, buttons, modals)
│ │ └── commands.ts # APP_COMMANDS + registerGlobalCommands/syncGuildCommands/syncCommands
│ └── telegram/
│ └── index.ts # TelegramDriver stub (not implemented yet)
├── github/
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/merge/close actions
│ └── store.ts # KV-based token CRUD + discord-link mapping (was token-store.ts)
├── web/ # HTTP UI/API routes
│ ├── oauth-routes.ts # GET /auth/github, callback (admin session / discord-link), DELETE /token/:userId
│ ├── action-routes.ts # POST /api/comment|merge|react (Bearer token auth via KV lookup)
│ ├── admin-routes.ts # /admin UI + GET/PUT /admin/api/routes (session + ADMIN_USER_IDS auth, validation)
│ ├── session.ts # Session CRUD (KV session:{id}), isAdminUser, cookie helpers
│ ├── groups.ts # Group CRUD (config:groups), resolveScope, hasAnyAccess
│ ├── home-routes.ts # home page
│ └── legal-routes.ts # legal / privacy / terms pages
└── lib/ # shared infra
├── i18n.ts # loadTranslations, t() with param interpolation
├── send-log.ts # SendRecord, recordSend, getSendLog
├── log.ts # JSON console logger (info/warn/error/fatal)
└── locales/ # en.ts, zh.ts translation dictionaries
```
## Responsibilities
@ -57,8 +84,8 @@ src/
`payload.repository.full_name`; fall back to `t("common.repository")` when missing.
- Do NOT use `"Comment on org/repo"` / `"Review on org/repo"` prefixes. Comments, reviews
and inline comments use the same `{repo}{#number}: {title}` title as their parent object.
- All event-specific emoji live in `src/formatter.ts` (via the `em()` helper), never in the
locale files. Emoji is controlled per group through the `Group.emoji` toggle (default true);
- All event-specific emoji live in `src/formatters/` (via the `emojiPrefix` helper), never in
the locale files. Emoji is controlled per group through the `Group.emoji` toggle (default true);
`showEmoji=false` must strip every emoji from titles, descriptions, fields and links.
- Milestone progress bars (🟢🟡🟠⬜) are data visualization and are exempt from the emoji toggle.
- Locale templates use a `{emoji}` placeholder immediately followed by the text (no space);

View file

@ -15,26 +15,42 @@ npm run dev # Start local dev server
```text
src/
├── index.ts # CF Workers entry (fetch + scheduled), scheduled = command sync
├── types.ts # Env, Config, Route, Filter, WebhookEvent, FormattedMessage
├── types.ts # Env, Config, Route, Filter, WebhookEvent, NeutralMessage
├── config.ts # Loads routes from KV (returns [] if unset), builds Config from env
├── server.ts # Hono app: /health, /webhook, /discord/interactions, mounts /auth, /admin + /
├── webhook.ts # HMAC verify (Web Crypto), parseEvent, extractBranch, matchRoute
├── discord.ts # Dispatch to Discord via REST (sendMessage)
├── discord-rest.ts # Discord REST sendMessage with retry + rate-limit handling
├── discord-interactions.ts # Ed25519 verify + interaction handlers (/gh, buttons, modals) + command registration
├── formatter.ts # 23 event formatters + generic fallback
├── github-oauth.ts # OAuth URL, callback token exchange, getUserOctokit
├── oauth-routes.ts # GET /auth/github, callback, DELETE /token/:userId (KV state)
├── action-routes.ts # POST /api/comment|merge|close|react (Bearer token auth via KV lookup)
├── admin-routes.ts # /admin API: routes, groups, me, logs (session + scope auth)
├── admin-session.ts # Admin session CRUD (KV session:{id}), cookie helpers
├── groups.ts # Group loading, group-admin access scoping
├── i18n.ts # Message language overrides (en/zh)
├── send-log.ts # Send logging (logs:send KV keys)
├── token-store.ts # KV-based token CRUD with findUserIdByToken reverse lookup
├── home-routes.ts # Landing page routes
├── legal-routes.ts # Legal page routes
└── log.ts # JSON console logger (info/warn/error/fatal)
├── core/
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → getDriver().send
├── events/ # GitHub webhook pipeline
│ ├── verify.ts # HMAC signature verification (Web Crypto, timing-safe)
│ ├── parse.ts # parseEvent (headers + body → WebhookEvent)
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword filtering
├── formatters/ # Platform-neutral formatters (produce NeutralMessage)
│ ├── index.ts # formatEvent: 24-event switch → NeutralMessage + re-exports
│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI
│ ├── helpers.ts # emojiPrefix, T, buildMessage
│ └── *.ts # push, pull-request, issues, comments, workflow, release, repo, ...
├── drivers/ # Platform drivers (pluggable push targets)
│ ├── types.ts # PlatformDriver interface + SendResult
│ ├── index.ts # getDriver() registry (discord + telegram stub)
│ ├── discord/ # index.ts (driver), render.ts (NeutralMessage → embed),
│ │ # rest.ts, interactions.ts, commands.ts
│ └── telegram/ # TelegramDriver stub (not implemented yet)
├── github/ # GitHub OAuth + as-user actions
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, actions
│ └── store.ts # KV-based token CRUD + discord-link mapping
├── web/ # HTTP UI/API routes
│ ├── oauth-routes.ts # GET /auth/github, callback, DELETE /token/:userId (KV state)
│ ├── action-routes.ts # POST /api/comment|merge|close|react (Bearer token auth via KV lookup)
│ ├── admin-routes.ts # /admin API: routes, groups, me, logs (session + scope auth)
│ ├── session.ts # Admin session CRUD (KV session:{id}), cookie helpers
│ ├── groups.ts # Group loading, group-admin access scoping
│ ├── home-routes.ts # Landing page routes
│ └── legal-routes.ts # Legal page routes
└── lib/ # Shared infrastructure
├── i18n.ts # Message language overrides (en/zh)
├── send-log.ts # Send logging (logs:send KV keys)
├── log.ts # JSON console logger (info/warn/error/fatal)
└── locales/ # en.ts, zh.ts translation dictionaries
```
## Scripts
@ -74,11 +90,11 @@ curl http://localhost:8787/health
## Adding a New Event Formatter
1. Add the event type to `GITHUB_COLORS` in `formatter.ts` (if new color needed)
1. Add the event type to `GITHUB_COLORS` in `src/formatters/colors.ts` (if new color needed)
2. Add action labels to `ACTION_LABELS` (if new actions)
3. Create a `formatEventType` function in `formatter.ts`
4. Add the case to the `formatEvent` switch statement
5. Update `extractBranch` in `webhook.ts` if the event has branch info
3. Create a `formatEventType` function in `src/formatters/`
4. Add the case to the `formatEvent` switch statement in `src/formatters/index.ts`
5. Update `extractBranch` in `src/events/match.ts` if the event has branch info
6. Add the event to the documentation in `docs/events/supported.md`
7. Subscribe to the event in your GitHub App settings

View file

@ -15,26 +15,42 @@ npm run dev # 启动本地开发服务器
```text
src/
├── index.ts # CF Workers 入口 (fetch + scheduled)scheduled = 命令同步
├── types.ts # Env、Config、Route、Filter、WebhookEvent、FormattedMessage
├── types.ts # Env、Config、Route、Filter、WebhookEvent、NeutralMessage
├── config.ts # 从 KV 加载路由(未设置时返回 []),从 env 构建 Config
├── server.ts # Hono 应用: /health、/webhook、/discord/interactions挂载 /auth、/admin + /
├── webhook.ts # HMAC 验证 (Web Crypto)、parseEvent、extractBranch、matchRoute
├── discord.ts # 通过 Discord REST 分发 (sendMessage)
├── discord-rest.ts # Discord REST sendMessage带重试和限流处理
├── discord-interactions.ts # Ed25519 验签 + 交互处理 (/gh、按钮、modal) + 命令注册
├── formatter.ts # 23 种事件格式化器 + 通用回退
├── github-oauth.ts # OAuth URL、回调 Token 交换、getUserOctokit
├── oauth-routes.ts # GET /auth/github、回调、DELETE /token/:userId (KV 状态)
├── action-routes.ts # POST /api/comment|merge|close|react (通过 KV 查找进行 Bearer Token 鉴权)
├── admin-routes.ts # /admin API路由、分组、me、日志会话 + 权限范围鉴权)
├── admin-session.ts # 管理员会话 CRUD (KV session:{id})、Cookie 辅助函数
├── groups.ts # 分组加载、分组管理员权限范围
├── i18n.ts # 消息语言覆盖 (en/zh)
├── send-log.ts # 发送日志 (logs:send KV 键)
├── token-store.ts # 基于 KV 的 Token CRUD带 findUserIdByToken 反向查找
├── home-routes.ts # 落地页路由
├── legal-routes.ts # 法律页面路由
└── log.ts # JSON 控制台日志 (info/warn/error/fatal)
├── core/
│ └── dispatch.ts # 平台中立分发:匹配路由 → formatEvent → getDriver().send
├── events/ # GitHub webhook 事件流水线
│ ├── verify.ts # HMAC 签名验证 (Web Crypto时间安全)
│ ├── parse.ts # parseEvent (headers + body → WebhookEvent)
│ └── match.ts # matchRoute、eventOwners、extractBranch、关键词过滤
├── formatters/ # 平台中立格式化器(产出 NeutralMessage
│ ├── index.ts # formatEvent24 事件 switch → NeutralMessage + re-export
│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI
│ ├── helpers.ts # emojiPrefix、T、buildMessage
│ └── *.ts # push、pull-request、issues、comments、workflow、release、repo 等
├── drivers/ # 平台驱动(可插拔推送目标)
│ ├── types.ts # PlatformDriver 接口 + SendResult
│ ├── index.ts # getDriver() 注册表discord + telegram 占位)
│ ├── discord/ # index.ts (驱动)、render.ts (NeutralMessage → embed)、
│ │ # rest.ts、interactions.ts、commands.ts
│ └── telegram/ # TelegramDriver 占位(未实现)
├── github/ # GitHub OAuth + 以用户身份操作
│ ├── oauth.ts # OAuth URL、回调 Token 交换、getUserOctokit、操作
│ └── store.ts # 基于 KV 的 Token CRUD + discord-link 映射
├── web/ # HTTP UI/API 路由
│ ├── oauth-routes.ts # GET /auth/github、回调、DELETE /token/:userId (KV 状态)
│ ├── action-routes.ts # POST /api/comment|merge|close|react (通过 KV 查找进行 Bearer Token 鉴权)
│ ├── admin-routes.ts # /admin API路由、分组、me、日志会话 + 权限范围鉴权)
│ ├── session.ts # 管理员会话 CRUD (KV session:{id})、Cookie 辅助函数
│ ├── groups.ts # 分组加载、分组管理员权限范围
│ ├── home-routes.ts # 落地页路由
│ └── legal-routes.ts # 法律页面路由
└── lib/ # 共享基础设施
├── i18n.ts # 消息语言覆盖 (en/zh)
├── send-log.ts # 发送日志 (logs:send KV 键)
├── log.ts # JSON 控制台日志 (info/warn/error/fatal)
└── locales/ # en.ts、zh.ts 翻译字典
```
## 脚本
@ -74,11 +90,11 @@ curl http://localhost:8787/health
## 添加新事件格式化器
1. 将事件类型添加到 `formatter.ts` 中的 `GITHUB_COLORS`(如果需要新颜色)
1. 将事件类型添加到 `src/formatters/colors.ts` 中的 `GITHUB_COLORS`(如果需要新颜色)
2. 将操作标签添加到 `ACTION_LABELS`(如果有新操作)
3. 在 `formatter.ts` 中创建 `formatEventType` 函数
4. 将 case 添加到 `formatEvent` switch 语句
5. 如果事件包含分支信息,更新 `webhook.ts` 中的 `extractBranch`
3. 在 `src/formatters/` 中创建 `formatEventType` 函数
4. 将 case 添加到 `src/formatters/index.ts` 中的 `formatEvent` switch 语句
5. 如果事件包含分支信息,更新 `src/events/match.ts` 中的 `extractBranch`
6. 将事件添加到 `docs/events/supported.md` 文档中
7. 在 GitHub App 设置中订阅该事件

View file

@ -6,7 +6,7 @@ import {
destroyAdminSession,
adminCookie,
clearAdminCookie,
} from "../admin-session";
} from "../web/session";
import { loadRoutes, saveRoutes, loadConfig } from "../config";
import type { Env, Route } from "../types";

View file

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { sendMessage } from "../discord-rest";
import { dispatchEvent } from "../discord";
import { sendMessage } from "../drivers/discord/rest";
import { dispatchEvent } from "../core/dispatch";
import type { Env, Route } from "../types";
function mockFetch(handler: (url: string, init?: RequestInit) => Response): void {

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test";
import { formatEvent } from "../formatter";
import { formatEvent } from "../formatters";
import type { Route, WebhookEvent } from "../types";
const route: Route = {
@ -31,7 +31,7 @@ describe("message title spec", () => {
sender,
}),
);
expect(msg.embeds![0].title).toBe("acme/widget: Pushed 1 commit");
expect(msg.title).toBe("acme/widget: Pushed 1 commit");
});
it("pull_request title is repo#number: title", () => {
@ -56,7 +56,7 @@ describe("message title spec", () => {
sender,
}),
);
expect(msg.embeds![0].title).toBe("acme/widget#7: Add feature");
expect(msg.title).toBe("acme/widget#7: Add feature");
});
it("issue_comment title has no 'Comment on' prefix", () => {
@ -77,8 +77,8 @@ describe("message title spec", () => {
sender,
}),
);
expect(msg.embeds![0].title).toBe("acme/widget#3: Bug report");
expect(msg.embeds![0].title).not.toContain("Comment on");
expect(msg.title).toBe("acme/widget#3: Bug report");
expect(msg.title).not.toContain("Comment on");
});
it("workflow_run title is repo: name — conclusion", () => {
@ -98,8 +98,8 @@ describe("message title spec", () => {
sender,
}),
);
expect(msg.embeds![0].title).toBe("acme/widget: CI — success");
expect(msg.embeds![0].fields![1].value).toBe("✅ build");
expect(msg.title).toBe("acme/widget: CI — success");
expect(msg.fields![1].value).toBe("✅ build");
});
it("workflow_run distinguishes queued and running from pending", () => {
@ -119,8 +119,8 @@ describe("message title spec", () => {
sender,
}),
);
expect(queued.embeds![0].title).toBe("acme/widget: CI — queued");
expect(queued.embeds![0].fields![0].value).toBe("⏳ queued");
expect(queued.title).toBe("acme/widget: CI — queued");
expect(queued.fields![0].value).toBe("⏳ queued");
const running = formatEvent(
route,
@ -131,8 +131,8 @@ describe("message title spec", () => {
sender,
}),
);
expect(running.embeds![0].title).toBe("acme/widget: CI — running");
expect(running.embeds![0].fields![0].value).toBe("🔄 running");
expect(running.title).toBe("acme/widget: CI — running");
expect(running.fields![0].value).toBe("🔄 running");
});
it("check_run uses status for queued and running", () => {
@ -146,8 +146,8 @@ describe("message title spec", () => {
route,
event("check_run", { check_run: checkRun, repository: repo, sender }),
);
expect(msg.embeds![0].title).toBe("acme/widget: Lint — running");
expect(msg.embeds![0].fields![0].value).toBe("🔄 running");
expect(msg.title).toBe("acme/widget: Lint — running");
expect(msg.fields![0].value).toBe("🔄 running");
});
it("unknown events fall back to repo: event: action", () => {
@ -155,7 +155,7 @@ describe("message title spec", () => {
route,
event("custom_event", { action: "ran", repository: repo, sender }),
);
expect(msg.embeds![0].title).toBe("acme/widget: custom_event: ran");
expect(msg.title).toBe("acme/widget: custom_event: ran");
});
});
@ -169,8 +169,8 @@ describe("group emoji toggle", () => {
sender,
}),
);
expect(msg.embeds![0].title).toBe("acme/widget: 📦 Repository Created");
expect(msg.embeds![0].description).toContain("🔗");
expect(msg.title).toBe("acme/widget: 📦 Repository Created");
expect(msg.description).toContain("🔗");
});
it("strips emoji when showEmoji is false", () => {
@ -184,9 +184,9 @@ describe("group emoji toggle", () => {
undefined,
false,
);
expect(msg.embeds![0].title).toBe("acme/widget: Repository Created");
expect(msg.embeds![0].title).not.toContain("📦");
expect(msg.embeds![0].description).not.toContain("🔗");
expect(msg.title).toBe("acme/widget: Repository Created");
expect(msg.title).not.toContain("📦");
expect(msg.description).not.toContain("🔗");
});
it("strips emoji from push description when disabled", () => {
@ -204,8 +204,8 @@ describe("group emoji toggle", () => {
undefined,
false,
);
expect(msg.embeds![0].description).not.toContain("⚠️");
expect(msg.embeds![0].description).not.toContain("🆕");
expect(msg.description).not.toContain("⚠️");
expect(msg.description).not.toContain("🆕");
});
it("strips emoji from workflow_run status when disabled", () => {
@ -227,7 +227,7 @@ describe("group emoji toggle", () => {
undefined,
false,
);
expect(msg.embeds![0].fields![0].value).toBe("failure");
expect(msg.embeds![0].fields![1].value).toBe("build");
expect(msg.fields![0].value).toBe("failure");
expect(msg.fields![1].value).toBe("build");
});
});

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test";
import { recordSend, getSendLog } from "../send-log";
import { recordSend, getSendLog } from "../lib/send-log";
function createMockKV(): KVNamespace {
const store = new Map<string, { value: string; expiration?: number }>();

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test";
import { saveToken, getToken, removeToken, findUserIdByToken } from "../token-store";
import { saveToken, getToken, removeToken, findUserIdByToken } from "../github/store";
function createMockKV(): KVNamespace {
const store = new Map<string, { value: string; expiration?: number }>();

View file

@ -1,6 +1,8 @@
import { describe, it, expect } from "bun:test";
import { createHmac } from "crypto";
import { verifySignature, parseEvent, matchRoute } from "../webhook";
import { verifySignature } from "../events/verify";
import { parseEvent } from "../events/parse";
import { matchRoute } from "../events/match";
import type { Route, WebhookEvent } from "../types";
function sign(body: string, secret: string): string {

View file

@ -1,5 +1,5 @@
import type { Env, Config, Route } from "./types";
import { log } from "./log";
import { log } from "./lib/log";
const CONFIG_CACHE_TTL = 60_000;
const ROUTES_KEY = "config:routes";

View file

@ -1,11 +1,11 @@
import type { Config, FormattedMessage, WebhookEvent, Env, Route } from "./types";
import { formatEvent } from "./formatter";
import { matchRoute, eventOwners } from "./webhook";
import { log } from "./log";
import { loadTranslations, type Translations } from "./i18n";
import { sendMessage } from "./discord-rest";
import { recordSend } from "./send-log";
import { loadGroups, groupAcceptsOwners } from "./groups";
import type { Config, WebhookEvent, Env, Route } 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 { recordSend } from "../lib/send-log";
import { loadGroups, groupAcceptsOwners } from "../web/groups";
import { getDriver } from "../drivers";
export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise<void> {
const langs = [...new Set(config.routes.map((r) => r.lang ?? "en"))];
@ -20,10 +20,6 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
const groupById = new Map(groups.map((g) => [g.id, g]));
const owners = eventOwners(event);
// A regular route counts as "matched" only when it passes both its filters
// and its group's owner restriction. Fallback routes ignore their own filters
// and fire whenever no regular route matched, so they still catch events that
// a regular route's group suppressed.
const accepted = (route: Route): boolean => {
if (!route.groupId) return true;
const group = groupById.get(route.groupId);
@ -50,7 +46,8 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
const group = route.groupId ? groupById.get(route.groupId) : undefined;
const showEmoji = group?.emoji !== false;
const message = formatEvent(route, event, tr, showEmoji);
await sendToChannel(route.target.channelId, message, env, route.target.threadId);
const result = await getDriver(route.target).send(message, route.target, env);
if (!result.ok) throw new Error(result.error ?? "Send failed");
await recordSend(env.KV, {
ts: Date.now(),
routeId: route.id,
@ -75,14 +72,3 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
await Promise.allSettled(tasks);
}
async function sendToChannel(
channelId: string,
message: FormattedMessage,
env: Env,
threadId?: string,
): Promise<void> {
const token = env.DISCORD_TOKEN ?? "";
const result = await sendMessage(token, channelId, message, threadId);
if (!result.ok) throw new Error(result.error ?? "Send failed");
}

View file

@ -0,0 +1,174 @@
import { log } from "../../lib/log";
import type { Env } from "../../types";
const DISCORD_API = "https://discord.com/api/v10";
const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const;
const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const;
export const MSG_CMD_ADD = "GitHub: 添加评论";
export const MSG_CMD_EDIT = "GitHub: 编辑评论";
export const MSG_CMD_DEL = "GitHub: 删除评论";
export const APP_COMMANDS = [
{
name: "gh",
type: COMMAND_TYPE.CHAT_INPUT,
description: "GitHub 集成",
options: [
{
type: OPTION_TYPE.SUB_COMMAND,
name: "login",
description: "绑定你的 GitHub 账号以用本人身份评论",
},
{ type: OPTION_TYPE.SUB_COMMAND, name: "logout", description: "解绑你的 GitHub 账号" },
{
type: OPTION_TYPE.SUB_COMMAND_GROUP,
name: "comment",
description: "对 issue/PR 评论进行增删改",
options: [
{
type: OPTION_TYPE.SUB_COMMAND,
name: "add",
description: "在 issue/PR 下新增评论",
options: [
{
type: OPTION_TYPE.STRING,
name: "link",
description: "issue/PR 链接",
required: true,
},
],
},
{
type: OPTION_TYPE.SUB_COMMAND,
name: "edit",
description: "编辑一条评论",
options: [
{
type: OPTION_TYPE.STRING,
name: "link",
description: "评论链接(含 #issuecomment-",
required: true,
},
],
},
{
type: OPTION_TYPE.SUB_COMMAND,
name: "del",
description: "删除一条评论",
options: [
{
type: OPTION_TYPE.STRING,
name: "link",
description: "评论链接(含 #issuecomment-",
required: true,
},
],
},
],
},
],
},
{ name: MSG_CMD_ADD, type: COMMAND_TYPE.MESSAGE },
{ name: MSG_CMD_EDIT, type: COMMAND_TYPE.MESSAGE },
{ name: MSG_CMD_DEL, type: COMMAND_TYPE.MESSAGE },
];
export async function getApplicationId(env: Env): Promise<string | null> {
if (env.DISCORD_APPLICATION_ID) return env.DISCORD_APPLICATION_ID;
try {
const cached = await env.KV.get("config:discord-app-id");
if (cached) return cached;
} catch {
// fall through to the API
}
const token = env.DISCORD_TOKEN ?? "";
if (!token) return null;
const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
log.warn({ status: res.status }, "Failed to fetch Discord application id");
return null;
}
const app = (await res.json()) as { id?: string };
if (app.id) {
try {
await env.KV.put("config:discord-app-id", app.id);
} catch {
// cache is best-effort
}
return app.id;
}
return null;
}
export async function registerGlobalCommands(env: Env): Promise<void> {
const token = env.DISCORD_TOKEN ?? "";
if (!token) return;
try {
if (await env.KV.get("cmd:registered:global")) return;
} catch {
// fall through and register
}
const appId = await getApplicationId(env);
if (!appId) return;
const res = await fetch(`${DISCORD_API}/applications/${appId}/commands`, {
method: "PUT",
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(APP_COMMANDS),
});
if (res.ok) {
try {
await env.KV.put("cmd:registered:global", "1", { expirationTtl: 86400 });
} catch {
// best-effort
}
log.info("Registered global application commands");
} else {
const err = await res.text();
log.warn({ status: res.status, err }, "Global command registration failed");
}
}
export async function syncGuildCommands(env: Env): Promise<void> {
const token = env.DISCORD_TOKEN ?? "";
if (!token) return;
const appId = await getApplicationId(env);
if (!appId) return;
const res = await fetch(`${DISCORD_API}/users/@me/guilds`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const err = await res.text();
log.warn({ status: res.status, err }, "Failed to list guilds");
return;
}
const guilds = (await res.json()) as Array<{ id: string }>;
for (const guild of guilds) {
try {
if (await env.KV.get(`cmd:guild:${guild.id}`)) continue;
const r = await fetch(`${DISCORD_API}/applications/${appId}/guilds/${guild.id}/commands`, {
method: "PUT",
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(APP_COMMANDS),
});
if (r.ok) {
await env.KV.put(`cmd:guild:${guild.id}`, "1");
log.info({ guildId: guild.id }, "Registered guild application commands");
} else {
const err = await r.text();
log.warn({ guildId: guild.id, status: r.status, err }, "Command registration failed");
}
} catch (err) {
log.warn({ guildId: guild.id, err: String(err) }, "Command registration failed");
}
}
}
export async function syncCommands(env: Env): Promise<void> {
if (!env.DISCORD_TOKEN) return;
await registerGlobalCommands(env);
await syncGuildCommands(env);
}

View file

@ -0,0 +1,17 @@
import type { Route, Env, NeutralMessage } from "../../types";
import type { PlatformDriver, SendResult } from "../types";
import { sendMessage } from "./rest";
import { renderNeutralMessage } from "./render";
export class DiscordDriver implements PlatformDriver {
readonly id = "discord";
async send(
message: NeutralMessage,
target: Route["target"],
env: Env,
): Promise<SendResult> {
const token = env.DISCORD_TOKEN ?? "";
return sendMessage(token, target.channelId, renderNeutralMessage(message), target.threadId);
}
}

View file

@ -1,4 +1,4 @@
import { log } from "./log";
import { log } from "../../lib/log";
import {
getOAuthURL,
commentAsUser,
@ -7,9 +7,10 @@ import {
deleteCommentAsUser,
mergePullRequestAsUser,
closePullRequestAsUser,
} from "./github-oauth";
import { getDiscordLink, removeDiscordLink } from "./token-store";
import type { Env } from "./types";
} from "../../github/oauth";
import { getDiscordLink, removeDiscordLink } from "../../github/store";
import type { Env } from "../../types";
import { MSG_CMD_ADD, MSG_CMD_EDIT, MSG_CMD_DEL } from "./commands";
const DISCORD_API = "https://discord.com/api/v10";
@ -17,14 +18,8 @@ const DISCORD_API = "https://discord.com/api/v10";
const INTERACTION_TYPE = { PING: 1, COMMAND: 2, BUTTON: 3, MODAL_SUBMIT: 5 } as const;
const CALLBACK_TYPE = { PONG: 1, MESSAGE: 4, DEFERRED_MESSAGE: 5, MODAL: 9 } as const;
const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const;
const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const;
const EPHEMERAL = 64;
// Right-click (message context-menu) command names → operation.
const MSG_CMD_ADD = "GitHub: 添加评论";
const MSG_CMD_EDIT = "GitHub: 编辑评论";
const MSG_CMD_DEL = "GitHub: 删除评论";
// Modal custom_id encodings (delimiter '|' never appears in owner/repo).
const MODAL_ADD = "ghc|add|"; // ghc|add|owner|repo|issueNumber
const MODAL_EDIT = "ghc|edit|"; // ghc|edit|owner|repo|commentId
@ -33,71 +28,6 @@ const MODAL_EDIT = "ghc|edit|"; // ghc|edit|owner|repo|commentId
const BTN_MERGE = "ghpr|merge|"; // ghpr|merge|owner|repo|pullNumber
const BTN_CLOSE = "ghpr|close|"; // ghpr|close|owner|repo|pullNumber
const APP_COMMANDS = [
{
name: "gh",
type: COMMAND_TYPE.CHAT_INPUT,
description: "GitHub 集成",
options: [
{
type: OPTION_TYPE.SUB_COMMAND,
name: "login",
description: "绑定你的 GitHub 账号以用本人身份评论",
},
{ type: OPTION_TYPE.SUB_COMMAND, name: "logout", description: "解绑你的 GitHub 账号" },
{
type: OPTION_TYPE.SUB_COMMAND_GROUP,
name: "comment",
description: "对 issue/PR 评论进行增删改",
options: [
{
type: OPTION_TYPE.SUB_COMMAND,
name: "add",
description: "在 issue/PR 下新增评论",
options: [
{
type: OPTION_TYPE.STRING,
name: "link",
description: "issue/PR 链接",
required: true,
},
],
},
{
type: OPTION_TYPE.SUB_COMMAND,
name: "edit",
description: "编辑一条评论",
options: [
{
type: OPTION_TYPE.STRING,
name: "link",
description: "评论链接(含 #issuecomment-",
required: true,
},
],
},
{
type: OPTION_TYPE.SUB_COMMAND,
name: "del",
description: "删除一条评论",
options: [
{
type: OPTION_TYPE.STRING,
name: "link",
description: "评论链接(含 #issuecomment-",
required: true,
},
],
},
],
},
],
},
{ name: MSG_CMD_ADD, type: COMMAND_TYPE.MESSAGE },
{ name: MSG_CMD_EDIT, type: COMMAND_TYPE.MESSAGE },
{ name: MSG_CMD_DEL, type: COMMAND_TYPE.MESSAGE },
];
// Comment link (has the comment id); check this BEFORE the plain issue regex.
const GITHUB_COMMENT_RE =
/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/\d+#issuecomment-(\d+)/;
@ -602,108 +532,3 @@ async function modalSubmit(
}
}
}
/**
* Resolve the Discord application id: env var KV cache Discord API
* (then cached in KV for later runs).
*/
export async function getApplicationId(env: Env): Promise<string | null> {
if (env.DISCORD_APPLICATION_ID) return env.DISCORD_APPLICATION_ID;
try {
const cached = await env.KV.get("config:discord-app-id");
if (cached) return cached;
} catch {
// fall through to the API
}
const token = env.DISCORD_TOKEN ?? "";
if (!token) return null;
const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
log.warn({ status: res.status }, "Failed to fetch Discord application id");
return null;
}
const app = (await res.json()) as { id?: string };
if (app.id) {
try {
await env.KV.put("config:discord-app-id", app.id);
} catch {
// cache is best-effort
}
return app.id;
}
return null;
}
/** Register commands globally (~1h propagation); dedup for a day. */
export async function registerGlobalCommands(env: Env): Promise<void> {
const token = env.DISCORD_TOKEN ?? "";
if (!token) return;
try {
if (await env.KV.get("cmd:registered:global")) return;
} catch {
// fall through and register
}
const appId = await getApplicationId(env);
if (!appId) return;
const res = await fetch(`${DISCORD_API}/applications/${appId}/commands`, {
method: "PUT",
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(APP_COMMANDS),
});
if (res.ok) {
try {
await env.KV.put("cmd:registered:global", "1", { expirationTtl: 86400 });
} catch {
// best-effort
}
log.info("Registered global application commands");
} else {
const err = await res.text();
log.warn({ status: res.status, err }, "Global command registration failed");
}
}
/** Register commands per guild for instant availability (new guilds only). */
export async function syncGuildCommands(env: Env): Promise<void> {
const token = env.DISCORD_TOKEN ?? "";
if (!token) return;
const appId = await getApplicationId(env);
if (!appId) return;
const res = await fetch(`${DISCORD_API}/users/@me/guilds`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const err = await res.text();
log.warn({ status: res.status, err }, "Failed to list guilds");
return;
}
const guilds = (await res.json()) as Array<{ id: string }>;
for (const guild of guilds) {
try {
if (await env.KV.get(`cmd:guild:${guild.id}`)) continue;
const r = await fetch(`${DISCORD_API}/applications/${appId}/guilds/${guild.id}/commands`, {
method: "PUT",
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(APP_COMMANDS),
});
if (r.ok) {
await env.KV.put(`cmd:guild:${guild.id}`, "1");
log.info({ guildId: guild.id }, "Registered guild application commands");
} else {
const err = await r.text();
log.warn({ guildId: guild.id, status: r.status, err }, "Command registration failed");
}
} catch (err) {
log.warn({ guildId: guild.id, err: String(err) }, "Command registration failed");
}
}
}
/** Entry point for the scheduled (cron) command sync. */
export async function syncCommands(env: Env): Promise<void> {
if (!env.DISCORD_TOKEN) return;
await registerGlobalCommands(env);
await syncGuildCommands(env);
}

View file

@ -0,0 +1,48 @@
import type { FormattedMessage, NeutralActionStyle, NeutralMessage } from "../../types";
function toStyle(style: NeutralActionStyle): number {
switch (style) {
case "primary":
return 3;
case "danger":
return 4;
default:
return 2;
}
}
export function renderNeutralMessage(message: NeutralMessage): FormattedMessage {
return {
embeds: [
{
title: message.title,
url: message.url,
color: message.color,
description: message.description,
author: message.author
? {
name: message.author.name,
icon_url: message.author.iconUrl,
url: message.author.url,
}
: undefined,
fields: message.fields,
footer: message.footer ? { text: message.footer } : undefined,
timestamp: message.timestamp,
},
],
components: message.actions?.length
? [
{
type: 1,
components: message.actions.map((action) => ({
type: 2,
style: toStyle(action.style),
label: action.label,
custom_id: action.id,
})),
},
]
: undefined,
};
}

View file

@ -1,4 +1,4 @@
import { log } from "./log";
import { log } from "../../lib/log";
const DISCORD_API = "https://discord.com/api/v10";

16
src/drivers/index.ts Normal file
View file

@ -0,0 +1,16 @@
import type { Route } from "../types";
import type { PlatformDriver } from "./types";
import { DiscordDriver } from "./discord";
import { TelegramDriver } from "./telegram";
const drivers: Record<string, PlatformDriver> = {
discord: new DiscordDriver(),
telegram: new TelegramDriver(),
};
export function getDriver(target: Route["target"]): PlatformDriver {
const platform = (target as { platform?: string }).platform ?? "discord";
const driver = drivers[platform];
if (!driver) throw new Error(`No driver for platform "${platform}"`);
return driver;
}

View file

@ -0,0 +1,14 @@
import type { Route, Env, NeutralMessage } from "../../types";
import type { PlatformDriver, SendResult } from "../types";
export class TelegramDriver implements PlatformDriver {
readonly id = "telegram";
async send(
_message: NeutralMessage,
_target: Route["target"],
_env: Env,
): Promise<SendResult> {
return { ok: false, error: "Telegram driver not implemented yet" };
}
}

11
src/drivers/types.ts Normal file
View file

@ -0,0 +1,11 @@
import type { Route, Env, NeutralMessage } from "../types";
export interface SendResult {
ok: boolean;
error?: string;
}
export interface PlatformDriver {
readonly id: string;
send(message: NeutralMessage, target: Route["target"], env: Env): Promise<SendResult>;
}

115
src/events/match.ts Normal file
View file

@ -0,0 +1,115 @@
import type { WebhookEvent, Route, Filter } from "../types";
const regexCache = new Map<string, RegExp>();
const keywordBodyCache = new WeakMap<WebhookEvent, string>();
const MAX_PATTERN_LENGTH = 200;
function compileKeywordRegex(pattern: string): RegExp | null {
if (pattern.length > MAX_PATTERN_LENGTH) return null;
const cached = regexCache.get(pattern);
if (cached) return cached;
try {
const re = new RegExp(pattern, "i");
regexCache.set(pattern, re);
return re;
} catch {
return null;
}
}
function getKeywordBody(event: WebhookEvent): string {
const cached = keywordBodyCache.get(event);
if (cached !== undefined) return cached;
const body = JSON.stringify(event.payload).toLowerCase();
keywordBodyCache.set(event, body);
return body;
}
function extractBranch(event: WebhookEvent): string | undefined {
if (event.event === "push") {
return (event.payload.ref as string)?.replace("refs/heads/", "");
}
if (
event.event === "pull_request" ||
event.event === "pull_request_review" ||
event.event === "pull_request_review_comment"
) {
const pr = event.payload.pull_request as { head?: { ref?: string } } | undefined;
return pr?.head?.ref;
}
if (event.event === "create" || event.event === "delete") {
return event.payload.ref as string | undefined;
}
if (event.event === "workflow_run") {
const wf = event.payload.workflow_run as { head_branch?: string } | undefined;
return wf?.head_branch;
}
if (event.event === "commit_comment") {
const comment = event.payload.comment as { position?: number | null } | undefined;
if (comment?.position != null) {
return undefined;
}
}
if (event.event === "code_scanning_alert") {
return event.payload.ref as string | undefined;
}
return undefined;
}
function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string): boolean {
let value: string | undefined;
switch (filter.type) {
case "event":
value = event.event;
break;
case "repo":
value = (event.payload.repository as { full_name?: string })?.full_name;
break;
case "actor":
value = (event.payload.sender as { login?: string })?.login;
break;
case "action":
value = event.payload.action as string;
break;
case "branch":
value = extractBranch(event);
break;
case "keyword": {
const body = keywordBody ?? getKeywordBody(event);
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
const matches = patterns.some((p) => {
const re = compileKeywordRegex(p);
if (!re) return body.includes(p.toLowerCase());
return re.test(body);
});
return filter.exclude ? !matches : matches;
}
default:
return false;
}
if (!value) return false;
const patterns = Array.isArray(filter.match) ? filter.match : [filter.match];
const matches = patterns.some((p) => value!.toLowerCase() === p.toLowerCase());
return filter.exclude ? !matches : matches;
}
export function eventOwners(event: WebhookEvent): string[] {
const owners = new Set<string>();
const repoOwner = (event.payload.repository as { owner?: { login?: string } } | undefined)?.owner
?.login;
if (repoOwner) owners.add(repoOwner);
const org = (event.payload.organization as { login?: string } | undefined)?.login;
if (org) owners.add(org);
return [...owners];
}
export function matchRoute(route: Route, event: WebhookEvent): boolean {
if (!route.enabled) return false;
const hasKeyword = route.filters.some((f) => f.type === "keyword");
const keywordBody = hasKeyword ? getKeywordBody(event) : undefined;
return route.filters.every((f) => matchFilter(f, event, keywordBody));
}

15
src/events/parse.ts Normal file
View file

@ -0,0 +1,15 @@
import type { WebhookEvent } from "../types";
export function parseEvent(headers: Record<string, string>, body: string): WebhookEvent | null {
const event = headers["x-github-event"];
const signature = headers["x-hub-signature-256"];
if (!event) return null;
try {
const payload = JSON.parse(body);
return { event, payload, signature };
} catch {
return null;
}
}

43
src/events/verify.ts Normal file
View file

@ -0,0 +1,43 @@
const keyCache = new Map<string, CryptoKey>();
async function getHmacKey(secret: string): Promise<CryptoKey> {
const cached = keyCache.get(secret);
if (cached) return cached;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
keyCache.set(secret, key);
return key;
}
export async function verifySignature(
payload: string,
signature: string | undefined,
secret: string,
): Promise<boolean> {
if (!signature) return false;
const encoder = new TextEncoder();
const key = await getHmacKey(secret);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
const expected = `sha256=${Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")}`;
try {
const a = encoder.encode(signature);
const b = encoder.encode(expected);
if (a.byteLength !== b.byteLength) return false;
let diff = 0;
for (let i = 0; i < a.byteLength; i++) {
diff |= a[i]! ^ b[i]!;
}
return diff === 0;
} catch {
return false;
}
}

File diff suppressed because it is too large Load diff

66
src/formatters/check.ts Normal file
View file

@ -0,0 +1,66 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatCheckRun(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const checkRun = payload.check_run as {
name?: string;
conclusion?: string;
html_url?: string;
status?: string;
output?: { title?: string; summary?: string };
};
const status =
checkRun.status === "queued"
? "queued"
: checkRun.status === "in_progress"
? "running"
: (checkRun.conclusion ?? "pending");
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const colorKey =
status === "success"
? "check_run_success"
: status === "failure"
? "check_run_failure"
: "check_run_other";
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.status"),
value: `${em(emoji)}${status}`,
inline: true,
});
if (checkRun.output?.title) {
fields.push({
name: t("fields.details"),
value: checkRun.output.title,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.check_run.title", {
repo: repo ?? t("common.repository"),
name: checkRun.name ?? "Check Run",
conclusion: status,
}),
url: checkRun.html_url,
color: GITHUB_COLORS[colorKey],
fields,
},
t,
repo,
);
}

64
src/formatters/colors.ts Normal file
View file

@ -0,0 +1,64 @@
export const GITHUB_COLORS = {
push: 0x2ea44f,
pull_request_opened: 0x2da44e,
pull_request_closed: 0xf85149,
pull_request_merged: 0x8957e5,
pull_request_ready_for_review: 0x2da44e,
pull_request_other: 0x1f6feb,
issues_opened: 0x2da44e,
issues_closed: 0xf85149,
issues_reopened: 0x1f6feb,
issues_other: 0x8957e5,
issue_comment: 0x6e7681,
workflow_run_success: 0x2da44e,
workflow_run_failure: 0xf85149,
workflow_run_other: 0xd29922,
release_published: 0x2da44e,
release_prerelease: 0xd29922,
release_deleted: 0xf85149,
create: 0x3fb950,
delete: 0xf85149,
star: 0xd29922,
fork: 0x1f6feb,
discussion: 0x8957e5,
check_run_success: 0x2da44e,
check_run_failure: 0xf85149,
check_run_other: 0xd29922,
pull_request_review_approved: 0x2da44e,
pull_request_review_changes: 0xf85149,
pull_request_review_commented: 0x8b949e,
commit_comment: 0x6e7681,
deployment_success: 0x2da44e,
deployment_failure: 0xf85149,
deployment_pending: 0xd29922,
member_added: 0x2da44e,
member_removed: 0xf85149,
label: 0x8957e5,
milestone_opened: 0x1f6feb,
milestone_closed: 0x2da44e,
discussion_created: 0x8957e5,
discussion_answered: 0x2da44e,
discussion_comment: 0x6e7681,
repository: 0x8b949e,
code_scanning_critical: 0xf85149,
code_scanning_high: 0xf85149,
code_scanning_medium: 0xd29922,
code_scanning_low: 0x8b949e,
dependabot_critical: 0xf85149,
dependabot_high: 0xf85149,
dependabot_medium: 0xd29922,
dependabot_low: 0x8b949e,
default: 0x8b949e,
} as const;
export const WORKFLOW_CONCLUSION_EMOJI: Record<string, string> = {
success: "✅",
failure: "❌",
cancelled: "🚫",
timed_out: "⏱️",
action_required: "⚠️",
neutral: "",
stale: "♻️",
queued: "⏳",
running: "🔄",
};

View file

@ -0,0 +1,43 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatIssueComment(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const issue = payload.issue as {
number?: number;
title?: string;
html_url?: string;
};
const comment = payload.comment as {
body?: string;
html_url?: string;
};
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const commentBody = comment.body?.slice(0, 500) ?? "";
const truncated = comment.body && comment.body.length > 500;
return buildMessage(
{
author,
title: t("events.issue_comment.title", {
repo: repo ?? t("common.repository"),
number: issue.number ?? "?",
title: issue.title ?? t("common.untitled"),
}),
url: comment.html_url ?? issue.html_url,
color: GITHUB_COLORS.issue_comment,
description: `${t("events.issue_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`,
},
t,
repo,
);
}

View file

@ -0,0 +1,50 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatCommitComment(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const comment = payload.comment as {
body?: string;
commit_id?: string;
html_url?: string;
};
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const commentBody = comment.body?.slice(0, 500) ?? "";
const truncated = comment.body && comment.body.length > 500;
const shortSha = comment.commit_id?.slice(0, 7) ?? "???????";
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (comment.commit_id) {
fields.push({
name: t("fields.commit"),
value: `\`${shortSha}\``,
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.commit_comment.title", {
repo: repo ?? t("common.repository"),
sha: shortSha,
}),
url: comment.html_url,
color: GITHUB_COLORS.commit_comment,
description: `${t("events.commit_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`,
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

84
src/formatters/create.ts Normal file
View file

@ -0,0 +1,84 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatCreate(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const refType = (payload.ref_type as string) ?? "branch";
const ref = (payload.ref as string) ?? t("common.unknown");
const emoji = refType === "tag" ? "🏷️" : "🌿";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.type"),
value: refType,
inline: true,
});
fields.push({
name: t("fields.name"),
value: `\`${ref}\``,
inline: true,
});
if (payload.description) {
fields.push({
name: t("fields.description"),
value: payload.description as string,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.create.title", {
repo: repo ?? t("common.repository"),
emoji: em(emoji),
type: refType,
ref,
}),
color: GITHUB_COLORS.create,
fields,
},
t,
repo,
);
}
export function formatDelete(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const refType = (payload.ref_type as string) ?? "branch";
const ref = (payload.ref as string) ?? t("common.unknown");
const emoji = refType === "tag" ? "🏷️" : "🌿";
const em = (e: string): string => emojiPrefix(e, showEmoji);
return buildMessage(
{
author,
title: t("events.delete.title", {
repo: repo ?? t("common.repository"),
emoji: em(emoji),
type: refType,
ref,
}),
color: GITHUB_COLORS.delete,
},
t,
repo,
);
}

View file

@ -0,0 +1,92 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatDeploymentStatus(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const status = payload.deployment_status as {
state?: string;
environment?: string;
environment_url?: string;
description?: string;
};
const deployment = payload.deployment as {
sha?: string;
ref?: string;
environment?: string;
};
const state = status.state ?? "pending";
const colorKey =
state === "success"
? "deployment_success"
: state === "failure"
? "deployment_failure"
: "deployment_pending";
const emoji = state === "success" ? "✅" : state === "failure" ? "❌" : "⏳";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const env = status.environment ?? deployment.environment ?? t("common.unknown");
const shortSha = deployment.sha?.slice(0, 7) ?? "???????";
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.status"),
value: `${em(emoji)}${status}`,
inline: true,
});
fields.push({
name: t("fields.environment"),
value: env,
inline: true,
});
if (deployment.ref) {
fields.push({
name: t("fields.branch_tag"),
value: `\`${deployment.ref}\``,
inline: true,
});
}
if (deployment.sha) {
fields.push({
name: t("fields.commit"),
value: `\`${shortSha}\``,
inline: true,
});
}
if (status.environment_url) {
fields.push({
name: t("fields.url"),
value: status.environment_url,
inline: false,
});
}
if (status.description) {
fields.push({
name: t("fields.description"),
value: status.description,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.deployment.title", { repo: repo ?? t("common.repository"), env, state }),
color: GITHUB_COLORS[colorKey],
fields,
},
t,
repo,
);
}

View file

@ -0,0 +1,96 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatDiscussion(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const discussion = payload.discussion as {
number?: number;
title?: string;
html_url?: string;
category?: { name?: string };
};
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const stateEmoji =
action === "answered"
? "✅"
: action === "closed"
? "🔴"
: action === "created"
? "🟢"
: action === "deleted"
? "🗑️"
: "💬";
const category = discussion.category?.name;
return buildMessage(
{
author,
title: t("events.discussion.title", {
repo: repo ?? t("common.repository"),
number: discussion.number ?? "?",
title: discussion.title ?? t("common.untitled"),
}),
url: discussion.html_url,
color:
action === "answered"
? GITHUB_COLORS.discussion_answered
: GITHUB_COLORS.discussion_created,
description: t("events.discussion.action_discussion", {
emoji: em(stateEmoji),
action: al,
category: category ? ` in **${category}**` : "",
}),
},
t,
repo,
);
}
export function formatDiscussionComment(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const comment = payload.comment as {
body?: string;
html_url?: string;
};
const discussion = payload.discussion as {
number?: number;
title?: string;
};
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const commentBody = comment.body?.slice(0, 500) ?? "";
const truncated = comment.body && comment.body.length > 500;
return buildMessage(
{
author,
title: t("events.discussion_comment.title", {
repo: repo ?? t("common.repository"),
number: discussion.number ?? "?",
title: discussion.title ?? t("common.untitled"),
}),
url: comment.html_url,
color: GITHUB_COLORS.discussion_comment,
description: `${t("events.discussion_comment.action_comment", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`,
},
t,
repo,
);
}

26
src/formatters/generic.ts Normal file
View file

@ -0,0 +1,26 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { type T, buildMessage } from "./helpers";
export function formatGeneric(
eventType: string,
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
_showEmoji: boolean,
): NeutralMessage {
return buildMessage(
{
author,
title: t("events.generic.title", {
repo: repo ?? t("common.repository"),
event: eventType,
action: payload.action ? `: ${payload.action}` : "",
}),
color: GITHUB_COLORS.default,
},
t,
repo,
);
}

25
src/formatters/helpers.ts Normal file
View file

@ -0,0 +1,25 @@
import type { NeutralMessage } from "../types";
import { t as translate } from "../lib/i18n";
import type { Translations } from "../lib/i18n";
export type T = (key: string, params?: Record<string, string | number>) => string;
export function emojiPrefix(emoji: string, show: boolean): string {
return show ? `${emoji} ` : "";
}
export function makeT(tr?: Translations): T {
return (key, params) => translate(key, params, undefined, tr);
}
export function buildMessage(
partial: Omit<Partial<NeutralMessage>, "title"> & { title: string },
t: T,
repo?: string
): NeutralMessage {
return {
...partial,
footer: partial.footer ?? t("common.footer", { repo: repo ?? t("common.github") }),
timestamp: partial.timestamp ?? new Date().toISOString(),
};
}

115
src/formatters/index.ts Normal file
View file

@ -0,0 +1,115 @@
import type { Route, WebhookEvent, NeutralMessage, NeutralAuthor } from "../types";
import type { Translations } from "../lib/i18n";
import { makeT, type T } from "./helpers";
import { formatPush } from "./push";
import { formatPullRequest } from "./pull-request";
import { formatPullRequestReview, formatPullRequestReviewComment } from "./review";
import { formatIssues } from "./issues";
import { formatIssueComment } from "./comments";
import { formatWorkflowRun } from "./workflow";
import { formatRelease } from "./release";
import { formatCreate, formatDelete } from "./create";
import { formatStar, formatFork } from "./repo";
import { formatCheckRun } from "./check";
import { formatCommitComment } from "./commit-comment";
import { formatDeploymentStatus } from "./deployment";
import { formatMember } from "./member";
import { formatLabel } from "./label";
import { formatMilestone } from "./milestone";
import { formatDiscussion, formatDiscussionComment } from "./discussion";
import { formatRepository } from "./repository";
import { formatCodeScanningAlert, formatDependabotAlert } from "./security";
import { formatGeneric } from "./generic";
export type { T } from "./helpers";
export { formatPush } from "./push";
export { formatPullRequest } from "./pull-request";
export { formatPullRequestReview, formatPullRequestReviewComment } from "./review";
export { formatIssues } from "./issues";
export { formatIssueComment } from "./comments";
export { formatWorkflowRun } from "./workflow";
export { formatRelease } from "./release";
export { formatCreate, formatDelete } from "./create";
export { formatStar, formatFork } from "./repo";
export { formatCheckRun } from "./check";
export { formatCommitComment } from "./commit-comment";
export { formatDeploymentStatus } from "./deployment";
export { formatMember } from "./member";
export { formatLabel } from "./label";
export { formatMilestone } from "./milestone";
export { formatDiscussion, formatDiscussionComment } from "./discussion";
export { formatRepository } from "./repository";
export { formatCodeScanningAlert, formatDependabotAlert } from "./security";
export { formatGeneric } from "./generic";
export function formatEvent(
route: Route,
event: WebhookEvent,
tr?: Translations,
showEmoji = true,
): NeutralMessage {
const { event: eventType, payload } = event;
const repo = (payload.repository as { full_name?: string })?.full_name;
const sender = (payload.sender as { login?: string })?.login;
const senderAvatar = (payload.sender as { avatar_url?: string })?.avatar_url;
const repoUrl = (payload.repository as { html_url?: string })?.html_url;
const t: T = makeT(tr);
const author: NeutralAuthor = {
name: sender ?? t("common.unknown"),
iconUrl: senderAvatar,
url: sender ? `https://github.com/${sender}` : undefined,
};
switch (eventType) {
case "push":
return formatPush(payload, repo, author, t, showEmoji);
case "pull_request":
return formatPullRequest(payload, repo, author, t, showEmoji);
case "pull_request_review":
return formatPullRequestReview(payload, repo, author, t, showEmoji);
case "pull_request_review_comment":
return formatPullRequestReviewComment(payload, repo, author, t, showEmoji);
case "issues":
return formatIssues(payload, repo, author, t, showEmoji);
case "issue_comment":
return formatIssueComment(payload, repo, author, t, showEmoji);
case "workflow_run":
return formatWorkflowRun(payload, repo, author, t, showEmoji);
case "release":
return formatRelease(payload, repo, author, t, showEmoji);
case "create":
return formatCreate(payload, repo, author, t, showEmoji);
case "delete":
return formatDelete(payload, repo, author, t, showEmoji);
case "star":
return formatStar(payload, repo, repoUrl, author, t, showEmoji);
case "fork":
return formatFork(payload, repo, repoUrl, author, t, showEmoji);
case "check_run":
return formatCheckRun(payload, repo, author, t, showEmoji);
case "commit_comment":
return formatCommitComment(payload, repo, author, t, showEmoji);
case "deployment_status":
return formatDeploymentStatus(payload, repo, author, t, showEmoji);
case "member":
return formatMember(payload, repo, author, t, showEmoji);
case "label":
return formatLabel(payload, repo, author, t, showEmoji);
case "milestone":
return formatMilestone(payload, repo, author, t, showEmoji);
case "discussion":
return formatDiscussion(payload, repo, author, t, showEmoji);
case "discussion_comment":
return formatDiscussionComment(payload, repo, author, t, showEmoji);
case "repository":
return formatRepository(payload, repo, repoUrl, author, t, showEmoji);
case "code_scanning_alert":
return formatCodeScanningAlert(payload, repo, author, t, showEmoji);
case "dependabot_alert":
return formatDependabotAlert(payload, repo, author, t, showEmoji);
default:
return formatGeneric(eventType, payload, repo, author, t, showEmoji);
}
}

87
src/formatters/issues.ts Normal file
View file

@ -0,0 +1,87 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatIssues(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "opened";
const issue = payload.issue as {
number?: number;
title?: string;
html_url?: string;
state?: string;
body?: string;
labels?: Array<{ name?: string; color?: string }>;
assignees?: Array<{ login?: string }>;
milestone?: { title?: string };
};
const colorKey =
action === "closed"
? "issues_closed"
: action === "reopened"
? "issues_reopened"
: action === "opened"
? "issues_opened"
: "issues_other";
const al = t("actions." + action) ?? action;
const stateEmoji = action === "closed" ? "🔴" : action === "opened" ? "🟢" : "🟣";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const descriptionParts: string[] = [];
descriptionParts.push(t("events.issues.action_issue", { emoji: em(stateEmoji), action: al }));
if (issue.body) {
const truncated = issue.body.slice(0, 300);
descriptionParts.push(`\n${truncated}${issue.body.length > 300 ? "..." : ""}`);
}
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (issue.labels && issue.labels.length > 0) {
fields.push({
name: t("fields.labels"),
value: issue.labels.map((l) => l.name).join(", "),
inline: true,
});
}
if (issue.assignees && issue.assignees.length > 0) {
fields.push({
name: t("fields.assignees"),
value: issue.assignees.map((a) => a.login).join(", "),
inline: true,
});
}
if (issue.milestone) {
fields.push({
name: t("fields.milestone"),
value: issue.milestone.title ?? t("common.unknown"),
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.issues.title", {
repo: repo ?? t("common.repository"),
number: issue.number ?? "?",
title: issue.title ?? t("common.untitled"),
}),
url: issue.html_url,
color: GITHUB_COLORS[colorKey],
description: descriptionParts.join("\n"),
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

64
src/formatters/label.ts Normal file
View file

@ -0,0 +1,64 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatLabel(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const label = payload.label as {
name?: string;
color?: string;
description?: string;
};
const al = t("actions." + action) ?? action;
const emoji = action === "deleted" ? "🗑️" : action === "edited" ? "✏️" : "🏷️";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (label.name) {
fields.push({
name: t("fields.label"),
value: label.name,
inline: true,
});
}
if (label.color) {
fields.push({
name: t("fields.color"),
value: `#${label.color}`,
inline: true,
});
}
if (label.description) {
fields.push({
name: t("fields.description"),
value: label.description,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.label.title", {
repo: repo ?? t("common.repository"),
emoji: em(emoji),
action: al,
name: label.name ?? t("common.unknown"),
}),
color: GITHUB_COLORS.label,
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

34
src/formatters/member.ts Normal file
View file

@ -0,0 +1,34 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatMember(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "added";
const member = payload.member as { login?: string } | undefined;
const al = t("actions." + action) ?? action;
const emoji = action === "added" ? "" : action === "removed" ? "" : "👤";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const memberName = member?.login ?? t("common.unknown");
return buildMessage(
{
author,
title: t("events.member.title", {
repo: repo ?? t("common.repository"),
emoji: em(emoji),
action: al,
name: memberName,
}),
color: action === "added" ? GITHUB_COLORS.member_added : GITHUB_COLORS.member_removed,
},
t,
repo,
);
}

View file

@ -0,0 +1,83 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatMilestone(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const milestone = payload.milestone as {
title?: string;
number?: number;
state?: string;
open_issues?: number;
closed_issues?: number;
due_on?: string;
html_url?: string;
};
const al = t("actions." + action) ?? action;
const stateEmoji = milestone.state === "closed" ? "✅" : "🔵";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (milestone.title) {
fields.push({
name: t("fields.milestone"),
value: milestone.title,
inline: true,
});
}
if (milestone.number) {
fields.push({
name: t("fields.number"),
value: `#${milestone.number}`,
inline: true,
});
}
if (milestone.open_issues != null && milestone.closed_issues != null) {
const total = milestone.open_issues + milestone.closed_issues;
const pct = total > 0 ? Math.round((milestone.closed_issues / total) * 100) : 0;
const bar = pct >= 75 ? "🟢🟢🟢" : pct >= 50 ? "🟡🟡" : pct > 0 ? "🟠" : "⬜";
fields.push({
name: t("fields.progress"),
value: `${bar} ${milestone.closed_issues}/${total} (${pct}%)`,
inline: false,
});
}
if (milestone.due_on) {
fields.push({
name: t("fields.due"),
value: milestone.due_on.split("T")[0],
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.milestone.title", {
repo: repo ?? t("common.repository"),
emoji: em(stateEmoji),
action: al,
title: milestone.title ?? t("common.unknown"),
}),
url: milestone.html_url,
color:
milestone.state === "closed"
? GITHUB_COLORS.milestone_closed
: GITHUB_COLORS.milestone_opened,
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

View file

@ -0,0 +1,112 @@
import type { NeutralMessage, NeutralAuthor, NeutralAction } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatPullRequest(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "opened";
const pr = payload.pull_request as {
number?: number;
title?: string;
html_url?: string;
state?: string;
draft?: boolean;
merged?: boolean;
head?: { ref?: string; sha?: string };
base?: { ref?: string };
body?: string;
labels?: Array<{ name?: string; color?: string }>;
changed_files?: number;
additions?: number;
deletions?: number;
};
const colorKey = pr.merged
? "pull_request_merged"
: action === "closed"
? "pull_request_closed"
: action === "ready_for_review"
? "pull_request_ready_for_review"
: action === "opened"
? "pull_request_opened"
: "pull_request_other";
const al = t("actions." + action) ?? action;
const stateEmoji = pr.merged
? "🟣"
: action === "closed"
? "🔴"
: action === "opened"
? "🟢"
: "🔵";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const descriptionParts: string[] = [];
descriptionParts.push(t("events.pr.action_pr", { emoji: em(stateEmoji), action: al }));
if (pr.body) {
const truncated = pr.body.slice(0, 300);
descriptionParts.push(`\n${truncated}${pr.body.length > 300 ? "..." : ""}`);
}
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (pr.head?.ref && pr.base?.ref) {
fields.push({
name: t("fields.branch"),
value: `\`${pr.head.ref}\`\`${pr.base.ref}\``,
inline: true,
});
}
if (pr.changed_files != null || pr.additions != null || pr.deletions != null) {
const parts: string[] = [];
if (pr.additions != null) parts.push(`+${pr.additions}`);
if (pr.deletions != null) parts.push(`-${pr.deletions}`);
if (pr.changed_files != null) parts.push(t("common.n_files", { count: pr.changed_files }));
fields.push({
name: t("fields.changes"),
value: parts.join(" | "),
inline: true,
});
}
if (pr.labels && pr.labels.length > 0) {
fields.push({
name: t("fields.labels"),
value: pr.labels.map((l) => l.name).join(", "),
inline: true,
});
}
const actionable = pr.state === "open" && !!repo && pr.number != null;
const actions: NeutralAction[] | undefined = actionable
? [
{ id: `ghpr|merge|${repo}|${pr.number}`, label: "合并", style: "primary" },
{ id: `ghpr|close|${repo}|${pr.number}`, label: "关闭", style: "danger" },
]
: undefined;
return buildMessage(
{
author,
title: t("events.pr.title", {
repo: repo ?? t("common.repository"),
number: pr.number ?? "?",
title: pr.title ?? t("common.untitled"),
}),
url: pr.html_url,
color: GITHUB_COLORS[colorKey],
description: descriptionParts.join("\n"),
fields: fields.length > 0 ? fields : undefined,
actions,
},
t,
repo,
);
}

106
src/formatters/push.ts Normal file
View file

@ -0,0 +1,106 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatPush(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const ref = (payload.ref as string)?.replace("refs/heads/", "").replace("refs/tags/", "tag: ");
const commits = (payload.commits ?? []) as Array<{
id?: string;
message?: string;
author?: { name?: string; email?: string };
added?: string[];
removed?: string[];
modified?: string[];
}>;
const count = commits.length;
const compareUrl = payload.compare as string | undefined;
const forced = payload.forced as boolean | undefined;
const created = payload.created as boolean | undefined;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const descriptionParts: string[] = [];
if (forced) {
descriptionParts.push(em("⚠️") + t("events.push.force_push"));
}
if (created) {
descriptionParts.push(em("🆕") + t("events.push.branch_created"));
}
descriptionParts.push(
t("events.push.commits_pushed", { count, s: count !== 1 ? "s" : "", ref: ref ?? "" }),
);
if (compareUrl) {
descriptionParts.push(t("events.push.view_comparison", { url: compareUrl }));
}
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (count <= 5) {
for (const c of commits) {
const shortId = c.id?.slice(0, 7) ?? "???????";
const msg = c.message?.split("\n")[0].slice(0, 72) ?? t("common.no_message");
fields.push({
name: `\`${shortId}\``,
value: msg,
inline: false,
});
}
} else {
const first3 = commits.slice(0, 3);
for (const c of first3) {
const shortId = c.id?.slice(0, 7) ?? "???????";
const msg = c.message?.split("\n")[0].slice(0, 72) ?? t("common.no_message");
fields.push({
name: `\`${shortId}\``,
value: msg,
inline: false,
});
}
fields.push({
name: `\u200b`,
value: t("common.and_n_more", { count: count - 3 }),
inline: false,
});
}
const added = commits.flatMap((c) => c.added ?? []);
const removed = commits.flatMap((c) => c.removed ?? []);
const modified = commits.flatMap((c) => c.modified ?? []);
if (added.length > 0 || removed.length > 0 || modified.length > 0) {
const changes: string[] = [];
if (added.length > 0) changes.push(t("events.push.added", { count: added.length }));
if (removed.length > 0) changes.push(t("events.push.removed", { count: removed.length }));
if (modified.length > 0) changes.push(t("events.push.modified", { count: modified.length }));
fields.push({
name: t("fields.changes"),
value: changes.join(" | "),
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.push.title", {
count,
s: count !== 1 ? "s" : "",
repo: repo ?? t("common.repository"),
}),
url: compareUrl,
color: GITHUB_COLORS.push,
description: descriptionParts.join("\n"),
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

62
src/formatters/release.ts Normal file
View file

@ -0,0 +1,62 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatRelease(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "published";
const release = payload.release as {
tag_name?: string;
name?: string;
html_url?: string;
body?: string;
prerelease?: boolean;
draft?: boolean;
author?: { login?: string };
};
const isPrerelease = release.prerelease;
const colorKey =
action === "deleted"
? "release_deleted"
: isPrerelease
? "release_prerelease"
: "release_published";
const al = t("actions." + action) ?? action;
const emoji = action === "deleted" ? "🗑️" : isPrerelease ? "⚠️" : "🚀";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const descriptionParts: string[] = [];
descriptionParts.push(
t("events.release.action_release", {
emoji: em(emoji),
action: al,
tag: release.tag_name ?? t("common.unknown"),
}),
);
if (release.body) {
const truncated = release.body.slice(0, 300);
descriptionParts.push(`\n${truncated}${release.body.length > 300 ? "..." : ""}`);
}
return buildMessage(
{
author,
title: t("events.release.title", {
repo: repo ?? t("common.repository"),
name: release.name ?? release.tag_name ?? "Release",
}),
url: release.html_url,
color: GITHUB_COLORS[colorKey],
description: descriptionParts.join("\n"),
},
t,
repo,
);
}

58
src/formatters/repo.ts Normal file
View file

@ -0,0 +1,58 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatStar(
payload: Record<string, unknown>,
repo: string | undefined,
repoUrl: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const actionLabel = action === "created" ? t("events.star.starred") : t("events.star.unstarred");
const em = (e: string): string => emojiPrefix(e, showEmoji);
return buildMessage(
{
author,
title: t("events.star.title", {
repo: repo ?? t("common.repository"),
emoji: em(action === "created" ? "⭐" : "💫"),
label: actionLabel,
}),
url: repoUrl,
color: GITHUB_COLORS.star,
},
t,
repo,
);
}
export function formatFork(
payload: Record<string, unknown>,
repo: string | undefined,
repoUrl: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const forkee = payload.forkee as { full_name?: string; html_url?: string } | undefined;
const em = (e: string): string => emojiPrefix(e, showEmoji);
return buildMessage(
{
author,
title: t("events.fork.title", {
repo: repo ?? t("common.repository"),
emoji: em("🍴"),
forkee: forkee?.full_name ?? t("common.unknown"),
}),
url: forkee?.html_url ?? repoUrl,
color: GITHUB_COLORS.fork,
},
t,
repo,
);
}

View file

@ -0,0 +1,92 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatRepository(
payload: Record<string, unknown>,
repo: string | undefined,
repoUrl: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (action === "renamed") {
const changes = payload.changes as { name?: { from?: string } } | undefined;
const newName =
(payload.repository as { full_name?: string })?.full_name ?? t("common.unknown");
fields.push({
name: t("fields.renamed"),
value: changes?.name?.from ? `${changes.name.from}${newName}` : newName,
inline: false,
});
}
if (action === "transferred") {
const changes = payload.changes as { owner?: { from?: { login?: string } } } | undefined;
const newOwner =
(payload.repository as { owner?: { login?: string } })?.owner?.login ?? t("common.unknown");
fields.push({
name: t("fields.transferred"),
value: changes?.owner?.from?.login ? `${changes.owner.from.login}${newOwner}` : newOwner,
inline: false,
});
}
const repoData = payload.repository as {
visibility?: string;
fork?: boolean;
description?: string | null;
};
const isCreateOrVisibility =
action === "created" || action === "publicized" || action === "privatized";
if (isCreateOrVisibility) {
if (repoData.visibility) {
fields.push({
name: t("events.repository.visibility"),
value: t("events.repository." + repoData.visibility) ?? repoData.visibility,
inline: true,
});
}
if (repoData.fork) {
fields.push({
name: t("common.repository"),
value: t("events.repository.is_fork"),
inline: true,
});
}
}
const descriptionParts: string[] = [];
if (isCreateOrVisibility && repoUrl) {
descriptionParts.push(`[${em("🔗")}${t("events.repository.open")}](${repoUrl})`);
}
if (isCreateOrVisibility && repoData.description) {
descriptionParts.push(`> ${repoData.description}`);
}
return buildMessage(
{
author,
title: t("events.repository.title", {
repo: repo ?? t("common.repository"),
emoji: em("📦"),
action: al,
}),
url: repoUrl,
color: GITHUB_COLORS.repository,
description: descriptionParts.length > 0 ? descriptionParts.join("\n") : undefined,
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

114
src/formatters/review.ts Normal file
View file

@ -0,0 +1,114 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatPullRequestReview(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "submitted";
const review = payload.review as {
state?: string;
body?: string;
html_url?: string;
};
const pr = payload.pull_request as {
number?: number;
title?: string;
html_url?: string;
};
const state = review.state ?? "commented";
const colorKey =
state === "approved"
? "pull_request_review_approved"
: state === "changes_requested"
? "pull_request_review_changes"
: "pull_request_review_commented";
const stateEmoji = state === "approved" ? "✅" : state === "changes_requested" ? "🔴" : "💬";
const al = t("actions." + action) ?? state;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const descriptionParts: string[] = [];
descriptionParts.push(t("events.pr_review.action_review", { emoji: em(stateEmoji), action: al }));
if (review.body) {
const truncated = review.body.slice(0, 500);
descriptionParts.push(`\n> ${truncated}${review.body.length > 500 ? "..." : ""}`);
}
return buildMessage(
{
author,
title: t("events.pr_review.title", {
repo: repo ?? t("common.repository"),
number: pr.number ?? "?",
title: pr.title ?? t("common.untitled"),
}),
url: review.html_url,
color: GITHUB_COLORS[colorKey],
description: descriptionParts.join("\n"),
},
t,
repo,
);
}
export function formatPullRequestReviewComment(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const comment = payload.comment as {
body?: string;
path?: string;
position?: number | null;
html_url?: string;
};
const pr = payload.pull_request as {
number?: number;
title?: string;
};
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const commentBody = comment.body?.slice(0, 400) ?? "";
const truncated = comment.body && comment.body.length > 400;
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
if (comment.path) {
const loc =
comment.position != null
? t("events.pr_review_comment.line", { position: comment.position })
: "";
fields.push({
name: t("fields.file"),
value: `\`${comment.path}\`${loc}`,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.pr_review_comment.title", {
repo: repo ?? t("common.repository"),
number: pr.number ?? "?",
title: pr.title ?? t("common.untitled"),
}),
url: comment.html_url,
color: GITHUB_COLORS.pull_request_review_commented,
description: `${t("events.pr_review_comment.action_inline", { emoji: em("💬"), action: al })}\n\n> ${commentBody}${truncated ? "..." : ""}`,
fields: fields.length > 0 ? fields : undefined,
},
t,
repo,
);
}

173
src/formatters/security.ts Normal file
View file

@ -0,0 +1,173 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatCodeScanningAlert(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const alert = payload.alert as {
rule?: { id?: string; severity?: string; description?: string };
most_recent_instance?: { location?: { path?: string } };
state?: string;
};
const severity = alert.rule?.severity ?? "warning";
const colorKey =
severity === "critical"
? "code_scanning_critical"
: severity === "high"
? "code_scanning_high"
: severity === "medium"
? "code_scanning_medium"
: "code_scanning_low";
const severityEmoji =
severity === "critical"
? "🔴"
: severity === "high"
? "🟠"
: severity === "medium"
? "🟡"
: "⚪";
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.severity"),
value: `${em(severityEmoji)}${severity}`,
inline: true,
});
if (alert.rule?.id) {
fields.push({
name: t("fields.rule"),
value: alert.rule.id,
inline: true,
});
}
if (alert.most_recent_instance?.location?.path) {
fields.push({
name: t("fields.file"),
value: `\`${alert.most_recent_instance.location.path}\``,
inline: false,
});
}
if (alert.rule?.description) {
fields.push({
name: t("fields.description"),
value: alert.rule.description,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.code_scanning.title", {
repo: repo ?? t("common.repository"),
action: al,
}),
color: GITHUB_COLORS[colorKey],
fields,
},
t,
repo,
);
}
export function formatDependabotAlert(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const action = (payload.action as string) ?? "created";
const alert = payload.alert as {
security_advisory?: { severity?: string; summary?: string; description?: string };
security_vulnerability?: {
package?: { name?: string };
vulnerable_version_range?: string;
first_patched_version?: { identifier?: string };
};
state?: string;
dependency?: { package?: { name?: string } };
html_url?: string;
};
const severity = alert.security_advisory?.severity ?? "medium";
const colorKey =
severity === "critical"
? "dependabot_critical"
: severity === "high"
? "dependabot_high"
: severity === "medium"
? "dependabot_medium"
: "dependabot_low";
const severityEmoji =
severity === "critical"
? "🔴"
: severity === "high"
? "🟠"
: severity === "medium"
? "🟡"
: "⚪";
const al = t("actions." + action) ?? action;
const em = (e: string): string => emojiPrefix(e, showEmoji);
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.severity"),
value: `${em(severityEmoji)}${severity}`,
inline: true,
});
const pkgName = alert.security_vulnerability?.package?.name ?? alert.dependency?.package?.name;
if (pkgName) {
fields.push({
name: t("fields.package"),
value: pkgName,
inline: true,
});
}
if (alert.security_vulnerability?.vulnerable_version_range) {
const patched = alert.security_vulnerability.first_patched_version?.identifier;
fields.push({
name: t("fields.vulnerable_range"),
value: `${alert.security_vulnerability.vulnerable_version_range}${patched ? ` → fix: \`${patched}\`` : ""}`,
inline: false,
});
}
if (alert.security_advisory?.summary) {
fields.push({
name: t("fields.summary"),
value: alert.security_advisory.summary,
inline: false,
});
}
return buildMessage(
{
author,
title: t("events.dependabot.title", { repo: repo ?? t("common.repository"), action: al }),
url: alert.html_url,
color: GITHUB_COLORS[colorKey],
fields,
},
t,
repo,
);
}

100
src/formatters/workflow.ts Normal file
View file

@ -0,0 +1,100 @@
import type { NeutralMessage, NeutralAuthor } from "../types";
import { GITHUB_COLORS, WORKFLOW_CONCLUSION_EMOJI } from "./colors";
import { emojiPrefix, type T, buildMessage } from "./helpers";
export function formatWorkflowRun(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
showEmoji: boolean,
): NeutralMessage {
const workflow = payload.workflow_run as {
name?: string;
conclusion?: string;
html_url?: string;
head_branch?: string;
run_number?: number;
created_at?: string;
updated_at?: string;
elapsed_seconds?: number;
jobs?: Array<{ name?: string; conclusion?: string }>;
};
const action = payload.action as string | undefined;
const status =
action === "in_progress"
? "running"
: action === "requested"
? "queued"
: (workflow.conclusion ?? "pending");
const emoji = WORKFLOW_CONCLUSION_EMOJI[status] ?? "⏳";
const em = (e: string): string => emojiPrefix(e, showEmoji);
const colorKey =
status === "success"
? "workflow_run_success"
: status === "failure"
? "workflow_run_failure"
: "workflow_run_other";
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
fields.push({
name: t("fields.status"),
value: `${em(emoji)}${status}`,
inline: true,
});
if (workflow.jobs?.length) {
const jobLines = workflow.jobs.map(
(j) => `${em(WORKFLOW_CONCLUSION_EMOJI[j.conclusion ?? ""] ?? "⏳")}${j.name ?? ""}`,
);
fields.push({
name: t("fields.job"),
value: jobLines.join("\n"),
inline: false,
});
}
if (workflow.head_branch) {
fields.push({
name: t("fields.branch"),
value: `\`${workflow.head_branch}\``,
inline: true,
});
}
if (workflow.run_number) {
fields.push({
name: t("fields.run"),
value: `#${workflow.run_number}`,
inline: true,
});
}
if (workflow.elapsed_seconds != null) {
const mins = Math.floor(workflow.elapsed_seconds / 60);
const secs = workflow.elapsed_seconds % 60;
fields.push({
name: t("fields.duration"),
value: `${mins}m ${secs}s`,
inline: true,
});
}
return buildMessage(
{
author,
title: t("events.workflow_run.title", {
repo: repo ?? t("common.repository"),
name: workflow.name ?? "Workflow",
conclusion: status,
}),
url: workflow.html_url,
color: GITHUB_COLORS[colorKey],
fields,
},
t,
repo,
);
}

View file

@ -1,5 +1,5 @@
import { Octokit } from "octokit";
import { saveToken, getToken } from "./token-store";
import { saveToken, getToken } from "./store";
export function getOAuthURL(clientId: string, state: string): string {
return `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=repo&state=${state}`;

View file

@ -1,7 +1,7 @@
import { createServer } from "./server";
import { syncCommands } from "./discord-interactions";
import { syncCommands } from "./drivers/discord/commands";
import type { Env } from "./types";
import { log } from "./log";
import { log } from "./lib/log";
const app = createServer();

View file

@ -1,15 +1,16 @@
import { Hono } from "hono";
import type { Env } from "./types";
import { verifySignature, parseEvent } from "./webhook";
import { dispatchEvent } from "./discord";
import { handleInteractionRequest } from "./discord-interactions";
import { createOAuthRoutes } from "./oauth-routes";
import { createActionRoutes } from "./action-routes";
import { createAdminRoutes } from "./admin-routes";
import { createLegalRoutes } from "./legal-routes";
import { createHomeRoutes } from "./home-routes";
import { verifySignature } from "./events/verify";
import { parseEvent } from "./events/parse";
import { dispatchEvent } from "./core/dispatch";
import { handleInteractionRequest } from "./drivers/discord/interactions";
import { createOAuthRoutes } from "./web/oauth-routes";
import { createActionRoutes } from "./web/action-routes";
import { createAdminRoutes } from "./web/admin-routes";
import { createLegalRoutes } from "./web/legal-routes";
import { createHomeRoutes } from "./web/home-routes";
import { loadConfig } from "./config";
import { log } from "./log";
import { log } from "./lib/log";
const MAX_BODY_SIZE = 1024 * 1024;

View file

@ -84,6 +84,38 @@ export interface WebhookEvent {
signature?: string;
}
export interface NeutralAuthor {
name: string;
iconUrl?: string;
url?: string;
}
export interface NeutralField {
name: string;
value: string;
inline?: boolean;
}
export type NeutralActionStyle = "primary" | "danger" | "secondary";
export interface NeutralAction {
id: string;
label: string;
style: NeutralActionStyle;
}
export interface NeutralMessage {
author?: NeutralAuthor;
title: string;
url?: string;
color?: number;
description?: string;
fields?: NeutralField[];
footer?: string;
timestamp?: string;
actions?: NeutralAction[];
}
export interface FormattedMessage {
embeds?: Array<{
title?: string;

View file

@ -1,8 +1,8 @@
import { Hono } from "hono";
import { getUserOctokit } from "./github-oauth";
import { findUserIdByToken } from "./token-store";
import type { Env } from "./types";
import { log } from "./log";
import { getUserOctokit } from "../github/oauth";
import { findUserIdByToken } from "../github/store";
import type { Env } from "../types";
import { log } from "../lib/log";
function extractBearerToken(c: {
req: { header: (name: string) => string | undefined };

View file

@ -1,15 +1,15 @@
import { Hono } from "hono";
import type { Env, Route, Group } from "./types";
import { loadRoutes, saveRoutes } from "./config";
import type { Env, Route, Group } from "../types";
import { loadRoutes, saveRoutes } from "../config";
import {
getAdminSession,
destroyAdminSession,
clearAdminCookie,
type AdminSession,
} from "./admin-session";
} from "./session";
import { loadGroups, saveGroups, resolveScope, hasAnyAccess, type AccessScope } from "./groups";
import { getSendLog } from "./send-log";
import { log } from "./log";
import { getSendLog } from "../lib/send-log";
import { log } from "../lib/log";
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;

View file

@ -1,6 +1,6 @@
import type { Env, Group } from "./types";
import { isAdminUser } from "./admin-session";
import { log } from "./log";
import type { Env, Group } from "../types";
import { isAdminUser } from "./session";
import { log } from "../lib/log";
const GROUPS_KEY = "config:groups";

View file

@ -1,5 +1,5 @@
import { Hono } from "hono";
import type { Env } from "./types";
import type { Env } from "../types";
type Lang = "zh" | "en";

View file

@ -1,5 +1,5 @@
import { Hono } from "hono";
import type { Env } from "./types";
import type { Env } from "../types";
type Lang = "zh" | "en";

View file

@ -1,9 +1,9 @@
import { Hono } from "hono";
import { getOAuthURL, handleOAuthCallback } from "./github-oauth";
import { removeToken, saveDiscordLink } from "./token-store";
import { createAdminSession, adminCookie } from "./admin-session";
import { getOAuthURL, handleOAuthCallback } from "../github/oauth";
import { removeToken, saveDiscordLink } from "../github/store";
import { createAdminSession, adminCookie } from "./session";
import { loadGroups, resolveScope, hasAnyAccess } from "./groups";
import type { Env } from "./types";
import type { Env } from "../types";
interface PendingState {
redirectTo: string;

View file

@ -1,4 +1,4 @@
import type { Env } from "./types";
import type { Env } from "../types";
const SESSION_COOKIE = "wh_admin_session";
const SESSION_TTL = 7 * 24 * 3600;