feat: enhance Discord Audit Bot with new commands and improved signature verification
This commit is contained in:
parent
01f42667c8
commit
18ae063785
520
src/index.ts
520
src/index.ts
@ -7,18 +7,28 @@ const APP_ID = process.env.APP_ID!;
|
|||||||
const PUBLIC_KEY = process.env.PUBLIC_KEY!;
|
const PUBLIC_KEY = process.env.PUBLIC_KEY!;
|
||||||
const DEFAULT_GUILD_ID = process.env.DEFAULT_GUILD_ID!;
|
const DEFAULT_GUILD_ID = process.env.DEFAULT_GUILD_ID!;
|
||||||
const ADMIN_KEY = process.env.ADMIN_KEY!;
|
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 PORT = Number(process.env.PORT || 3000);
|
||||||
const MAX_EVENT_AGE_SEC = Number(process.env.MAX_EVENT_AGE_SEC || 300);
|
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) {
|
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);
|
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 ====== */
|
/** ====== Helpers ====== */
|
||||||
const API = "https://discord.com/api/v10";
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
const hexToUint8 = (hex: string) => {
|
const hexToUint8 = (hex: string) => {
|
||||||
if (hex.length % 2 !== 0) throw new Error("Invalid hex");
|
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) {
|
async function verifySignature(req: Request, rawBody: string) {
|
||||||
const sig = req.headers.get("X-Signature-Ed25519") || "";
|
const sig = req.headers.get("X-Signature-Ed25519") || "";
|
||||||
const ts = req.headers.get("X-Signature-Timestamp") || "";
|
const ts = req.headers.get("X-Signature-Timestamp") || "";
|
||||||
if (!sig || !ts) {
|
if (!sig || !ts) return false;
|
||||||
console.warn("[VERIFY] Missing signature or timestamp header");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const msg = new TextEncoder().encode(ts + rawBody);
|
const msg = new TextEncoder().encode(ts + rawBody);
|
||||||
const sigBin = hexToUint8(sig);
|
return nacl.sign.detached.verify(
|
||||||
const pub = hexToUint8(PUBLIC_KEY);
|
hexToUint8(Buffer.from(msg).toString("hex")),
|
||||||
const ok = nacl.sign.detached.verify(msg, sigBin, pub);
|
hexToUint8(sig),
|
||||||
if (!ok) console.warn("[VERIFY] Invalid Discord signature");
|
hexToUint8(PUBLIC_KEY)
|
||||||
return ok;
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) =>
|
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) {
|
async function discordFetch(
|
||||||
console.log(`[DISCORD] → ${init?.method || "GET"} ${path}`);
|
path: string,
|
||||||
const res = await fetch(`${API}${path}`, {
|
init?: RequestInit
|
||||||
|
): Promise<Response> {
|
||||||
|
const r = await fetch(`${API}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bot ${BOT_TOKEN}`,
|
Authorization: `Bot ${BOT_TOKEN}`,
|
||||||
@ -56,12 +79,15 @@ async function discordFetch(path: string, init?: RequestInit) {
|
|||||||
...(init?.headers || {}),
|
...(init?.headers || {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(`[DISCORD] ← ${res.status} ${path}`);
|
if (r.status === 429) {
|
||||||
return res;
|
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) {
|
async function interactionCallback(id: string, token: string, body: any) {
|
||||||
console.log(`[INTERACTION] Responding to ${id}`);
|
|
||||||
return fetch(`${API}/interactions/${id}/${token}/callback`, {
|
return fetch(`${API}/interactions/${id}/${token}/callback`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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 */
|
/** Find most recent MEMBER_UPDATE with deaf=true or mute=true within window */
|
||||||
function findRecentVoicePunish(
|
function findRecentVoicePunish(
|
||||||
audit: any
|
audit: any
|
||||||
): { entry: any; kind: string } | null {
|
): { entry: any; kind: "deafen" | "mute" } | null {
|
||||||
const entries = audit?.audit_log_entries ?? [];
|
const entries = audit?.audit_log_entries ?? [];
|
||||||
console.log(`[AUDIT] Fetched ${entries.length} entries`);
|
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.action_type !== 24) continue; // MEMBER_UPDATE
|
if (e.action_type !== 24) continue; // MEMBER_UPDATE
|
||||||
const changes = e.changes || [];
|
const changes = e.changes || [];
|
||||||
const deafChange = changes.find((c: any) => c.key === "deaf");
|
const deaf = changes.find((c: any) => c.key === "deaf")?.new_value === true;
|
||||||
const muteChange = changes.find((c: any) => c.key === "mute");
|
const mute = changes.find((c: any) => c.key === "mute")?.new_value === true;
|
||||||
|
if (!deaf && !mute) continue;
|
||||||
let kind = "";
|
|
||||||
if (deafChange?.new_value === true) kind = "deafen";
|
|
||||||
if (muteChange?.new_value === true) kind = "mute";
|
|
||||||
|
|
||||||
if (!kind) continue;
|
|
||||||
|
|
||||||
const ageSec = (Date.now() - snowflakeToMs(e.id)) / 1000;
|
const ageSec = (Date.now() - snowflakeToMs(e.id)) / 1000;
|
||||||
console.log(
|
if (ageSec <= MAX_EVENT_AGE_SEC)
|
||||||
`[AUDIT] Found ${kind} event: executor=${e.user_id}, target=${e.target_id}, age=${ageSec}s`
|
return { entry: e, kind: deaf ? "deafen" : "mute" };
|
||||||
);
|
|
||||||
if (ageSec <= MAX_EVENT_AGE_SEC) return { entry: e, kind };
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPoll(channelId: string, content: string) {
|
/** ====== Message + Reaction helpers (REST polling) ====== */
|
||||||
console.log("[POLL] Creating poll");
|
const encEmoji = (e: string) => encodeURIComponent(e);
|
||||||
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, ✏️ = เปลี่ยนชื่อเล่น, ✅ = ไม่ทำอะไร",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendMessage(channelId: string, content: string) {
|
async function sendMessage(channelId: string, content: string) {
|
||||||
console.log(`[MSG] Sending message to channel ${channelId}`);
|
const r = await discordFetch(`/channels/${channelId}/messages`, {
|
||||||
await discordFetch(`/channels/${channelId}/messages`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ content }),
|
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 ====== */
|
/** ====== Hono app ====== */
|
||||||
@ -142,107 +240,245 @@ const app = new Hono();
|
|||||||
/** Health */
|
/** Health */
|
||||||
app.get("/healthz", (c) => c.text("ok"));
|
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) => {
|
app.post("/admin/register", async (c) => {
|
||||||
if (c.req.header("X-Admin-Key") !== ADMIN_KEY) {
|
if (c.req.header("X-Admin-Key") !== ADMIN_KEY)
|
||||||
console.warn("[ADMIN] Unauthorized attempt to register command");
|
|
||||||
return c.text("forbidden", 403);
|
return c.text("forbidden", 403);
|
||||||
}
|
|
||||||
const { guildId = DEFAULT_GUILD_ID } = await c.req.json().catch(() => ({}));
|
const { guildId = DEFAULT_GUILD_ID } = await c.req.json().catch(() => ({}));
|
||||||
console.log(`[ADMIN] Registering /checkaudit for guild ${guildId}`);
|
|
||||||
const body = {
|
const cmds = [
|
||||||
name: "checkaudit",
|
|
||||||
description: "ตรวจสอบ audit log หา mute/deafen",
|
|
||||||
type: 1,
|
|
||||||
};
|
|
||||||
const r = await discordFetch(
|
|
||||||
`/applications/${APP_ID}/guilds/${guildId}/commands`,
|
|
||||||
{
|
{
|
||||||
method: "POST",
|
name: "checkaudit",
|
||||||
body: JSON.stringify(body),
|
description: "Scan recent audit logs for mute/deafen",
|
||||||
}
|
type: 1,
|
||||||
);
|
},
|
||||||
const j = await r.json().catch(() => ({}));
|
{
|
||||||
console.log(`[ADMIN] Registration status=${r.status}`, j);
|
name: "talk",
|
||||||
return c.json({ ok: r.ok, status: r.status, resp: j });
|
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) => {
|
app.post("/interactions", async (c) => {
|
||||||
const raw = await c.req.text();
|
const raw = await c.req.text();
|
||||||
|
if (!(await verifySignatureSafe(c.req.raw, raw)))
|
||||||
if (!(await verifySignature(c.req.raw, raw)))
|
return c.text("unauthorized", 401);
|
||||||
return c.text("bad request", 401);
|
|
||||||
|
|
||||||
const interaction = JSON.parse(raw);
|
const interaction = JSON.parse(raw);
|
||||||
const { type } = interaction;
|
const { type } = interaction;
|
||||||
|
|
||||||
console.log(`[INTERACTION] type=${type}`);
|
// PING
|
||||||
|
if (type === 1) return c.json({ type: 1 });
|
||||||
|
|
||||||
// Ping
|
// APPLICATION_COMMAND
|
||||||
if (type === 1) {
|
|
||||||
console.log("[INTERACTION] Received PING");
|
|
||||||
return c.json({ type: 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Application Command
|
|
||||||
if (type === 2) {
|
if (type === 2) {
|
||||||
|
const name = interaction?.data?.name;
|
||||||
const id = interaction.id as string;
|
const id = interaction.id as string;
|
||||||
const token = interaction.token as string;
|
const token = interaction.token as string;
|
||||||
const guildId = interaction.guild_id as string;
|
const guildId = interaction.guild_id as string;
|
||||||
const channelId = interaction.channel_id as string;
|
const channelId = interaction.channel_id as string;
|
||||||
|
|
||||||
console.log(
|
if (name === "checkaudit") {
|
||||||
`[INTERACTION] Slash command invoked: guild=${guildId}, channel=${channelId}, id=${id}`
|
// 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, {
|
await interactionCallback(id, token, {
|
||||||
type: 4,
|
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);
|
return c.body(null, 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore others
|
|
||||||
return c.body(null, 204);
|
return c.body(null, 204);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user