diff --git a/src/index.ts b/src/index.ts index d3c1c01..613dd30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,18 +7,28 @@ const APP_ID = process.env.APP_ID!; const PUBLIC_KEY = process.env.PUBLIC_KEY!; const DEFAULT_GUILD_ID = process.env.DEFAULT_GUILD_ID!; const ADMIN_KEY = process.env.ADMIN_KEY!; +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY!; +const PUNISH_ROLE_ID = process.env.PUNISH_ROLE_ID || ""; const PORT = Number(process.env.PORT || 3000); const MAX_EVENT_AGE_SEC = Number(process.env.MAX_EVENT_AGE_SEC || 300); +const REACTION_TIMEOUT_SEC = Number(process.env.REACTION_TIMEOUT_SEC || 120); +const RENAME_TIMEOUT_SEC = Number(process.env.RENAME_TIMEOUT_SEC || 60); if (!BOT_TOKEN || !APP_ID || !PUBLIC_KEY || !ADMIN_KEY) { - console.error("[BOOT] Missing env: BOT_TOKEN, APP_ID, PUBLIC_KEY, ADMIN_KEY"); + console.error("[BOOT] Missing required envs"); process.exit(1); } -console.log("[BOOT] Starting Discord Audit Bot…"); +console.log("[BOOT] Discord Audit Bot starting…"); + +/** ====== Constants ====== */ +const API = "https://discord.com/api/v10"; +const DECIDER_USER_ID = "311380871901085707"; +const EMOJI_RENAME = "✏️"; +const EMOJI_ROLE = "😭"; /** ====== Helpers ====== */ -const API = "https://discord.com/api/v10"; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const hexToUint8 = (hex: string) => { if (hex.length % 2 !== 0) throw new Error("Invalid hex"); @@ -31,24 +41,37 @@ const hexToUint8 = (hex: string) => { async function verifySignature(req: Request, rawBody: string) { const sig = req.headers.get("X-Signature-Ed25519") || ""; const ts = req.headers.get("X-Signature-Timestamp") || ""; - if (!sig || !ts) { - console.warn("[VERIFY] Missing signature or timestamp header"); - return false; - } + if (!sig || !ts) return false; const msg = new TextEncoder().encode(ts + rawBody); - const sigBin = hexToUint8(sig); - const pub = hexToUint8(PUBLIC_KEY); - const ok = nacl.sign.detached.verify(msg, sigBin, pub); - if (!ok) console.warn("[VERIFY] Invalid Discord signature"); - return ok; + return nacl.sign.detached.verify( + hexToUint8(Buffer.from(msg).toString("hex")), + hexToUint8(sig), + hexToUint8(PUBLIC_KEY) + ); +} + +// Use direct uint8 approach (safer in Bun) +async function verifySignatureSafe(req: Request, rawBody: string) { + const sig = req.headers.get("X-Signature-Ed25519") || ""; + const ts = req.headers.get("X-Signature-Timestamp") || ""; + if (!sig || !ts) return false; + const enc = new TextEncoder(); + const msg = enc.encode(ts + rawBody); + return nacl.sign.detached.verify( + msg, + hexToUint8(sig), + hexToUint8(PUBLIC_KEY) + ); } const snowflakeToMs = (id: string) => - Number((BigInt(id) >> BigInt(22)) + BigInt(1420070400000)); // Discord epoch + Number((BigInt(id) >> BigInt(22)) + BigInt(1420070400000)); -async function discordFetch(path: string, init?: RequestInit) { - console.log(`[DISCORD] → ${init?.method || "GET"} ${path}`); - const res = await fetch(`${API}${path}`, { +async function discordFetch( + path: string, + init?: RequestInit +): Promise { + const r = await fetch(`${API}${path}`, { ...init, headers: { Authorization: `Bot ${BOT_TOKEN}`, @@ -56,12 +79,15 @@ async function discordFetch(path: string, init?: RequestInit) { ...(init?.headers || {}), }, }); - console.log(`[DISCORD] ← ${res.status} ${path}`); - return res; + if (r.status === 429) { + const j = await r.json().catch(() => ({} as any)); + const retry = (j?.retry_after ? Number(j.retry_after) : 1) * 1000; + await sleep(retry); + } + return r; } async function interactionCallback(id: string, token: string, body: any) { - console.log(`[INTERACTION] Responding to ${id}`); return fetch(`${API}/interactions/${id}/${token}/callback`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -69,71 +95,143 @@ async function interactionCallback(id: string, token: string, body: any) { }); } +async function editOriginal(token: string, content: string) { + return fetch(`${API}/webhooks/${APP_ID}/${token}/messages/@original`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); +} + +/** ====== Audit utils ====== */ /** Find most recent MEMBER_UPDATE with deaf=true or mute=true within window */ function findRecentVoicePunish( audit: any -): { entry: any; kind: string } | null { +): { entry: any; kind: "deafen" | "mute" } | null { const entries = audit?.audit_log_entries ?? []; - console.log(`[AUDIT] Fetched ${entries.length} entries`); for (const e of entries) { if (e.action_type !== 24) continue; // MEMBER_UPDATE const changes = e.changes || []; - const deafChange = changes.find((c: any) => c.key === "deaf"); - const muteChange = changes.find((c: any) => c.key === "mute"); - - let kind = ""; - if (deafChange?.new_value === true) kind = "deafen"; - if (muteChange?.new_value === true) kind = "mute"; - - if (!kind) continue; - + const deaf = changes.find((c: any) => c.key === "deaf")?.new_value === true; + const mute = changes.find((c: any) => c.key === "mute")?.new_value === true; + if (!deaf && !mute) continue; const ageSec = (Date.now() - snowflakeToMs(e.id)) / 1000; - console.log( - `[AUDIT] Found ${kind} event: executor=${e.user_id}, target=${e.target_id}, age=${ageSec}s` - ); - if (ageSec <= MAX_EVENT_AGE_SEC) return { entry: e, kind }; + if (ageSec <= MAX_EVENT_AGE_SEC) + return { entry: e, kind: deaf ? "deafen" : "mute" }; } return null; } -async function createPoll(channelId: string, content: string) { - console.log("[POLL] Creating poll"); - const body = { - content, - poll: { - question: { text: "ลงโทษ(ไอเนก)ยังไงดี?" }, - answers: [ - { text: "เปลี่ยน Role" }, - { text: "เปลี่ยนชื่อเล่น" }, - { text: "ไม่ทำอะไร" }, - ], - duration: 3600, - }, - }; - const r = await discordFetch(`/channels/${channelId}/messages`, { - method: "POST", - body: JSON.stringify(body), - }); - if (!r.ok) { - console.warn( - `[POLL] Poll creation failed (${r.status}), falling back to text + reactions` - ); - await discordFetch(`/channels/${channelId}/messages`, { - method: "POST", - body: JSON.stringify({ - content: - "ไม่สามารถสร้างโพลได้ ให้กดอิโมจิแทน: 🧩 = เปลี่ยน Role, ✏️ = เปลี่ยนชื่อเล่น, ✅ = ไม่ทำอะไร", - }), - }); - } -} +/** ====== Message + Reaction helpers (REST polling) ====== */ +const encEmoji = (e: string) => encodeURIComponent(e); async function sendMessage(channelId: string, content: string) { - console.log(`[MSG] Sending message to channel ${channelId}`); - await discordFetch(`/channels/${channelId}/messages`, { + const r = await discordFetch(`/channels/${channelId}/messages`, { method: "POST", body: JSON.stringify({ content }), }); + const j = await r.json().catch(() => ({})); + return { ok: r.ok, status: r.status, data: j }; +} + +async function addReaction( + channelId: string, + messageId: string, + emoji: string +) { + // PUT /channels/{channel.id}/messages/{message.id}/reactions/{emoji}/@me + const r = await discordFetch( + `/channels/${channelId}/messages/${messageId}/reactions/${encEmoji( + emoji + )}/@me`, + { method: "PUT", headers: { "Content-Type": "application/json" } } + ); + return r.ok; +} + +async function listReactionUsers( + channelId: string, + messageId: string, + emoji: string +) { + const r = await discordFetch( + `/channels/${channelId}/messages/${messageId}/reactions/${encEmoji( + emoji + )}?limit=100` + ); + if (!r.ok) return []; + const users = await r.json().catch(() => []); + return Array.isArray(users) ? users : []; +} + +async function fetchNewMessagesAfter( + channelId: string, + afterMessageId: string +) { + const r = await discordFetch( + `/channels/${channelId}/messages?after=${afterMessageId}&limit=50` + ); + if (!r.ok) return []; + const arr = await r.json().catch(() => []); + if (!Array.isArray(arr)) return []; + // Newest first; reverse to oldest→newest + return arr.reverse(); +} + +async function setNickname(guildId: string, userId: string, nick: string) { + const r = await discordFetch(`/guilds/${guildId}/members/${userId}`, { + method: "PATCH", + body: JSON.stringify({ nick }), + }); + return r.ok; +} + +async function removeRole(guildId: string, userId: string, roleId: string) { + const r = await discordFetch( + `/guilds/${guildId}/members/${userId}/roles/${roleId}`, + { + method: "DELETE", + } + ); + return r.ok; +} + +/** ====== OpenRouter /talk ====== */ +const randomSystem = () => { + const pool = [ + "You are a terse analyst. Answer with precision.", + "You are a sarcastic librarian. Be clear, mildly dry.", + "You are a pragmatic engineer. Prefer bullet points and tradeoffs.", + "You are a security reviewer. Be skeptical and concrete.", + ]; + return pool[Math.floor(Math.random() * pool.length)]; +}; + +async function callOpenRouterChat(userPrompt: string) { + const r = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "deepseek/deepseek-chat-v3.1:free", + messages: [ + { role: "system", content: randomSystem() }, + { + role: "user", + content: userPrompt || "Say something useful in 3–5 bullet points.", + }, + ], + stream: false, + }), + }); + if (!r.ok) { + const txt = await r.text(); + return `OpenRouter error ${r.status}: ${txt}`; + } + const j = await r.json(); + return j?.choices?.[0]?.message?.content || "(no content)"; } /** ====== Hono app ====== */ @@ -142,107 +240,245 @@ const app = new Hono(); /** Health */ app.get("/healthz", (c) => c.text("ok")); -/** Register or upsert the /checkaudit command for a guild */ +/** Admin: register both commands */ app.post("/admin/register", async (c) => { - if (c.req.header("X-Admin-Key") !== ADMIN_KEY) { - console.warn("[ADMIN] Unauthorized attempt to register command"); + if (c.req.header("X-Admin-Key") !== ADMIN_KEY) return c.text("forbidden", 403); - } const { guildId = DEFAULT_GUILD_ID } = await c.req.json().catch(() => ({})); - console.log(`[ADMIN] Registering /checkaudit for guild ${guildId}`); - const body = { - name: "checkaudit", - description: "ตรวจสอบ audit log หา mute/deafen", - type: 1, - }; - const r = await discordFetch( - `/applications/${APP_ID}/guilds/${guildId}/commands`, + + const cmds = [ { - method: "POST", - body: JSON.stringify(body), - } - ); - const j = await r.json().catch(() => ({})); - console.log(`[ADMIN] Registration status=${r.status}`, j); - return c.json({ ok: r.ok, status: r.status, resp: j }); + name: "checkaudit", + description: "Scan recent audit logs for mute/deafen", + type: 1, + }, + { + name: "talk", + description: "Chat with the AI", + type: 1, + options: [ + { + name: "prompt", + description: "Your message", + type: 3, + required: false, + }, + ], + }, + ]; + + const results: any[] = []; + for (const body of cmds) { + const r = await discordFetch( + `/applications/${APP_ID}/guilds/${guildId}/commands`, + { + method: "POST", + body: JSON.stringify(body), + } + ); + results.push({ + ok: r.ok, + status: r.status, + body: await r.json().catch(() => ({})), + }); + } + return c.json({ results }); }); -/** Discord Interactions endpoint */ +/** Interactions endpoint */ app.post("/interactions", async (c) => { const raw = await c.req.text(); - - if (!(await verifySignature(c.req.raw, raw))) - return c.text("bad request", 401); + if (!(await verifySignatureSafe(c.req.raw, raw))) + return c.text("unauthorized", 401); const interaction = JSON.parse(raw); const { type } = interaction; - console.log(`[INTERACTION] type=${type}`); + // PING + if (type === 1) return c.json({ type: 1 }); - // Ping - if (type === 1) { - console.log("[INTERACTION] Received PING"); - return c.json({ type: 1 }); - } - - // Application Command + // APPLICATION_COMMAND if (type === 2) { + const name = interaction?.data?.name; const id = interaction.id as string; const token = interaction.token as string; const guildId = interaction.guild_id as string; const channelId = interaction.channel_id as string; - console.log( - `[INTERACTION] Slash command invoked: guild=${guildId}, channel=${channelId}, id=${id}` - ); + if (name === "checkaudit") { + // Immediate ephemeral ACK + await interactionCallback(id, token, { type: 5, data: { flags: 64 } }); - // Immediate ephemeral ack + // Background job + queueMicrotask(async () => { + // Fetch audit logs + const logsResp = await discordFetch( + `/guilds/${guildId}/audit-logs?action_type=24&limit=10` + ); + if (!logsResp.ok) { + await editOriginal(token, "Failed to read audit logs."); + return; + } + const logs = await logsResp.json(); + const hit = findRecentVoicePunish(logs); + if (!hit) { + await editOriginal(token, "No recent mute/deafen found."); + return; + } + + const e = hit.entry; + const executorId = e.user_id as string; // the user who did the action + const targetId = e.target_id as string; // the member affected + const kind = hit.kind === "deafen" ? "ปิดหู" : "ปิดไมค์"; + + // Decision message + const intro = `ตรวจพบเหตุการณ์ ${kind}\nผู้กระทำ: <@${executorId}> → เป้าหมาย: <@${targetId}>\nเลือกการลงโทษโดยการใส่อีโมจิ:\n${EMOJI_RENAME} = เปลี่ยนชื่อเล่น, ${EMOJI_ROLE} = ลบ Role`; + const sent = await sendMessage(channelId, intro); + if (!sent.ok) { + await editOriginal(token, "Cannot post decision message."); + return; + } + const msgId = sent.data.id as string; + + // Seed reactions so users can tap + await addReaction(channelId, msgId, EMOJI_RENAME); + await addReaction(channelId, msgId, EMOJI_ROLE); + + // Poll reactions up to timeout + const deadline = Date.now() + REACTION_TIMEOUT_SEC * 1000; + let chosen: "rename" | "role" | null = null; + + while (Date.now() < deadline && !chosen) { + const [renameUsers, roleUsers] = await Promise.all([ + listReactionUsers(channelId, msgId, EMOJI_RENAME), + listReactionUsers(channelId, msgId, EMOJI_ROLE), + ]); + // Priority: decider user + const deciderRename = renameUsers.find( + (u: any) => u?.id === DECIDER_USER_ID + ); + const deciderRole = roleUsers.find( + (u: any) => u?.id === DECIDER_USER_ID + ); + if (deciderRename) chosen = "rename"; + else if (deciderRole) chosen = "role"; + else { + // Otherwise first to 2 votes + if ((renameUsers?.length || 0) >= 2) chosen = "rename"; + else if ((roleUsers?.length || 0) >= 2) chosen = "role"; + } + if (!chosen) await sleep(2000); + } + + if (!chosen) { + await sendMessage(channelId, "หมดเวลา ไม่มีการเลือกลงโทษ"); + await editOriginal(token, "No decision taken."); + return; + } + + if (chosen === "rename") { + // Ask for new nickname + const ask = await sendMessage( + channelId, + `เลือก: เปลี่ยนชื่อเล่น\nโปรดพิมพ์ชื่อใหม่ในข้อความถัดไปภายใน ${RENAME_TIMEOUT_SEC} วินาที` + ); + if (!ask.ok) { + await sendMessage(channelId, "ไม่สามารถถามชื่อใหม่ได้"); + return; + } + const askId = ask.data.id as string; + const renameDeadline = Date.now() + RENAME_TIMEOUT_SEC * 1000; + let newNick: string | null = null; + + while (Date.now() < renameDeadline && !newNick) { + const news = await fetchNewMessagesAfter(channelId, askId); + const firstUserMsg = news.find( + (m: any) => + !m?.author?.bot && + typeof m?.content === "string" && + m.content.trim().length > 0 + ); + if (firstUserMsg) + newNick = firstUserMsg.content.trim().slice(0, 32); + if (!newNick) await sleep(2000); + } + + if (!newNick) { + await sendMessage( + channelId, + "หมดเวลาไม่ได้รับชื่อใหม่ ยกเลิกการเปลี่ยนชื่อ" + ); + } else { + const ok = await setNickname(guildId, executorId, newNick); + if (ok) { + await sendMessage( + channelId, + `ตั้งชื่อใหม่ให้ <@${executorId}> เป็น **${newNick}** แล้ว` + ); + } else { + await sendMessage( + channelId, + "เปลี่ยนชื่อไม่สำเร็จ (สิทธิ์ไม่พอหรือ role hierarchy)" + ); + } + } + } else if (chosen === "role") { + if (!PUNISH_ROLE_ID) { + await sendMessage( + channelId, + "ยังไม่ได้ตั้งค่า PUNISH_ROLE_ID จึงลบ role ไม่ได้" + ); + } else { + const ok = await removeRole(guildId, executorId, PUNISH_ROLE_ID); + if (ok) { + await sendMessage( + channelId, + `ลบ role ออกจาก <@${executorId}> แล้ว` + ); + } else { + await sendMessage( + channelId, + "ลบ role ไม่สำเร็จ (สิทธิ์ไม่พอครับนาย)" + ); + } + } + } + + // Taunt + await sendMessage(channelId, "โดนซะบ้าง 🙂"); + await editOriginal(token, "Done."); + }); + + // We already deferred + return c.body(null, 204); + } + + if (name === "talk") { + // Read optional prompt + const promptOpt = interaction?.data?.options?.find( + (o: any) => o.name === "prompt" + ); + const prompt = promptOpt?.value || ""; + + // Deferred ephemeral + await interactionCallback(id, token, { type: 5, data: { flags: 64 } }); + + queueMicrotask(async () => { + const reply = await callOpenRouterChat(prompt); + await editOriginal(token, reply); + }); + + return c.body(null, 204); + } + + // Unknown command await interactionCallback(id, token, { type: 4, - data: { content: "กำลังตรวจสอบ... (น่าจะไอเนก)", flags: 64 }, // ephemeral ภาษาไทย + data: { content: "Unknown command", flags: 64 }, }); - - // Background work - queueMicrotask(async () => { - console.log("[WORK] Fetching audit logs…"); - const logsResp = await discordFetch( - `/guilds/${guildId}/audit-logs?action_type=24&limit=10` - ); - if (!logsResp.ok) { - console.error("[WORK] Failed to fetch audit logs", logsResp.status); - await sendMessage(channelId, "ไม่สามารถอ่าน audit logs ได้"); - return; - } - const logs = await logsResp.json(); - - const hit = findRecentVoicePunish(logs); - if (!hit) { - console.log("[WORK] No mute/deafen event found"); - await sendMessage(channelId, "ไม่เจอคน mute/deafen เลยนะ (ล่าสุด)"); - return; - } - - const e = hit.entry; - const executorId = e.user_id; - const targetId = e.target_id; - const kind = hit.kind === "deafen" ? "ปิดหู" : "ปิดไมค์"; - - console.log( - `[WORK] ${kind} event confirmed. Executor=${executorId}, Target=${targetId}` - ); - - await createPoll( - channelId, - `มีคนแอบเนียน! ${kind}. คนทำ: <@${executorId}> → คนโดน: <@${targetId}>` - ); - - await sendMessage(channelId, "โดน!"); - }); - return c.body(null, 204); } - // Ignore others return c.body(null, 204); });