mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat: add Telegram push support with multi-target routes
Add a platform-aware route target system so a single route can forward to several destinations at once (e.g. a Discord channel and a Telegram group). Route.target becomes Route.targets[] with per-entry platform, channelId/threadId for Discord and chatId/topicId for Telegram; the legacy single-target format is normalized on load and accepted by the admin API. Implement the Telegram driver with HTML rendering and Bot API sendMessage (chat_id + message_thread_id for topics, retry on 429/5xx), plus /gh commands served over POST /telegram/webhook: login, logout, comment, merge and close. The comment/merge/close commands resolve the issue or PR from the replied-to notification message. OAuth binding now stores a D1 telegram_links mapping and replies with a confirmation. Sync the Telegram webhook from the scheduled trigger via setWebhook.
This commit is contained in:
parent
dcbe93be91
commit
bd7a8f2632
39 changed files with 1165 additions and 162 deletions
|
|
@ -11,6 +11,10 @@ DISCORD_PUBLIC_KEY=your-public-key
|
||||||
DISCORD_APPLICATION_ID=your-application-id
|
DISCORD_APPLICATION_ID=your-application-id
|
||||||
DISCORD_CHANNEL_ID=your-channel-id
|
DISCORD_CHANNEL_ID=your-channel-id
|
||||||
|
|
||||||
|
# Telegram
|
||||||
|
TELEGRAM_TOKEN=your-bot-token
|
||||||
|
TELEGRAM_WEBHOOK_SECRET=your-webhook-secret
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
PORT=3000
|
PORT=3000
|
||||||
BASE_URL=http://localhost:3000
|
BASE_URL=http://localhost:3000
|
||||||
|
|
|
||||||
18
AGENTS.md
18
AGENTS.md
|
|
@ -40,7 +40,7 @@ src/
|
||||||
│ # milestone, discussion, repository, security, generic
|
│ # milestone, discussion, repository, security, generic
|
||||||
├── drivers/ # Platform drivers (pluggable push targets)
|
├── drivers/ # Platform drivers (pluggable push targets)
|
||||||
│ ├── types.ts # PlatformDriver interface + SendResult
|
│ ├── types.ts # PlatformDriver interface + SendResult
|
||||||
│ ├── index.ts # getDriver() registry (discord default + telegram stub)
|
│ ├── index.ts # getDriver() registry (discord default + telegram)
|
||||||
│ ├── discord/
|
│ ├── discord/
|
||||||
│ │ ├── index.ts # DiscordDriver: send → renderNeutralMessage + rest.sendMessage
|
│ │ ├── index.ts # DiscordDriver: send → renderNeutralMessage + rest.sendMessage
|
||||||
│ │ ├── render.ts # renderNeutralMessage: NeutralMessage → Discord FormattedMessage
|
│ │ ├── render.ts # renderNeutralMessage: NeutralMessage → Discord FormattedMessage
|
||||||
|
|
@ -48,10 +48,14 @@ src/
|
||||||
│ │ ├── interactions.ts # Ed25519 verify + interaction handlers (/gh, buttons, modals)
|
│ │ ├── interactions.ts # Ed25519 verify + interaction handlers (/gh, buttons, modals)
|
||||||
│ │ └── commands.ts # APP_COMMANDS + registerGlobalCommands/syncGuildCommands/syncCommands
|
│ │ └── commands.ts # APP_COMMANDS + registerGlobalCommands/syncGuildCommands/syncCommands
|
||||||
│ └── telegram/
|
│ └── telegram/
|
||||||
│ └── index.ts # TelegramDriver stub (not implemented yet)
|
│ ├── index.ts # TelegramDriver: send → renderNeutralMessage + rest.sendMessage
|
||||||
|
│ ├── render.ts # renderNeutralMessage: NeutralMessage → Telegram HTML (parse_mode HTML)
|
||||||
|
│ ├── rest.ts # Telegram Bot API sendMessage (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/
|
├── github/
|
||||||
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/merge/close actions
|
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/merge/close actions
|
||||||
│ └── store.ts # KV token CRUD + D1 discord-link mapping (was token-store.ts)
|
│ └── store.ts # KV token CRUD + D1 discord-link/telegram-link mapping (was token-store.ts)
|
||||||
├── web/ # HTTP UI/API routes
|
├── web/ # HTTP UI/API routes
|
||||||
│ ├── oauth-routes.ts # GET /auth/github, callback (admin session / discord-link), DELETE /token/:userId
|
│ ├── oauth-routes.ts # GET /auth/github, callback (admin session / discord-link), DELETE /token/:userId
|
||||||
│ ├── action-routes.ts # POST /api/comment|merge|react (Bearer token auth via KV lookup)
|
│ ├── action-routes.ts # POST /api/comment|merge|react (Bearer token auth via KV lookup)
|
||||||
|
|
@ -71,11 +75,14 @@ src/
|
||||||
|
|
||||||
- Verify GitHub webhook signatures (Web Crypto HMAC-SHA256)
|
- Verify GitHub webhook signatures (Web Crypto HMAC-SHA256)
|
||||||
- Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body)
|
- Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body)
|
||||||
|
- Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured)
|
||||||
- Filter events by: event type, repo name, actor, action, branch, keyword (regex supported)
|
- Filter events by: event type, repo name, actor, action, branch, keyword (regex supported)
|
||||||
- Format 23+ event types as Discord embeds
|
- Format 23+ event types as Discord embeds
|
||||||
- Route messages to Discord channels/threads via REST
|
- Route messages to Discord channels/threads and Telegram chats/topics via REST
|
||||||
- Serve `/gh` slash commands + message context-menu commands + PR merge/close buttons + comment modals
|
- Serve `/gh` slash commands + message context-menu commands + PR merge/close buttons + comment modals
|
||||||
|
- Serve Telegram `/gh` commands (login/logout/comment/merge/close) via reply-message parsing
|
||||||
- Sync application commands from the scheduled trigger (global ~1h propagation + per-guild instant)
|
- Sync application commands from the scheduled trigger (global ~1h propagation + per-guild instant)
|
||||||
|
- Sync the Telegram webhook URL from the scheduled trigger (setWebhook)
|
||||||
|
|
||||||
## Message Format Spec
|
## Message Format Spec
|
||||||
|
|
||||||
|
|
@ -105,8 +112,9 @@ npm run lint # ESLint
|
||||||
- **Production**: `wrangler secret put <NAME>` for each secret
|
- **Production**: `wrangler secret put <NAME>` for each secret
|
||||||
- **Routes**: KV key `config:routes` (JSON array, empty until configured)
|
- **Routes**: KV key `config:routes` (JSON array, empty until configured)
|
||||||
- **KV namespace**: Required binding for token/state/config/session storage
|
- **KV namespace**: Required binding for token/state/config/session storage
|
||||||
- **D1 database**: Binding `DB` (database `webhooker`, id `214a0104-3235-47c0-b7bf-ddda95f3c8ac`) for `send_logs` + `discord_links` tables
|
- **D1 database**: Binding `DB` (database `webhooker`, id `214a0104-3235-47c0-b7bf-ddda95f3c8ac`) for `send_logs` + `discord_links` + `telegram_links` tables
|
||||||
- **Discord**: `DISCORD_PUBLIC_KEY` (Interactions Endpoint signature verification, from Discord Developer Portal) and `DISCORD_APPLICATION_ID` (optional, auto-resolved via `GET /oauth2/applications/@me` when omitted) are required for interactions
|
- **Discord**: `DISCORD_PUBLIC_KEY` (Interactions Endpoint signature verification, from Discord Developer Portal) and `DISCORD_APPLICATION_ID` (optional, auto-resolved via `GET /oauth2/applications/@me` when omitted) are required for interactions
|
||||||
|
- **Telegram**: `TELEGRAM_TOKEN` (Bot API token from BotFather) required for Telegram routes; `TELEGRAM_WEBHOOK_SECRET` (optional secret token for `POST /telegram/webhook` verification)
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|
|
||||||
13
README.md
13
README.md
|
|
@ -57,7 +57,7 @@ npx wrangler dev # Start local dev server
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
Routes are stored in KV (`config:routes` as JSON). There are **no default routes** — every route (including its target `channelId` / `threadId`) must be defined explicitly, either via the Web UI (`/admin`) or by storing a JSON array in KV:
|
Routes are stored in KV (`config:routes` as JSON). There are **no default routes** — every route (including its target) must be defined explicitly, either via the Web UI (`/admin`) or by storing a JSON array in KV:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
|
|
@ -66,12 +66,19 @@ Routes are stored in KV (`config:routes` as JSON). There are **no default routes
|
||||||
"name": "Push Events",
|
"name": "Push Events",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"filters": [{ "type": "event", "match": "push" }],
|
"filters": [{ "type": "event", "match": "push" }],
|
||||||
"target": { "channelId": "CHANNEL_ID" }
|
"target": { "platform": "discord", "channelId": "CHANNEL_ID" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "telegram-issues",
|
||||||
|
"name": "Issues to Telegram",
|
||||||
|
"enabled": true,
|
||||||
|
"filters": [{ "type": "event", "match": "issues" }],
|
||||||
|
"target": { "platform": "telegram", "chatId": "-1001234567890" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
The `target.channelId` is required and is used as-is; there is no fallback to a default channel.
|
`target.platform` selects the push target: `discord` (default) or `telegram`. Discord routes require `target.channelId` (optional `threadId` for a thread); Telegram routes require `target.chatId` (the group chat id, optional `topicId` for a topic). There is no fallback to a default channel.
|
||||||
|
|
||||||
### Web UI (`/admin`)
|
### Web UI (`/admin`)
|
||||||
|
|
||||||
|
|
|
||||||
13
README.zh.md
13
README.zh.md
|
|
@ -57,7 +57,7 @@ npx wrangler dev # 启动本地开发服务器
|
||||||
|
|
||||||
### 路由配置
|
### 路由配置
|
||||||
|
|
||||||
路由存储在 KV(`config:routes`,JSON 格式)。**没有默认路由**——每条路由(包括目标 `channelId` / `threadId`)都必须显式定义,可通过 Web 控制台(`/admin`)或直接向 KV 存储 JSON 数组:
|
路由存储在 KV(`config:routes`,JSON 格式)。**没有默认路由**——每条路由(包括目标)都必须显式定义,可通过 Web 控制台(`/admin`)或直接向 KV 存储 JSON 数组:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
|
|
@ -66,12 +66,19 @@ npx wrangler dev # 启动本地开发服务器
|
||||||
"name": "Push 事件",
|
"name": "Push 事件",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"filters": [{ "type": "event", "match": "push" }],
|
"filters": [{ "type": "event", "match": "push" }],
|
||||||
"target": { "channelId": "频道ID" }
|
"target": { "platform": "discord", "channelId": "频道ID" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "telegram-issues",
|
||||||
|
"name": "Issue 推送 Telegram",
|
||||||
|
"enabled": true,
|
||||||
|
"filters": [{ "type": "event", "match": "issues" }],
|
||||||
|
"target": { "platform": "telegram", "chatId": "-1001234567890" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
`target.channelId` 必填且按原样使用,不存在默认频道回退。
|
`target.platform` 选择推送目标:`discord`(默认)或 `telegram`。Discord 路由需 `target.channelId`(可选 `threadId` 指向子区);Telegram 路由需 `target.chatId`(群组聊天 ID,可选 `topicId` 指向话题)。不存在默认频道回退。
|
||||||
|
|
||||||
### Web 控制台(`/admin`)
|
### Web 控制台(`/admin`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@
|
||||||
<span class="route-name">{{ route.name || t("route.untitled") }}</span>
|
<span class="route-name">{{ route.name || t("route.untitled") }}</span>
|
||||||
<span class="route-id">{{ route.id }}</span>
|
<span class="route-id">{{ route.id }}</span>
|
||||||
<span v-if="route.lang" class="badge lang">{{ route.lang }}</span>
|
<span v-if="route.lang" class="badge lang">{{ route.lang }}</span>
|
||||||
|
<span
|
||||||
|
v-for="(tg, i) in route.targets"
|
||||||
|
:key="i"
|
||||||
|
class="badge"
|
||||||
|
:class="tg.platform === 'telegram' ? 'fallback' : 'lang'"
|
||||||
|
>{{ tg.platform === "telegram" ? "Telegram" : "Discord" }}</span
|
||||||
|
>
|
||||||
<span v-if="route.fallback" class="badge fallback">{{ t("route.fallback") }}</span>
|
<span v-if="route.fallback" class="badge fallback">{{ t("route.fallback") }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
|
|
@ -36,15 +43,21 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="target">
|
<div class="target">
|
||||||
<span
|
<div v-for="(tg, i) in route.targets" :key="i" class="target-group">
|
||||||
><b>{{ t("route.channel") }}</b
|
<span class="target-plat"
|
||||||
><code>{{ route.target.channelId }}</code></span
|
><b>{{ tg.platform === "telegram" ? t("route.chat") : t("route.channel") }}</b
|
||||||
|
><code>{{ tg.platform === "telegram" ? tg.chatId : tg.channelId }}</code></span
|
||||||
>
|
>
|
||||||
<span v-if="route.target.threadId"
|
<span v-if="tg.platform === 'telegram' && tg.topicId"
|
||||||
|
><b>{{ t("route.topic") }}</b
|
||||||
|
><code>{{ tg.topicId }}</code></span
|
||||||
|
>
|
||||||
|
<span v-else-if="tg.platform !== 'telegram' && tg.threadId"
|
||||||
><b>{{ t("route.thread") }}</b
|
><b>{{ t("route.thread") }}</b
|
||||||
><code>{{ route.target.threadId }}</code></span
|
><code>{{ tg.threadId }}</code></span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,27 +74,48 @@
|
||||||
</button>
|
</button>
|
||||||
<div class="err">{{ filterError }}</div>
|
<div class="err">{{ filterError }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row2">
|
|
||||||
<div class="field">
|
|
||||||
<label>{{ t("routeEditor.channel") }}</label>
|
|
||||||
<input
|
|
||||||
v-model="form.channelId"
|
|
||||||
type="text"
|
|
||||||
:placeholder="t('routeEditor.channelPlaceholder')"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label
|
<label
|
||||||
>{{ t("routeEditor.thread") }}
|
>{{ t("routeEditor.targets") }}
|
||||||
<span class="lbl-note">{{ t("routeEditor.threadNote") }}</span></label
|
<span class="lbl-note">{{ t("routeEditor.targetsNote") }}</span></label
|
||||||
>
|
>
|
||||||
|
<div v-for="(tg, i) in form.targets" :key="i" class="filter-row">
|
||||||
|
<select v-model="tg.platform">
|
||||||
|
<option value="discord">Discord</option>
|
||||||
|
<option value="telegram">Telegram</option>
|
||||||
|
</select>
|
||||||
|
<template v-if="tg.platform === 'discord'">
|
||||||
<input
|
<input
|
||||||
v-model="form.threadId"
|
v-model="tg.channelId"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('routeEditor.channelPlaceholder')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="tg.threadId"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="t('routeEditor.threadPlaceholder')"
|
:placeholder="t('routeEditor.threadPlaceholder')"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
v-model="tg.chatId"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('routeEditor.chatPlaceholder')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="tg.topicId"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('routeEditor.topicPlaceholder')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<button type="button" class="icon-btn danger" @click="form.targets.splice(i, 1)">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost add-filter" @click="addTarget">
|
||||||
|
{{ t("routeEditor.addTarget") }}
|
||||||
|
</button>
|
||||||
|
<div class="err">{{ targetError }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="err">{{ formError }}</div>
|
<div class="err">{{ formError }}</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -113,7 +134,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, watch } from "vue";
|
import { reactive, watch } from "vue";
|
||||||
import type { Filter, Route } from "~/types";
|
import type { Filter, Route, RouteTarget } from "~/types";
|
||||||
import { FILTER_TYPES, fmtMatch } from "~/types";
|
import { FILTER_TYPES, fmtMatch } from "~/types";
|
||||||
|
|
||||||
interface FilterForm extends Filter {
|
interface FilterForm extends Filter {
|
||||||
|
|
@ -129,16 +150,30 @@ const emit = defineEmits<{
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isEdit = computed(() => props.route != null);
|
const isEdit = computed(() => props.route != null);
|
||||||
const filterError = ref("");
|
const filterError = ref("");
|
||||||
|
const targetError = ref("");
|
||||||
const formError = ref("");
|
const formError = ref("");
|
||||||
|
|
||||||
|
interface TargetForm extends RouteTarget {
|
||||||
|
platform: "discord" | "telegram";
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankTarget(): TargetForm {
|
||||||
|
return {
|
||||||
|
platform: "discord",
|
||||||
|
channelId: "",
|
||||||
|
threadId: "",
|
||||||
|
chatId: "",
|
||||||
|
topicId: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: "",
|
id: "",
|
||||||
name: "",
|
name: "",
|
||||||
lang: "",
|
lang: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
fallback: false,
|
fallback: false,
|
||||||
channelId: "",
|
targets: [] as TargetForm[],
|
||||||
threadId: "",
|
|
||||||
filters: [] as FilterForm[],
|
filters: [] as FilterForm[],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -160,6 +195,11 @@ function addFilter(): void {
|
||||||
filterError.value = "";
|
filterError.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addTarget(): void {
|
||||||
|
form.targets.push(blankTarget());
|
||||||
|
targetError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
(open) => {
|
(open) => {
|
||||||
|
|
@ -170,8 +210,10 @@ watch(
|
||||||
form.lang = r?.lang ?? "";
|
form.lang = r?.lang ?? "";
|
||||||
form.enabled = r?.enabled ?? true;
|
form.enabled = r?.enabled ?? true;
|
||||||
form.fallback = r?.fallback ?? false;
|
form.fallback = r?.fallback ?? false;
|
||||||
form.channelId = r?.target.channelId ?? "";
|
form.targets =
|
||||||
form.threadId = r?.target.threadId ?? "";
|
r && r.targets.length
|
||||||
|
? r.targets.map((tg) => ({ ...blankTarget(), ...tg }))
|
||||||
|
: [blankTarget()];
|
||||||
form.filters = (
|
form.filters = (
|
||||||
r && r.filters.length
|
r && r.filters.length
|
||||||
? r.filters
|
? r.filters
|
||||||
|
|
@ -180,6 +222,7 @@ watch(
|
||||||
: [{ type: "event", match: "", exclude: false }]
|
: [{ type: "event", match: "", exclude: false }]
|
||||||
).map((f) => ({ ...f, matchText: fmtMatch(f.match) })) as FilterForm[];
|
).map((f) => ({ ...f, matchText: fmtMatch(f.match) })) as FilterForm[];
|
||||||
filterError.value = "";
|
filterError.value = "";
|
||||||
|
targetError.value = "";
|
||||||
formError.value = "";
|
formError.value = "";
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -214,6 +257,20 @@ function collect(): Route | null {
|
||||||
filterError.value = t("routeEditor.errAddFilter");
|
filterError.value = t("routeEditor.errAddFilter");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targets: RouteTarget[] = [];
|
||||||
|
for (let i = 0; i < form.targets.length; i++) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
targetError.value = "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: form.id.trim(),
|
id: form.id.trim(),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
|
|
@ -221,10 +278,7 @@ function collect(): Route | null {
|
||||||
fallback: form.fallback || undefined,
|
fallback: form.fallback || undefined,
|
||||||
lang: form.lang.trim() || undefined,
|
lang: form.lang.trim() || undefined,
|
||||||
filters,
|
filters,
|
||||||
target: {
|
targets,
|
||||||
channelId: form.channelId.trim(),
|
|
||||||
threadId: form.threadId.trim() || undefined,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,10 +294,23 @@ function save(): void {
|
||||||
formError.value = t("routeEditor.errName");
|
formError.value = t("routeEditor.errName");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!route.target.channelId) {
|
if (!route.targets.length) {
|
||||||
formError.value = t("routeEditor.errChannel");
|
targetError.value = t("routeEditor.errTargets");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
for (let i = 0; i < route.targets.length; i++) {
|
||||||
|
const tg = route.targets[i]!;
|
||||||
|
if (tg.platform === "telegram") {
|
||||||
|
if (!tg.chatId) {
|
||||||
|
targetError.value = t("routeEditor.errChat", { n: i + 1 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (!tg.channelId) {
|
||||||
|
targetError.value = t("routeEditor.errChannel", { n: i + 1 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
emit("save", route);
|
emit("save", route);
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ const en: Dict = {
|
||||||
"route.noFilters": "no filters",
|
"route.noFilters": "no filters",
|
||||||
"route.channel": "CHANNEL",
|
"route.channel": "CHANNEL",
|
||||||
"route.thread": "THREAD",
|
"route.thread": "THREAD",
|
||||||
|
"route.chat": "CHAT",
|
||||||
|
"route.topic": "TOPIC",
|
||||||
"route.fallback": "fallback",
|
"route.fallback": "fallback",
|
||||||
"filter.event": "Event",
|
"filter.event": "Event",
|
||||||
"filter.repo": "Repo",
|
"filter.repo": "Repo",
|
||||||
|
|
@ -64,13 +66,24 @@ const en: Dict = {
|
||||||
"routeEditor.thread": "Thread ID",
|
"routeEditor.thread": "Thread ID",
|
||||||
"routeEditor.threadNote": "(optional)",
|
"routeEditor.threadNote": "(optional)",
|
||||||
"routeEditor.threadPlaceholder": "Optional thread ID",
|
"routeEditor.threadPlaceholder": "Optional thread ID",
|
||||||
|
"routeEditor.platform": "Platform",
|
||||||
|
"routeEditor.chat": "Chat ID",
|
||||||
|
"routeEditor.chatPlaceholder": "Telegram group chat ID",
|
||||||
|
"routeEditor.topic": "Topic ID",
|
||||||
|
"routeEditor.topicNote": "(optional)",
|
||||||
|
"routeEditor.topicPlaceholder": "Optional topic (message_thread_id)",
|
||||||
|
"routeEditor.errChat": "Target {n} chat ID is required",
|
||||||
|
"routeEditor.targets": "Targets",
|
||||||
|
"routeEditor.targetsNote": "(one or more destinations)",
|
||||||
|
"routeEditor.addTarget": "+ Add target",
|
||||||
|
"routeEditor.errTargets": "Add at least one target",
|
||||||
"routeEditor.cancel": "Cancel",
|
"routeEditor.cancel": "Cancel",
|
||||||
"routeEditor.save": "Save route",
|
"routeEditor.save": "Save route",
|
||||||
"routeEditor.errFilterMatch": "Filter {n} needs a match value",
|
"routeEditor.errFilterMatch": "Filter {n} needs a match value",
|
||||||
"routeEditor.errAddFilter": "Add at least one filter",
|
"routeEditor.errAddFilter": "Add at least one filter",
|
||||||
"routeEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
|
"routeEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
|
||||||
"routeEditor.errName": "Name is required",
|
"routeEditor.errName": "Name is required",
|
||||||
"routeEditor.errChannel": "Channel ID is required",
|
"routeEditor.errChannel": "Target {n} channel ID is required",
|
||||||
"groupEditor.editTitle": "Edit group",
|
"groupEditor.editTitle": "Edit group",
|
||||||
"groupEditor.newTitle": "New group",
|
"groupEditor.newTitle": "New group",
|
||||||
"groupEditor.close": "Close",
|
"groupEditor.close": "Close",
|
||||||
|
|
@ -161,6 +174,8 @@ const zh: Dict = {
|
||||||
"route.noFilters": "无过滤器",
|
"route.noFilters": "无过滤器",
|
||||||
"route.channel": "频道",
|
"route.channel": "频道",
|
||||||
"route.thread": "子区",
|
"route.thread": "子区",
|
||||||
|
"route.chat": "群组",
|
||||||
|
"route.topic": "话题",
|
||||||
"route.fallback": "兜底",
|
"route.fallback": "兜底",
|
||||||
"filter.event": "事件",
|
"filter.event": "事件",
|
||||||
"filter.repo": "仓库",
|
"filter.repo": "仓库",
|
||||||
|
|
@ -191,13 +206,24 @@ const zh: Dict = {
|
||||||
"routeEditor.thread": "子区 ID",
|
"routeEditor.thread": "子区 ID",
|
||||||
"routeEditor.threadNote": "(可选)",
|
"routeEditor.threadNote": "(可选)",
|
||||||
"routeEditor.threadPlaceholder": "可选的子区 ID",
|
"routeEditor.threadPlaceholder": "可选的子区 ID",
|
||||||
|
"routeEditor.platform": "平台",
|
||||||
|
"routeEditor.chat": "群组 ID",
|
||||||
|
"routeEditor.chatPlaceholder": "Telegram 群组聊天 ID",
|
||||||
|
"routeEditor.topic": "话题 ID",
|
||||||
|
"routeEditor.topicNote": "(可选)",
|
||||||
|
"routeEditor.topicPlaceholder": "可选的话题 (message_thread_id)",
|
||||||
|
"routeEditor.errChat": "第 {n} 个目标群组 ID 为必填项",
|
||||||
|
"routeEditor.targets": "目标",
|
||||||
|
"routeEditor.targetsNote": "(一个或多个推送目标)",
|
||||||
|
"routeEditor.addTarget": "+ 添加目标",
|
||||||
|
"routeEditor.errTargets": "至少添加一个目标",
|
||||||
"routeEditor.cancel": "取消",
|
"routeEditor.cancel": "取消",
|
||||||
"routeEditor.save": "保存路由",
|
"routeEditor.save": "保存路由",
|
||||||
"routeEditor.errFilterMatch": "第 {n} 个过滤器需要匹配值",
|
"routeEditor.errFilterMatch": "第 {n} 个过滤器需要匹配值",
|
||||||
"routeEditor.errAddFilter": "至少添加一个过滤器",
|
"routeEditor.errAddFilter": "至少添加一个过滤器",
|
||||||
"routeEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
|
"routeEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
|
||||||
"routeEditor.errName": "名称为必填项",
|
"routeEditor.errName": "名称为必填项",
|
||||||
"routeEditor.errChannel": "频道 ID 为必填项",
|
"routeEditor.errChannel": "第 {n} 个目标频道 ID 为必填项",
|
||||||
"groupEditor.editTitle": "编辑分组",
|
"groupEditor.editTitle": "编辑分组",
|
||||||
"groupEditor.newTitle": "新建分组",
|
"groupEditor.newTitle": "新建分组",
|
||||||
"groupEditor.close": "关闭",
|
"groupEditor.close": "关闭",
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,20 @@ export interface Filter {
|
||||||
exclude?: boolean;
|
exclude?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RouteTarget {
|
||||||
|
platform?: "discord" | "telegram";
|
||||||
|
channelId?: string;
|
||||||
|
threadId?: string;
|
||||||
|
chatId?: string;
|
||||||
|
topicId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
filters: Filter[];
|
filters: Filter[];
|
||||||
target: {
|
targets: RouteTarget[];
|
||||||
channelId: string;
|
|
||||||
threadId?: string;
|
|
||||||
};
|
|
||||||
lang?: string;
|
lang?: string;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
fallback?: boolean;
|
fallback?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ https://your-worker.workers.dev
|
||||||
| `GET` | `/health` | None | Health check |
|
| `GET` | `/health` | None | Health check |
|
||||||
| `POST` | `/webhook` | HMAC signature | GitHub webhook ingestion |
|
| `POST` | `/webhook` | HMAC signature | GitHub webhook ingestion |
|
||||||
| `POST` | `/discord/interactions` | Ed25519 signature | Discord interactions (slash commands, buttons, modals) |
|
| `POST` | `/discord/interactions` | Ed25519 signature | Discord interactions (slash commands, buttons, modals) |
|
||||||
|
| `POST` | `/telegram/webhook` | Secret token | Telegram updates (bot `/gh` commands) |
|
||||||
| `GET` | `/auth/github` | None | Start GitHub OAuth flow |
|
| `GET` | `/auth/github` | None | Start GitHub OAuth flow |
|
||||||
| `GET` | `/auth/github/callback` | None | OAuth callback |
|
| `GET` | `/auth/github/callback` | None | OAuth callback |
|
||||||
| `DELETE` | `/auth/token/:userId` | None | Revoke user token |
|
| `DELETE` | `/auth/token/:userId` | None | Revoke user token |
|
||||||
|
|
@ -38,7 +39,7 @@ See [Configuration → Web UI](../guide/configuration.md#web-ui) for setup. Admi
|
||||||
|
|
||||||
- `GET /admin` — Serves the config console HTML
|
- `GET /admin` — Serves the config console HTML
|
||||||
- `GET /admin/api/routes` — Returns `{ "routes": Route[] }`
|
- `GET /admin/api/routes` — Returns `{ "routes": Route[] }`
|
||||||
- `PUT /admin/api/routes` — Body `{ "routes": Route[] }`; validates each route (id pattern, unique id, name, enabled, `groupId`, filters — empty only allowed for `fallback` routes — and string `target.channelId`) and persists to KV `config:routes`. Returns `200 { ok, count }` or `400 { error }` / `401 { error }`.
|
- `PUT /admin/api/routes` — Body `{ "routes": Route[] }`; validates each route (id pattern, unique id, name, enabled, `groupId`, filters — empty only allowed for `fallback` routes — and platform-aware target: `target.channelId` for Discord, `target.chatId` for Telegram) and persists to KV `config:routes`. Returns `200 { ok, count }` or `400 { error }` / `401 { error }`.
|
||||||
|
|
||||||
## Health Check
|
## Health Check
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,15 @@ src/
|
||||||
│ └── *.ts # push, pull-request, issues, comments, workflow, release, repo, ...
|
│ └── *.ts # push, pull-request, issues, comments, workflow, release, repo, ...
|
||||||
├── drivers/ # Platform drivers (pluggable push targets)
|
├── drivers/ # Platform drivers (pluggable push targets)
|
||||||
│ ├── types.ts # PlatformDriver interface + SendResult
|
│ ├── types.ts # PlatformDriver interface + SendResult
|
||||||
│ ├── index.ts # getDriver() registry (discord + telegram stub)
|
│ ├── index.ts # getDriver() registry (discord + telegram)
|
||||||
│ ├── discord/ # index.ts (driver), render.ts (NeutralMessage → embed),
|
│ ├── discord/ # index.ts (driver), render.ts (NeutralMessage → embed),
|
||||||
│ │ # rest.ts, interactions.ts, commands.ts
|
│ │ # rest.ts, interactions.ts, commands.ts
|
||||||
│ └── telegram/ # TelegramDriver stub (not implemented yet)
|
│ └── 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
|
├── github/ # GitHub OAuth + as-user actions
|
||||||
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, actions
|
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, actions
|
||||||
│ └── store.ts # KV token CRUD + D1 discord-link mapping
|
│ └── store.ts # KV token CRUD + D1 discord-link/telegram-link mapping
|
||||||
├── web/ # HTTP UI/API routes
|
├── web/ # HTTP UI/API routes
|
||||||
│ ├── oauth-routes.ts # GET /auth/github, callback, DELETE /token/:userId (KV state)
|
│ ├── oauth-routes.ts # GET /auth/github, callback, DELETE /token/:userId (KV state)
|
||||||
│ ├── action-routes.ts # POST /api/comment|merge|close|react (Bearer token auth via KV lookup)
|
│ ├── action-routes.ts # POST /api/comment|merge|close|react (Bearer token auth via KV lookup)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ WebHooker requires several secrets to function. For local development, store the
|
||||||
| `GITHUB_CLIENT_ID` | OAuth client ID from App settings |
|
| `GITHUB_CLIENT_ID` | OAuth client ID from App settings |
|
||||||
| `GITHUB_CLIENT_SECRET` | OAuth client secret from App settings |
|
| `GITHUB_CLIENT_SECRET` | OAuth client secret from App settings |
|
||||||
| `DISCORD_TOKEN` | Discord bot token |
|
| `DISCORD_TOKEN` | Discord bot token |
|
||||||
|
| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes |
|
||||||
|
|
||||||
### Optional Secrets
|
### Optional Secrets
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ WebHooker requires several secrets to function. For local development, store the
|
||||||
| ------------------------ | ----------------------------------------------------------------------------- | --------------------------------- |
|
| ------------------------ | ----------------------------------------------------------------------------- | --------------------------------- |
|
||||||
| `DISCORD_PUBLIC_KEY` | Discord application public key (Developer Portal) — required for interactions | Unset → interactions return `401` |
|
| `DISCORD_PUBLIC_KEY` | Discord application public key (Developer Portal) — required for interactions | Unset → interactions return `401` |
|
||||||
| `DISCORD_APPLICATION_ID` | Discord application id; auto-resolved when omitted | Auto-resolved |
|
| `DISCORD_APPLICATION_ID` | Discord application id; auto-resolved when omitted | Auto-resolved |
|
||||||
|
| `TELEGRAM_WEBHOOK_SECRET`| Secret token for `POST /telegram/webhook` verification (X-Telegram-Bot-Api-Secret-Token) | Disabled (no verification) |
|
||||||
| `BASE_URL` | Public URL for OAuth callbacks | `http://localhost:8787` |
|
| `BASE_URL` | Public URL for OAuth callbacks | `http://localhost:8787` |
|
||||||
| `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access the Web UI | Disabled |
|
| `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access the Web UI | Disabled |
|
||||||
|
|
||||||
|
|
@ -54,7 +56,7 @@ The console lets you add, edit, delete, and toggle routes. Saved routes are writ
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
Routes define which events get forwarded to which Discord channels. They are stored in Cloudflare KV under the key `config:routes` as a JSON array.
|
Routes define which events get forwarded to which channel (Discord or Telegram). They are stored in Cloudflare KV under the key `config:routes` as a JSON array.
|
||||||
|
|
||||||
There are **no default routes** — each route must define its own target. If no routes are configured, no events are forwarded.
|
There are **no default routes** — each route must define its own target. If no routes are configured, no events are forwarded.
|
||||||
|
|
||||||
|
|
@ -71,14 +73,17 @@ There are **no default routes** — each route must define its own target. If no
|
||||||
{ "type": "event", "match": "push" },
|
{ "type": "event", "match": "push" },
|
||||||
{ "type": "repo", "match": "org/repo", "exclude": false }
|
{ "type": "repo", "match": "org/repo", "exclude": false }
|
||||||
],
|
],
|
||||||
"target": {
|
"targets": [
|
||||||
|
{
|
||||||
|
"platform": "discord",
|
||||||
"channelId": "REQUIRED_CHANNEL_ID",
|
"channelId": "REQUIRED_CHANNEL_ID",
|
||||||
"threadId": "OPTIONAL_THREAD_ID"
|
"threadId": "OPTIONAL_THREAD_ID"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`target.channelId` is required and used as-is; there is no fallback to a default channel.
|
Each entry of `targets` is a push destination, so one route can forward to several channels at once (e.g. a Discord channel **and** a Telegram group). `target.platform` selects the platform: `discord` (default) or `telegram`. For **Discord**, `target.channelId` is required (a thread in `target.threadId` is optional). For **Telegram**, `target.chatId` (the group/supergroup chat id, e.g. `-1001234567890`) is required and `target.topicId` (the `message_thread_id` of a topic, equivalent of a Discord thread) is optional. There is no fallback to a default channel.
|
||||||
|
|
||||||
Other route fields:
|
Other route fields:
|
||||||
|
|
||||||
|
|
@ -102,10 +107,13 @@ Other route fields:
|
||||||
{ "type": "event", "match": "pull_request" },
|
{ "type": "event", "match": "pull_request" },
|
||||||
{ "type": "actor", "match": "[bot]", "exclude": true }
|
{ "type": "actor", "match": "[bot]", "exclude": true }
|
||||||
],
|
],
|
||||||
"target": {
|
"targets": [
|
||||||
"channelId": "1234567890",
|
{
|
||||||
"threadId": "9876543210"
|
"platform": "telegram",
|
||||||
|
"chatId": "-1001234567890",
|
||||||
|
"topicId": "9876543210"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ npx wrangler secret put GITHUB_CLIENT_ID
|
||||||
npx wrangler secret put GITHUB_CLIENT_SECRET
|
npx wrangler secret put GITHUB_CLIENT_SECRET
|
||||||
npx wrangler secret put DISCORD_TOKEN
|
npx wrangler secret put DISCORD_TOKEN
|
||||||
npx wrangler secret put DISCORD_PUBLIC_KEY # Discord app public key (Developer Portal) — required for interactions
|
npx wrangler secret put DISCORD_PUBLIC_KEY # Discord app public key (Developer Portal) — required for interactions
|
||||||
|
npx wrangler secret put TELEGRAM_TOKEN # Telegram bot token (BotFather) — required for Telegram routes
|
||||||
npx wrangler secret put ADMIN_USER_IDS # comma-separated GitHub IDs/logins allowed into the Web UI
|
npx wrangler secret put ADMIN_USER_IDS # comma-separated GitHub IDs/logins allowed into the Web UI
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ https://your-worker.workers.dev
|
||||||
| `GET` | `/health` | 无 | 健康检查 |
|
| `GET` | `/health` | 无 | 健康检查 |
|
||||||
| `POST` | `/webhook` | HMAC 签名 | GitHub webhook 接入 |
|
| `POST` | `/webhook` | HMAC 签名 | GitHub webhook 接入 |
|
||||||
| `POST` | `/discord/interactions` | Ed25519 签名 | Discord 交互(斜杠命令、按钮、modal) |
|
| `POST` | `/discord/interactions` | Ed25519 签名 | Discord 交互(斜杠命令、按钮、modal) |
|
||||||
|
| `POST` | `/telegram/webhook` | Secret token | Telegram 更新(bot `/gh` 命令) |
|
||||||
| `GET` | `/auth/github` | 无 | 启动 GitHub OAuth 流程 |
|
| `GET` | `/auth/github` | 无 | 启动 GitHub OAuth 流程 |
|
||||||
| `GET` | `/auth/github/callback` | 无 | OAuth 回调 |
|
| `GET` | `/auth/github/callback` | 无 | OAuth 回调 |
|
||||||
| `DELETE` | `/auth/token/:userId` | 无 | 撤销用户 Token |
|
| `DELETE` | `/auth/token/:userId` | 无 | 撤销用户 Token |
|
||||||
|
|
@ -38,7 +39,7 @@ https://your-worker.workers.dev
|
||||||
|
|
||||||
- `GET /admin` — 提供配置控制台 HTML
|
- `GET /admin` — 提供配置控制台 HTML
|
||||||
- `GET /admin/api/routes` — 返回 `{ "routes": Route[] }`
|
- `GET /admin/api/routes` — 返回 `{ "routes": Route[] }`
|
||||||
- `PUT /admin/api/routes` — 请求体为 `{ "routes": Route[] }`;校验每条路由(id 格式、唯一 id、name、enabled、groupId、过滤器、字符串 `target.channelId`)并持久化到 KV `config:routes`。返回 `200 { ok, count }` 或 `400 { error }` / `401 { error }`。
|
- `PUT /admin/api/routes` — 请求体为 `{ "routes": Route[] }`;校验每条路由(id 格式、唯一 id、name、enabled、groupId、过滤器、平台感知的 target:Discord 需 `target.channelId`,Telegram 需 `target.chatId`)并持久化到 KV `config:routes`。返回 `200 { ok, count }` 或 `400 { error }` / `401 { error }`。
|
||||||
|
|
||||||
## 健康检查
|
## 健康检查
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,15 @@ src/
|
||||||
│ └── *.ts # push、pull-request、issues、comments、workflow、release、repo 等
|
│ └── *.ts # push、pull-request、issues、comments、workflow、release、repo 等
|
||||||
├── drivers/ # 平台驱动(可插拔推送目标)
|
├── drivers/ # 平台驱动(可插拔推送目标)
|
||||||
│ ├── types.ts # PlatformDriver 接口 + SendResult
|
│ ├── types.ts # PlatformDriver 接口 + SendResult
|
||||||
│ ├── index.ts # getDriver() 注册表(discord + telegram 占位)
|
│ ├── index.ts # getDriver() 注册表(discord + telegram)
|
||||||
│ ├── discord/ # index.ts (驱动)、render.ts (NeutralMessage → embed)、
|
│ ├── discord/ # index.ts (驱动)、render.ts (NeutralMessage → embed)、
|
||||||
│ │ # rest.ts、interactions.ts、commands.ts
|
│ │ # rest.ts、interactions.ts、commands.ts
|
||||||
│ └── telegram/ # TelegramDriver 占位(未实现)
|
│ └── 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 + 以用户身份操作
|
├── github/ # GitHub OAuth + 以用户身份操作
|
||||||
│ ├── oauth.ts # OAuth URL、回调 Token 交换、getUserOctokit、操作
|
│ ├── oauth.ts # OAuth URL、回调 Token 交换、getUserOctokit、操作
|
||||||
│ └── store.ts # KV Token CRUD + D1 discord-link 映射
|
│ └── store.ts # KV Token CRUD + D1 discord-link/telegram-link 映射
|
||||||
├── web/ # HTTP UI/API 路由
|
├── web/ # HTTP UI/API 路由
|
||||||
│ ├── oauth-routes.ts # GET /auth/github、回调、DELETE /token/:userId (KV 状态)
|
│ ├── oauth-routes.ts # GET /auth/github、回调、DELETE /token/:userId (KV 状态)
|
||||||
│ ├── action-routes.ts # POST /api/comment|merge|close|react (通过 KV 查找进行 Bearer Token 鉴权)
|
│ ├── action-routes.ts # POST /api/comment|merge|close|react (通过 KV 查找进行 Bearer Token 鉴权)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ WebHooker 需要多个密钥才能运行。本地开发时存储在 `.dev.vars`
|
||||||
| `GITHUB_CLIENT_ID` | App 设置中的 OAuth 客户端 ID |
|
| `GITHUB_CLIENT_ID` | App 设置中的 OAuth 客户端 ID |
|
||||||
| `GITHUB_CLIENT_SECRET` | App 设置中的 OAuth 客户端密钥 |
|
| `GITHUB_CLIENT_SECRET` | App 设置中的 OAuth 客户端密钥 |
|
||||||
| `DISCORD_TOKEN` | Discord Bot Token |
|
| `DISCORD_TOKEN` | Discord Bot Token |
|
||||||
|
| `TELEGRAM_TOKEN` | Telegram Bot Token(BotFather 获取)—— Telegram 路由必需 |
|
||||||
|
|
||||||
### 可选密钥
|
### 可选密钥
|
||||||
|
|
||||||
|
|
@ -23,6 +24,7 @@ WebHooker 需要多个密钥才能运行。本地开发时存储在 `.dev.vars`
|
||||||
| `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID(或登录名),逗号分隔 | 未设置时 WebUI 关闭 |
|
| `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID(或登录名),逗号分隔 | 未设置时 WebUI 关闭 |
|
||||||
| `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取),交互功能必需 | 未设置时交互返回 401 |
|
| `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取),交互功能必需 | 未设置时交互返回 401 |
|
||||||
| `DISCORD_APPLICATION_ID` | Discord 应用 ID;省略时自动获取 | 自动获取 |
|
| `DISCORD_APPLICATION_ID` | Discord 应用 ID;省略时自动获取 | 自动获取 |
|
||||||
|
| `TELEGRAM_WEBHOOK_SECRET`| `POST /telegram/webhook` 验签密钥(X-Telegram-Bot-Api-Secret-Token) | 未设置时不校验 |
|
||||||
|
|
||||||
## Web 控制台
|
## Web 控制台
|
||||||
|
|
||||||
|
|
@ -54,7 +56,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
|
||||||
|
|
||||||
## 路由
|
## 路由
|
||||||
|
|
||||||
路由定义了哪些事件被转发到哪些 Discord 频道。它们以 JSON 数组形式存储在 Cloudflare KV 中,键为 `config:routes`。
|
路由定义了哪些事件被转发到哪些频道(Discord 或 Telegram)。它们以 JSON 数组形式存储在 Cloudflare KV 中,键为 `config:routes`。
|
||||||
|
|
||||||
**没有默认路由**——每条路由必须自行定义目标频道。若未配置任何路由,则不会转发任何事件。
|
**没有默认路由**——每条路由必须自行定义目标频道。若未配置任何路由,则不会转发任何事件。
|
||||||
|
|
||||||
|
|
@ -71,14 +73,17 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
|
||||||
{ "type": "event", "match": "push" },
|
{ "type": "event", "match": "push" },
|
||||||
{ "type": "repo", "match": "org/repo", "exclude": false }
|
{ "type": "repo", "match": "org/repo", "exclude": false }
|
||||||
],
|
],
|
||||||
"target": {
|
"targets": [
|
||||||
|
{
|
||||||
|
"platform": "discord",
|
||||||
"channelId": "必填频道ID",
|
"channelId": "必填频道ID",
|
||||||
"threadId": "可选线程ID"
|
"threadId": "可选线程ID"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`target.channelId` 必填且按原样使用,不存在默认频道回退。
|
`targets` 数组的每一项是一个推送目标,因此一条路由可同时转发到多个频道(例如同时发到 Discord 频道 **和** Telegram 群组)。`target.platform` 选择推送目标:`discord`(默认)或 `telegram`。**Discord** 需 `target.channelId`(`target.threadId` 可选的子区);**Telegram** 需 `target.chatId`(群组/超级群组聊天 id,如 `-1001234567890`),`target.topicId`(话题的 `message_thread_id`,相当于 Discord 的子区)可选。不存在默认频道回退。
|
||||||
|
|
||||||
其他路由字段:
|
其他路由字段:
|
||||||
|
|
||||||
|
|
@ -102,10 +107,13 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
|
||||||
{ "type": "event", "match": "pull_request" },
|
{ "type": "event", "match": "pull_request" },
|
||||||
{ "type": "actor", "match": "[bot]", "exclude": true }
|
{ "type": "actor", "match": "[bot]", "exclude": true }
|
||||||
],
|
],
|
||||||
"target": {
|
"targets": [
|
||||||
"channelId": "1234567890",
|
{
|
||||||
"threadId": "9876543210"
|
"platform": "telegram",
|
||||||
|
"chatId": "-1001234567890",
|
||||||
|
"topicId": "9876543210"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ npx wrangler secret put GITHUB_CLIENT_ID
|
||||||
npx wrangler secret put GITHUB_CLIENT_SECRET
|
npx wrangler secret put GITHUB_CLIENT_SECRET
|
||||||
npx wrangler secret put DISCORD_TOKEN
|
npx wrangler secret put DISCORD_TOKEN
|
||||||
npx wrangler secret put DISCORD_PUBLIC_KEY # Discord 应用的公钥(开发者门户获取),交互功能必需
|
npx wrangler secret put DISCORD_PUBLIC_KEY # Discord 应用的公钥(开发者门户获取),交互功能必需
|
||||||
|
npx wrangler secret put TELEGRAM_TOKEN # Telegram Bot Token(BotFather 获取)—— Telegram 路由必需
|
||||||
npx wrangler secret put ADMIN_USER_IDS # 逗号分隔的 GitHub ID/登录名,允许进入 Web UI
|
npx wrangler secret put ADMIN_USER_IDS # 逗号分隔的 GitHub ID/登录名,允许进入 Web UI
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
4
migrations/0003_telegram_links.sql
Normal file
4
migrations/0003_telegram_links.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS telegram_links (
|
||||||
|
telegram_user_id TEXT PRIMARY KEY,
|
||||||
|
github_user_id TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
@ -52,7 +52,7 @@ const sampleRoutes: Route[] = [
|
||||||
name: "Backend PRs",
|
name: "Backend PRs",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
filters: [{ type: "event", match: "pull_request" }],
|
filters: [{ type: "event", match: "pull_request" }],
|
||||||
target: { channelId: "111" },
|
targets: [{ channelId: "111" }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -133,6 +133,6 @@ describe("config routes persistence", () => {
|
||||||
const second = await loadConfig(env);
|
const second = await loadConfig(env);
|
||||||
expect(second.routes).toHaveLength(1);
|
expect(second.routes).toHaveLength(1);
|
||||||
expect(second.routes[0]!.id).toBe("backend-prs");
|
expect(second.routes[0]!.id).toBe("backend-prs");
|
||||||
expect(second.routes[0]!.target.channelId).toBe("111");
|
expect(second.routes[0]!.targets[0]!.channelId).toBe("111");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ describe("dispatchEvent fallback routing", () => {
|
||||||
name: "Regular Push",
|
name: "Regular Push",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
filters: [{ type: "event", match: "push" }],
|
filters: [{ type: "event", match: "push" }],
|
||||||
target: { channelId: "111" },
|
targets: [{ channelId: "111" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "catch-all",
|
id: "catch-all",
|
||||||
|
|
@ -137,7 +137,7 @@ describe("dispatchEvent fallback routing", () => {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
filters: [],
|
filters: [],
|
||||||
fallback: true,
|
fallback: true,
|
||||||
target: { channelId: "222" },
|
targets: [{ channelId: "222" }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const route: Route = {
|
||||||
name: "Test",
|
name: "Test",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
filters: [],
|
filters: [],
|
||||||
target: { channelId: "111" },
|
targets: [{ channelId: "111" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
function event(ev: string, payload: Record<string, unknown>): WebhookEvent {
|
function event(ev: string, payload: Record<string, unknown>): WebhookEvent {
|
||||||
|
|
|
||||||
201
src/__tests__/telegram-commands.test.ts
Normal file
201
src/__tests__/telegram-commands.test.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { describe, it, expect, afterEach } from "bun:test";
|
||||||
|
import { handleTelegramUpdate } from "../drivers/telegram/commands";
|
||||||
|
import { handleTelegramWebhookRequest } from "../drivers/telegram/updates";
|
||||||
|
import type { Env } from "../types";
|
||||||
|
|
||||||
|
function mockFetch(handler: (url: string, init?: RequestInit) => Response): void {
|
||||||
|
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
|
||||||
|
Promise.resolve(handler(String(input), init));
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoredFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = restoredFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
function createMockKV(): KVNamespace {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
get: async (key: string, type?: string) => {
|
||||||
|
const v = store.get(key);
|
||||||
|
if (v === undefined) return null;
|
||||||
|
return type === "json" ? JSON.parse(v) : v;
|
||||||
|
},
|
||||||
|
put: async (key: string, value: string) => {
|
||||||
|
store.set(key, value);
|
||||||
|
},
|
||||||
|
delete: async (key: string) => {
|
||||||
|
store.delete(key);
|
||||||
|
},
|
||||||
|
list: async () => ({ keys: [] }),
|
||||||
|
} as unknown as KVNamespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockDB(): D1Database {
|
||||||
|
const links = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
prepare: (sql: string) => ({
|
||||||
|
bind: (...args: unknown[]) => ({
|
||||||
|
run: async (): Promise<{ success: boolean }> => {
|
||||||
|
const m = sql.match(/INSERT OR REPLACE INTO telegram_links \(telegram_user_id, github_user_id\) VALUES \(\?, \?\)/);
|
||||||
|
if (m) links.set(String(args[0]), String(args[1]));
|
||||||
|
const del = sql.match(/DELETE FROM telegram_links WHERE telegram_user_id = \?/);
|
||||||
|
if (del) links.delete(String(args[0]));
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
all: async (): Promise<{ results: Array<Record<string, unknown>> }> => {
|
||||||
|
const sel = sql.match(/SELECT github_user_id FROM telegram_links WHERE telegram_user_id = \?/);
|
||||||
|
if (sel) {
|
||||||
|
const val = links.get(String(args[0]));
|
||||||
|
return { results: val ? [{ github_user_id: val }] : [] };
|
||||||
|
}
|
||||||
|
return { results: [] };
|
||||||
|
},
|
||||||
|
first: async () => null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as unknown as D1Database;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEnv(): Env {
|
||||||
|
return {
|
||||||
|
GITHUB_WEBHOOK_SECRET: "secret",
|
||||||
|
GITHUB_CLIENT_ID: "client-id",
|
||||||
|
TELEGRAM_TOKEN: "tg-token",
|
||||||
|
TELEGRAM_WEBHOOK_SECRET: "wh-secret",
|
||||||
|
KV: createMockKV(),
|
||||||
|
DB: createMockDB(),
|
||||||
|
} as Env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reply(chatId: string, topicId?: number): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
message_id: 1,
|
||||||
|
from: { id: 111, first_name: "Rhen" },
|
||||||
|
chat: { id: chatId, type: "supergroup" },
|
||||||
|
message_thread_id: topicId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("telegram-commands /gh login", () => {
|
||||||
|
it("stores state with telegramUserId and replies with OAuth URL", async () => {
|
||||||
|
let sentBody: Record<string, unknown> | undefined;
|
||||||
|
mockFetch((_url, init) => {
|
||||||
|
sentBody = JSON.parse(String(init!.body)) as Record<string, unknown>;
|
||||||
|
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = createEnv();
|
||||||
|
await handleTelegramUpdate(env, {
|
||||||
|
message: { ...reply("-100123"), text: "/gh login" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sentBody?.chat_id).toBe("-100123");
|
||||||
|
expect(String(sentBody?.text)).toContain("github.com/login/oauth/authorize");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("telegram-commands /gh logout", () => {
|
||||||
|
it("replies bound/unbound message", async () => {
|
||||||
|
let sentText = "";
|
||||||
|
mockFetch((_url, init) => {
|
||||||
|
sentText = String((JSON.parse(String(init!.body)) as Record<string, unknown>).text);
|
||||||
|
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = createEnv();
|
||||||
|
await handleTelegramUpdate(env, {
|
||||||
|
message: { ...reply("-100123"), text: "/gh logout" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sentText).toContain("已解绑");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("telegram-commands /gh comment", () => {
|
||||||
|
it("replies when no reply_to_message link present", async () => {
|
||||||
|
let sentText = "";
|
||||||
|
mockFetch((_url, init) => {
|
||||||
|
sentText = String((JSON.parse(String(init!.body)) as Record<string, unknown>).text);
|
||||||
|
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = createEnv();
|
||||||
|
await handleTelegramUpdate(env, {
|
||||||
|
message: { ...reply("-100123"), text: "/gh comment hello" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sentText).toContain("还没有绑定");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses the replied-to GitHub link as target", async () => {
|
||||||
|
const env = createEnv();
|
||||||
|
const { saveTelegramLink } = await import("../github/store");
|
||||||
|
await saveTelegramLink(env.DB, "111", "111980217");
|
||||||
|
await env.KV.put(
|
||||||
|
"token:111980217",
|
||||||
|
JSON.stringify({
|
||||||
|
userId: "111980217",
|
||||||
|
accessToken: "ghu_test",
|
||||||
|
expiresAt: Date.now() + 3600_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls: Array<{ url: string; body: string }> = [];
|
||||||
|
mockFetch((url, init) => {
|
||||||
|
calls.push({ url, body: String(init!.body) });
|
||||||
|
if (String(url).endsWith("/sendMessage")) {
|
||||||
|
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ html_url: "https://github.com/acme/widget/issues/7#issuecomment-9" }),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleTelegramUpdate(env, {
|
||||||
|
message: {
|
||||||
|
...reply("-100123"),
|
||||||
|
text: "/gh comment hello",
|
||||||
|
reply_to_message: {
|
||||||
|
...reply("-100123"),
|
||||||
|
text: "acme/widget#7: Add feature",
|
||||||
|
entities: [{ type: "text_link", url: "https://github.com/acme/widget/issues/7" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ghCall = calls.find((c) => c.url.includes("/repos/"));
|
||||||
|
expect(ghCall).toBeDefined();
|
||||||
|
expect(ghCall!.url).toContain("/repos/acme/widget/issues/7/comments");
|
||||||
|
const ghBody = JSON.parse(ghCall!.body) as Record<string, unknown>;
|
||||||
|
expect(ghBody.body).toBe("hello");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("telegram-updates webhook", () => {
|
||||||
|
it("rejects requests without the secret token", async () => {
|
||||||
|
const env = createEnv();
|
||||||
|
const res = await handleTelegramWebhookRequest(
|
||||||
|
new Request("https://example.com/telegram/webhook", { method: "POST", body: "{}" }),
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts requests with the correct secret token", async () => {
|
||||||
|
mockFetch(() => new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 }));
|
||||||
|
const env = createEnv();
|
||||||
|
const res = await handleTelegramWebhookRequest(
|
||||||
|
new Request("https://example.com/telegram/webhook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-Telegram-Bot-Api-Secret-Token": "wh-secret" },
|
||||||
|
body: JSON.stringify({ update_id: 1 }),
|
||||||
|
}),
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.text()).toBe("ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
92
src/__tests__/telegram.test.ts
Normal file
92
src/__tests__/telegram.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { describe, it, expect, afterEach } from "bun:test";
|
||||||
|
import { sendMessage } from "../drivers/telegram/rest";
|
||||||
|
import { renderNeutralMessage } from "../drivers/telegram/render";
|
||||||
|
import type { NeutralMessage } from "../types";
|
||||||
|
|
||||||
|
function mockFetch(handler: (url: string, init?: RequestInit) => Response): void {
|
||||||
|
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
|
||||||
|
Promise.resolve(handler(String(input), init));
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoredFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = restoredFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("telegram renderNeutralMessage", () => {
|
||||||
|
it("renders title, fields and footer as HTML", () => {
|
||||||
|
const message: NeutralMessage = {
|
||||||
|
title: "acme/widget: Add feature",
|
||||||
|
url: "https://github.com/acme/widget",
|
||||||
|
fields: [{ name: "Status", value: "success" }],
|
||||||
|
footer: "acme/widget",
|
||||||
|
};
|
||||||
|
const out = renderNeutralMessage(message);
|
||||||
|
expect(out).toContain('<b><a href="https://github.com/acme/widget">acme/widget: Add feature</a></b>');
|
||||||
|
expect(out).toContain("<b>Status</b>: success");
|
||||||
|
expect(out).toContain("<i>acme/widget</i>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes HTML special characters", () => {
|
||||||
|
const out = renderNeutralMessage({
|
||||||
|
title: "a <b> & \"c\"",
|
||||||
|
fields: [{ name: "body", value: "<script>alert(1)</script>" }],
|
||||||
|
});
|
||||||
|
expect(out).not.toContain("<b>acme");
|
||||||
|
expect(out).toContain("<script>alert(1)</script>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("telegram-rest sendMessage", () => {
|
||||||
|
it("posts to the bot API with chat_id and parse_mode", async () => {
|
||||||
|
let capturedUrl = "";
|
||||||
|
let capturedInit: RequestInit | undefined;
|
||||||
|
mockFetch((url, init) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
capturedInit = init;
|
||||||
|
return new Response(JSON.stringify({ ok: true, result: { message_id: 42 } }), {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sendMessage("token-abc", "-100123", "hello");
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.messageId).toBe("42");
|
||||||
|
expect(capturedUrl).toBe("https://api.telegram.org/bottoken-abc/sendMessage");
|
||||||
|
const body = JSON.parse(String(capturedInit!.body)) as Record<string, unknown>;
|
||||||
|
expect(body.chat_id).toBe("-100123");
|
||||||
|
expect(body.parse_mode).toBe("HTML");
|
||||||
|
expect(body.message_thread_id).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes message_thread_id when topicId is given", async () => {
|
||||||
|
let capturedInit: RequestInit | undefined;
|
||||||
|
mockFetch((_url, init) => {
|
||||||
|
capturedInit = init;
|
||||||
|
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendMessage("t", "-100123", "hello", "999");
|
||||||
|
const body = JSON.parse(String(capturedInit!.body)) as Record<string, unknown>;
|
||||||
|
expect(body.message_thread_id).toBe(999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error on non-ok response", async () => {
|
||||||
|
mockFetch(() =>
|
||||||
|
new Response(JSON.stringify({ ok: false, description: "chat not found" }), { status: 400 }),
|
||||||
|
);
|
||||||
|
const result = await sendMessage("t", "-100123", "hello");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toContain("chat not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when token is missing", async () => {
|
||||||
|
const result = await sendMessage("", "-100123", "hello");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.errorCode).toBe("NO_TOKEN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -51,7 +51,7 @@ describe("matchRoute", () => {
|
||||||
name: "Test",
|
name: "Test",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
filters: [],
|
filters: [],
|
||||||
target: { channelId: "123" },
|
targets: [{ channelId: "123" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
it("matches all events when no filters", () => {
|
it("matches all events when no filters", () => {
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,26 @@ let configCache: { config: Config; expiresAt: number } | null = null;
|
||||||
export async function loadRoutes(kv: KVNamespace): Promise<Route[]> {
|
export async function loadRoutes(kv: KVNamespace): Promise<Route[]> {
|
||||||
try {
|
try {
|
||||||
const stored = await kv.get<Route[]>(ROUTES_KEY, "json");
|
const stored = await kv.get<Route[]>(ROUTES_KEY, "json");
|
||||||
if (stored) return stored;
|
if (stored) return normalizeRoutes(stored);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn({ err }, "Failed to load routes from KV");
|
log.warn({ err }, "Failed to load routes from KV");
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate legacy single-target routes (`target`) to the array form (`targets`).
|
||||||
|
*/
|
||||||
|
function normalizeRoutes(routes: Route[]): Route[] {
|
||||||
|
return routes.map((r) => {
|
||||||
|
if (r.targets && r.targets.length > 0) return r;
|
||||||
|
const legacy = (r as Route & { target?: Route["targets"][number] }).target;
|
||||||
|
if (!legacy) return r;
|
||||||
|
const { target: _target, ...rest } = r as Route & { target?: Route["targets"][number] };
|
||||||
|
return { ...rest, targets: [legacy] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise<void> {
|
export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise<void> {
|
||||||
await kv.put(ROUTES_KEY, JSON.stringify(routes));
|
await kv.put(ROUTES_KEY, JSON.stringify(routes));
|
||||||
configCache = null;
|
configCache = null;
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,23 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
return matchRoute(route, event);
|
return matchRoute(route, event);
|
||||||
})
|
})
|
||||||
.map(async (route) => {
|
.map(async (route) => {
|
||||||
const target = route.target.threadId
|
const targets = route.targets && route.targets.length > 0 ? route.targets : [];
|
||||||
? `${route.target.channelId}/${route.target.threadId}`
|
if (targets.length === 0) return;
|
||||||
: route.target.channelId;
|
|
||||||
|
const tr = trMap.get(route.lang ?? "en")!;
|
||||||
|
const group = route.groupId ? groupById.get(route.groupId) : undefined;
|
||||||
|
const showEmoji = group?.emoji !== false;
|
||||||
|
const message = formatEvent(route, event, tr, showEmoji);
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const targetStr =
|
||||||
|
target.platform === "telegram"
|
||||||
|
? target.topicId
|
||||||
|
? `${target.chatId}/${target.topicId}`
|
||||||
|
: (target.chatId ?? "")
|
||||||
|
: target.threadId
|
||||||
|
? `${target.channelId}/${target.threadId}`
|
||||||
|
: (target.channelId ?? "");
|
||||||
|
|
||||||
const base: {
|
const base: {
|
||||||
ts: number;
|
ts: number;
|
||||||
|
|
@ -55,7 +69,7 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
event: event.event,
|
event: event.event,
|
||||||
repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name,
|
repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name,
|
||||||
target,
|
target: targetStr,
|
||||||
deliveryId: event.deliveryId,
|
deliveryId: event.deliveryId,
|
||||||
actor: (event.payload.sender as { login?: string } | undefined)?.login,
|
actor: (event.payload.sender as { login?: string } | undefined)?.login,
|
||||||
action: (event.payload.action as string | undefined),
|
action: (event.payload.action as string | undefined),
|
||||||
|
|
@ -63,12 +77,8 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
|
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const tr = trMap.get(route.lang ?? "en")!;
|
const driver = getDriver(target);
|
||||||
const group = route.groupId ? groupById.get(route.groupId) : undefined;
|
const result = await driver.send(message, target, env);
|
||||||
const showEmoji = group?.emoji !== false;
|
|
||||||
const message = formatEvent(route, event, tr, showEmoji);
|
|
||||||
const driver = getDriver(route.target);
|
|
||||||
const result = await driver.send(message, route.target, env);
|
|
||||||
const durationMs = Date.now() - started;
|
const durationMs = Date.now() - started;
|
||||||
if (!result.ok) throw new Error(result.error ?? "Send failed");
|
if (!result.ok) throw new Error(result.error ?? "Send failed");
|
||||||
await recordSend(env.DB, {
|
await recordSend(env.DB, {
|
||||||
|
|
@ -89,7 +99,8 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
durationMs,
|
durationMs,
|
||||||
});
|
});
|
||||||
log.error({ routeId: route.id, err }, "Route failed");
|
log.error({ routeId: route.id, target: targetStr, err }, "Route failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Route, Env, NeutralMessage } from "../../types";
|
import type { RouteTarget, Env, NeutralMessage } from "../../types";
|
||||||
import type { PlatformDriver, SendResult } from "../types";
|
import type { PlatformDriver, SendResult } from "../types";
|
||||||
import { sendMessage } from "./rest";
|
import { sendMessage } from "./rest";
|
||||||
import { renderNeutralMessage } from "./render";
|
import { renderNeutralMessage } from "./render";
|
||||||
|
|
@ -6,8 +6,12 @@ import { renderNeutralMessage } from "./render";
|
||||||
export class DiscordDriver implements PlatformDriver {
|
export class DiscordDriver implements PlatformDriver {
|
||||||
readonly id = "discord";
|
readonly id = "discord";
|
||||||
|
|
||||||
async send(message: NeutralMessage, target: Route["target"], env: Env): Promise<SendResult> {
|
async send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult> {
|
||||||
|
const channelId = target.channelId ?? "";
|
||||||
|
if (!channelId) {
|
||||||
|
return { ok: false, error: "target.channelId is required", errorCode: "NO_TARGET" };
|
||||||
|
}
|
||||||
const token = env.DISCORD_TOKEN ?? "";
|
const token = env.DISCORD_TOKEN ?? "";
|
||||||
return sendMessage(token, target.channelId, renderNeutralMessage(message), target.threadId);
|
return sendMessage(token, channelId, renderNeutralMessage(message), target.threadId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Route } from "../types";
|
import type { RouteTarget } from "../types";
|
||||||
import type { PlatformDriver } from "./types";
|
import type { PlatformDriver } from "./types";
|
||||||
import { DiscordDriver } from "./discord";
|
import { DiscordDriver } from "./discord";
|
||||||
import { TelegramDriver } from "./telegram";
|
import { TelegramDriver } from "./telegram";
|
||||||
|
|
@ -8,8 +8,8 @@ const drivers: Record<string, PlatformDriver> = {
|
||||||
telegram: new TelegramDriver(),
|
telegram: new TelegramDriver(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getDriver(target: Route["target"]): PlatformDriver {
|
export function getDriver(target: RouteTarget): PlatformDriver {
|
||||||
const platform = (target as { platform?: string }).platform ?? "discord";
|
const platform = target.platform ?? "discord";
|
||||||
const driver = drivers[platform];
|
const driver = drivers[platform];
|
||||||
if (!driver) throw new Error(`No driver for platform "${platform}"`);
|
if (!driver) throw new Error(`No driver for platform "${platform}"`);
|
||||||
return driver;
|
return driver;
|
||||||
|
|
|
||||||
231
src/drivers/telegram/commands.ts
Normal file
231
src/drivers/telegram/commands.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { log } from "../../lib/log";
|
||||||
|
import {
|
||||||
|
getOAuthURL,
|
||||||
|
commentAsUser,
|
||||||
|
mergePullRequestAsUser,
|
||||||
|
closePullRequestAsUser,
|
||||||
|
} from "../../github/oauth";
|
||||||
|
import { getTelegramLink, removeTelegramLink } from "../../github/store";
|
||||||
|
import type { Env } from "../../types";
|
||||||
|
import { sendMessage } from "./rest";
|
||||||
|
|
||||||
|
const GITHUB_ISSUE_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/;
|
||||||
|
const GITHUB_PR_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/;
|
||||||
|
|
||||||
|
interface TelegramMessage {
|
||||||
|
message_id?: number;
|
||||||
|
text?: string;
|
||||||
|
from?: { id?: number; first_name?: string; username?: string };
|
||||||
|
chat?: { id?: number; type?: string; title?: string };
|
||||||
|
date?: number;
|
||||||
|
message_thread_id?: number;
|
||||||
|
entities?: Array<{ type?: string; url?: string; offset?: number; length?: number }>;
|
||||||
|
reply_to_message?: TelegramMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Target {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
number: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatIdOf(msg: TelegramMessage): string | null {
|
||||||
|
return msg.chat?.id != null ? String(msg.chat.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userIdOf(msg: TelegramMessage): string | null {
|
||||||
|
return msg.from?.id != null ? String(msg.from.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract a GitHub issue/PR link from a message (entities text_link or raw text). */
|
||||||
|
function extractTarget(msg: TelegramMessage, prOnly = false): Target | null {
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const ent of msg.entities ?? []) {
|
||||||
|
if (ent.type === "text_link" && ent.url) urls.push(ent.url);
|
||||||
|
}
|
||||||
|
if (msg.text) {
|
||||||
|
for (const u of msg.text.match(/https?:\/\/github\.com\/[^\s]+/g) ?? []) urls.push(u);
|
||||||
|
}
|
||||||
|
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]) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reply(env: Env, chatId: string, topicId: string | undefined, text: string): Promise<void> {
|
||||||
|
await sendMessage(env.TELEGRAM_TOKEN ?? "", chatId, text, topicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errText(err: unknown): string {
|
||||||
|
const t = err instanceof Error ? err.message : String(err);
|
||||||
|
if (t === "GITHUB_TOKEN_EXPIRED") return "GitHub 授权已过期或无效,请重新使用 /gh login 绑定。";
|
||||||
|
if (t === "GITHUB_FORBIDDEN") return "GitHub 拒绝了此操作:你的账号没有权限。";
|
||||||
|
if (t === "GITHUB_NOT_FOUND") return "找不到目标(可能已删除或仓库不可访问)。";
|
||||||
|
return `操作失败:${t}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdLogin(env: Env, msg: TelegramMessage): Promise<void> {
|
||||||
|
const chatId = chatIdOf(msg);
|
||||||
|
const telegramUserId = userIdOf(msg);
|
||||||
|
if (!chatId || !telegramUserId) return;
|
||||||
|
const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined;
|
||||||
|
|
||||||
|
const clientId = env.GITHUB_CLIENT_ID;
|
||||||
|
if (!clientId) return reply(env, chatId, topicId, "服务器未配置 GitHub OAuth(GITHUB_CLIENT_ID)。");
|
||||||
|
|
||||||
|
const state = crypto.randomUUID().replace(/-/g, "");
|
||||||
|
await env.KV.put(
|
||||||
|
`state:${state}`,
|
||||||
|
JSON.stringify({
|
||||||
|
redirectTo: "/",
|
||||||
|
telegramUserId,
|
||||||
|
telegramChatId: chatId,
|
||||||
|
expiresAt: Date.now() + 600_000,
|
||||||
|
}),
|
||||||
|
{ expirationTtl: 600 },
|
||||||
|
);
|
||||||
|
const url = getOAuthURL(clientId, state);
|
||||||
|
await reply(env, chatId, topicId, `点击链接授权 GitHub,即可用**本人身份**评论(10 分钟内有效):\n${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdLogout(env: Env, msg: TelegramMessage): Promise<void> {
|
||||||
|
const chatId = chatIdOf(msg);
|
||||||
|
const telegramUserId = userIdOf(msg);
|
||||||
|
if (!chatId || !telegramUserId) return;
|
||||||
|
const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined;
|
||||||
|
await removeTelegramLink(env.DB, telegramUserId);
|
||||||
|
await reply(env, chatId, topicId, "已解绑你的 GitHub 账号。");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdComment(env: Env, msg: TelegramMessage, body: string): Promise<void> {
|
||||||
|
const chatId = chatIdOf(msg);
|
||||||
|
const telegramUserId = userIdOf(msg);
|
||||||
|
if (!chatId || !telegramUserId) return;
|
||||||
|
const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined;
|
||||||
|
|
||||||
|
const githubUserId = await getTelegramLink(env.DB, telegramUserId);
|
||||||
|
if (!githubUserId) {
|
||||||
|
return reply(env, chatId, topicId, "你还没有绑定 GitHub 账号,请先使用 /gh login。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = msg.reply_to_message;
|
||||||
|
const target = source ? extractTarget(source) : null;
|
||||||
|
if (!target) {
|
||||||
|
return reply(
|
||||||
|
env,
|
||||||
|
chatId,
|
||||||
|
topicId,
|
||||||
|
"找不到 issue / PR 链接,请在对应的 GitHub 通知消息上回复 /gh comment。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { htmlUrl, login } = await commentAsUser(
|
||||||
|
env.KV,
|
||||||
|
githubUserId,
|
||||||
|
target.owner,
|
||||||
|
target.repo,
|
||||||
|
target.number,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
await reply(env, chatId, topicId, `已以 **@${login}** 身份评论:${htmlUrl}`);
|
||||||
|
} catch (err) {
|
||||||
|
await reply(env, chatId, topicId, errText(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdMergeClose(env: Env, msg: TelegramMessage, op: "merge" | "close"): Promise<void> {
|
||||||
|
const chatId = chatIdOf(msg);
|
||||||
|
const telegramUserId = userIdOf(msg);
|
||||||
|
if (!chatId || !telegramUserId) return;
|
||||||
|
const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined;
|
||||||
|
|
||||||
|
const githubUserId = await getTelegramLink(env.DB, telegramUserId);
|
||||||
|
if (!githubUserId) {
|
||||||
|
return reply(env, chatId, topicId, "你还没有绑定 GitHub 账号,请先使用 /gh login。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = msg.reply_to_message;
|
||||||
|
const target = source ? extractTarget(source, true) : null;
|
||||||
|
if (!target) {
|
||||||
|
return reply(
|
||||||
|
env,
|
||||||
|
chatId,
|
||||||
|
topicId,
|
||||||
|
"找不到 PR 链接,请在对应的 GitHub PR 通知消息上回复 /gh merge 或 /gh close。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (op === "merge") {
|
||||||
|
await mergePullRequestAsUser(env.KV, githubUserId, target.owner, target.repo, target.number);
|
||||||
|
} else {
|
||||||
|
await closePullRequestAsUser(env.KV, githubUserId, target.owner, target.repo, target.number);
|
||||||
|
}
|
||||||
|
const label = op === "merge" ? "合并" : "关闭";
|
||||||
|
await reply(env, chatId, topicId, `✅ 已${label} PR ${target.owner}/${target.repo}#${target.number}`);
|
||||||
|
} catch (err) {
|
||||||
|
await reply(env, chatId, topicId, errText(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register the Telegram webhook to point at this worker (called from cron). */
|
||||||
|
export async function syncTelegramWebhook(env: Env): Promise<void> {
|
||||||
|
const token = env.TELEGRAM_TOKEN;
|
||||||
|
if (!token) return;
|
||||||
|
const baseUrl = env.BASE_URL;
|
||||||
|
if (!baseUrl) return;
|
||||||
|
const secret = env.TELEGRAM_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
const res = await fetch(`https://api.telegram.org/bot${token}/setWebhook`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: `${baseUrl.replace(/\/$/, "")}/telegram/webhook`,
|
||||||
|
secret_token: secret || undefined,
|
||||||
|
allowed_updates: ["message"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
log.error({ status: res.status, err }, "Failed to set Telegram webhook");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a single Telegram update (message). Called from the webhook route. */
|
||||||
|
export async function handleTelegramUpdate(env: Env, update: unknown): Promise<void> {
|
||||||
|
const message = (update as { message?: TelegramMessage })?.message;
|
||||||
|
if (!message?.text) return;
|
||||||
|
|
||||||
|
const text = message.text.trim();
|
||||||
|
const m = text.match(/^\/gh(?:\s+|$)(.*)$/s);
|
||||||
|
if (!m) return;
|
||||||
|
|
||||||
|
const rest = m[1].trim();
|
||||||
|
const [sub, ...args] = rest.split(/\s+/);
|
||||||
|
const body = args.join(" ").trim();
|
||||||
|
|
||||||
|
switch (sub) {
|
||||||
|
case "login":
|
||||||
|
return cmdLogin(env, message);
|
||||||
|
case "logout":
|
||||||
|
return cmdLogout(env, message);
|
||||||
|
case "comment":
|
||||||
|
if (!body) {
|
||||||
|
const chatId = chatIdOf(message);
|
||||||
|
const topicId =
|
||||||
|
message.message_thread_id != null ? String(message.message_thread_id) : undefined;
|
||||||
|
if (chatId) await reply(env, chatId, topicId, "请附上评论内容:/gh comment 你的评论");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return cmdComment(env, message, body);
|
||||||
|
case "merge":
|
||||||
|
return cmdMergeClose(env, message, "merge");
|
||||||
|
case "close":
|
||||||
|
return cmdMergeClose(env, message, "close");
|
||||||
|
default:
|
||||||
|
log.info({ text }, "Unhandled /gh command from Telegram");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import type { Route, Env, NeutralMessage } from "../../types";
|
import type { RouteTarget, Env, NeutralMessage } from "../../types";
|
||||||
import type { PlatformDriver, SendResult } from "../types";
|
import type { PlatformDriver, SendResult } from "../types";
|
||||||
|
import { sendMessage } from "./rest";
|
||||||
|
import { renderNeutralMessage } from "./render";
|
||||||
|
|
||||||
export class TelegramDriver implements PlatformDriver {
|
export class TelegramDriver implements PlatformDriver {
|
||||||
readonly id = "telegram";
|
readonly id = "telegram";
|
||||||
|
|
||||||
async send(_message: NeutralMessage, _target: Route["target"], _env: Env): Promise<SendResult> {
|
async send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult> {
|
||||||
return { ok: false, error: "Telegram driver not implemented yet" };
|
const chatId = target.chatId ?? "";
|
||||||
|
if (!chatId) {
|
||||||
|
return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" };
|
||||||
|
}
|
||||||
|
const token = env.TELEGRAM_TOKEN ?? "";
|
||||||
|
return sendMessage(token, chatId, renderNeutralMessage(message), target.topicId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
src/drivers/telegram/render.ts
Normal file
45
src/drivers/telegram/render.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { NeutralMessage } from "../../types";
|
||||||
|
|
||||||
|
function esc(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineUrl(url?: string, text?: string): string {
|
||||||
|
const label = esc(text ?? url ?? "");
|
||||||
|
if (!url) return label;
|
||||||
|
return `<a href="${esc(url)}">${label}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderNeutralMessage(message: NeutralMessage): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const title = inlineUrl(message.url, message.title);
|
||||||
|
parts.push(`<b>${title}</b>`);
|
||||||
|
|
||||||
|
if (message.author) {
|
||||||
|
const author = message.author.url
|
||||||
|
? `<a href="${esc(message.author.url)}">${esc(message.author.name)}</a>`
|
||||||
|
: esc(message.author.name);
|
||||||
|
parts.push(`👤 ${author}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.description) {
|
||||||
|
parts.push(esc(message.description));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of message.fields ?? []) {
|
||||||
|
parts.push(`<b>${esc(field.name)}</b>: ${esc(field.value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: string[] = [];
|
||||||
|
if (message.footer) meta.push(esc(message.footer));
|
||||||
|
if (message.timestamp) meta.push(esc(message.timestamp));
|
||||||
|
if (meta.length > 0) {
|
||||||
|
parts.push(`<i>${meta.join(" · ")}</i>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
82
src/drivers/telegram/rest.ts
Normal file
82
src/drivers/telegram/rest.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { log } from "../../lib/log";
|
||||||
|
import type { SendResult } from "../types";
|
||||||
|
|
||||||
|
const TELEGRAM_API = "https://api.telegram.org";
|
||||||
|
|
||||||
|
interface TelegramResponse {
|
||||||
|
ok?: boolean;
|
||||||
|
description?: string;
|
||||||
|
result?: { message_id?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage(
|
||||||
|
token: string,
|
||||||
|
chatId: string,
|
||||||
|
text: string,
|
||||||
|
topicId?: string,
|
||||||
|
): Promise<SendResult> {
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
chat_id: chatId,
|
||||||
|
text,
|
||||||
|
parse_mode: "HTML",
|
||||||
|
disable_web_page_preview: true,
|
||||||
|
};
|
||||||
|
if (topicId) {
|
||||||
|
body.message_thread_id = Number(topicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${TELEGRAM_API}/bot${token}/sendMessage`;
|
||||||
|
let lastStatus = 0;
|
||||||
|
let lastError = "";
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
lastStatus = res.status;
|
||||||
|
const data = (await res.json().catch(() => null)) as TelegramResponse | null;
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
const retryAfter = (data as { retry_after?: number })?.retry_after ?? 1;
|
||||||
|
lastError = data?.description ?? `Rate limited (retry_after=${retryAfter})`;
|
||||||
|
await new Promise((r) => setTimeout(r, retryAfter * 1000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
lastError = data?.description ?? `HTTP ${res.status}`;
|
||||||
|
log.error({ status: res.status, err: lastError, chatId, attempts: attempt + 1 }, "Telegram API error");
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: lastError,
|
||||||
|
errorCode: res.status >= 500 ? "TELEGRAM_5XX" : "TELEGRAM_ERROR",
|
||||||
|
status: res.status,
|
||||||
|
attempts: attempt + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: res.status,
|
||||||
|
messageId: data?.result?.message_id != null ? String(data.result.message_id) : undefined,
|
||||||
|
attempts: attempt + 1,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err.message : String(err);
|
||||||
|
log.error({ err, chatId, attempts: attempt + 1 }, "Failed to send Telegram message");
|
||||||
|
if (attempt === 2) {
|
||||||
|
return { ok: false, error: lastError, errorCode: "NETWORK", status: lastStatus, attempts: attempt + 1 };
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: lastError || "Failed to send Telegram message", errorCode: "RETRIES", status: lastStatus, attempts: 3 };
|
||||||
|
}
|
||||||
45
src/drivers/telegram/updates.ts
Normal file
45
src/drivers/telegram/updates.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { Env } from "../../types";
|
||||||
|
import { handleTelegramUpdate } from "./commands";
|
||||||
|
|
||||||
|
const MAX_BODY_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
|
function timingSafeEqual(a: string, b: string): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a POST to the Telegram webhook endpoint. */
|
||||||
|
export async function handleTelegramWebhookRequest(request: Request, env: Env): Promise<Response> {
|
||||||
|
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||||
|
if (contentLength > MAX_BODY_SIZE) {
|
||||||
|
return new Response("Request too large", { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = env.TELEGRAM_WEBHOOK_SECRET;
|
||||||
|
if (secret) {
|
||||||
|
const token = request.headers.get("X-Telegram-Bot-Api-Secret-Token");
|
||||||
|
if (!token || !timingSafeEqual(token, secret)) {
|
||||||
|
return new Response("Invalid secret", { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await request.text();
|
||||||
|
if (rawBody.length > MAX_BODY_SIZE) {
|
||||||
|
return new Response("Request too large", { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let update: unknown;
|
||||||
|
try {
|
||||||
|
update = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
return new Response("Invalid JSON", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegram expects a quick 200; process commands in the background.
|
||||||
|
await handleTelegramUpdate(env, update);
|
||||||
|
return new Response("ok");
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Route, Env, NeutralMessage } from "../types";
|
import type { RouteTarget, Env, NeutralMessage } from "../types";
|
||||||
|
|
||||||
export interface SendResult {
|
export interface SendResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
|
@ -11,5 +11,5 @@ export interface SendResult {
|
||||||
|
|
||||||
export interface PlatformDriver {
|
export interface PlatformDriver {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
send(message: NeutralMessage, target: Route["target"], env: Env): Promise<SendResult>;
|
send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,3 +97,29 @@ export async function getDiscordLink(
|
||||||
export async function removeDiscordLink(db: D1Database, discordUserId: string): Promise<void> {
|
export async function removeDiscordLink(db: D1Database, discordUserId: string): Promise<void> {
|
||||||
await db.prepare("DELETE FROM discord_links WHERE discord_user_id = ?").bind(discordUserId).run();
|
await db.prepare("DELETE FROM discord_links WHERE discord_user_id = ?").bind(discordUserId).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveTelegramLink(
|
||||||
|
db: D1Database,
|
||||||
|
telegramUserId: string,
|
||||||
|
githubUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare("INSERT OR REPLACE INTO telegram_links (telegram_user_id, github_user_id) VALUES (?, ?)")
|
||||||
|
.bind(telegramUserId, githubUserId)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTelegramLink(
|
||||||
|
db: D1Database,
|
||||||
|
telegramUserId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare("SELECT github_user_id FROM telegram_links WHERE telegram_user_id = ?")
|
||||||
|
.bind(telegramUserId)
|
||||||
|
.all<{ github_user_id: string }>();
|
||||||
|
return results[0]?.github_user_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTelegramLink(db: D1Database, telegramUserId: string): Promise<void> {
|
||||||
|
await db.prepare("DELETE FROM telegram_links WHERE telegram_user_id = ?").bind(telegramUserId).run();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createServer } from "./server";
|
import { createServer } from "./server";
|
||||||
import { syncCommands } from "./drivers/discord/commands";
|
import { syncCommands } from "./drivers/discord/commands";
|
||||||
|
import { syncTelegramWebhook } from "./drivers/telegram/commands";
|
||||||
import type { Env } from "./types";
|
import type { Env } from "./types";
|
||||||
import { log } from "./lib/log";
|
import { log } from "./lib/log";
|
||||||
|
|
||||||
|
|
@ -16,5 +17,10 @@ export default {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error({ err }, "Discord command sync from cron failed");
|
log.error({ err }, "Discord command sync from cron failed");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await syncTelegramWebhook(env);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "Telegram webhook sync from cron failed");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { verifySignature } from "./events/verify";
|
||||||
import { parseEvent } from "./events/parse";
|
import { parseEvent } from "./events/parse";
|
||||||
import { dispatchEvent } from "./core/dispatch";
|
import { dispatchEvent } from "./core/dispatch";
|
||||||
import { handleInteractionRequest } from "./drivers/discord/interactions";
|
import { handleInteractionRequest } from "./drivers/discord/interactions";
|
||||||
|
import { handleTelegramWebhookRequest } from "./drivers/telegram/updates";
|
||||||
import { createOAuthRoutes } from "./web/oauth-routes";
|
import { createOAuthRoutes } from "./web/oauth-routes";
|
||||||
import { createActionRoutes } from "./web/action-routes";
|
import { createActionRoutes } from "./web/action-routes";
|
||||||
import { createAdminRoutes } from "./web/admin-routes";
|
import { createAdminRoutes } from "./web/admin-routes";
|
||||||
|
|
@ -71,6 +72,7 @@ export function createServer(): Hono<{ Bindings: Env }> {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/discord/interactions", (c) => handleInteractionRequest(c.req.raw, c.env));
|
app.post("/discord/interactions", (c) => handleInteractionRequest(c.req.raw, c.env));
|
||||||
|
app.post("/telegram/webhook", (c) => handleTelegramWebhookRequest(c.req.raw, c.env));
|
||||||
|
|
||||||
app.notFound((c) => {
|
app.notFound((c) => {
|
||||||
if (c.env.ASSETS) {
|
if (c.env.ASSETS) {
|
||||||
|
|
|
||||||
15
src/types.ts
15
src/types.ts
|
|
@ -13,6 +13,8 @@ export interface Env {
|
||||||
GITHUB_REPO_URL?: string;
|
GITHUB_REPO_URL?: string;
|
||||||
DISCORD_PUBLIC_KEY?: string;
|
DISCORD_PUBLIC_KEY?: string;
|
||||||
DISCORD_APPLICATION_ID?: string;
|
DISCORD_APPLICATION_ID?: string;
|
||||||
|
TELEGRAM_TOKEN?: string;
|
||||||
|
TELEGRAM_WEBHOOK_SECRET?: string;
|
||||||
ASSETS?: Fetcher;
|
ASSETS?: Fetcher;
|
||||||
KV: KVNamespace;
|
KV: KVNamespace;
|
||||||
DB: D1Database;
|
DB: D1Database;
|
||||||
|
|
@ -33,15 +35,20 @@ export interface Config {
|
||||||
routes: Route[];
|
routes: Route[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RouteTarget {
|
||||||
|
platform?: "discord" | "telegram";
|
||||||
|
channelId?: string;
|
||||||
|
threadId?: string;
|
||||||
|
chatId?: string;
|
||||||
|
topicId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
filters: Filter[];
|
filters: Filter[];
|
||||||
target: {
|
targets: RouteTarget[];
|
||||||
channelId: string;
|
|
||||||
threadId?: string;
|
|
||||||
};
|
|
||||||
lang?: string;
|
lang?: string;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -98,16 +98,66 @@ function validateRoutes(
|
||||||
return { ok: false, error: `route "${r.id}" filter[${j}].exclude must be boolean` };
|
return { ok: false, error: `route "${r.id}" filter[${j}].exclude must be boolean` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const target = r.target as Record<string, unknown> | undefined;
|
const rawTarget = r.target as Record<string, unknown> | undefined;
|
||||||
if (!target || typeof target !== "object")
|
const rawTargets = r.targets as unknown;
|
||||||
return { ok: false, error: `route "${r.id}" needs a target` };
|
if (rawTargets === undefined && rawTarget && typeof rawTarget === "object") {
|
||||||
|
const legacy = validateTarget(r, rawTarget);
|
||||||
|
if (!legacy.ok) return legacy;
|
||||||
|
(r as Record<string, unknown>).targets = [legacy.target];
|
||||||
|
delete (r as Record<string, unknown>).target;
|
||||||
|
} else if (Array.isArray(rawTargets)) {
|
||||||
|
if (rawTargets.length === 0) {
|
||||||
|
return { ok: false, error: `route "${r.id}" needs at least one target` };
|
||||||
|
}
|
||||||
|
const normalized: Route["targets"] = [];
|
||||||
|
for (let j = 0; j < rawTargets.length; j++) {
|
||||||
|
const t = rawTargets[j] as Record<string, unknown>;
|
||||||
|
if (!t || typeof t !== "object") {
|
||||||
|
return { ok: false, error: `route "${r.id}".targets[${j}] is not an object` };
|
||||||
|
}
|
||||||
|
const result = validateTarget(r, t);
|
||||||
|
if (!result.ok) return result;
|
||||||
|
normalized.push(result.target);
|
||||||
|
}
|
||||||
|
(r as Record<string, unknown>).targets = normalized;
|
||||||
|
} else {
|
||||||
|
return { ok: false, error: `route "${r.id}" needs a targets array` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, routes: routes as Route[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTarget(
|
||||||
|
r: Record<string, unknown>,
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
): { ok: true; target: Route["targets"][number] } | { ok: false; error: string } {
|
||||||
|
const platform = target.platform === undefined ? "discord" : target.platform;
|
||||||
|
if (platform !== "discord" && platform !== "telegram") {
|
||||||
|
return { ok: false, error: `route "${r.id}".target.platform must be "discord" or "telegram"` };
|
||||||
|
}
|
||||||
|
if (platform === "telegram") {
|
||||||
|
if (typeof target.chatId !== "string" || target.chatId.trim().length === 0)
|
||||||
|
return { ok: false, error: `route "${r.id}".target.chatId is required` };
|
||||||
|
if (target.topicId !== undefined && typeof target.topicId !== "string") {
|
||||||
|
return { ok: false, error: `route "${r.id}".target.topicId must be a string` };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (typeof target.channelId !== "string" || target.channelId.trim().length === 0)
|
if (typeof target.channelId !== "string" || target.channelId.trim().length === 0)
|
||||||
return { ok: false, error: `route "${r.id}".target.channelId is required` };
|
return { ok: false, error: `route "${r.id}".target.channelId is required` };
|
||||||
if (target.threadId !== undefined && typeof target.threadId !== "string") {
|
if (target.threadId !== undefined && typeof target.threadId !== "string") {
|
||||||
return { ok: false, error: `route "${r.id}".target.threadId must be a string` };
|
return { ok: false, error: `route "${r.id}".target.threadId must be a string` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ok: true, routes: routes as Route[] };
|
return {
|
||||||
|
ok: true,
|
||||||
|
target: {
|
||||||
|
platform,
|
||||||
|
channelId: platform === "telegram" ? undefined : (target.channelId as string),
|
||||||
|
threadId: platform === "telegram" ? undefined : ((target.threadId as string) ?? undefined),
|
||||||
|
chatId: platform === "telegram" ? (target.chatId as string) : undefined,
|
||||||
|
topicId: platform === "telegram" ? ((target.topicId as string) ?? undefined) : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateGroups(
|
function validateGroups(
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { getOAuthURL, handleOAuthCallback } from "../github/oauth";
|
import { getOAuthURL, handleOAuthCallback } from "../github/oauth";
|
||||||
import { removeToken, saveDiscordLink } from "../github/store";
|
import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store";
|
||||||
import { createAdminSession, adminCookie } from "./session";
|
import { createAdminSession, adminCookie } from "./session";
|
||||||
import { loadGroups, resolveScope, hasAnyAccess } from "./groups";
|
import { loadGroups, resolveScope, hasAnyAccess } from "./groups";
|
||||||
|
import { sendMessage } from "../drivers/telegram/rest";
|
||||||
import type { Env } from "../types";
|
import type { Env } from "../types";
|
||||||
|
|
||||||
interface PendingState {
|
interface PendingState {
|
||||||
redirectTo: string;
|
redirectTo: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
discordUserId?: string;
|
discordUserId?: string;
|
||||||
|
telegramUserId?: string;
|
||||||
|
telegramChatId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkedPage(login: string): string {
|
function linkedPage(login: string): string {
|
||||||
|
|
@ -91,6 +94,19 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
|
||||||
return c.json({ ok: true, discordUserId: pending.discordUserId, login: result.login });
|
return c.json({ ok: true, discordUserId: pending.discordUserId, login: result.login });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Telegram account-linking flow: bind the Telegram user to this GitHub account.
|
||||||
|
if (pending.telegramUserId) {
|
||||||
|
await saveTelegramLink(c.env.DB, pending.telegramUserId, result.userId);
|
||||||
|
if (pending.telegramChatId && c.env.TELEGRAM_TOKEN) {
|
||||||
|
await sendMessage(
|
||||||
|
c.env.TELEGRAM_TOKEN,
|
||||||
|
pending.telegramChatId,
|
||||||
|
`✅ GitHub 账号已绑定:**@${result.login}**。现在可以用 /gh comment 评论了。`,
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, telegramUserId: pending.telegramUserId, login: result.login });
|
||||||
|
}
|
||||||
|
|
||||||
const isBrowser = (c.req.header("accept") ?? "").includes("text/html");
|
const isBrowser = (c.req.header("accept") ?? "").includes("text/html");
|
||||||
if (isBrowser) {
|
if (isBrowser) {
|
||||||
const groups = await loadGroups(c.env.KV);
|
const groups = await loadGroups(c.env.KV);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue