import { Hono } from "hono"; import * as nacl from "tweetnacl"; /** ====== Env ====== */ const BOT_TOKEN = process.env.BOT_TOKEN!; 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 required envs"); process.exit(1); } console.log("[BOOT] Discord Audit Bot starting…"); /** ====== Constants ====== */ const API = "https://discord.com/api/v10"; const DECIDER_USER_ID = "311380871901085707"; const RENAME_EMOJIS = ["✏️", "✏"]; // VS16 + no-VS16 const ROLE_EMOJIS = ["😭"]; /** ====== Helpers ====== */ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const hexToUint8 = (hex: string) => { if (hex.length % 2 !== 0) throw new Error("Invalid hex"); const u = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) u[i / 2] = parseInt(hex.slice(i, i + 2), 16); return u; }; 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) return false; const msg = new TextEncoder().encode(ts + rawBody); 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)); async function discordFetch( path: string, init?: RequestInit ): Promise { const r = await fetch(`${API}${path}`, { ...init, headers: { Authorization: `Bot ${BOT_TOKEN}`, "Content-Type": "application/json", ...(init?.headers || {}), }, }); 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) { return fetch(`${API}/interactions/${id}/${token}/callback`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } 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: "deafen" | "mute" } | null { const entries = audit?.audit_log_entries ?? []; for (const e of entries) { if (e.action_type !== 24) continue; // MEMBER_UPDATE const changes = e.changes || []; 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; if (ageSec <= MAX_EVENT_AGE_SEC) return { entry: e, kind: deaf ? "deafen" : "mute" }; } return null; } /** ====== Message + Reaction helpers (REST polling) ====== */ const encEmoji = (e: string) => encodeURIComponent(e); async function sendMessage(channelId: string, content: string) { 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 listReactionUsersMulti( channelId: string, messageId: string, emojis: string[] ) { const results = await Promise.all( emojis.map((e) => listReactionUsers(channelId, messageId, e)) ); const byId = new Map(); for (const arr of results) { for (const u of arr) byId.set(u.id, u); // de-dupe across variants } return Array.from(byId.values()); } 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 = ["คุณเป็นคนพูดจาเกรียนๆ"]; 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: "moonshotai/kimi-k2:free", messages: [ { role: "system", content: randomSystem() }, { role: "user", content: userPrompt || "ผมชื่อว่าเนก ตอบคำถามผมเสมอ. ผมเป็นคนไม่ดี", }, ], 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 ====== */ const app = new Hono(); /** Health */ app.get("/healthz", (c) => c.text("ok")); /** Admin: register both commands */ app.post("/admin/register", async (c) => { if (c.req.header("X-Admin-Key") !== ADMIN_KEY) return c.text("forbidden", 403); const { guildId = DEFAULT_GUILD_ID } = await c.req.json().catch(() => ({})); const cmds = [ { 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 }); }); /** Interactions endpoint */ app.post("/interactions", async (c) => { const raw = await c.req.text(); if (!(await verifySignatureSafe(c.req.raw, raw))) return c.text("unauthorized", 401); const interaction = JSON.parse(raw); const { type } = interaction; // PING if (type === 1) return c.json({ type: 1 }); // 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; if (name === "checkaudit") { // Immediate ephemeral ACK await interactionCallback(id, token, { type: 5, data: { flags: 64 } }); // 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${RENAME_EMOJIS[0]} = เปลี่ยนชื่อเล่น, ${ROLE_EMOJIS[0]} = ลบ 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 for (const e of RENAME_EMOJIS) await addReaction(channelId, msgId, e); for (const e of ROLE_EMOJIS) await addReaction(channelId, msgId, e); // Identify the bot user ID (same as application_id for bot apps) const botUserId = String(interaction.application_id); // 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 [renameUsersAll, roleUsersAll] = await Promise.all([ listReactionUsersMulti(channelId, msgId, RENAME_EMOJIS), listReactionUsersMulti(channelId, msgId, ROLE_EMOJIS), ]); // Exclude the bot itself from counts const renameUsers = renameUsersAll.filter( (u: any) => u?.id !== botUserId ); const roleUsers = roleUsersAll.filter( (u: any) => u?.id !== botUserId ); // Priority: specific decider const deciderRename = renameUsers.some( (u: any) => u?.id === DECIDER_USER_ID ); const deciderRole = roleUsers.some( (u: any) => u?.id === DECIDER_USER_ID ); console.log( `[VOTE] rename=${renameUsers.length} role=${roleUsers.length} deciderRename=${deciderRename} deciderRole=${deciderRole}` ); if (deciderRename) chosen = "rename"; else if (deciderRole) chosen = "role"; else { // Otherwise: first path to reach 2 human votes if (renameUsers.length >= 2) chosen = "rename"; else if (roleUsers.length >= 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, `เลือก: เปลี่ยนชื่อเล่นให้ <@${executorId}>\nพิมพ์ชื่อใหม่ (≤32 ตัวอักษร) ภายใน ${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, "เปลี่ยนชื่อไม่สำเร็จ (สิทธิ์ไม่พอครับพี่)" ); } } } 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: "Unknown command", flags: 64 }, }); return c.body(null, 204); } return c.body(null, 204); }); export default { port: PORT, fetch: app.fetch, };