WebHooker/server/lib/drivers/discord/interactions.ts

534 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 OAuthGITHUB_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));
}
}
}