mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +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
|
|
@ -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 { sendMessage } from "./rest";
|
||||
import { renderNeutralMessage } from "./render";
|
||||
|
|
@ -6,8 +6,12 @@ import { renderNeutralMessage } from "./render";
|
|||
export class DiscordDriver implements PlatformDriver {
|
||||
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 ?? "";
|
||||
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 { DiscordDriver } from "./discord";
|
||||
import { TelegramDriver } from "./telegram";
|
||||
|
|
@ -8,8 +8,8 @@ const drivers: Record<string, PlatformDriver> = {
|
|||
telegram: new TelegramDriver(),
|
||||
};
|
||||
|
||||
export function getDriver(target: Route["target"]): PlatformDriver {
|
||||
const platform = (target as { platform?: string }).platform ?? "discord";
|
||||
export function getDriver(target: RouteTarget): PlatformDriver {
|
||||
const platform = target.platform ?? "discord";
|
||||
const driver = drivers[platform];
|
||||
if (!driver) throw new Error(`No driver for platform "${platform}"`);
|
||||
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 { sendMessage } from "./rest";
|
||||
import { renderNeutralMessage } from "./render";
|
||||
|
||||
export class TelegramDriver implements PlatformDriver {
|
||||
readonly id = "telegram";
|
||||
|
||||
async send(_message: NeutralMessage, _target: Route["target"], _env: Env): Promise<SendResult> {
|
||||
return { ok: false, error: "Telegram driver not implemented yet" };
|
||||
async send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult> {
|
||||
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 {
|
||||
ok: boolean;
|
||||
|
|
@ -11,5 +11,5 @@ export interface SendResult {
|
|||
|
||||
export interface PlatformDriver {
|
||||
readonly id: string;
|
||||
send(message: NeutralMessage, target: Route["target"], env: Env): Promise<SendResult>;
|
||||
send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue