mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat: migrate to Nuxt 4 (Nitro) and Tailwind CSS v3
This commit is contained in:
parent
f4959eebf8
commit
b139712a91
166 changed files with 19790 additions and 5539 deletions
|
|
@ -26,7 +26,7 @@ ADMIN_USER_IDS=your-github-id,your-github-login
|
|||
# Server / public URL (OAuth callbacks + Telegram webhook sync)
|
||||
BASE_URL=http://localhost:8787
|
||||
|
||||
# Optional
|
||||
# DOCS_URL=https://your-docs-site
|
||||
# GITHUB_REPO_URL=https://github.com/ReCloudStudio/WebHooker
|
||||
# LEGAL_CONTACT=contact@example.com
|
||||
# Optional (public runtime config, consumed by the Nuxt pages)
|
||||
# NUXT_PUBLIC_DOCS_URL=https://your-docs-site
|
||||
# NUXT_PUBLIC_REPO_URL=https://github.com/ReCloudStudio/WebHooker
|
||||
# NUXT_PUBLIC_LEGAL_CONTACT=contact@example.com
|
||||
|
|
|
|||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -1,9 +1,8 @@
|
|||
node_modules/
|
||||
dist/
|
||||
admin/node_modules/
|
||||
admin/.output/
|
||||
admin/.nuxt/
|
||||
admin/dist
|
||||
.output/
|
||||
.nuxt/
|
||||
admin/
|
||||
docs/.vitepress/dist/
|
||||
docs/.vitepress/cache/
|
||||
.env
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
.output
|
||||
.nuxt
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
node_modules
|
||||
dist
|
||||
data
|
||||
.output
|
||||
.nuxt
|
||||
wrangler.jsonc
|
||||
bun.lock
|
||||
|
|
|
|||
153
AGENTS.md
153
AGENTS.md
|
|
@ -2,18 +2,19 @@
|
|||
|
||||
## Project Purpose
|
||||
|
||||
Cloudflare Worker that receives GitHub webhooks and dispatches processed events to Discord channels/threads and Telegram chats/topics, and receives Discord interactions (slash commands, buttons, modals) via the Interactions Endpoint plus Telegram bot `/gh` commands via the Telegram webhook.
|
||||
Nuxt 4 (Nitro) app deployed as a Cloudflare Worker that receives GitHub webhooks and dispatches processed events to Discord channels/threads and Telegram chats/topics, and receives Discord interactions (slash commands, buttons, modals) via the Interactions Endpoint plus Telegram bot `/gh` commands via the Telegram webhook.
|
||||
|
||||
Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord (REST) / Telegram (Bot API)
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- Runtime: Cloudflare Workers
|
||||
- HTTP framework: Hono
|
||||
- Runtime: Cloudflare Workers via the Nitro `cloudflare_module` preset (`_worker.js`), H3 event handlers in `server/routes/`
|
||||
- UI: Vue 3 + Tailwind CSS v3 (`@nuxtjs/tailwindcss`); admin console is a client-side SPA (`routeRules: "/admin/**": { ssr: false }`), home/legal pages render server-side
|
||||
- Styling: all theme colors are RGB-triplet CSS variables in `app/assets/css/main.css` mapped into `tailwind.config.ts` (so `bg-accent/10` opacity modifiers work); the design tokens switch with `prefers-color-scheme` (unless `<html data-theme="light">`); repeated control patterns are `@apply` component classes in the CSS `@layer components`
|
||||
- Discord interactions: HTTPS Interactions Endpoint (`POST /discord/interactions`, Ed25519-signed) — no Discord Gateway / Durable Object; bot stays offline, messages always sent via REST
|
||||
- Storage: Cloudflare KV (tokens, OAuth state, route config `config:routes`, group config `config:groups`, admin sessions, delivery dedup, message-update tracking `msg:*`, i18n overrides `i18n:*`) + D1 (`send_logs`, `discord_links`, `telegram_links`)
|
||||
- Signature verification: Web Crypto API (HMAC-SHA256 for GitHub/Gitea, Ed25519 for Discord, timing-safe secret-token compare for Telegram)
|
||||
- Webhook providers: pluggable forge adapters under `src/providers/` (github, gitea) — each verifies its own signature format and normalizes its payload to a GitHub-shaped `WebhookEvent`; a `custom` provider accepts arbitrary signed JSON posts (`X-WebHooker-Signature`) as `custom` events; GitLab etc. can be added later
|
||||
- Webhook providers: pluggable forge adapters under `server/lib/providers/` (github, gitea) — each verifies its own signature format and normalizes its payload to a GitHub-shaped `WebhookEvent`; a `custom` provider accepts arbitrary signed JSON posts (`X-WebHooker-Signature`) as `custom` events; GitLab etc. can be added later
|
||||
- Per-group webhook ingress: optional `POST /webhook/{groupId}` with a per-group secret in KV (`tenant:{groupId}`) — Gitea/classic-GitHub/custom webhooks are verified against the group's secret instead of the operator's global ones; only that group's routes fire. The legacy `POST /webhook` (global secrets, all routes) stays untouched
|
||||
- GitHub App tenant isolation: `Group.installationId` binds a group to one GitHub App installation; events whose `payload.installation.id` differs are rejected at dispatch (hard isolation on top of the optional `owners` list). The App's Setup URL points at `GET /auth/github/install`, which renders a choice page (create `inst-{installationId}` or bind to a group the signed-in user owns, verified by role); `POST /auth/github/install/bind` performs the provisioning. `installation.created` webhook events auto-provision as a fallback (create `inst-{installationId}` or bind existing groups whose `owners` match the installing account)
|
||||
- GitHub OAuth: octokit (token is stored hashed for reverse lookup)
|
||||
|
|
@ -27,73 +28,84 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord
|
|||
## Architecture
|
||||
|
||||
```text
|
||||
src/
|
||||
├── index.ts # CF Workers entry (fetch + scheduled), scheduled = Discord command sync + Telegram webhook sync
|
||||
├── types.ts # Env, Config, Route, Filter, Group, WebhookEvent, NeutralMessage
|
||||
├── config.ts # loadRoutes/saveRoutes (KV config:routes, cache w/ 60s TTL), loadConfig from env
|
||||
├── server.ts # Hono app: /health, /webhook, /webhook/:groupId, /discord/interactions, /telegram/webhook, mounts /auth, /admin + /
|
||||
├── webhook.ts # processWebhook/handleWebhook: tenant lookup, provider detect/verify/parse, dedup, scoped dispatch
|
||||
├── core/
|
||||
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (recordSend + group filter + per-group webhook log)
|
||||
├── events/ # Provider-agnostic route matching
|
||||
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword regex filtering
|
||||
├── providers/ # Forge webhook providers (verify + parse/normalize to GitHub-shaped events)
|
||||
│ ├── types.ts # Provider interface (matches/verify/parse)
|
||||
│ ├── hmac.ts # HMAC-SHA256 + timing-safe compare helpers
|
||||
│ ├── index.ts # detectProvider() registry (gitea, github, custom)
|
||||
│ ├── github/ # X-GitHub-Event + X-Hub-Signature-256 ("sha256=" prefix); extracts installation.id
|
||||
│ │ ├── verify.ts # HMAC signature verify
|
||||
│ │ └── parse.ts # parseEvent (headers + body → WebhookEvent)
|
||||
│ ├── gitea/ # X-Gitea-Event + X-Gitea-Signature (plain hex HMAC)
|
||||
│ │ ├── verify.ts # HMAC signature verify (no prefix)
|
||||
│ │ └── parse.ts # parse + normalize Gitea payloads to GitHub shape
|
||||
│ └── custom/ # X-WebHooker-Signature (sha256= HMAC) + arbitrary JSON → `custom` events
|
||||
│ └── index.ts # matches/verify/parse for non-forge senders
|
||||
├── formatters/ # Platform-neutral message formatters (was formatter.ts)
|
||||
│ ├── index.ts # formatEvent: 29-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, ping, custom
|
||||
├── drivers/ # Platform drivers (pluggable push targets)
|
||||
│ ├── types.ts # PlatformDriver interface + SendResult (send + edit)
|
||||
│ ├── index.ts # getDriver() registry (discord default + telegram)
|
||||
│ ├── discord/
|
||||
│ │ ├── index.ts # DiscordDriver: send/edit → renderNeutralMessage + rest.sendMessage/editMessage
|
||||
│ │ ├── render.ts # renderNeutralMessage: NeutralMessage → Discord FormattedMessage
|
||||
│ │ ├── rest.ts # Discord REST sendMessage/editMessage 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: send/edit → renderNeutralMessage + rest.sendMessage (avatar rich-header card)
|
||||
│ ├── render.ts # renderNeutralMessage: NeutralMessage → Telegram HTML (parse_mode HTML)
|
||||
│ ├── rest.ts # Telegram Bot API sendMessage/sendPhoto/editMessage* (chat_id + message_thread_id), retry
|
||||
│ ├── updates.ts # POST /telegram/webhook: secret-token verify + handleTelegramUpdate
|
||||
│ └── commands.ts # Telegram /gh login|logout|comment|merge|close + reply-message parsing + syncTelegramWebhook
|
||||
├── github/
|
||||
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/getComment/editComment/deleteComment/merge/close actions
|
||||
│ └── store.ts # KV token CRUD + D1 discord-link/telegram-link mapping (was token-store.ts)
|
||||
├── web/ # HTTP UI/API routes
|
||||
│ ├── oauth-routes.ts # GET /auth/github, callback (admin session / invite accept / self-signup / discord-link / telegram-link), DELETE /token/:userId
|
||||
│ ├── action-routes.ts # POST /api/comment|merge|close|react (Bearer token auth via shared middleware)
|
||||
│ ├── admin-routes.ts # /admin UI + GET/PUT /admin/api/routes|groups|me|logs|invites|audit (auth middleware + role guards, validation)
|
||||
│ ├── auth.ts # Shared auth middleware + guards: sessionMiddleware, requireAnyAccess, requireGroup(Role), bearerAuthMiddleware, clientIp
|
||||
│ ├── invites.ts # Invite CRUD (KV invite:{token}, 7d TTL) + acceptInvite (join group as admin/viewer)
|
||||
│ ├── session.ts # Session CRUD (KV session:{id}), isAdminUser, cookie helpers
|
||||
│ ├── groups.ts # Group CRUD (config:groups), member roles (normalizeGroupMembers/memberRole), resolveScope + role helpers (roleAt/canEditRoutes/canEditGroup)
|
||||
│ ├── tenants.ts # Per-group webhook secret CRUD (KV tenant:{groupId}, 32-byte random hex)
|
||||
│ ├── home-routes.ts # landing page (zh/en)
|
||||
│ ├── legal-routes.ts # /terms + /privacy pages (zh/en)
|
||||
│ └── richheader-routes.ts # GET /api/richheader: Open Graph page for Telegram avatar link-preview card
|
||||
└── lib/ # shared infra
|
||||
app/ # Vue 3 UI (Nuxt app dir)
|
||||
├── app.vue # root component (NuxtPage)
|
||||
├── assets/css/main.css # Tailwind entry: theme tokens (RGB-triplet vars) + @layer components (@apply) + Vue transition glue
|
||||
├── pages/ # index (landing), terms, privacy, admin/[...slug] (console SPA)
|
||||
├── components/ # ConsolePage, RouteCard/Editor, GroupEditor, MembersPanel, WebhookPanel,
|
||||
│ # SendLogs, AuditLog, AppToasts, LegalLayout
|
||||
├── composables/ # useI18n, useToasts, useGroups, useGroupRoutes, useLogs, useAudit, useInvites, useWebhook
|
||||
├── types.ts # shared client types (Route, Group, Filter, ...)
|
||||
└── utils/legal.ts # terms/privacy HTML bodies (zh/en)
|
||||
server/ # Nitro server
|
||||
├── routes/ # H3 handlers: /health, /webhook[/:groupId], /discord/interactions, /telegram/webhook,
|
||||
│ # /auth/github*, /admin/{login,logout,invite,api/**}, /api/{comment,merge,close,react,richheader}
|
||||
├── tasks/ # scheduled (cron */5): discord-sync, telegram-sync, audit-prune
|
||||
├── error-handler.ts # JSON error handler
|
||||
└── lib/
|
||||
├── types.ts # Env, Config, Route, Filter, Group, WebhookEvent, NeutralMessage
|
||||
├── config.ts # loadRoutes/saveRoutes (KV config:routes, cache w/ 60s TTL), loadConfig from env
|
||||
├── cf.ts # cfEnv(event) — env bindings from event.context.cloudflare
|
||||
├── http.ts # shared HTTP helpers
|
||||
├── webhook.ts # processWebhook/handleWebhook: tenant lookup, provider detect/verify/parse, dedup, scoped dispatch
|
||||
├── core/
|
||||
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (recordSend + group filter + per-group webhook log)
|
||||
├── events/
|
||||
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword regex filtering
|
||||
├── providers/ # Forge webhook providers (verify + parse/normalize to GitHub-shaped events)
|
||||
│ ├── types.ts # Provider interface (matches/verify/parse)
|
||||
│ ├── hmac.ts # HMAC-SHA256 + timing-safe compare helpers
|
||||
│ ├── index.ts # detectProvider() registry (gitea, github, custom)
|
||||
│ ├── github/ # X-GitHub-Event + X-Hub-Signature-256 ("sha256=" prefix); extracts installation.id
|
||||
│ │ ├── verify.ts
|
||||
│ │ └── parse.ts
|
||||
│ ├── gitea/ # X-Gitea-Event + X-Gitea-Signature (plain hex HMAC)
|
||||
│ │ ├── verify.ts
|
||||
│ │ └── parse.ts # parse + normalize Gitea payloads to GitHub shape
|
||||
│ └── custom/ # X-WebHooker-Signature (sha256= HMAC) + arbitrary JSON → `custom` events
|
||||
├── formatters/ # Platform-neutral message formatters (was formatter.ts)
|
||||
│ ├── index.ts # formatEvent: 29-event switch → NeutralMessage + re-exports
|
||||
│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI
|
||||
│ ├── helpers.ts # emojiPrefix, T, buildMessage, commitLink/branchLink/tagLink
|
||||
│ └── *.ts # push, pull-request, issues, comments, workflow, release, create,
|
||||
│ # repo, check, review, commit-comment, deployment, member, label,
|
||||
│ # milestone, discussion, repository, security, generic, ping, custom
|
||||
├── drivers/ # Platform drivers (pluggable push targets)
|
||||
│ ├── types.ts # PlatformDriver interface + SendResult (send + edit)
|
||||
│ ├── index.ts # getDriver() registry (discord default + telegram)
|
||||
│ ├── discord/
|
||||
│ │ ├── index.ts # DiscordDriver: send/edit → renderNeutralMessage + rest.sendMessage/editMessage
|
||||
│ │ ├── render.ts # renderNeutralMessage: NeutralMessage → Discord FormattedMessage
|
||||
│ │ ├── rest.ts # Discord REST sendMessage/editMessage 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: send/edit → renderNeutralMessage + rest.sendMessage (avatar rich-header card)
|
||||
│ ├── render.ts # renderNeutralMessage: NeutralMessage → Telegram HTML (parse_mode HTML)
|
||||
│ ├── rest.ts # Telegram Bot API sendMessage/sendPhoto/editMessage* (chat_id + message_thread_id), retry
|
||||
│ ├── updates.ts # POST /telegram/webhook: secret-token verify + handleTelegramUpdate
|
||||
│ └── commands.ts # Telegram /gh login|logout|comment|merge|close + reply-message parsing + syncTelegramWebhook
|
||||
├── github/
|
||||
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/getComment/editComment/deleteComment/merge/close actions
|
||||
│ └── store.ts # KV token CRUD + D1 discord-link/telegram-link mapping (was token-store.ts)
|
||||
├── web/ # HTTP UI/API logic (called from server/routes)
|
||||
│ ├── oauth.ts # handleOAuthStart/Callback, install page + bind, personal-group self-signup
|
||||
│ ├── actions.ts # POST /api/comment|merge|close|react (Bearer token auth via shared middleware)
|
||||
│ ├── admin.ts # adminLogin/Logout, adminApi* (routes|groups|me|logs|invites|audit|webhook)
|
||||
│ ├── auth.ts # Shared auth middleware + guards: requireAnyAccess, requireGroup(Role), bearerUserId, clientIp
|
||||
│ ├── invites.ts # Invite CRUD (KV invite:{token}, 7d TTL) + acceptInvite (join group as admin/viewer)
|
||||
│ ├── session.ts # Session CRUD (KV session:{id}), isAdminUser, cookie helpers
|
||||
│ ├── groups.ts # Group CRUD (config:groups), member roles (normalizeGroupMembers/memberRole), resolveScope + role helpers (roleAt/canEditRoutes/canEditGroup)
|
||||
│ ├── tenants.ts # Per-group webhook secret CRUD (KV tenant:{groupId}, 32-byte random hex)
|
||||
│ └── richheader.ts # GET /api/richheader: Open Graph page for Telegram avatar link-preview card
|
||||
└── lib/ # shared infra
|
||||
├── i18n.ts # loadTranslations (KV i18n:{lang} overrides), t() with param interpolation
|
||||
├── send-log.ts # SendRecord, recordSend/getSendLog/getSendLogById (D1 send_logs)
|
||||
├── audit.ts # recordAudit/getAuditLog/pruneAuditLogs (D1 audit_logs, best-effort writes)
|
||||
├── log.ts # JSON console logger (info/warn/error/fatal)
|
||||
└── locales/ # en.ts, zh.ts translation dictionaries
|
||||
|
||||
src/__tests__/ # bun test unit tests (webhook, formatter, discord, telegram, admin, groups, invites, audit, send-log, token-store)
|
||||
tests/ # bun test unit tests (webhook, formatter, discord, telegram, admin, groups, invites, audit, send-log, token-store, ...)
|
||||
```
|
||||
|
||||
## Responsibilities
|
||||
|
|
@ -129,12 +141,12 @@ src/__tests__/ # bun test unit tests (webhook, formatter, discord, te
|
|||
`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/formatters/` (via the `emojiPrefix` helper), never in
|
||||
- All event-specific emoji live in `server/lib/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.
|
||||
- Commit hashes, branches and tags render as inline code wrapped in a hyperlink
|
||||
(`commitLink`/`branchLink`/`tagLink` helpers in `src/formatters/helpers.ts`, e.g.
|
||||
(`commitLink`/`branchLink`/`tagLink` helpers in `server/lib/formatters/helpers.ts`, e.g.
|
||||
``[`abc123d`](https://.../commit/abc123def456)``, ``[`main`](https://.../tree/main)``),
|
||||
falling back to plain inline code when the repo base URL is unavailable.
|
||||
- Locale templates use a `{emoji}` placeholder immediately followed by the text (no space);
|
||||
|
|
@ -143,10 +155,11 @@ src/__tests__/ # bun test unit tests (webhook, formatter, discord, te
|
|||
## Development
|
||||
|
||||
```bash
|
||||
npx wrangler dev # Local dev (Miniflare)
|
||||
npm run typecheck # Type checking
|
||||
npm run dev # Nuxt dev (HMR + Nitro dev server)
|
||||
npx wrangler dev # Miniflare preview of a built worker (npm run build first)
|
||||
npm run typecheck # Type checking (nuxt typecheck)
|
||||
npm run lint # ESLint
|
||||
npm test # Unit tests (bun test, under src/__tests__)
|
||||
npm test # Unit tests (bun test, under tests/)
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
|
|
|||
22
Dockerfile
22
Dockerfile
|
|
@ -1,22 +0,0 @@
|
|||
FROM oven/bun:1 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN bun run build
|
||||
|
||||
FROM base AS production
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY config.yaml ./
|
||||
RUN mkdir -p data
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["bun", "run", "dist/index.js"]
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# WebHooker
|
||||
|
||||
GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook events via Cloudflare Workers, applies filters, and routes formatted messages to Discord channels/threads and Telegram chats/topics. Forge-specific adapters live under `src/providers/` (GitHub + Gitea today; GitLab etc. can be added later).
|
||||
GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook events via Cloudflare Workers, applies filters, and routes formatted messages to Discord channels/threads and Telegram chats/topics. Forge-specific adapters live under `server/lib/providers/` (GitHub + Gitea today; GitLab etc. can be added later).
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook event
|
|||
## Architecture
|
||||
|
||||
```text
|
||||
GitHub Webhook → Cloudflare Worker (Hono)
|
||||
GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
|
||||
├── POST /webhook → verify → dedup → filter → format → Discord (REST) / Telegram (Bot API)
|
||||
├── POST /discord/interactions → verify (Ed25519) → handle command/button/modal
|
||||
├── POST /telegram/webhook → verify (secret token) → handle /gh commands
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# WebHooker
|
||||
|
||||
GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare Workers 接收 webhook 事件,应用过滤器,将格式化消息路由到 Discord 频道/子区与 Telegram 群组/话题。各 forge 适配器位于 `src/providers/`(目前支持 GitHub + Gitea;GitLab 等可后续扩展)。
|
||||
GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare Workers 接收 webhook 事件,应用过滤器,将格式化消息路由到 Discord 频道/子区与 Telegram 群组/话题。各 forge 适配器位于 `server/lib/providers/`(目前支持 GitHub + Gitea;GitLab 等可后续扩展)。
|
||||
|
||||
## 功能特性
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare W
|
|||
## 架构
|
||||
|
||||
```text
|
||||
GitHub Webhook → Cloudflare Worker (Hono)
|
||||
GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
|
||||
├── POST /webhook → 验证 → 去重 → 过滤 → 格式化 → Discord (REST) / Telegram (Bot API)
|
||||
├── POST /discord/interactions → 验证 (Ed25519) → 处理命令/按钮/modal
|
||||
├── POST /telegram/webhook → 验证 (secret token) → 处理 /gh 命令
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
<template>
|
||||
<div class="shell">
|
||||
<NuxtPage />
|
||||
<AppToasts />
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load diff
1469
admin/bun.lock
1469
admin/bun.lock
File diff suppressed because it is too large
Load diff
|
|
@ -1,22 +0,0 @@
|
|||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
compatibilityDate: "2025-01-01",
|
||||
app: {
|
||||
head: {
|
||||
title: "WebHooker · Config Console",
|
||||
link: [
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
css: ["~/assets/css/main.css"],
|
||||
devtools: { enabled: false },
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: "/admin/api",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"name": "webhooker-admin",
|
||||
"dependencies": {
|
||||
"nuxt": "^3.21.10",
|
||||
"oxc-parser": "^0.144.0",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<template>
|
||||
<ConsolePage />
|
||||
</template>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
await navigateTo("/admin", { redirectCode: 302 });
|
||||
</script>
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
<template>
|
||||
<ConsolePage />
|
||||
<NuxtPage />
|
||||
</template>
|
||||
833
app/assets/css/main.css
Normal file
833
app/assets/css/main.css
Normal file
|
|
@ -0,0 +1,833 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Theme tokens — RGB triplets so Tailwind opacity modifiers (bg-accent/10)
|
||||
* work against them; values switch automatically with prefers-color-scheme
|
||||
* (unless an explicit data-theme="light" is set on <html>).
|
||||
* ------------------------------------------------------------------------- */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--font-ui: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--header-bg: rgba(255, 255, 255, 0.82);
|
||||
--scrim: rgba(15, 23, 42, 0.4);
|
||||
--knob: #ffffff;
|
||||
--dot: rgba(15, 23, 42, 0.05);
|
||||
--shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
|
||||
--bg: 246 247 249;
|
||||
--surface: 255 255 255;
|
||||
--surface-2: 243 244 246;
|
||||
--surface-3: 236 238 242;
|
||||
--border: 230 232 236;
|
||||
--border-strong: 212 216 222;
|
||||
--text: 15 23 42;
|
||||
--muted: 91 100 114;
|
||||
--faint: 154 163 178;
|
||||
--accent: 79 70 229;
|
||||
--accent-strong: 67 56 202;
|
||||
--accent-dim: 238 242 255;
|
||||
--accent-text: 55 48 163;
|
||||
--accent-border: 224 231 255;
|
||||
--ok: 22 163 74;
|
||||
--ok-dim: 236 253 243;
|
||||
--warn: 180 83 9;
|
||||
--warn-dim: 254 243 199;
|
||||
--bad: 220 38 38;
|
||||
--bad-dim: 254 242 242;
|
||||
--body-text: 48 54 61;
|
||||
--code-bg: 240 241 243;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--header-bg: rgba(15, 20, 28, 0.82);
|
||||
--scrim: rgba(2, 6, 12, 0.6);
|
||||
--knob: #dfe4ee;
|
||||
--dot: rgba(148, 163, 184, 0.08);
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--bg: 11 15 23;
|
||||
--surface: 19 26 36;
|
||||
--surface-2: 26 34 48;
|
||||
--surface-3: 35 45 61;
|
||||
--border: 39 49 67;
|
||||
--border-strong: 56 70 96;
|
||||
--text: 230 233 239;
|
||||
--muted: 154 166 184;
|
||||
--faint: 107 119 137;
|
||||
--accent: 99 102 241;
|
||||
--accent-strong: 129 140 248;
|
||||
--accent-dim: 28 33 64;
|
||||
--accent-text: 199 210 254;
|
||||
--accent-border: 47 55 102;
|
||||
--ok: 34 197 94;
|
||||
--ok-dim: 16 36 26;
|
||||
--warn: 251 191 36;
|
||||
--warn-dim: 42 36 16;
|
||||
--bad: 240 82 82;
|
||||
--bad-dim: 42 22 24;
|
||||
--body-text: 195 202 214;
|
||||
--code-bg: 26 34 48;
|
||||
}
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-screen bg-bg text-text font-ui text-sm leading-[1.55] antialiased;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(var(--dot) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.6), transparent 40%);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ---- Layout ---- */
|
||||
.shell {
|
||||
@apply relative z-[1] min-h-screen;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply sticky top-0 z-20 flex items-center justify-between gap-4 px-8 py-3.5 border-b border-border;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
@apply grid place-items-center h-[38px] w-[38px] rounded-[10px] bg-accent text-white font-extrabold text-[15px] tracking-wide;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
@apply text-lg font-extrabold tracking-tight;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
@apply mt-px block text-[10px] font-semibold uppercase tracking-[2.5px] text-faint;
|
||||
}
|
||||
|
||||
.main {
|
||||
@apply relative z-[1] mx-auto max-w-[1040px] px-8 pb-24 pt-8 max-sm:px-4 max-sm:pb-20 max-sm:pt-5;
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn {
|
||||
@apply inline-flex cursor-pointer items-center justify-center gap-2 rounded-sm border border-border-strong bg-surface px-4 py-2 font-ui text-[13px] font-semibold text-text no-underline transition-[border-color,background,color,transform] duration-150;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
@apply border-accent text-accent;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
@apply translate-y-px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
@apply border-accent bg-accent text-white;
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
@apply border-accent-strong bg-accent-strong text-white;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply border-transparent bg-transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
@apply border-transparent bg-surface-2 text-text;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-3 py-1.5 text-xs;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply px-6 py-3 text-sm;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@apply grid h-[30px] w-[30px] place-items-center cursor-pointer rounded-sm border border-border bg-transparent text-[13px] text-muted transition-all duration-150;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
@apply border-accent bg-accent-dim text-accent;
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
@apply cursor-not-allowed opacity-35;
|
||||
}
|
||||
|
||||
.icon-btn:disabled:hover {
|
||||
@apply border-border bg-transparent text-muted;
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
@apply border-bad bg-bad-dim text-bad;
|
||||
}
|
||||
|
||||
/* ---- Cards ---- */
|
||||
.card {
|
||||
@apply animate-rise rounded border border-border bg-surface p-5 transition-[border-color,transform] duration-150;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@apply border-border-strong;
|
||||
}
|
||||
|
||||
.card.disabled {
|
||||
@apply opacity-55;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
@apply flex items-start justify-between gap-3.5;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@apply flex flex-wrap items-center gap-2.5;
|
||||
}
|
||||
|
||||
.route-name {
|
||||
@apply text-base font-bold tracking-tight;
|
||||
}
|
||||
|
||||
.route-id {
|
||||
@apply font-mono text-xs text-faint;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-block rounded-full bg-surface-2 px-2 py-0.5 text-[11px] font-semibold text-muted;
|
||||
}
|
||||
|
||||
.badge.lang {
|
||||
@apply bg-accent-dim text-accent-text;
|
||||
}
|
||||
|
||||
.badge.fallback {
|
||||
@apply bg-warn-dim text-warn;
|
||||
}
|
||||
|
||||
.badge.stop {
|
||||
@apply bg-bad-dim text-bad;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
@apply flex flex-shrink-0 gap-1.5;
|
||||
}
|
||||
|
||||
/* ---- Toggle switch ---- */
|
||||
.switch {
|
||||
@apply relative h-[22px] w-[38px] flex-shrink-0;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
@apply h-0 w-0 opacity-0;
|
||||
}
|
||||
|
||||
.switch .track {
|
||||
@apply absolute inset-0 cursor-pointer rounded-full border border-border-strong bg-surface-3 transition-[background,border-color] duration-200;
|
||||
}
|
||||
|
||||
.switch .track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--knob);
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.2);
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
|
||||
.switch input:checked + .track {
|
||||
@apply border-accent bg-accent;
|
||||
}
|
||||
|
||||
.switch input:checked + .track::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* ---- Filter chips ---- */
|
||||
.chips {
|
||||
@apply mt-3.5 flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-1.5 rounded-full bg-surface-2 px-2.5 py-1 text-xs;
|
||||
}
|
||||
|
||||
.chip .f-type {
|
||||
@apply text-[10px] font-bold uppercase tracking-wider text-faint;
|
||||
}
|
||||
|
||||
.chip .f-val {
|
||||
@apply font-medium text-text;
|
||||
}
|
||||
|
||||
.chip.exclude {
|
||||
@apply bg-bad-dim;
|
||||
}
|
||||
|
||||
.chip.exclude .f-type {
|
||||
@apply text-bad;
|
||||
}
|
||||
|
||||
/* ---- Targets / meta ---- */
|
||||
.target {
|
||||
@apply mt-3 flex flex-wrap gap-x-[18px] gap-y-2 text-xs text-muted;
|
||||
}
|
||||
|
||||
.target b {
|
||||
@apply mr-1 font-semibold tracking-wider text-faint;
|
||||
}
|
||||
|
||||
.target code {
|
||||
@apply rounded-[5px] bg-surface-2 px-1.5 py-px font-mono text-text;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@apply py-[72px] text-center text-muted;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
/* ---- Login ---- */
|
||||
.login {
|
||||
@apply grid min-h-[62vh] place-items-center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
@apply max-w-[420px] rounded-2xl border border-border bg-surface px-[52px] py-12 text-center;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
@apply mb-2.5 text-[22px] font-extrabold tracking-tight;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
@apply mb-6 leading-relaxed text-muted;
|
||||
}
|
||||
|
||||
.login-card code {
|
||||
@apply rounded-[5px] bg-accent-dim px-1.5 py-px font-mono text-accent;
|
||||
}
|
||||
|
||||
/* ---- Drawer / overlay ---- */
|
||||
.overlay {
|
||||
@apply fixed inset-0 z-40 backdrop-blur-[2px];
|
||||
background: var(--scrim);
|
||||
}
|
||||
|
||||
.editor {
|
||||
@apply fixed bottom-0 right-0 top-0 z-50 flex w-[min(520px,100vw)] flex-col border-l border-border bg-surface shadow-drawer;
|
||||
}
|
||||
|
||||
.editor-head {
|
||||
@apply flex items-center justify-between border-b border-border px-6 py-5;
|
||||
}
|
||||
|
||||
.editor-head h2 {
|
||||
@apply text-lg font-extrabold tracking-tight;
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
@apply flex-1 overflow-y-auto px-6 py-[22px];
|
||||
}
|
||||
|
||||
.editor-foot {
|
||||
@apply flex justify-end gap-2 border-t border-border px-6 py-4;
|
||||
}
|
||||
|
||||
/* ---- Forms ---- */
|
||||
.field {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.field > label {
|
||||
@apply mb-1.5 block text-[11px] font-bold uppercase tracking-[1.2px] text-faint;
|
||||
}
|
||||
|
||||
.lbl-note {
|
||||
@apply font-medium normal-case tracking-normal text-faint;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@apply mt-1.5 text-[11px] text-faint;
|
||||
}
|
||||
|
||||
.err {
|
||||
@apply mt-2 min-h-4 text-xs text-bad;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-sm border border-border-strong bg-surface px-3 py-2.5 font-ui text-[13.5px] text-text transition-[border-color,box-shadow] duration-150;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
@apply border-accent outline-none ring-[3px] ring-accent-dim;
|
||||
}
|
||||
|
||||
.select {
|
||||
@apply w-full cursor-pointer rounded-sm border border-border-strong bg-surface px-3 py-2.5 font-ui text-[13.5px] text-text outline-none;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
@apply border-accent ring-[3px] ring-accent-dim;
|
||||
}
|
||||
|
||||
.row2 {
|
||||
@apply grid grid-cols-2 gap-3 max-sm:grid-cols-1;
|
||||
}
|
||||
|
||||
.inline {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.inline input[type="checkbox"] {
|
||||
@apply h-4 w-4 accent-accent;
|
||||
}
|
||||
|
||||
.inline span {
|
||||
@apply text-[12.5px] font-medium text-muted;
|
||||
}
|
||||
|
||||
.templates {
|
||||
@apply flex flex-wrap gap-1.5;
|
||||
}
|
||||
|
||||
.template-chip {
|
||||
@apply cursor-pointer rounded-full border border-border-strong bg-surface-2 px-3 py-1 font-ui text-xs font-semibold text-muted transition-[border-color,color,background] duration-150;
|
||||
}
|
||||
|
||||
.template-chip:hover {
|
||||
@apply border-accent text-accent;
|
||||
}
|
||||
|
||||
.template-chip.active {
|
||||
@apply border-accent bg-accent-dim text-accent;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
@apply mb-2 grid grid-cols-[130px_1fr_auto_auto] items-center gap-2 rounded-sm border border-border bg-surface-2 p-2.5 max-sm:grid-cols-[100px_1fr_auto];
|
||||
}
|
||||
|
||||
.filter-row select {
|
||||
@apply w-full rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-medium text-text;
|
||||
}
|
||||
|
||||
.filter-row input[type="text"] {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.target-row {
|
||||
@apply mb-2 grid grid-cols-[minmax(130px,1fr)_auto] items-center gap-2 rounded-sm border border-border bg-surface-2 p-2.5 max-sm:grid-cols-[1fr_auto];
|
||||
grid-template-areas:
|
||||
"select del"
|
||||
"in1 in2";
|
||||
}
|
||||
|
||||
.target-row select {
|
||||
@apply w-full rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-medium text-text;
|
||||
grid-area: select;
|
||||
}
|
||||
|
||||
.target-row input.tg-in1 {
|
||||
grid-area: in1;
|
||||
}
|
||||
|
||||
.target-row input.tg-in2 {
|
||||
grid-area: in2;
|
||||
}
|
||||
|
||||
.target-row > .icon-btn {
|
||||
grid-area: del;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.add-filter {
|
||||
@apply mt-1 w-full justify-center;
|
||||
}
|
||||
|
||||
/* ---- Toasts ---- */
|
||||
.toasts {
|
||||
@apply pointer-events-none fixed bottom-7 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2;
|
||||
}
|
||||
|
||||
.toast {
|
||||
@apply pointer-events-auto cursor-pointer rounded-full bg-text px-[18px] py-2.5 text-[13px] font-semibold text-white;
|
||||
}
|
||||
|
||||
.toast.ok {
|
||||
@apply bg-ok;
|
||||
}
|
||||
|
||||
.toast.bad {
|
||||
@apply bg-bad;
|
||||
}
|
||||
|
||||
/* ---- Tabs ---- */
|
||||
.tabs {
|
||||
@apply mb-5 inline-flex gap-0.5 rounded-[10px] border border-border bg-surface-2 p-1;
|
||||
}
|
||||
|
||||
.tab {
|
||||
@apply cursor-pointer rounded-[7px] border-none bg-transparent px-4 py-1.5 font-ui text-[13px] font-semibold text-muted transition-[background,color] duration-150;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
@apply text-text;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
@apply bg-surface text-accent shadow-[0_1px_3px_rgba(15,23,42,0.08)];
|
||||
}
|
||||
|
||||
/* ---- Toolbar / KPIs ---- */
|
||||
.toolbar {
|
||||
@apply mb-[18px] flex items-center justify-between;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
@apply text-[11px] font-bold uppercase tracking-[1.5px] text-faint;
|
||||
}
|
||||
|
||||
.kpi {
|
||||
@apply ml-2 text-2xl font-extrabold tracking-tight text-accent;
|
||||
}
|
||||
|
||||
.status {
|
||||
@apply flex items-center gap-2 text-xs font-medium text-muted;
|
||||
}
|
||||
|
||||
.dot {
|
||||
@apply h-2 w-2 rounded-full bg-border-strong;
|
||||
}
|
||||
|
||||
.dot.ok {
|
||||
@apply bg-ok;
|
||||
}
|
||||
|
||||
.dot.bad {
|
||||
@apply bg-bad;
|
||||
}
|
||||
|
||||
/* ---- Logs / audit ---- */
|
||||
.log-toolbar {
|
||||
@apply mb-3.5 flex flex-wrap items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.log-filters {
|
||||
@apply flex items-center gap-1.5;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
@apply text-[11px] font-bold uppercase tracking-wider text-muted;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
@apply cursor-pointer rounded border border-border bg-surface px-2 py-1 text-[13px] text-text outline-none;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
@apply border-accent;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
@apply flex flex-col gap-2.5;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
@apply rounded border border-border border-l-[3px] border-l-ok bg-surface p-4 transition-colors duration-150;
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
@apply border-border-strong;
|
||||
}
|
||||
|
||||
.log-entry.fail {
|
||||
@apply border-l-bad;
|
||||
}
|
||||
|
||||
.log-entry.clickable {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
@apply flex flex-wrap items-center gap-2.5;
|
||||
}
|
||||
|
||||
.log-route {
|
||||
@apply text-sm font-bold tracking-tight;
|
||||
}
|
||||
|
||||
.log-event {
|
||||
@apply rounded-full bg-accent-dim px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-accent;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
@apply ml-auto text-xs font-medium text-faint;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
@apply mt-2 flex flex-wrap gap-x-[18px] gap-y-1 text-xs text-muted;
|
||||
}
|
||||
|
||||
.log-meta b {
|
||||
@apply mr-1 font-semibold tracking-wider text-faint;
|
||||
}
|
||||
|
||||
.log-meta code {
|
||||
@apply rounded-[5px] bg-surface-2 px-1.5 py-px font-mono text-text;
|
||||
}
|
||||
|
||||
.log-error {
|
||||
@apply mt-2 rounded-sm bg-bad-dim px-2.5 py-2 text-xs font-medium break-all text-bad;
|
||||
}
|
||||
|
||||
.empty-log {
|
||||
@apply py-14 text-center text-faint;
|
||||
}
|
||||
|
||||
.log-status {
|
||||
@apply rounded-full px-2.5 py-0.5 text-[11px] font-bold tracking-wide;
|
||||
}
|
||||
|
||||
.log-status.ok {
|
||||
@apply bg-ok-dim text-ok;
|
||||
}
|
||||
|
||||
.log-status.bad {
|
||||
@apply bg-bad-dim text-bad;
|
||||
}
|
||||
|
||||
.log-detail {
|
||||
@apply max-h-[82vh] w-[calc(100vw-48px)] max-w-[720px] overflow-y-auto rounded border border-border bg-surface p-[22px] shadow-modal;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 z-[60] flex items-center justify-center p-6 backdrop-blur-[2px];
|
||||
background: var(--scrim);
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
@apply mb-3.5 flex items-center justify-between;
|
||||
}
|
||||
|
||||
.detail-head h3 {
|
||||
@apply text-[15px] tracking-wide;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
@apply grid grid-cols-[minmax(120px,30%)_1fr] gap-x-4 gap-y-1.5 text-[13px];
|
||||
}
|
||||
|
||||
.detail-grid dt {
|
||||
@apply pt-1.5 text-[11px] font-semibold tracking-wide text-faint;
|
||||
}
|
||||
|
||||
.detail-grid dd {
|
||||
@apply break-all pt-1.5 text-text;
|
||||
}
|
||||
|
||||
.detail-grid code {
|
||||
@apply rounded-[5px] bg-surface-2 px-1.5 py-0.5 font-mono text-xs;
|
||||
}
|
||||
|
||||
.detail-grid dd code {
|
||||
@apply whitespace-pre-wrap;
|
||||
}
|
||||
|
||||
/* ---- Members / invites ---- */
|
||||
.members-panel {
|
||||
@apply mt-7 rounded border border-border bg-surface p-5;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
@apply mb-3 flex flex-wrap items-baseline gap-2.5;
|
||||
}
|
||||
|
||||
.panel-head h3 {
|
||||
@apply text-sm tracking-wide;
|
||||
}
|
||||
|
||||
.members-panel h4 {
|
||||
@apply my-3.5 mb-2 text-xs;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
@apply m-0 mb-3 flex list-none flex-col gap-2 p-0;
|
||||
}
|
||||
|
||||
.member-row,
|
||||
.invite-row {
|
||||
@apply flex flex-wrap items-center gap-2.5 rounded-sm border border-border bg-surface-2 px-3 py-2;
|
||||
}
|
||||
|
||||
.member-login {
|
||||
@apply min-w-[120px] flex-1 text-[13px] font-semibold;
|
||||
}
|
||||
|
||||
.role-pill {
|
||||
@apply rounded-full px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide;
|
||||
}
|
||||
|
||||
.role-pill.owner {
|
||||
@apply bg-accent-dim text-accent;
|
||||
}
|
||||
|
||||
.role-pill.admin {
|
||||
@apply bg-ok-dim text-ok;
|
||||
}
|
||||
|
||||
.role-pill.viewer {
|
||||
@apply border border-border bg-surface text-faint;
|
||||
}
|
||||
|
||||
.member-add,
|
||||
.invite-create {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
@apply min-w-[200px] rounded border border-border bg-surface px-2.5 py-1.5 text-[13px] text-text outline-none;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
@apply border-accent;
|
||||
}
|
||||
|
||||
.invite-box {
|
||||
@apply mt-3.5 border-t border-dashed border-border pt-1.5;
|
||||
}
|
||||
|
||||
.invite-list {
|
||||
@apply m-2.5 mb-0 flex list-none flex-col gap-2 p-0;
|
||||
}
|
||||
|
||||
.invite-token {
|
||||
@apply font-mono text-xs text-muted;
|
||||
}
|
||||
|
||||
.invite-exp {
|
||||
@apply text-xs text-faint;
|
||||
}
|
||||
|
||||
/* ---- Legal pages ---- */
|
||||
.legal-body h2 {
|
||||
@apply mb-2 mt-7 text-[17px] font-bold;
|
||||
}
|
||||
|
||||
.legal-body p,
|
||||
.legal-body li {
|
||||
@apply text-[15px] text-body-text;
|
||||
}
|
||||
|
||||
.legal-body a {
|
||||
@apply text-accent;
|
||||
}
|
||||
|
||||
.legal-body ul {
|
||||
@apply pl-5;
|
||||
}
|
||||
|
||||
.legal-body code {
|
||||
@apply rounded-[6px] border border-border bg-code-bg px-1.5 py-px font-mono text-[13px];
|
||||
}
|
||||
|
||||
/* ---- Webhook panel ---- */
|
||||
.webhook-panel .wh-row {
|
||||
@apply mb-2 flex flex-wrap items-center gap-2.5;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-label {
|
||||
@apply min-w-[52px] text-[11px] font-bold uppercase tracking-wider text-muted;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-value {
|
||||
@apply min-w-[180px] flex-1 rounded-sm border border-border bg-surface-2 px-2.5 py-1.5 text-[12.5px] break-all;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-actions {
|
||||
@apply mt-1.5 flex gap-2;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-usage {
|
||||
@apply mt-3.5;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-usage summary {
|
||||
@apply cursor-pointer text-xs text-muted;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-code {
|
||||
@apply mt-2.5 overflow-x-auto rounded-sm border border-border bg-surface-2 p-3 text-[11.5px] leading-relaxed text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Vue transition glue (lifecycle classes) ---- */
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition:
|
||||
opacity 0.22s,
|
||||
transform 0.22s;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div>
|
||||
<header>
|
||||
<div class="shell">
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">WH</div>
|
||||
<div>
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
<span class="tagline">{{ t("app.tagline") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost btn-sm" @click="toggle">{{ t("app.langToggle") }}</button>
|
||||
<button
|
||||
v-if="!needLogin && selectedGroup && canEditRoutes(selectedGroup.id)"
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<main class="main">
|
||||
<div v-if="needLogin" class="login">
|
||||
<div class="login-card">
|
||||
<h2>{{ t("login.title") }}</h2>
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
<article
|
||||
v-for="(g, i) in groups"
|
||||
:key="g.id"
|
||||
class="card group-card"
|
||||
class="card cursor-pointer"
|
||||
:style="{ animationDelay: i * 45 + 'ms' }"
|
||||
@click="enterGroup(g)"
|
||||
>
|
||||
|
|
@ -165,7 +165,9 @@
|
|||
}}</code></span
|
||||
>
|
||||
</div>
|
||||
<div class="group-open">{{ t("groups.open") }}</div>
|
||||
<div class="group-open mt-2.5 text-right text-xs font-medium text-accent">
|
||||
{{ t("groups.open") }}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
|
|
@ -187,6 +189,7 @@
|
|||
<SendLogs
|
||||
:logs="logs"
|
||||
:loading="logsLoading"
|
||||
:error="logsError"
|
||||
:groups="groups"
|
||||
:selected-group-id="logFilterGroup"
|
||||
@refresh="loadLogs(50, logFilterGroup || undefined)"
|
||||
|
|
@ -255,7 +258,12 @@ if (view.value === null) {
|
|||
throw createError({ statusCode: 404, statusMessage: "Page not found", fatal: false });
|
||||
}
|
||||
|
||||
const { logs, loading: logsLoading, load: loadLogs } = useSendLogs();
|
||||
const {
|
||||
logs,
|
||||
loading: logsLoading,
|
||||
error: logsError,
|
||||
load: loadLogs,
|
||||
} = useSendLogs();
|
||||
const {
|
||||
entries: auditEntries,
|
||||
loading: auditLoading,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<template>
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="open" class="overlay" @click.self="close"></div>
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.namePlaceholder')"
|
||||
required
|
||||
/>
|
||||
|
|
@ -25,6 +26,7 @@
|
|||
<input
|
||||
v-model="form.id"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.idPlaceholder')"
|
||||
required
|
||||
/>
|
||||
|
|
@ -39,6 +41,7 @@
|
|||
<input
|
||||
v-model="form.lang"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.langPlaceholder')"
|
||||
/>
|
||||
<div class="hint">{{ t("groupEditor.langHint") }}</div>
|
||||
|
|
@ -71,6 +74,7 @@
|
|||
<input
|
||||
v-model="form.owners"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.ownersPlaceholder')"
|
||||
/>
|
||||
<div class="hint">{{ t("groupEditor.ownersHint") }}</div>
|
||||
|
|
@ -80,14 +84,14 @@
|
|||
>{{ t("groupEditor.owners") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.ownersSuperOnly") }}</span></label
|
||||
>
|
||||
<input v-model="ownersReadonly" type="text" disabled />
|
||||
<input v-model="ownersReadonly" type="text" class="input opacity-60" disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
>{{ t("groupEditor.providers") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.providersNote") }}</span></label
|
||||
>
|
||||
<div class="provider-options">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -115,6 +119,7 @@
|
|||
<input
|
||||
v-model="form.installationId"
|
||||
type="text"
|
||||
class="input"
|
||||
inputmode="numeric"
|
||||
:placeholder="t('groupEditor.installationIdPlaceholder')"
|
||||
/>
|
||||
|
|
@ -125,7 +130,7 @@
|
|||
>{{ t("groupEditor.logTarget") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.logTargetNote") }}</span></label
|
||||
>
|
||||
<select v-model="form.logPlatform">
|
||||
<select v-model="form.logPlatform" class="select">
|
||||
<option value="">{{ t("groupEditor.logDisabled") }}</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
|
|
@ -134,11 +139,13 @@
|
|||
<input
|
||||
v-model="form.logChannelId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.channelPlaceholder')"
|
||||
/>
|
||||
<input
|
||||
v-model="form.logThreadId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.threadPlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -146,11 +153,13 @@
|
|||
<input
|
||||
v-model="form.logChatId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.chatPlaceholder')"
|
||||
/>
|
||||
<input
|
||||
v-model="form.logTopicId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.topicPlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -313,3 +322,4 @@ function save(): void {
|
|||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
65
app/components/LegalLayout.vue
Normal file
65
app/components/LegalLayout.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<div class="mx-auto max-w-[760px] px-5 pb-20 pt-12">
|
||||
<header class="mb-7 flex flex-wrap items-center justify-between gap-4">
|
||||
<NuxtLink
|
||||
class="text-base font-extrabold tracking-[-0.01em] text-text no-underline"
|
||||
:to="q('/terms')"
|
||||
>Web<span class="text-accent">Hooker</span></NuxtLink
|
||||
>
|
||||
<nav class="flex gap-2">
|
||||
<NuxtLink
|
||||
class="rounded-full border border-transparent px-3.5 py-1.5 text-[13px] text-muted no-underline transition-colors hover:text-text"
|
||||
:class="active === 'terms' ? 'border-accent-border bg-accent-dim text-accent' : ''"
|
||||
:to="q('/terms')"
|
||||
>
|
||||
{{ t("服务条款", "Terms") }}
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="rounded-full border border-transparent px-3.5 py-1.5 text-[13px] text-muted no-underline transition-colors hover:text-text"
|
||||
:class="active === 'privacy' ? 'border-accent-border bg-accent-dim text-accent' : ''"
|
||||
:to="q('/privacy')"
|
||||
>
|
||||
{{ t("隐私政策", "Privacy") }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</header>
|
||||
<article
|
||||
class="rounded-xl border border-border bg-surface px-10 py-9 shadow-[0_1px_3px_var(--shadow)]"
|
||||
>
|
||||
<h1 class="mb-1.5 text-[26px] font-extrabold tracking-[-0.02em]">{{ title }}</h1>
|
||||
<p class="mb-6 text-[13px] text-muted">{{ t("最后更新", "Last updated") }}: {{ updated }}</p>
|
||||
<div class="legal-body" v-html="body" />
|
||||
</article>
|
||||
<footer class="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="text-[13px] text-muted">WebHooker · GitHub → Discord</span>
|
||||
<NuxtLink
|
||||
class="rounded-full border border-border bg-surface px-3.5 py-1.5 text-[13px] text-muted no-underline transition-colors hover:text-text"
|
||||
:to="altLink"
|
||||
>{{ lang === "zh" ? "English" : "中文" }}</NuxtLink
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { pickLang, type Lang } from "~/utils/legal";
|
||||
|
||||
const props = defineProps<{ active: "terms" | "privacy"; title: string; body: string }>();
|
||||
|
||||
const route = useRoute();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const lang = computed<Lang>(() => pickLang(String(route.query.lang ?? "")));
|
||||
const altLang = computed(() => (lang.value === "zh" ? "en" : "zh"));
|
||||
const updated = computed(() => "2026-08-01");
|
||||
|
||||
const t = (zh: string, en: string): string => (lang.value === "zh" ? zh : en);
|
||||
const q = (p: string): string => `${p}?lang=${lang.value}`;
|
||||
const altLink = computed(() =>
|
||||
q(props.active === "terms" ? "/terms" : "/privacy").replace(`lang=${lang.value}`, `lang=${altLang.value}`),
|
||||
);
|
||||
|
||||
useHead({ title: `${props.title} · WebHooker` });
|
||||
void config;
|
||||
</script>
|
||||
|
|
@ -67,8 +67,8 @@
|
|||
>
|
||||
</div>
|
||||
<div class="target">
|
||||
<div v-for="(tg, i) in route.targets" :key="i" class="target-group">
|
||||
<span class="target-plat"
|
||||
<div v-for="(tg, i) in route.targets" :key="i" class="flex flex-wrap gap-x-[18px] gap-y-2">
|
||||
<span
|
||||
><b>{{ tg.platform === "telegram" ? t("route.chat") : t("route.channel") }}</b
|
||||
><code>{{ tg.platform === "telegram" ? tg.chatId : tg.channelId }}</code></span
|
||||
>
|
||||
|
|
@ -33,13 +33,14 @@
|
|||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('routeEditor.namePlaceholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t("routeEditor.id") }}</label>
|
||||
<input v-model="form.id" type="text" placeholder="my-route" required />
|
||||
<input v-model="form.id" type="text" class="input" placeholder="my-route" required />
|
||||
<div class="hint">{{ t("routeEditor.idHint") }}</div>
|
||||
</div>
|
||||
<div class="field inline">
|
||||
|
|
@ -68,6 +69,7 @@
|
|||
<input
|
||||
v-model="form.discordRolesText"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('routeEditor.discordRolesPlaceholder')"
|
||||
/>
|
||||
<div class="hint">{{ t("routeEditor.discordRolesHint") }}</div>
|
||||
|
|
@ -308,10 +310,10 @@ function collect(): Route | null {
|
|||
const tg = form.targets[i]!;
|
||||
targets.push({
|
||||
platform: tg.platform,
|
||||
channelId: tg.channelId.trim() || undefined,
|
||||
threadId: tg.threadId.trim() || undefined,
|
||||
chatId: tg.chatId.trim() || undefined,
|
||||
topicId: tg.topicId.trim() || undefined,
|
||||
channelId: (tg.channelId ?? "").trim() || undefined,
|
||||
threadId: (tg.threadId ?? "").trim() || undefined,
|
||||
chatId: (tg.chatId ?? "").trim() || undefined,
|
||||
topicId: (tg.topicId ?? "").trim() || undefined,
|
||||
});
|
||||
}
|
||||
targetError.value = "";
|
||||
|
|
@ -90,6 +90,7 @@ const { loadById } = useSendLogs();
|
|||
const props = defineProps<{
|
||||
logs: SendRecord[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
groups: Group[];
|
||||
selectedGroupId: string;
|
||||
}>();
|
||||
146
app/pages/index.vue
Normal file
146
app/pages/index.vue
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<template>
|
||||
<div class="relative z-[1] mx-auto flex min-h-screen max-w-[720px] flex-col px-5 pb-16 pt-[72px]">
|
||||
<div class="mb-10 text-center">
|
||||
<div
|
||||
class="mb-5 inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-accent text-xl font-extrabold tracking-tight text-white shadow-[0_8px_24px_-8px_var(--accent)]"
|
||||
>
|
||||
WH
|
||||
</div>
|
||||
<h1 class="mb-2.5 text-[34px] font-extrabold tracking-[-0.03em]">
|
||||
Web<span class="text-accent">Hooker</span>
|
||||
</h1>
|
||||
<p class="m-0 text-[15px] leading-relaxed text-muted">
|
||||
{{
|
||||
lang === "zh"
|
||||
? "将 GitHub webhook 事件转发到 Discord 频道,并支持在 Discord 中以你本人身份操作 GitHub。"
|
||||
: "Forward GitHub webhook events to Discord, and act on GitHub from Discord as yourself."
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<nav class="grid gap-3">
|
||||
<a
|
||||
v-for="it in items"
|
||||
:key="it.label"
|
||||
class="flex items-center gap-4 rounded-[14px] border border-border bg-surface px-5 py-[18px] text-text no-underline shadow-card transition-all duration-150 hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover"
|
||||
:class="it.primary ? 'border-accent bg-accent shadow-none hover:border-accent hover:shadow-accent-lg' : ''"
|
||||
:href="it.href"
|
||||
:target="it.external ? '_blank' : undefined"
|
||||
:rel="it.external ? 'noopener noreferrer' : undefined"
|
||||
>
|
||||
<span
|
||||
class="flex h-11 w-11 flex-none items-center justify-center rounded-[11px] text-accent"
|
||||
:class="it.primary ? 'bg-white/15 text-white' : 'bg-surface-2'"
|
||||
v-html="icon(it.icon)"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="text-[15px] font-bold" :class="it.primary ? 'text-white' : ''">{{
|
||||
it.label
|
||||
}}</span>
|
||||
<span class="text-[13px]" :class="it.primary ? 'text-white/80' : 'text-muted'">{{
|
||||
it.desc
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="flex-none text-lg" :class="it.primary ? 'text-white' : 'text-faint'">{{
|
||||
it.external ? "↗" : "→"
|
||||
}}</span>
|
||||
</a>
|
||||
</nav>
|
||||
<footer class="mt-auto flex flex-wrap items-center justify-between gap-3 pt-10">
|
||||
<span class="text-[13px] text-muted">WebHooker · GitHub → Discord</span>
|
||||
<NuxtLink
|
||||
class="rounded-full border border-border bg-surface px-3.5 py-1.5 text-[13px] text-muted no-underline transition-colors hover:border-border-strong hover:text-text"
|
||||
:to="`/?lang=${altLang}`"
|
||||
>{{ lang === "zh" ? "English" : "中文" }}</NuxtLink
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const DEFAULT_REPO = "https://github.com/ReCloudStudio/WebHooker";
|
||||
const DEFAULT_DOCS = "https://webhooker.docs.worldexecute.me";
|
||||
|
||||
const route = useRoute();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const lang = computed(() => (route.query.lang === "en" ? "en" : "zh"));
|
||||
const altLang = computed(() => (lang.value === "zh" ? "en" : "zh"));
|
||||
const repo = computed(() => (config.public.repoUrl as string) || DEFAULT_REPO);
|
||||
const docsBase = computed(() => ((config.public.docsUrl as string) || DEFAULT_DOCS).replace(/\/+$/, ""));
|
||||
|
||||
const t = (zh: string, en: string): string => (lang.value === "zh" ? zh : en);
|
||||
|
||||
const items = computed(() => {
|
||||
const docs = lang.value === "zh" ? `${docsBase.value}/zh` : `${docsBase.value}/`;
|
||||
const q = (p: string): string => `${p}?lang=${lang.value}`;
|
||||
return [
|
||||
{
|
||||
href: docs,
|
||||
label: t("文档", "Documentation"),
|
||||
desc: t("部署、配置与事件参考", "Deployment, configuration & event reference"),
|
||||
icon: "docs",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: repo.value,
|
||||
label: t("GitHub 仓库", "GitHub Repository"),
|
||||
desc: t("源代码、问题与发布", "Source code, issues & releases"),
|
||||
icon: "github",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: q("/terms"),
|
||||
label: t("服务条款", "Terms of Service"),
|
||||
desc: t("使用本服务的条款", "The terms for using this service"),
|
||||
icon: "terms",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
href: q("/privacy"),
|
||||
label: t("隐私政策", "Privacy Policy"),
|
||||
desc: t("我们如何处理你的数据", "How we handle your data"),
|
||||
icon: "privacy",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
href: "/admin/login",
|
||||
label: t("登录控制台", "Sign in to Console"),
|
||||
desc: t("管理路由与分组", "Manage routes and groups"),
|
||||
icon: "login",
|
||||
external: false,
|
||||
primary: true,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const ICON_PATHS: Record<string, string> = {
|
||||
docs: '<path d="M4 4a2 2 0 0 1 2-2h7l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z"/><path d="M13 2v5h5"/><path d="M8 12h8M8 16h6"/>',
|
||||
github:
|
||||
'<path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.36 1.09 2.94.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.5 9.5 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.85v2.74c0 .27.18.58.69.48A10 10 0 0 0 12 2z"/>',
|
||||
terms: '<path d="M9 12h6M9 16h6M9 8h2"/><path d="M6 2h9l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"/>',
|
||||
privacy:
|
||||
'<path d="M12 2l7 3v6c0 5-3.5 8.5-7 10-3.5-1.5-7-5-7-10V5l7-3z"/><path d="M9 12l2 2 4-4"/>',
|
||||
login:
|
||||
'<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/>',
|
||||
};
|
||||
|
||||
function icon(name: string): string {
|
||||
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" width="22" height="22">${ICON_PATHS[name] ?? ""}</svg>`;
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: "WebHooker",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
lang.value === "zh"
|
||||
? "GitHub webhook 转发到 Discord"
|
||||
: "GitHub webhooks forwarded to Discord",
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
17
app/pages/privacy.vue
Normal file
17
app/pages/privacy.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<LegalLayout
|
||||
active="privacy"
|
||||
:title="lang === 'zh' ? '隐私政策' : 'Privacy Policy'"
|
||||
:body="privacyBody(lang, contact)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { pickLang, legalContact, privacyBody } from "~/utils/legal";
|
||||
|
||||
const route = useRoute();
|
||||
const config = useRuntimeConfig();
|
||||
const lang = computed(() => pickLang(String(route.query.lang ?? "")));
|
||||
const contact = computed(() => legalContact(lang.value, String(config.public.legalContact ?? "")));
|
||||
</script>
|
||||
17
app/pages/terms.vue
Normal file
17
app/pages/terms.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<LegalLayout
|
||||
active="terms"
|
||||
:title="lang === 'zh' ? '服务条款' : 'Terms of Service'"
|
||||
:body="termsBody(lang, contact)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { pickLang, legalContact, termsBody } from "~/utils/legal";
|
||||
|
||||
const route = useRoute();
|
||||
const config = useRuntimeConfig();
|
||||
const lang = computed(() => pickLang(String(route.query.lang ?? "")));
|
||||
const contact = computed(() => legalContact(lang.value, String(config.public.legalContact ?? "")));
|
||||
</script>
|
||||
|
|
@ -1,85 +1,18 @@
|
|||
import { Hono } from "hono";
|
||||
import type { Env } from "../types";
|
||||
|
||||
type Lang = "zh" | "en";
|
||||
export type Lang = "zh" | "en";
|
||||
|
||||
const CONTACT_FALLBACK = "the repository maintainer (open an issue on the project repository)";
|
||||
const CONTACT_FALLBACK_ZH = "项目维护者(在项目仓库提交 issue)";
|
||||
export const LEGAL_UPDATED = "2026-08-01";
|
||||
|
||||
function pickLang(raw: string | undefined): Lang {
|
||||
export function pickLang(raw: string | undefined): Lang {
|
||||
return raw === "en" ? "en" : "zh";
|
||||
}
|
||||
|
||||
function layout(opts: {
|
||||
lang: Lang;
|
||||
active: "terms" | "privacy";
|
||||
title: string;
|
||||
updated: string;
|
||||
body: string;
|
||||
}): string {
|
||||
const { lang, active, title, updated, body } = opts;
|
||||
const altLang: Lang = lang === "zh" ? "en" : "zh";
|
||||
const t = (zh: string, en: string): string => (lang === "zh" ? zh : en);
|
||||
const q = (p: string): string => `${p}?lang=${lang}`;
|
||||
const langLabel = t("English", "中文");
|
||||
return `<!doctype html>
|
||||
<html lang="${lang === "zh" ? "zh-CN" : "en"}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="index,follow">
|
||||
<title>${title} · WebHooker</title>
|
||||
<style>
|
||||
:root{--bg:#f6f7f9;--surface:#fff;--border:#e5e7eb;--text:#1f2328;--muted:#57606a;--accent:#4f46e5;--body-text:#30363d;--code-bg:#f0f1f3;--accent-dim:#eef2ff;--accent-border:#e0e7ff;--shadow:rgba(0,0,0,.05)}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#0b0f17;--surface:#131a24;--border:#273143;--text:#e6e9ef;--muted:#9aa6b8;--accent:#818cf8;--body-text:#c3cad6;--code-bg:#1a2230;--accent-dim:#1c2140;--accent-border:#2f3766;--shadow:rgba(0,0,0,.5);color-scheme:dark}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:'Plus Jakarta Sans',system-ui,-apple-system,'Segoe UI',sans-serif;line-height:1.7;-webkit-font-smoothing:antialiased}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:48px 20px 80px}
|
||||
header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:28px;flex-wrap:wrap}
|
||||
.brand{font-weight:800;font-size:16px;letter-spacing:-.01em;text-decoration:none;color:var(--text)}
|
||||
.brand span{color:var(--accent)}
|
||||
.tabs{display:flex;gap:8px}
|
||||
.tab{font-size:13px;text-decoration:none;color:var(--muted);padding:6px 14px;border-radius:999px;border:1px solid transparent}
|
||||
.tab.active{color:var(--accent);background:var(--accent-dim);border-color:var(--accent-border)}
|
||||
.tab:hover{color:var(--text)}
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:36px 40px;box-shadow:0 1px 3px var(--shadow)}
|
||||
h1{font-size:26px;margin:0 0 6px;letter-spacing:-.02em}
|
||||
.updated{color:var(--muted);font-size:13px;margin:0 0 24px}
|
||||
h2{font-size:17px;margin:28px 0 8px}
|
||||
p,li{color:var(--body-text);font-size:15px}
|
||||
a{color:var(--accent)}
|
||||
ul{padding-left:20px}
|
||||
code{background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:1px 6px;font-family:'JetBrains Mono',ui-monospace,monospace;font-size:13px}
|
||||
footer{margin-top:24px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.langlink{font-size:13px;text-decoration:none;color:var(--muted);border:1px solid var(--border);border-radius:999px;padding:6px 14px;background:var(--surface)}
|
||||
.langlink:hover{color:var(--text)}
|
||||
.muted{color:var(--muted);font-size:13px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<a class="brand" href="${q("/terms")}">Web<span>Hooker</span></a>
|
||||
<nav class="tabs">
|
||||
<a class="tab ${active === "terms" ? "active" : ""}" href="${q("/terms")}">${t("服务条款", "Terms")}</a>
|
||||
<a class="tab ${active === "privacy" ? "active" : ""}" href="${q("/privacy")}">${t("隐私政策", "Privacy")}</a>
|
||||
</nav>
|
||||
</header>
|
||||
<article class="card">
|
||||
<h1>${title}</h1>
|
||||
<p class="updated">${t("最后更新", "Last updated")}: ${updated}</p>
|
||||
${body}
|
||||
</article>
|
||||
<footer>
|
||||
<span class="muted">WebHooker · GitHub → Discord</span>
|
||||
<a class="langlink" href="${q(active === "terms" ? "/terms" : "/privacy").replace(`lang=${lang}`, `lang=${altLang}`)}">${langLabel}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
export function legalContact(lang: Lang, envContact: string): string {
|
||||
return envContact || (lang === "zh" ? CONTACT_FALLBACK_ZH : CONTACT_FALLBACK);
|
||||
}
|
||||
|
||||
function termsBody(lang: Lang, contact: string): string {
|
||||
export function termsBody(lang: Lang, contact: string): string {
|
||||
if (lang === "en") {
|
||||
return `
|
||||
<p>These Terms of Service ("Terms") govern your use of the WebHooker Discord application and bot ("the Service"), which forwards GitHub webhook events to Discord channels and lets you comment on GitHub from Discord using your own linked GitHub account.</p>
|
||||
|
|
@ -136,7 +69,7 @@ function termsBody(lang: Lang, contact: string): string {
|
|||
<p>关于本条款的问题可联系${contact}。</p>`;
|
||||
}
|
||||
|
||||
function privacyBody(lang: Lang, contact: string): string {
|
||||
export function privacyBody(lang: Lang, contact: string): string {
|
||||
if (lang === "en") {
|
||||
return `
|
||||
<p>This Privacy Policy explains what data the WebHooker Discord application ("the Service") processes, why, and how it is stored.</p>
|
||||
|
|
@ -210,39 +143,3 @@ function privacyBody(lang: Lang, contact: string): string {
|
|||
<h2>9. 联系方式</h2>
|
||||
<p>如有隐私问题或数据删除请求,请联系${contact}。</p>`;
|
||||
}
|
||||
|
||||
const UPDATED = "2026-08-01";
|
||||
|
||||
export function createLegalRoutes(): Hono<{ Bindings: Env }> {
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.get("/terms", (c) => {
|
||||
const lang = pickLang(c.req.query("lang"));
|
||||
const contact = c.env.LEGAL_CONTACT ?? (lang === "zh" ? CONTACT_FALLBACK_ZH : CONTACT_FALLBACK);
|
||||
return c.html(
|
||||
layout({
|
||||
lang,
|
||||
active: "terms",
|
||||
title: lang === "zh" ? "服务条款" : "Terms of Service",
|
||||
updated: UPDATED,
|
||||
body: termsBody(lang, contact),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/privacy", (c) => {
|
||||
const lang = pickLang(c.req.query("lang"));
|
||||
const contact = c.env.LEGAL_CONTACT ?? (lang === "zh" ? CONTACT_FALLBACK_ZH : CONTACT_FALLBACK);
|
||||
return c.html(
|
||||
layout({
|
||||
lang,
|
||||
active: "privacy",
|
||||
title: lang === "zh" ? "隐私政策" : "Privacy Policy",
|
||||
updated: UPDATED,
|
||||
body: privacyBody(lang, contact),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# API Overview
|
||||
|
||||
WebHooker exposes an HTTP API via Hono on Cloudflare Workers.
|
||||
WebHooker exposes an HTTP API via Nitro on Cloudflare Workers.
|
||||
|
||||
## Base URL
|
||||
|
||||
|
|
|
|||
|
|
@ -13,61 +13,75 @@ npm run dev # Start local dev server
|
|||
## Project Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
├── index.ts # CF Workers entry (fetch + scheduled), scheduled = Discord command sync + Telegram webhook sync
|
||||
├── types.ts # Env, Config, Route, Filter, Group, WebhookEvent, NeutralMessage
|
||||
├── config.ts # Loads routes from KV (returns [] if unset), builds Config from env
|
||||
├── server.ts # Hono app: /health, /webhook, /discord/interactions, /telegram/webhook, mounts /auth, /admin + /
|
||||
├── core/
|
||||
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → getDriver().send/edit
|
||||
├── events/ # Provider-agnostic route matching
|
||||
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword filtering
|
||||
├── providers/ # Forge webhook providers (verify + parse/normalize)
|
||||
│ ├── types.ts # Provider interface (matches/verify/parse)
|
||||
│ ├── index.ts # detectProvider() registry (github, gitea)
|
||||
│ ├── github/ # X-GitHub-Event + X-Hub-Signature-256
|
||||
│ └── gitea/ # X-Gitea-Event + X-Gitea-Signature (normalized payloads)
|
||||
├── formatters/ # Platform-neutral formatters (produce NeutralMessage)
|
||||
│ ├── index.ts # formatEvent: 28-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, ping
|
||||
├── drivers/ # Platform drivers (pluggable push targets)
|
||||
│ ├── types.ts # PlatformDriver interface + SendResult (send + edit)
|
||||
│ ├── index.ts # getDriver() registry (discord + telegram)
|
||||
│ ├── discord/ # index.ts (driver), render.ts (NeutralMessage → embed),
|
||||
│ │ # rest.ts, interactions.ts, commands.ts
|
||||
│ └── telegram/ # index.ts (driver), render.ts (NeutralMessage → Telegram HTML),
|
||||
│ # rest.ts (chat_id + message_thread_id), updates.ts (webhook verify),
|
||||
│ # commands.ts (/gh login|logout|comment|merge|close + reply parsing)
|
||||
├── github/ # GitHub OAuth + as-user actions
|
||||
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/merge/close actions
|
||||
│ └── store.ts # KV token CRUD + D1 discord-link/telegram-link mapping
|
||||
├── web/ # HTTP UI/API routes
|
||||
│ ├── oauth-routes.ts # GET /auth/github, callback (admin session / discord-link / telegram-link), DELETE /token/:userId
|
||||
│ ├── 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
|
||||
│ └── richheader-routes.ts # GET /api/richheader (Telegram avatar card)
|
||||
└── lib/ # Shared infrastructure
|
||||
app/ # Vue 3 UI (Nuxt app dir)
|
||||
├── app.vue # Root component (NuxtPage)
|
||||
├── assets/css/main.css # Tailwind CSS entry: theme tokens (RGB-triplet CSS variables) + @layer components (@apply)
|
||||
├── pages/ # index (landing), terms, privacy, admin/[...slug] (console SPA)
|
||||
├── components/ # ConsolePage, RouteCard/Editor, GroupEditor, MembersPanel, WebhookPanel,
|
||||
│ # SendLogs, AuditLog, AppToasts, LegalLayout
|
||||
├── composables/ # useI18n, useToasts, useGroups, useGroupRoutes, useLogs, useAudit, useInvites, useWebhook
|
||||
├── types.ts # Shared client types (Route, Group, Filter, ...)
|
||||
└── utils/legal.ts # Terms/privacy HTML bodies (zh/en)
|
||||
server/ # Nitro server (H3 handlers in server/routes/)
|
||||
├── routes/ # /health, /webhook[/:groupId], /discord/interactions, /telegram/webhook,
|
||||
│ # /auth/github*, /admin/{login,logout,invite,api/**}, /api/{comment,merge,close,react,richheader}
|
||||
├── tasks/ # Scheduled (cron */5): discord-sync, telegram-sync, audit-prune
|
||||
├── error-handler.ts # JSON error handler
|
||||
└── lib/
|
||||
├── types.ts # Env, Config, Route, Filter, Group, WebhookEvent, NeutralMessage
|
||||
├── config.ts # Loads routes from KV (returns [] if unset), builds Config from env
|
||||
├── core/
|
||||
│ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → getDriver().send/edit
|
||||
├── events/ # Provider-agnostic route matching
|
||||
│ └── match.ts # matchRoute, eventOwners, extractBranch, keyword filtering
|
||||
├── providers/ # Forge webhook providers (verify + parse/normalize)
|
||||
│ ├── types.ts # Provider interface (matches/verify/parse)
|
||||
│ ├── index.ts # detectProvider() registry (github, gitea, custom)
|
||||
│ ├── github/ # X-GitHub-Event + X-Hub-Signature-256
|
||||
│ └── gitea/ # X-Gitea-Event + X-Gitea-Signature (normalized payloads)
|
||||
├── formatters/ # Platform-neutral formatters (produce NeutralMessage)
|
||||
│ ├── index.ts # formatEvent: 29-event switch → NeutralMessage + re-exports
|
||||
│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI
|
||||
│ ├── helpers.ts # emojiPrefix, T, buildMessage, commitLink/branchLink/tagLink
|
||||
│ └── *.ts # push, pull-request, issues, comments, workflow, release, create, repo,
|
||||
│ # check, review, commit-comment, deployment, member, label, milestone,
|
||||
│ # discussion, repository, security, generic, ping, custom
|
||||
├── drivers/ # Platform drivers (pluggable push targets)
|
||||
│ ├── types.ts # PlatformDriver interface + SendResult (send + edit)
|
||||
│ ├── index.ts # getDriver() registry (discord + telegram)
|
||||
│ ├── discord/ # index.ts (driver), render.ts (NeutralMessage → embed),
|
||||
│ │ # rest.ts, interactions.ts, commands.ts
|
||||
│ └── telegram/ # index.ts (driver), render.ts (NeutralMessage → Telegram HTML),
|
||||
│ # rest.ts (chat_id + message_thread_id), updates.ts (webhook verify),
|
||||
│ # commands.ts (/gh login|logout|comment|merge|close + reply parsing)
|
||||
├── github/ # GitHub OAuth + as-user actions
|
||||
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/merge/close actions
|
||||
│ └── store.ts # KV token CRUD + D1 discord-link/telegram-link mapping
|
||||
├── web/ # HTTP UI/API logic (called from server/routes)
|
||||
│ ├── oauth.ts # GET /auth/github, callback (admin session / discord-link / telegram-link / install bind)
|
||||
│ ├── actions.ts # POST /api/comment|merge|close|react (Bearer token auth via KV lookup)
|
||||
│ ├── admin.ts # /admin API: routes, groups, me, logs, invites, audit (session + scope auth)
|
||||
│ ├── auth.ts # Shared auth middleware + guards
|
||||
│ ├── invites.ts # Invite CRUD + acceptInvite
|
||||
│ ├── session.ts # Admin session CRUD (KV session:{id}), cookie helpers
|
||||
│ ├── groups.ts # Group loading, group-admin access scoping
|
||||
│ ├── tenants.ts # Per-group webhook secret CRUD
|
||||
│ └── richheader.ts # GET /api/richheader (Telegram avatar card)
|
||||
└── lib/ # Shared infrastructure
|
||||
├── i18n.ts # Message language overrides (en/zh)
|
||||
├── send-log.ts # Send logging (D1 send_logs)
|
||||
├── audit.ts # Audit logging (D1 audit_logs)
|
||||
├── log.ts # JSON console logger (info/warn/error/fatal)
|
||||
└── locales/ # en.ts, zh.ts translation dictionaries
|
||||
|
||||
src/__tests__/ # Unit tests (bun test)
|
||||
tests/ # Unit tests (bun test)
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
| ---------------------- | ------------------------------- |
|
||||
| `npm run dev` | Start wrangler dev server |
|
||||
| `npm run dev` | Start Nuxt dev server (HMR) |
|
||||
| `npm run typecheck` | TypeScript type checking |
|
||||
| `npm run lint` | ESLint (TypeScript) |
|
||||
| `npm run lint:md` | Markdownlint (Markdown) |
|
||||
|
|
@ -100,11 +114,11 @@ curl http://localhost:8787/health
|
|||
|
||||
## Adding a New Event Formatter
|
||||
|
||||
1. Add the event type to `GITHUB_COLORS` in `src/formatters/colors.ts` (if new color needed)
|
||||
2. Add action labels to the locale dictionaries in `src/lib/locales/en.ts` and `src/lib/locales/zh.ts` (if new actions)
|
||||
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
|
||||
1. Add the event type to `GITHUB_COLORS` in `server/lib/formatters/colors.ts` (if new color needed)
|
||||
2. Add action labels to the locale dictionaries in `server/lib/lib/locales/en.ts` and `server/lib/lib/locales/zh.ts` (if new actions)
|
||||
3. Create a `formatEventType` function in `server/lib/formatters/`
|
||||
4. Add the case to the `formatEvent` switch statement in `server/lib/formatters/index.ts`
|
||||
5. Update `extractBranch` in `server/lib/events/match.ts` if the event has branch info
|
||||
6. Add the event to the documentation in `docs/events/supported.md` and `docs/zh/events/supported.md`
|
||||
7. Add the event to the README (`README.md` and `README.zh.md`) event tables and the GitHub App event subscription list
|
||||
8. Subscribe to the event in your GitHub App settings
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# Introduction
|
||||
|
||||
WebHooker is a GitHub/Gitea webhook dispatcher built on Cloudflare Workers. It receives webhook events from supported forges (GitHub, Gitea — more can be added via `src/providers/`), applies configurable filters, formats them into rich messages, and delivers them to Discord channels/threads (embeds) and Telegram chats/topics (HTML) via their REST APIs. In-Discord `/gh` interactions arrive via an HTTPS Interactions Endpoint (Ed25519-verified); Telegram `/gh` commands arrive via the Telegram webhook. Routes and groups are managed through a built-in Web UI.
|
||||
WebHooker is a GitHub/Gitea webhook dispatcher built on Cloudflare Workers. It receives webhook events from supported forges (GitHub, Gitea — more can be added via `server/lib/providers/`), applies configurable filters, formats them into rich messages, and delivers them to Discord channels/threads (embeds) and Telegram chats/topics (HTML) via their REST APIs. In-Discord `/gh` interactions arrive via an HTTPS Interactions Endpoint (Ed25519-verified); Telegram `/gh` commands arrive via the Telegram webhook. Routes and groups are managed through a built-in Web UI.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
GitHub / Gitea Webhook → Cloudflare Worker (Hono)
|
||||
GitHub / Gitea Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
|
||||
├── POST /webhook → detect provider → verify → dedup → filter → format → Discord (REST) / Telegram (Bot API)
|
||||
├── POST /discord/interactions → verify (Ed25519) → handle /gh slash & context commands
|
||||
├── POST /telegram/webhook → verify (secret token) → handle /gh commands
|
||||
|
|
@ -38,10 +38,10 @@ GitHub / Gitea Webhook → Cloudflare Worker (Hono)
|
|||
## Tech Stack
|
||||
|
||||
- **Runtime**: Cloudflare Workers
|
||||
- **HTTP Framework**: Hono
|
||||
- **HTTP Framework**: Nuxt 4 / Nitro (H3)
|
||||
- **Discord delivery**: Discord REST API (interactions via an Ed25519-verified HTTPS Interactions Endpoint)
|
||||
- **Telegram delivery**: Telegram Bot API (webhook with optional secret-token verification)
|
||||
- **Web UI**: Nuxt 3 static SPA served from Worker assets
|
||||
- **Web UI**: Nuxt 4 (Vue 3 + Tailwind CSS v3) — server-rendered home/legal pages, client-side `/admin` console
|
||||
- **Storage**: Cloudflare KV + D1
|
||||
- **Auth**: Web Crypto API (HMAC-SHA256, Ed25519), octokit (GitHub API)
|
||||
- **Language**: TypeScript
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# API 概览
|
||||
|
||||
WebHooker 通过 Hono 在 Cloudflare Workers 上提供 HTTP API。
|
||||
WebHooker 通过 Nitro(Nuxt 4)在 Cloudflare Workers 上提供 HTTP API。
|
||||
|
||||
## 基础 URL
|
||||
|
||||
|
|
|
|||
|
|
@ -13,61 +13,75 @@ npm run dev # 启动本地开发服务器
|
|||
## 项目结构
|
||||
|
||||
```text
|
||||
src/
|
||||
├── index.ts # CF Workers 入口 (fetch + scheduled),scheduled = Discord 命令同步 + Telegram webhook 同步
|
||||
├── types.ts # Env、Config、Route、Filter、Group、WebhookEvent、NeutralMessage
|
||||
├── config.ts # 从 KV 加载路由(未设置时返回 []),从 env 构建 Config
|
||||
├── server.ts # Hono 应用: /health、/webhook、/discord/interactions、/telegram/webhook,挂载 /auth、/admin + /
|
||||
├── core/
|
||||
│ └── dispatch.ts # 平台中立分发:匹配路由 → formatEvent → getDriver().send/edit
|
||||
├── events/ # 与提供方无关的路由匹配
|
||||
│ └── match.ts # matchRoute、eventOwners、extractBranch、关键词过滤
|
||||
├── providers/ # Forge webhook 提供方(验证 + 解析/归一化)
|
||||
│ ├── types.ts # Provider 接口(matches/verify/parse)
|
||||
│ ├── index.ts # detectProvider() 注册表(github、gitea)
|
||||
│ ├── github/ # X-GitHub-Event + X-Hub-Signature-256
|
||||
│ └── gitea/ # X-Gitea-Event + X-Gitea-Signature(归一化载荷)
|
||||
├── formatters/ # 平台中立格式化器(产出 NeutralMessage)
|
||||
│ ├── index.ts # formatEvent:28 事件 switch → NeutralMessage + re-export
|
||||
│ ├── 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、ping
|
||||
├── drivers/ # 平台驱动(可插拔推送目标)
|
||||
│ ├── types.ts # PlatformDriver 接口 + SendResult(send + edit)
|
||||
│ ├── index.ts # getDriver() 注册表(discord + telegram)
|
||||
│ ├── discord/ # index.ts (驱动)、render.ts (NeutralMessage → embed)、
|
||||
│ │ # rest.ts、interactions.ts、commands.ts
|
||||
│ └── telegram/ # index.ts (驱动)、render.ts (NeutralMessage → Telegram HTML)、
|
||||
│ # rest.ts (chat_id + message_thread_id)、updates.ts (webhook 验签)、
|
||||
│ # commands.ts (/gh login|logout|comment|merge|close + 引用消息解析)
|
||||
├── github/ # GitHub OAuth + 以用户身份操作
|
||||
│ ├── oauth.ts # OAuth URL、回调 Token 交换、getUserOctokit、评论/合并/关闭操作
|
||||
│ └── store.ts # KV Token CRUD + D1 discord-link/telegram-link 映射
|
||||
├── web/ # HTTP UI/API 路由
|
||||
│ ├── oauth-routes.ts # GET /auth/github、回调(管理员会话 / Discord 绑定 / Telegram 绑定)、DELETE /token/:userId
|
||||
│ ├── 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 # 法律页面路由
|
||||
│ └── richheader-routes.ts # GET /api/richheader(Telegram 头像卡片)
|
||||
└── lib/ # 共享基础设施
|
||||
app/ # Vue 3 UI(Nuxt app 目录)
|
||||
├── app.vue # 根组件 (NuxtPage)
|
||||
├── assets/css/main.css # Tailwind CSS 入口:主题令牌(RGB 三元组 CSS 变量)+ @layer components (@apply)
|
||||
├── pages/ # index(落地页)、terms、privacy、admin/[...slug](控制台 SPA)
|
||||
├── components/ # ConsolePage、RouteCard/Editor、GroupEditor、MembersPanel、WebhookPanel、
|
||||
│ # SendLogs、AuditLog、AppToasts、LegalLayout
|
||||
├── composables/ # useI18n、useToasts、useGroups、useGroupRoutes、useLogs、useAudit、useInvites、useWebhook
|
||||
├── types.ts # 共享客户端类型 (Route、Group、Filter、...)
|
||||
└── utils/legal.ts # 服务条款/隐私政策 HTML 正文 (zh/en)
|
||||
server/ # Nitro 服务器(H3 处理器位于 server/routes/)
|
||||
├── routes/ # /health、/webhook[/:groupId]、/discord/interactions、/telegram/webhook、
|
||||
│ # /auth/github*、/admin/{login,logout,invite,api/**}、/api/{comment,merge,close,react,richheader}
|
||||
├── tasks/ # 定时任务 (cron */5):discord-sync、telegram-sync、audit-prune
|
||||
├── error-handler.ts # JSON 错误处理器
|
||||
└── lib/
|
||||
├── types.ts # Env、Config、Route、Filter、Group、WebhookEvent、NeutralMessage
|
||||
├── config.ts # 从 KV 加载路由(未设置时返回 []),从 env 构建 Config
|
||||
├── core/
|
||||
│ └── dispatch.ts # 平台中立分发:匹配路由 → formatEvent → getDriver().send/edit
|
||||
├── events/ # 与提供方无关的路由匹配
|
||||
│ └── match.ts # matchRoute、eventOwners、extractBranch、关键词过滤
|
||||
├── providers/ # Forge webhook 提供方(验证 + 解析/归一化)
|
||||
│ ├── types.ts # Provider 接口(matches/verify/parse)
|
||||
│ ├── index.ts # detectProvider() 注册表(github、gitea、custom)
|
||||
│ ├── github/ # X-GitHub-Event + X-Hub-Signature-256
|
||||
│ └── gitea/ # X-Gitea-Event + X-Gitea-Signature(归一化载荷)
|
||||
├── formatters/ # 平台中立格式化器(产出 NeutralMessage)
|
||||
│ ├── index.ts # formatEvent:29 事件 switch → NeutralMessage + re-export
|
||||
│ ├── colors.ts # GITHUB_COLORS + WORKFLOW_CONCLUSION_EMOJI
|
||||
│ ├── helpers.ts # emojiPrefix、T、buildMessage、commitLink/branchLink/tagLink
|
||||
│ └── *.ts # push、pull-request、issues、comments、workflow、release、create、repo、
|
||||
│ # check、review、commit-comment、deployment、member、label、milestone、
|
||||
│ # discussion、repository、security、generic、ping、custom
|
||||
├── drivers/ # 平台驱动(可插拔推送目标)
|
||||
│ ├── types.ts # PlatformDriver 接口 + SendResult(send + edit)
|
||||
│ ├── index.ts # getDriver() 注册表(discord + telegram)
|
||||
│ ├── discord/ # index.ts (驱动)、render.ts (NeutralMessage → embed)、
|
||||
│ │ # rest.ts、interactions.ts、commands.ts
|
||||
│ └── telegram/ # index.ts (驱动)、render.ts (NeutralMessage → Telegram HTML)、
|
||||
│ # rest.ts (chat_id + message_thread_id)、updates.ts (webhook 验签)、
|
||||
│ # commands.ts (/gh login|logout|comment|merge|close + 引用消息解析)
|
||||
├── github/ # GitHub OAuth + 以用户身份操作
|
||||
│ ├── oauth.ts # OAuth URL、回调 Token 交换、getUserOctokit、评论/合并/关闭操作
|
||||
│ └── store.ts # KV Token CRUD + D1 discord-link/telegram-link 映射
|
||||
├── web/ # HTTP UI/API 逻辑(由 server/routes 调用)
|
||||
│ ├── oauth.ts # GET /auth/github、回调(管理员会话 / Discord 绑定 / Telegram 绑定 / install 绑定)
|
||||
│ ├── actions.ts # POST /api/comment|merge|close|react (通过 KV 查找进行 Bearer Token 鉴权)
|
||||
│ ├── admin.ts # /admin API:路由、分组、me、日志、邀请、审计(会话 + 权限范围鉴权)
|
||||
│ ├── auth.ts # 共享鉴权中间件 + 守卫
|
||||
│ ├── invites.ts # 邀请 CRUD + acceptInvite
|
||||
│ ├── session.ts # 管理员会话 CRUD (KV session:{id})、Cookie 辅助函数
|
||||
│ ├── groups.ts # 分组加载、分组管理员权限范围
|
||||
│ ├── tenants.ts # 每分组 webhook 密钥 CRUD
|
||||
│ └── richheader.ts # GET /api/richheader(Telegram 头像卡片)
|
||||
└── lib/ # 共享基础设施
|
||||
├── i18n.ts # 消息语言覆盖 (en/zh)
|
||||
├── send-log.ts # 发送日志 (D1 send_logs)
|
||||
├── audit.ts # 审计日志 (D1 audit_logs)
|
||||
├── log.ts # JSON 控制台日志 (info/warn/error/fatal)
|
||||
└── locales/ # en.ts、zh.ts 翻译字典
|
||||
|
||||
src/__tests__/ # 单元测试 (bun test)
|
||||
tests/ # 单元测试 (bun test)
|
||||
```
|
||||
|
||||
## 脚本
|
||||
|
||||
| 命令 | 说明 |
|
||||
| ---------------------- | ----------------------------- |
|
||||
| `npm run dev` | 启动 wrangler dev 服务器 |
|
||||
| `npm run dev` | 启动 Nuxt 开发服务器 (HMR) |
|
||||
| `npm run typecheck` | TypeScript 类型检查 |
|
||||
| `npm run lint` | ESLint (TypeScript) |
|
||||
| `npm run lint:md` | Markdownlint (Markdown) |
|
||||
|
|
@ -100,11 +114,11 @@ curl http://localhost:8787/health
|
|||
|
||||
## 添加新事件格式化器
|
||||
|
||||
1. 将事件类型添加到 `src/formatters/colors.ts` 中的 `GITHUB_COLORS`(如果需要新颜色)
|
||||
2. 将操作标签添加到 `src/lib/locales/en.ts` 与 `src/lib/locales/zh.ts` 的翻译字典(如果有新操作)
|
||||
3. 在 `src/formatters/` 中创建 `formatEventType` 函数
|
||||
4. 将 case 添加到 `src/formatters/index.ts` 中的 `formatEvent` switch 语句
|
||||
5. 如果事件包含分支信息,更新 `src/events/match.ts` 中的 `extractBranch`
|
||||
1. 将事件类型添加到 `server/lib/formatters/colors.ts` 中的 `GITHUB_COLORS`(如果需要新颜色)
|
||||
2. 将操作标签添加到 `server/lib/lib/locales/en.ts` 与 `server/lib/lib/locales/zh.ts` 的翻译字典(如果有新操作)
|
||||
3. 在 `server/lib/formatters/` 中创建 `formatEventType` 函数
|
||||
4. 将 case 添加到 `server/lib/formatters/index.ts` 中的 `formatEvent` switch 语句
|
||||
5. 如果事件包含分支信息,更新 `server/lib/events/match.ts` 中的 `extractBranch`
|
||||
6. 将事件添加到 `docs/events/supported.md` 与 `docs/zh/events/supported.md` 文档中
|
||||
7. 将事件添加到 README(`README.md` 与 `README.zh.md`)的事件表与 GitHub App 事件订阅列表中
|
||||
8. 在 GitHub App 设置中订阅该事件
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# 简介
|
||||
|
||||
WebHooker 是一个基于 Cloudflare Workers 构建的 GitHub/Gitea webhook 调度器。它接收来自受支持 forge(GitHub、Gitea——更多可通过 `src/providers/` 扩展)的 webhook 事件,应用可配置的过滤器,将事件格式化为富消息,并通过各自 REST API 投递到 Discord 频道/子区(embed)与 Telegram 群组/话题(HTML)。Discord 内的 `/gh` 交互通过 HTTPS Interactions Endpoint(Ed25519 验签)送达;Telegram 的 `/gh` 命令通过 Telegram webhook 送达。路由与分组通过内置的 Web UI 管理。
|
||||
WebHooker 是一个基于 Cloudflare Workers 构建的 GitHub/Gitea webhook 调度器。它接收来自受支持 forge(GitHub、Gitea——更多可通过 `server/lib/providers/` 扩展)的 webhook 事件,应用可配置的过滤器,将事件格式化为富消息,并通过各自 REST API 投递到 Discord 频道/子区(embed)与 Telegram 群组/话题(HTML)。Discord 内的 `/gh` 交互通过 HTTPS Interactions Endpoint(Ed25519 验签)送达;Telegram 的 `/gh` 命令通过 Telegram webhook 送达。路由与分组通过内置的 Web UI 管理。
|
||||
|
||||
## 架构
|
||||
|
||||
```text
|
||||
GitHub / Gitea Webhook → Cloudflare Worker (Hono)
|
||||
GitHub / Gitea Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
|
||||
├── POST /webhook → 识别提供方 → 验证 → 去重 → 过滤 → 格式化 → Discord (REST) / Telegram (Bot API)
|
||||
├── POST /discord/interactions → 验证 (Ed25519) → 处理 /gh 斜杠与右键命令
|
||||
├── POST /telegram/webhook → 验证 (secret token) → 处理 /gh 命令
|
||||
|
|
@ -38,7 +38,7 @@ GitHub / Gitea Webhook → Cloudflare Worker (Hono)
|
|||
## 技术栈
|
||||
|
||||
- **运行时**: Cloudflare Workers
|
||||
- **HTTP 框架**: Hono
|
||||
- **HTTP 框架**: Nuxt 4 / Nitro (H3)
|
||||
- **Discord 投递**: Discord REST API(交互通过 Ed25519 验签的 HTTPS Interactions Endpoint)
|
||||
- **Telegram 投递**: Telegram Bot API(webhook 带可选 secret-token 校验)
|
||||
- **Web UI**: Nuxt 3 静态 SPA,由 Worker 资源托管
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import tsParser from "@typescript-eslint/parser";
|
|||
|
||||
export default defineConfig([
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
ignores: ["docs/.vitepress/**", ".output/**", ".nuxt/**", "dist/**"],
|
||||
},
|
||||
{
|
||||
files: ["server/lib/**/*.ts", "server/routes/**/*.ts", "server/tasks/**/*.ts", "tests/**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
|
|
|
|||
43
nuxt.config.ts
Normal file
43
nuxt.config.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-07-15",
|
||||
modules: ["@nuxtjs/tailwindcss"],
|
||||
// Target: Cloudflare Workers (single _worker.js via the cloudflare_module preset).
|
||||
nitro: {
|
||||
preset: "cloudflare_module",
|
||||
errorHandler: "~~/server/error-handler",
|
||||
experimental: {
|
||||
tasks: true,
|
||||
},
|
||||
scheduledTasks: {
|
||||
"*/5 * * * *": ["discord-sync", "telegram-sync", "audit-prune"],
|
||||
},
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
title: "WebHooker",
|
||||
link: [
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
tailwindcss: {
|
||||
cssPath: "~/assets/css/main.css",
|
||||
configPath: "tailwind.config",
|
||||
},
|
||||
devtools: { enabled: false },
|
||||
runtimeConfig: {
|
||||
// Overridable via NUXT_PUBLIC_DOCS_URL / NUXT_PUBLIC_REPO_URL.
|
||||
public: {
|
||||
docsUrl: "",
|
||||
repoUrl: "",
|
||||
},
|
||||
},
|
||||
// The config console stays a client-side SPA (same behavior as the old
|
||||
// standalone admin app); home/legal pages render server-side.
|
||||
routeRules: {
|
||||
"/admin/**": { ssr: false },
|
||||
},
|
||||
});
|
||||
15928
package-lock.json
generated
Normal file
15928
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
package.json
31
package.json
|
|
@ -1,35 +1,46 @@
|
|||
{
|
||||
"name": "webhooker",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "GitHub / Gitea webhook → Discord / Telegram dispatcher on Cloudflare Workers (Nuxt 4)",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"deploy": "wrangler deploy",
|
||||
"db:migrate": "wrangler d1 migrations apply webhooker --local",
|
||||
"db:migrate:prod": "wrangler d1 migrations apply webhooker --remote",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"cf:dev": "nuxt build && wrangler dev",
|
||||
"preview": "nuxt preview",
|
||||
"typecheck": "nuxt typecheck",
|
||||
"lint": "eslint .",
|
||||
"lint:md": "markdownlint '**/*.md' --ignore node_modules --ignore dist",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "bun test",
|
||||
"db:migrate": "wrangler d1 migrations apply webhooker --local",
|
||||
"db:migrate:prod": "wrangler d1 migrations apply webhooker --remote",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs",
|
||||
"test": "bun test"
|
||||
"docs:preview": "vitepress preview docs"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0",
|
||||
"octokit": "^4.1.0"
|
||||
"h3": "^1.15.3",
|
||||
"nuxt": "^4.1.0",
|
||||
"octokit": "^4.1.0",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^5.20260801.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
||||
"@typescript-eslint/parser": "^8.21.0",
|
||||
"eslint": "^9.18.0",
|
||||
"markdownlint-cli": "^0.49.1",
|
||||
"prettier": "^3.9.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.7.0",
|
||||
"vitepress": "^1.6.4",
|
||||
"vue-router": "^5.2.0",
|
||||
"vue-tsc": "^3.0.0",
|
||||
"wrangler": "4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
server/error-handler.ts
Normal file
22
server/error-handler.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import defaultNitroErrorHandler, {
|
||||
defineNitroErrorHandler,
|
||||
} from "nitropack/runtime/error";
|
||||
import { setResponseStatus } from "h3";
|
||||
|
||||
/**
|
||||
* Keep the legacy API error contract (`{ error: string }` JSON) for the
|
||||
* machine-facing endpoints; everything else (pages) uses the default handler.
|
||||
*/
|
||||
export default defineNitroErrorHandler((error, event) => {
|
||||
const path = event.path ?? "";
|
||||
if (
|
||||
path.startsWith("/admin/api/") ||
|
||||
path.startsWith("/api/") ||
|
||||
path.startsWith("/auth/") ||
|
||||
path.startsWith("/webhook")
|
||||
) {
|
||||
setResponseStatus(event, error.statusCode || 500);
|
||||
return { error: error.statusMessage || error.message || "Internal Server Error" };
|
||||
}
|
||||
return defaultNitroErrorHandler(error, event);
|
||||
});
|
||||
39
server/lib/cf.ts
Normal file
39
server/lib/cf.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { H3Event } from "h3";
|
||||
import type { Env } from "./types";
|
||||
|
||||
/**
|
||||
* Cloudflare bindings (KV, D1, secrets) from the request context. Works on
|
||||
* Cloudflare Workers (fetch and scheduled) and in tests that stub
|
||||
* `event.context.cloudflare`. Throws with a clear message in other runtimes.
|
||||
*/
|
||||
export function cfEnv(event: H3Event): Env {
|
||||
const env = (event.context.cloudflare as { env?: Env } | undefined)?.env;
|
||||
if (!env) {
|
||||
throw new Error("Cloudflare bindings unavailable — run via wrangler dev/deploy");
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/** `waitUntil` from the CF execution context (no-op outside workers). */
|
||||
export function cfWaitUntil(event: H3Event): (promise: Promise<unknown>) => void {
|
||||
const cloudflare = event.context.cloudflare as
|
||||
| {
|
||||
ctx?: { waitUntil?: (p: Promise<unknown>) => void };
|
||||
context?: { waitUntil?: (p: Promise<unknown>) => void };
|
||||
}
|
||||
| undefined;
|
||||
const waitUntil =
|
||||
(event.context.waitUntil as ((p: Promise<unknown>) => void) | undefined) ??
|
||||
cloudflare?.context?.waitUntil ??
|
||||
cloudflare?.ctx?.waitUntil;
|
||||
return waitUntil ? waitUntil.bind(event.context) : () => undefined;
|
||||
}
|
||||
|
||||
/** Lowercased request headers (the provider detection reads lowercase keys). */
|
||||
export function headersFrom(event: H3Event): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
event.headers.forEach((value, key) => {
|
||||
out[key.toLowerCase()] = value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ function extractTarget(msg: TelegramMessage, prOnly = false): Target | null {
|
|||
for (const url of urls) {
|
||||
const re = prOnly ? GITHUB_PR_RE : GITHUB_ISSUE_RE;
|
||||
const m = url.match(re);
|
||||
if (m) return { owner: m[1], repo: m[2], number: Number(m[3]) };
|
||||
if (m && m[1] && m[2] && m[3]) return { owner: m[1], repo: m[2], number: Number(m[3]) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -219,7 +219,7 @@ export async function handleTelegramUpdate(env: Env, update: unknown): Promise<v
|
|||
const m = text.match(/^\/gh(?:\s+|$)(.*)$/s);
|
||||
if (!m) return;
|
||||
|
||||
const rest = m[1].trim();
|
||||
const rest = (m[1] ?? "").trim();
|
||||
const [sub, ...args] = rest.split(/\s+/);
|
||||
const body = args.join(" ").trim();
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ export function formatMilestone(
|
|||
if (milestone.due_on) {
|
||||
fields.push({
|
||||
name: t("fields.due"),
|
||||
value: milestone.due_on.split("T")[0],
|
||||
value: milestone.due_on.split("T")[0] ?? "",
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ export function formatPush(
|
|||
|
||||
const commitField = (c: (typeof commits)[number]): { name: string; value: string } => {
|
||||
const shortId = c.id?.slice(0, 7) ?? "???????";
|
||||
const msg = c.message?.split("\n")[0].slice(0, 72) ?? t("common.no_message");
|
||||
const msg = (c.message?.split("\n")[0] ?? "").slice(0, 72) || t("common.no_message");
|
||||
const url = baseUrl && c.id ? `${baseUrl}/commit/${c.id}` : null;
|
||||
const hash = url ? `[\`${shortId}\`](${url})` : `\`${shortId}\``;
|
||||
return { name: `\u200b`, value: `${hash} ${msg}` };
|
||||
28
server/lib/http.ts
Normal file
28
server/lib/http.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { H3Event } from "h3";
|
||||
import { setResponseStatus } from "h3";
|
||||
|
||||
/**
|
||||
* Map a thrown h3 error to the API's legacy JSON contract
|
||||
* (`{ error: string }` with the proper status code).
|
||||
*/
|
||||
export function toApiError(event: H3Event, err: unknown): { error: string } {
|
||||
const e = err as { statusCode?: number; statusMessage?: string; message?: string };
|
||||
setResponseStatus(event, e.statusCode ?? 500);
|
||||
return { error: e.statusMessage ?? e.message ?? "Internal Server Error" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an API handler so thrown h3 errors (401/403/400/...) become
|
||||
* `{ error }` JSON responses instead of the default HTML error page.
|
||||
*/
|
||||
export function wrapApi<Args extends unknown[]>(
|
||||
fn: (event: H3Event, ...args: Args) => Promise<unknown>,
|
||||
): (event: H3Event, ...args: Args) => Promise<unknown> {
|
||||
return async (event, ...args) => {
|
||||
try {
|
||||
return await fn(event, ...args);
|
||||
} catch (err) {
|
||||
return toApiError(event, err);
|
||||
}
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue