mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat: migrate to Nuxt 4 (Nitro) and Tailwind CSS v3
This commit is contained in:
parent
f4959eebf8
commit
b139712a91
166 changed files with 19790 additions and 5539 deletions
174
server/lib/drivers/discord/commands.ts
Normal file
174
server/lib/drivers/discord/commands.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { log } from "../../lib/log";
|
||||
import type { Env } from "../../types";
|
||||
|
||||
const DISCORD_API = "https://discord.com/api/v10";
|
||||
|
||||
const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const;
|
||||
const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const;
|
||||
|
||||
export const MSG_CMD_ADD = "GitHub: 添加评论";
|
||||
export const MSG_CMD_EDIT = "GitHub: 编辑评论";
|
||||
export const MSG_CMD_DEL = "GitHub: 删除评论";
|
||||
|
||||
export const APP_COMMANDS = [
|
||||
{
|
||||
name: "gh",
|
||||
type: COMMAND_TYPE.CHAT_INPUT,
|
||||
description: "GitHub 集成",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND,
|
||||
name: "login",
|
||||
description: "绑定你的 GitHub 账号以用本人身份评论",
|
||||
},
|
||||
{ type: OPTION_TYPE.SUB_COMMAND, name: "logout", description: "解绑你的 GitHub 账号" },
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND_GROUP,
|
||||
name: "comment",
|
||||
description: "对 issue/PR 评论进行增删改",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND,
|
||||
name: "add",
|
||||
description: "在 issue/PR 下新增评论",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.STRING,
|
||||
name: "link",
|
||||
description: "issue/PR 链接",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND,
|
||||
name: "edit",
|
||||
description: "编辑一条评论",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.STRING,
|
||||
name: "link",
|
||||
description: "评论链接(含 #issuecomment-)",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: OPTION_TYPE.SUB_COMMAND,
|
||||
name: "del",
|
||||
description: "删除一条评论",
|
||||
options: [
|
||||
{
|
||||
type: OPTION_TYPE.STRING,
|
||||
name: "link",
|
||||
description: "评论链接(含 #issuecomment-)",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: MSG_CMD_ADD, type: COMMAND_TYPE.MESSAGE },
|
||||
{ name: MSG_CMD_EDIT, type: COMMAND_TYPE.MESSAGE },
|
||||
{ name: MSG_CMD_DEL, type: COMMAND_TYPE.MESSAGE },
|
||||
];
|
||||
|
||||
export async function getApplicationId(env: Env): Promise<string | null> {
|
||||
if (env.DISCORD_APPLICATION_ID) return env.DISCORD_APPLICATION_ID;
|
||||
try {
|
||||
const cached = await env.KV.get("config:discord-app-id");
|
||||
if (cached) return cached;
|
||||
} catch {
|
||||
// fall through to the API
|
||||
}
|
||||
const token = env.DISCORD_TOKEN ?? "";
|
||||
if (!token) return null;
|
||||
const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
log.warn({ status: res.status }, "Failed to fetch Discord application id");
|
||||
return null;
|
||||
}
|
||||
const app = (await res.json()) as { id?: string };
|
||||
if (app.id) {
|
||||
try {
|
||||
await env.KV.put("config:discord-app-id", app.id);
|
||||
} catch {
|
||||
// cache is best-effort
|
||||
}
|
||||
return app.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function registerGlobalCommands(env: Env): Promise<void> {
|
||||
const token = env.DISCORD_TOKEN ?? "";
|
||||
if (!token) return;
|
||||
try {
|
||||
if (await env.KV.get("cmd:registered:global")) return;
|
||||
} catch {
|
||||
// fall through and register
|
||||
}
|
||||
const appId = await getApplicationId(env);
|
||||
if (!appId) return;
|
||||
const res = await fetch(`${DISCORD_API}/applications/${appId}/commands`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(APP_COMMANDS),
|
||||
});
|
||||
if (res.ok) {
|
||||
try {
|
||||
await env.KV.put("cmd:registered:global", "1", { expirationTtl: 86400 });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
log.info("Registered global application commands");
|
||||
} else {
|
||||
const err = await res.text();
|
||||
log.warn({ status: res.status, err }, "Global command registration failed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncGuildCommands(env: Env): Promise<void> {
|
||||
const token = env.DISCORD_TOKEN ?? "";
|
||||
if (!token) return;
|
||||
const appId = await getApplicationId(env);
|
||||
if (!appId) return;
|
||||
const res = await fetch(`${DISCORD_API}/users/@me/guilds`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log.warn({ status: res.status, err }, "Failed to list guilds");
|
||||
return;
|
||||
}
|
||||
const guilds = (await res.json()) as Array<{ id: string }>;
|
||||
for (const guild of guilds) {
|
||||
try {
|
||||
if (await env.KV.get(`cmd:guild:${guild.id}`)) continue;
|
||||
const r = await fetch(`${DISCORD_API}/applications/${appId}/guilds/${guild.id}/commands`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(APP_COMMANDS),
|
||||
});
|
||||
if (r.ok) {
|
||||
await env.KV.put(`cmd:guild:${guild.id}`, "1");
|
||||
log.info({ guildId: guild.id }, "Registered guild application commands");
|
||||
} else {
|
||||
const err = await r.text();
|
||||
log.warn({ guildId: guild.id, status: r.status, err }, "Command registration failed");
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ guildId: guild.id, err: String(err) }, "Command registration failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncCommands(env: Env): Promise<void> {
|
||||
if (!env.DISCORD_TOKEN) return;
|
||||
await registerGlobalCommands(env);
|
||||
await syncGuildCommands(env);
|
||||
}
|
||||
31
server/lib/drivers/discord/index.ts
Normal file
31
server/lib/drivers/discord/index.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { RouteTarget, Env, NeutralMessage } from "../../types";
|
||||
import type { PlatformDriver, SendResult } from "../types";
|
||||
import { sendMessage, editMessage } from "./rest";
|
||||
import { renderNeutralMessage } from "./render";
|
||||
|
||||
export class DiscordDriver implements PlatformDriver {
|
||||
readonly id = "discord";
|
||||
|
||||
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, channelId, renderNeutralMessage(message), target.threadId);
|
||||
}
|
||||
|
||||
async edit(
|
||||
message: NeutralMessage,
|
||||
target: RouteTarget,
|
||||
env: Env,
|
||||
messageId: string,
|
||||
): 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 editMessage(token, channelId, messageId, renderNeutralMessage(message), target.threadId);
|
||||
}
|
||||
}
|
||||
534
server/lib/drivers/discord/interactions.ts
Normal file
534
server/lib/drivers/discord/interactions.ts
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
import { log } from "../../lib/log";
|
||||
import {
|
||||
getOAuthURL,
|
||||
commentAsUser,
|
||||
getCommentAsUser,
|
||||
editCommentAsUser,
|
||||
deleteCommentAsUser,
|
||||
mergePullRequestAsUser,
|
||||
closePullRequestAsUser,
|
||||
} from "../../github/oauth";
|
||||
import { getDiscordLink, removeDiscordLink } from "../../github/store";
|
||||
import type { Env } from "../../types";
|
||||
import { MSG_CMD_ADD, MSG_CMD_EDIT, MSG_CMD_DEL } from "./commands";
|
||||
|
||||
const DISCORD_API = "https://discord.com/api/v10";
|
||||
|
||||
// Discord interaction protocol constants
|
||||
const INTERACTION_TYPE = { PING: 1, COMMAND: 2, BUTTON: 3, MODAL_SUBMIT: 5 } as const;
|
||||
const CALLBACK_TYPE = { PONG: 1, MESSAGE: 4, DEFERRED_MESSAGE: 5, MODAL: 9 } as const;
|
||||
const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const;
|
||||
const EPHEMERAL = 64;
|
||||
|
||||
// Modal custom_id encodings (delimiter '|' never appears in owner/repo).
|
||||
const MODAL_ADD = "ghc|add|"; // ghc|add|owner|repo|issueNumber
|
||||
const MODAL_EDIT = "ghc|edit|"; // ghc|edit|owner|repo|commentId
|
||||
|
||||
// PR notification button custom_id encodings.
|
||||
const BTN_MERGE = "ghpr|merge|"; // ghpr|merge|owner|repo|pullNumber
|
||||
const BTN_CLOSE = "ghpr|close|"; // ghpr|close|owner|repo|pullNumber
|
||||
|
||||
// Comment link (has the comment id); check this BEFORE the plain issue regex.
|
||||
const GITHUB_COMMENT_RE =
|
||||
/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/\d+#issuecomment-(\d+)/;
|
||||
const GITHUB_ISSUE_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/;
|
||||
|
||||
interface Interaction {
|
||||
id: string;
|
||||
token: string;
|
||||
type: number;
|
||||
channel_id?: string;
|
||||
member?: { user?: { id?: string } };
|
||||
user?: { id?: string };
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const MAX_BODY_SIZE = 1024 * 1024;
|
||||
const TIMESTAMP_TOLERANCE_SECONDS = 180;
|
||||
|
||||
function hexToBytes(hex: string): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(hex.length / 2);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an interaction's Ed25519 signature (X-Signature-Ed25519 over
|
||||
* timestamp + raw body, signed by the Discord application public key).
|
||||
*/
|
||||
export async function verifyDiscordSignature(
|
||||
publicKey: string,
|
||||
timestamp: string,
|
||||
signatureHex: string,
|
||||
rawBody: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
hexToBytes(publicKey),
|
||||
{ name: "Ed25519" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
return await crypto.subtle.verify(
|
||||
{ name: "Ed25519" },
|
||||
key,
|
||||
hexToBytes(signatureHex),
|
||||
new TextEncoder().encode(timestamp + rawBody),
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn({ err: String(err) }, "Failed to verify Discord signature");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle a POST to the Discord Interactions Endpoint. */
|
||||
export async function handleInteractionRequest(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 signature = request.headers.get("X-Signature-Ed25519");
|
||||
const timestamp = request.headers.get("X-Signature-Timestamp");
|
||||
if (!signature || !timestamp || !env.DISCORD_PUBLIC_KEY) {
|
||||
log.warn(
|
||||
{ hasSig: !!signature, hasTs: !!timestamp, hasKey: !!env.DISCORD_PUBLIC_KEY },
|
||||
"Discord interaction missing signature",
|
||||
);
|
||||
return new Response("Invalid signature", { status: 401 });
|
||||
}
|
||||
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TIMESTAMP_TOLERANCE_SECONDS) {
|
||||
return new Response("Invalid signature", { status: 401 });
|
||||
}
|
||||
|
||||
const rawBody = await request.text();
|
||||
if (rawBody.length > MAX_BODY_SIZE) {
|
||||
return new Response("Request too large", { status: 413 });
|
||||
}
|
||||
|
||||
const valid = await verifyDiscordSignature(env.DISCORD_PUBLIC_KEY, timestamp, signature, rawBody);
|
||||
if (!valid) {
|
||||
return new Response("Invalid signature", { status: 401 });
|
||||
}
|
||||
|
||||
let interaction: Interaction;
|
||||
try {
|
||||
interaction = JSON.parse(rawBody) as Interaction;
|
||||
} catch {
|
||||
return new Response("Invalid JSON", { status: 400 });
|
||||
}
|
||||
|
||||
// Discord's connection check.
|
||||
if (interaction.type === INTERACTION_TYPE.PING) {
|
||||
return new Response(JSON.stringify({ type: CALLBACK_TYPE.PONG }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Handle the interaction via the callback webhook; respond 202 with no body
|
||||
// as required for interactions received over the HTTP endpoint.
|
||||
await handleInteraction(env, interaction).catch((err) =>
|
||||
log.error({ err: String(err) }, "Interaction handler failed"),
|
||||
);
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
async function handleInteraction(env: Env, interaction: Interaction): Promise<void> {
|
||||
const userId = interaction.member?.user?.id ?? interaction.user?.id ?? null;
|
||||
const id = interaction.id;
|
||||
const token = interaction.token;
|
||||
|
||||
if (interaction.type === INTERACTION_TYPE.BUTTON) {
|
||||
const data = interaction.data as { custom_id?: string; message?: { id?: string } };
|
||||
return handleButton(
|
||||
env,
|
||||
id,
|
||||
token,
|
||||
userId,
|
||||
interaction.channel_id,
|
||||
data.message?.id,
|
||||
data.custom_id,
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction.type === INTERACTION_TYPE.COMMAND) {
|
||||
const data = interaction.data as {
|
||||
name?: string;
|
||||
type?: number;
|
||||
target_id?: string;
|
||||
options?: Array<{
|
||||
name: string;
|
||||
options?: Array<{
|
||||
name: string;
|
||||
value?: string;
|
||||
options?: Array<{ name: string; value?: string }>;
|
||||
}>;
|
||||
}>;
|
||||
resolved?: {
|
||||
messages?: Record<string, { embeds?: Array<{ url?: string }>; content?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
// Right-click (message context-menu) commands.
|
||||
if (data.type === COMMAND_TYPE.MESSAGE) {
|
||||
const op =
|
||||
data.name === MSG_CMD_ADD
|
||||
? "add"
|
||||
: data.name === MSG_CMD_EDIT
|
||||
? "edit"
|
||||
: data.name === MSG_CMD_DEL
|
||||
? "del"
|
||||
: null;
|
||||
if (!op) return;
|
||||
const target = data.target_id ? data.resolved?.messages?.[data.target_id] : undefined;
|
||||
const source = target?.embeds?.[0]?.url ?? target?.content ?? "";
|
||||
return commentOp(env, id, token, userId, op, source);
|
||||
}
|
||||
|
||||
// Slash command /gh ...
|
||||
if (data.name === "gh" && data.type === COMMAND_TYPE.CHAT_INPUT) {
|
||||
const top = data.options?.[0];
|
||||
if (top?.name === "login") return cmdLogin(env, id, token, userId);
|
||||
if (top?.name === "logout") return cmdLogout(env, id, token, userId);
|
||||
if (top?.name === "comment") {
|
||||
const sub = top.options?.[0];
|
||||
const op =
|
||||
sub?.name === "add"
|
||||
? "add"
|
||||
: sub?.name === "edit"
|
||||
? "edit"
|
||||
: sub?.name === "del"
|
||||
? "del"
|
||||
: null;
|
||||
if (!op) return;
|
||||
const link = sub?.options?.find((o) => o.name === "link")?.value ?? "";
|
||||
return commentOp(env, id, token, userId, op, link);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.type === INTERACTION_TYPE.MODAL_SUBMIT) {
|
||||
return modalSubmit(env, id, token, userId, interaction.data);
|
||||
}
|
||||
}
|
||||
|
||||
/** Respond to an interaction with an ephemeral text message. */
|
||||
async function respond(env: Env, id: string, token: string, content: string): Promise<void> {
|
||||
await interactionCallback(env, id, token, {
|
||||
type: CALLBACK_TYPE.MESSAGE,
|
||||
data: { content, flags: EPHEMERAL },
|
||||
});
|
||||
}
|
||||
|
||||
async function interactionCallback(
|
||||
env: Env,
|
||||
id: string,
|
||||
token: string,
|
||||
body: unknown,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/callback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log.warn({ status: res.status, err }, "Interaction callback failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the deferred (ephemeral) response body with the final result. */
|
||||
async function updateOriginal(env: Env, id: string, token: string, content: string): Promise<void> {
|
||||
const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/messages/@original`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log.warn({ status: res.status, err }, "Failed to update interaction response");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PR notification buttons: merge or close the PR as the clicker's linked
|
||||
* GitHub account. The clicker must have run `/gh login` first.
|
||||
*/
|
||||
async function handleButton(
|
||||
env: Env,
|
||||
id: string,
|
||||
token: string,
|
||||
userId: string | null,
|
||||
channelId: string | undefined,
|
||||
messageId: string | undefined,
|
||||
customId: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
||||
const githubUserId = await getDiscordLink(env.DB, userId);
|
||||
if (!githubUserId) {
|
||||
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
||||
}
|
||||
|
||||
let op: "merge" | "close";
|
||||
let rest: string;
|
||||
if (customId?.startsWith(BTN_MERGE)) {
|
||||
op = "merge";
|
||||
rest = customId.slice(BTN_MERGE.length);
|
||||
} else if (customId?.startsWith(BTN_CLOSE)) {
|
||||
op = "close";
|
||||
rest = customId.slice(BTN_CLOSE.length);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const [owner, repo, number] = rest.split("|");
|
||||
if (!owner || !repo || !number) return;
|
||||
|
||||
// Acknowledge first (deferred, ephemeral) so the clicker sees a spinner
|
||||
// while the GitHub API call runs.
|
||||
await interactionCallback(env, id, token, {
|
||||
type: CALLBACK_TYPE.DEFERRED_MESSAGE,
|
||||
data: { flags: EPHEMERAL },
|
||||
});
|
||||
|
||||
try {
|
||||
if (op === "merge") {
|
||||
await mergePullRequestAsUser(env.KV, githubUserId, owner, repo, Number(number));
|
||||
} else {
|
||||
await closePullRequestAsUser(env.KV, githubUserId, owner, repo, Number(number));
|
||||
}
|
||||
// Remove the buttons from the notification so nobody double-clicks.
|
||||
if (channelId && messageId) {
|
||||
await fetch(`${DISCORD_API}/channels/${channelId}/messages/${messageId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bot ${env.DISCORD_TOKEN ?? ""}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ components: [] }),
|
||||
}).catch((err) => log.warn({ err: String(err) }, "Failed to strip PR buttons"));
|
||||
}
|
||||
const label = op === "merge" ? "合并" : "关闭";
|
||||
await updateOriginal(env, id, token, `✅ 已${label} PR ${owner}/${repo}#${number}`);
|
||||
} catch (err) {
|
||||
await updateOriginal(env, id, token, errText(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdLogin(env: Env, id: string, token: string, userId: string | null): Promise<void> {
|
||||
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
||||
const clientId = env.GITHUB_CLIENT_ID;
|
||||
if (!clientId) return respond(env, id, token, "服务器未配置 GitHub OAuth(GITHUB_CLIENT_ID)。");
|
||||
|
||||
const state = crypto.randomUUID().replace(/-/g, "");
|
||||
await env.KV.put(
|
||||
`state:${state}`,
|
||||
JSON.stringify({ redirectTo: "/", discordUserId: userId, expiresAt: Date.now() + 600_000 }),
|
||||
{ expirationTtl: 600 },
|
||||
);
|
||||
const url = getOAuthURL(clientId, state);
|
||||
await respond(
|
||||
env,
|
||||
id,
|
||||
token,
|
||||
`点击链接授权 GitHub,即可用**本人身份**评论(仅你可见,10 分钟内有效):\n${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function cmdLogout(
|
||||
env: Env,
|
||||
id: string,
|
||||
token: string,
|
||||
userId: string | null,
|
||||
): Promise<void> {
|
||||
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
||||
await removeDiscordLink(env.DB, userId);
|
||||
await respond(env, id, token, "已解绑你的 GitHub 账号。");
|
||||
}
|
||||
|
||||
/** Map a GitHub op error code to a user-facing (Chinese) message. */
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified entry for add/edit/del, from either a slash command (source = link
|
||||
* option) or a right-click message command (source = notification embed url).
|
||||
*/
|
||||
async function commentOp(
|
||||
env: Env,
|
||||
id: string,
|
||||
token: string,
|
||||
userId: string | null,
|
||||
op: "add" | "edit" | "del",
|
||||
source: string,
|
||||
): Promise<void> {
|
||||
if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。");
|
||||
const githubUserId = await getDiscordLink(env.DB, userId);
|
||||
if (!githubUserId) {
|
||||
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
||||
}
|
||||
|
||||
if (op === "add") {
|
||||
const m = source.match(GITHUB_ISSUE_RE);
|
||||
if (!m)
|
||||
return respond(
|
||||
env,
|
||||
id,
|
||||
token,
|
||||
"找不到 issue / PR 链接(右键 issue/PR 通知,或用 link 传入链接)。",
|
||||
);
|
||||
return openCommentModal(
|
||||
env,
|
||||
id,
|
||||
token,
|
||||
`${MODAL_ADD}${m[1]}|${m[2]}|${m[3]}`,
|
||||
`评论 ${m[1]}/${m[2]}#${m[3]}`,
|
||||
);
|
||||
}
|
||||
|
||||
// edit / del both need a specific comment id.
|
||||
const m = source.match(GITHUB_COMMENT_RE);
|
||||
if (!m) {
|
||||
return respond(
|
||||
env,
|
||||
id,
|
||||
token,
|
||||
"找不到评论链接(需含 `#issuecomment-...`,请右键某条评论通知,或粘贴评论链接)。",
|
||||
);
|
||||
}
|
||||
const [, owner, repo, commentId] = m;
|
||||
|
||||
if (op === "del") {
|
||||
try {
|
||||
await deleteCommentAsUser(env.KV, githubUserId, owner!, repo!, Number(commentId));
|
||||
return respond(env, id, token, `已删除评论 ${owner}/${repo}#issuecomment-${commentId}。`);
|
||||
} catch (err) {
|
||||
return respond(env, id, token, errText(err));
|
||||
}
|
||||
}
|
||||
|
||||
// edit: fetch current body to prefill the modal.
|
||||
let prefill = "";
|
||||
try {
|
||||
const { body } = await getCommentAsUser(env.KV, githubUserId, owner!, repo!, Number(commentId));
|
||||
prefill = body;
|
||||
} catch (err) {
|
||||
return respond(env, id, token, errText(err));
|
||||
}
|
||||
return openCommentModal(
|
||||
env,
|
||||
id,
|
||||
token,
|
||||
`${MODAL_EDIT}${owner}|${repo}|${commentId}`,
|
||||
`编辑评论 #${commentId}`,
|
||||
prefill,
|
||||
);
|
||||
}
|
||||
|
||||
/** Open a modal to collect/edit comment body. */
|
||||
async function openCommentModal(
|
||||
env: Env,
|
||||
id: string,
|
||||
token: string,
|
||||
customId: string,
|
||||
title: string,
|
||||
prefill = "",
|
||||
): Promise<void> {
|
||||
await interactionCallback(env, id, token, {
|
||||
type: CALLBACK_TYPE.MODAL,
|
||||
data: {
|
||||
custom_id: customId,
|
||||
title: title.slice(0, 45),
|
||||
components: [
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
{
|
||||
type: 4,
|
||||
custom_id: "body",
|
||||
label: "评论内容",
|
||||
style: 2,
|
||||
required: true,
|
||||
max_length: 2000,
|
||||
value: prefill.slice(0, 2000) || undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function modalSubmit(
|
||||
env: Env,
|
||||
id: string,
|
||||
token: string,
|
||||
userId: string | null,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
const d = data as {
|
||||
custom_id?: string;
|
||||
components?: Array<{ components?: Array<{ custom_id?: string; value?: string }> }>;
|
||||
};
|
||||
const customId = d.custom_id;
|
||||
if (!userId || !customId) return;
|
||||
|
||||
const body = d.components?.[0]?.components?.find((c) => c.custom_id === "body")?.value?.trim();
|
||||
if (!body) return respond(env, id, token, "评论内容不能为空。");
|
||||
|
||||
const githubUserId = await getDiscordLink(env.DB, userId);
|
||||
if (!githubUserId) {
|
||||
return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。");
|
||||
}
|
||||
|
||||
// ghc|add|owner|repo|issueNumber
|
||||
if (customId.startsWith(MODAL_ADD)) {
|
||||
const [owner, repo, number] = customId.slice(MODAL_ADD.length).split("|");
|
||||
if (!owner || !repo || !number)
|
||||
return respond(env, id, token, "内部错误:无法解析目标 issue。");
|
||||
try {
|
||||
const { htmlUrl, login } = await commentAsUser(
|
||||
env.KV,
|
||||
githubUserId,
|
||||
owner,
|
||||
repo,
|
||||
Number(number),
|
||||
body,
|
||||
);
|
||||
return respond(env, id, token, `已以 **@${login}** 身份评论:${htmlUrl}`);
|
||||
} catch (err) {
|
||||
return respond(env, id, token, errText(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ghc|edit|owner|repo|commentId
|
||||
if (customId.startsWith(MODAL_EDIT)) {
|
||||
const [owner, repo, commentId] = customId.slice(MODAL_EDIT.length).split("|");
|
||||
if (!owner || !repo || !commentId)
|
||||
return respond(env, id, token, "内部错误:无法解析目标评论。");
|
||||
try {
|
||||
const { htmlUrl } = await editCommentAsUser(
|
||||
env.KV,
|
||||
githubUserId,
|
||||
owner,
|
||||
repo,
|
||||
Number(commentId),
|
||||
body,
|
||||
);
|
||||
return respond(env, id, token, `已更新评论:${htmlUrl}`);
|
||||
} catch (err) {
|
||||
return respond(env, id, token, errText(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
52
server/lib/drivers/discord/render.ts
Normal file
52
server/lib/drivers/discord/render.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { FormattedMessage, NeutralActionStyle, NeutralMessage } from "../../types";
|
||||
|
||||
function toStyle(style: NeutralActionStyle): number {
|
||||
switch (style) {
|
||||
case "primary":
|
||||
return 3;
|
||||
case "danger":
|
||||
return 4;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderNeutralMessage(message: NeutralMessage): FormattedMessage {
|
||||
const content = message.mentionRoleIds?.length
|
||||
? message.mentionRoleIds.map((id) => `<@&${id}>`).join(" ")
|
||||
: undefined;
|
||||
return {
|
||||
content,
|
||||
embeds: [
|
||||
{
|
||||
title: message.title,
|
||||
url: message.url,
|
||||
color: message.color,
|
||||
description: message.description,
|
||||
author: message.author
|
||||
? {
|
||||
name: message.author.name,
|
||||
icon_url: message.author.iconUrl,
|
||||
url: message.author.url,
|
||||
}
|
||||
: undefined,
|
||||
fields: message.fields,
|
||||
footer: message.footer ? { text: message.footer } : undefined,
|
||||
timestamp: message.timestamp,
|
||||
},
|
||||
],
|
||||
components: message.actions?.length
|
||||
? [
|
||||
{
|
||||
type: 1,
|
||||
components: message.actions.map((action) => ({
|
||||
type: 2,
|
||||
style: toStyle(action.style),
|
||||
label: action.label,
|
||||
custom_id: action.id,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
103
server/lib/drivers/discord/rest.ts
Normal file
103
server/lib/drivers/discord/rest.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { log } from "../../lib/log";
|
||||
import type { SendResult } from "../types";
|
||||
|
||||
const DISCORD_API = "https://discord.com/api/v10";
|
||||
|
||||
interface DiscordMessage {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
async function request(
|
||||
url: string,
|
||||
method: string,
|
||||
token: string,
|
||||
message: unknown,
|
||||
channelId: string,
|
||||
): Promise<SendResult> {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
const rateLimit = (await res.json()) as { retry_after?: number };
|
||||
const retryAfter = (rateLimit.retry_after ?? 1) * 1000;
|
||||
log.warn({ retryAfter, attempt }, "Rate limited");
|
||||
await new Promise((r) => setTimeout(r, retryAfter));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
if (res.status >= 500) {
|
||||
log.error({ status: res.status, err, attempt, channelId }, "Discord API 5xx");
|
||||
if (attempt === 2)
|
||||
return {
|
||||
ok: false,
|
||||
error: err,
|
||||
errorCode: "DISCORD_5XX",
|
||||
status: res.status,
|
||||
attempts: attempt + 1,
|
||||
};
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
log.error({ status: res.status, err, channelId }, "Discord API error");
|
||||
return {
|
||||
ok: false,
|
||||
error: err,
|
||||
errorCode: "DISCORD_ERROR",
|
||||
status: res.status,
|
||||
attempts: attempt + 1,
|
||||
};
|
||||
}
|
||||
|
||||
let messageId: string | undefined;
|
||||
try {
|
||||
const data = (await res.json()) as DiscordMessage;
|
||||
messageId = data.id;
|
||||
} catch {
|
||||
// ignore malformed success body
|
||||
}
|
||||
|
||||
return { ok: true, status: res.status, messageId, attempts: attempt + 1 };
|
||||
} catch (err) {
|
||||
log.error({ err, attempt, channelId }, "Failed to send message");
|
||||
if (attempt === 2)
|
||||
return { ok: false, error: String(err), errorCode: "NETWORK", attempts: attempt + 1 };
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, error: "Max retries exceeded", errorCode: "RETRIES", attempts: 3 };
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
token: string,
|
||||
channelId: string,
|
||||
message: unknown,
|
||||
threadId?: string,
|
||||
): Promise<SendResult> {
|
||||
const url = threadId
|
||||
? `${DISCORD_API}/channels/${threadId}/messages`
|
||||
: `${DISCORD_API}/channels/${channelId}/messages`;
|
||||
return request(url, "POST", token, message, channelId);
|
||||
}
|
||||
|
||||
export async function editMessage(
|
||||
token: string,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
message: unknown,
|
||||
threadId?: string,
|
||||
): Promise<SendResult> {
|
||||
const base = threadId ?? channelId;
|
||||
const url = `${DISCORD_API}/channels/${base}/messages/${messageId}`;
|
||||
return request(url, "PATCH", token, message, channelId);
|
||||
}
|
||||
16
server/lib/drivers/index.ts
Normal file
16
server/lib/drivers/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { RouteTarget } from "../types";
|
||||
import type { PlatformDriver } from "./types";
|
||||
import { DiscordDriver } from "./discord";
|
||||
import { TelegramDriver } from "./telegram";
|
||||
|
||||
const drivers: Record<string, PlatformDriver> = {
|
||||
discord: new DiscordDriver(),
|
||||
telegram: new TelegramDriver(),
|
||||
};
|
||||
|
||||
export function getDriver(target: RouteTarget): PlatformDriver {
|
||||
const platform = target.platform ?? "discord";
|
||||
const driver = drivers[platform];
|
||||
if (!driver) throw new Error(`No driver for platform "${platform}"`);
|
||||
return driver;
|
||||
}
|
||||
247
server/lib/drivers/telegram/commands.ts
Normal file
247
server/lib/drivers/telegram/commands.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
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 && m[1] && m[2] && m[3]) 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");
|
||||
}
|
||||
}
|
||||
64
server/lib/drivers/telegram/index.ts
Normal file
64
server/lib/drivers/telegram/index.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { RouteTarget, Env, NeutralMessage } from "../../types";
|
||||
import type { PlatformDriver, SendResult } from "../types";
|
||||
import { sendMessage, sendPhoto, editMessageText, editMessageCaption } from "./rest";
|
||||
import { renderNeutralMessage } from "./render";
|
||||
|
||||
function smallAvatar(url: string): string {
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${url}${sep}s=64`;
|
||||
}
|
||||
|
||||
function richHeaderUrl(message: NeutralMessage, avatar: string, host: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (message.author?.name) params.set("title", message.author.name);
|
||||
if (message.title) params.set("content", message.title);
|
||||
params.set("avatar", avatar);
|
||||
return `${host.replace(/\/+$/, "")}/api/richheader?${params.toString()}`;
|
||||
}
|
||||
|
||||
export class TelegramDriver implements PlatformDriver {
|
||||
readonly id = "telegram";
|
||||
|
||||
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 ?? "";
|
||||
const text = renderNeutralMessage(message);
|
||||
const avatar = message.author?.iconUrl;
|
||||
const richHeaderHost = env.TELEGRAM_RICH_HEADER_HOST ?? env.BASE_URL;
|
||||
if (avatar && richHeaderHost) {
|
||||
const rhUrl = richHeaderUrl(message, avatar, richHeaderHost);
|
||||
return sendMessage(token, chatId, text, target.topicId, rhUrl);
|
||||
}
|
||||
if (avatar) {
|
||||
return sendPhoto(token, chatId, smallAvatar(avatar), text, target.topicId);
|
||||
}
|
||||
return sendMessage(token, chatId, text, target.topicId);
|
||||
}
|
||||
|
||||
async edit(
|
||||
message: NeutralMessage,
|
||||
target: RouteTarget,
|
||||
env: Env,
|
||||
messageId: string,
|
||||
): Promise<SendResult> {
|
||||
const chatId = target.chatId ?? "";
|
||||
if (!chatId) {
|
||||
return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" };
|
||||
}
|
||||
const token = env.TELEGRAM_TOKEN ?? "";
|
||||
const text = renderNeutralMessage(message);
|
||||
const avatar = message.author?.iconUrl;
|
||||
const richHeaderHost = env.TELEGRAM_RICH_HEADER_HOST ?? env.BASE_URL;
|
||||
if (avatar && richHeaderHost) {
|
||||
const rhUrl = richHeaderUrl(message, avatar, richHeaderHost);
|
||||
return editMessageText(token, chatId, messageId, text, target.topicId, rhUrl);
|
||||
}
|
||||
if (avatar) {
|
||||
return editMessageCaption(token, chatId, messageId, text, target.topicId);
|
||||
}
|
||||
return editMessageText(token, chatId, messageId, text, target.topicId);
|
||||
}
|
||||
}
|
||||
63
server/lib/drivers/telegram/render.ts
Normal file
63
server/lib/drivers/telegram/render.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { NeutralMessage } from "../../types";
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function mdToHtml(s: string): string {
|
||||
let out = esc(s);
|
||||
out = out.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
(_m, label, url) => `<a href="${esc(url)}">${label}</a>`,
|
||||
);
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
|
||||
out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
out = out.replace(/(^|[^*])\*([^*]+)\*/g, "$1<i>$2</i>");
|
||||
out = out.replace(/~~([^~]+)~~/g, "<s>$1</s>");
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts?: string): string {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return ts;
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
|
||||
}
|
||||
|
||||
export function renderNeutralMessage(message: NeutralMessage): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
const title = message.url
|
||||
? `<a href="${esc(message.url)}">${mdToHtml(message.title)}</a>`
|
||||
: mdToHtml(message.title);
|
||||
parts.push(`<b>${title}</b>`);
|
||||
|
||||
if (message.author) {
|
||||
const name = mdToHtml(message.author.name);
|
||||
const author = message.author.url ? `<a href="${esc(message.author.url)}">${name}</a>` : name;
|
||||
parts.push(`👤 ${author}`);
|
||||
}
|
||||
|
||||
if (message.description) {
|
||||
parts.push(mdToHtml(message.description));
|
||||
}
|
||||
|
||||
for (const field of message.fields ?? []) {
|
||||
parts.push(`<b>${mdToHtml(field.name)}</b>: ${mdToHtml(field.value)}`);
|
||||
}
|
||||
|
||||
const meta: string[] = [];
|
||||
if (message.footer) meta.push(esc(message.footer));
|
||||
const ts = formatTimestamp(message.timestamp);
|
||||
if (ts) meta.push(ts);
|
||||
if (meta.length > 0) {
|
||||
parts.push(`<i>${meta.join(" · ")}</i>`);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
189
server/lib/drivers/telegram/rest.ts
Normal file
189
server/lib/drivers/telegram/rest.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
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 };
|
||||
}
|
||||
|
||||
async function post(
|
||||
token: string,
|
||||
method: string,
|
||||
body: Record<string, unknown>,
|
||||
chatId: string,
|
||||
): Promise<SendResult> {
|
||||
const url = `${TELEGRAM_API}/bot${token}/${method}`;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
token: string,
|
||||
chatId: string,
|
||||
text: string,
|
||||
topicId?: string,
|
||||
linkPreviewUrl?: 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: !linkPreviewUrl,
|
||||
};
|
||||
if (linkPreviewUrl) {
|
||||
body.link_preview_options = {
|
||||
url: linkPreviewUrl,
|
||||
prefer_small_media: true,
|
||||
show_above_text: true,
|
||||
};
|
||||
}
|
||||
if (topicId) {
|
||||
body.message_thread_id = Number(topicId);
|
||||
}
|
||||
return post(token, "sendMessage", body, chatId);
|
||||
}
|
||||
|
||||
export async function sendPhoto(
|
||||
token: string,
|
||||
chatId: string,
|
||||
photoUrl: string,
|
||||
caption?: 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,
|
||||
photo: photoUrl,
|
||||
parse_mode: "HTML",
|
||||
};
|
||||
if (caption) {
|
||||
body.caption = caption;
|
||||
}
|
||||
if (topicId) {
|
||||
body.message_thread_id = Number(topicId);
|
||||
}
|
||||
return post(token, "sendPhoto", body, chatId);
|
||||
}
|
||||
|
||||
export async function editMessageText(
|
||||
token: string,
|
||||
chatId: string,
|
||||
messageId: string,
|
||||
text: string,
|
||||
topicId?: string,
|
||||
linkPreviewUrl?: string,
|
||||
): Promise<SendResult> {
|
||||
if (!token) {
|
||||
return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" };
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
text,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: !linkPreviewUrl,
|
||||
};
|
||||
if (linkPreviewUrl) {
|
||||
body.link_preview_options = {
|
||||
url: linkPreviewUrl,
|
||||
prefer_small_media: true,
|
||||
show_above_text: true,
|
||||
};
|
||||
}
|
||||
if (topicId) {
|
||||
body.message_thread_id = Number(topicId);
|
||||
}
|
||||
return post(token, "editMessageText", body, chatId);
|
||||
}
|
||||
|
||||
export async function editMessageCaption(
|
||||
token: string,
|
||||
chatId: string,
|
||||
messageId: string,
|
||||
caption: 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,
|
||||
message_id: messageId,
|
||||
caption,
|
||||
parse_mode: "HTML",
|
||||
};
|
||||
if (topicId) {
|
||||
body.message_thread_id = Number(topicId);
|
||||
}
|
||||
return post(token, "editMessageCaption", body, chatId);
|
||||
}
|
||||
45
server/lib/drivers/telegram/updates.ts
Normal file
45
server/lib/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");
|
||||
}
|
||||
25
server/lib/drivers/types.ts
Normal file
25
server/lib/drivers/types.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { RouteTarget, Env, NeutralMessage } from "../types";
|
||||
|
||||
export interface SendResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
status?: number;
|
||||
messageId?: string;
|
||||
attempts?: number;
|
||||
}
|
||||
|
||||
export interface PlatformDriver {
|
||||
readonly id: string;
|
||||
send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult>;
|
||||
/**
|
||||
* Edit an already-sent message in place (e.g. workflow run progress updates).
|
||||
* Must be implemented by drivers that support message updates.
|
||||
*/
|
||||
edit(
|
||||
message: NeutralMessage,
|
||||
target: RouteTarget,
|
||||
env: Env,
|
||||
messageId: string,
|
||||
): Promise<SendResult>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue