/** * Stoat Role Bot — Self-hosted bot for Stoat (stoat.chat) role assignment * via reactions and text commands. Uses Revolt/Stoat API (revolt.js). */ import { Client } from "revolt.js"; import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = process.env.CONFIG_PATH || "/app/config"; const ROLES_FILE = join(CONFIG_PATH, "roles.json"); const PREFIX = (process.env.PREFIX || "!").trim(); function loadConfig() { if (!existsSync(ROLES_FILE)) return { servers: {} }; try { return JSON.parse(readFileSync(ROLES_FILE, "utf8")); } catch (e) { console.warn("Could not load roles config:", e.message); return { servers: {} }; } } function saveConfig(data) { mkdirSync(CONFIG_PATH, { recursive: true }); writeFileSync(ROLES_FILE, JSON.stringify(data, null, 2), "utf8"); } function getReactionRoles(serverId) { const config = loadConfig(); return config.servers?.[serverId]?.reaction_roles ?? {}; } function getAssignableRoles(serverId) { const config = loadConfig(); return config.servers?.[serverId]?.assignable_roles ?? []; } function getRoleByName(server, roleName) { const ordered = server.orderedRoles || []; return ordered.find((r) => r.name === roleName) ?? null; } function emojiKey(emojiId) { // Revolt: unicode emoji come as the character(s); custom as id string return typeof emojiId === "string" ? emojiId : String(emojiId); } const client = new Client(); // Debug: log when client emits any message-related event (so we see which event name is used) if (process.env.DEBUG) { const origEmit = client.emit.bind(client); client.emit = function (event, ...args) { const en = String(event); if (en !== "error" && en.toLowerCase().includes("message")) { const first = args[0]; const id = first?.id ?? first?._id ?? (typeof first === "object" ? "(object)" : ""); console.info("[Stoat Role Bot] Client.emit:", en, id); } return origEmit(event, ...args); }; } // Prevent unhandled "error" from crashing the process (e.g. WebSocket errors) client.on("error", (err) => console.warn("[Stoat Role Bot] Client error:", err?.message || err)); // Log connection errors and crashes so container logs show why the bot might stay offline process.on("unhandledRejection", (err) => { console.error("[Stoat Role Bot] Unhandled rejection:", err); }); process.on("uncaughtException", (err) => { console.error("[Stoat Role Bot] Uncaught exception:", err); process.exit(1); }); client.on("ready", async () => { console.info(`[Stoat Role Bot] Logged in as ${client.user?.username} — bot is online`); const names = client.eventNames?.(); if (names?.length) console.info("[Stoat Role Bot] Client event names:", names.filter((n) => typeof n === "string").slice(0, 30).join(", ")); // Some revolt.js versions emit on the raw events client only if (client.events && typeof client.events.on === "function") { // Log every event from the wire so we see what actually arrives when someone sends a message const origEmitEvents = client.events.emit.bind(client.events); client.events.emit = function (eventName, ...args) { if (String(eventName) !== "error") { const data = args[0]; const keys = data && typeof data === "object" ? Object.keys(data).slice(0, 12).join(", ") : ""; console.info("[Stoat Role Bot] client.events.emit:", eventName, keys || "(no keys)"); } return origEmitEvents(eventName, ...args); }; client.events.on("Message", (data) => { const keys = data && typeof data === "object" ? Object.keys(data).join(", ") : ""; console.info("[Stoat Role Bot] Message handler, keys:", keys); const id = data?.id ?? data?._id; if (!id) { console.warn("[Stoat Role Bot] Message event had no id, keys:", data && Object.keys(data)); return; } setImmediate(() => { const msg = client.messages?.get(id); if (msg) { handleMessage(msg); } else { console.warn("[Stoat Role Bot] client.messages.get(" + id + ") returned nothing, trying handleMessage with raw data"); if (data?.content != null && data?.channel) { handleMessage({ ...data, authorId: data.author ?? data.authorId, channel: client.channels?.get(data.channel), server: data.server ?? client.channels?.get(data.channel)?.server, content: data.content, contentPlain: data.contentPlain ?? data.content, reply: async (text) => { const ch = client.channels?.get(data.channel); if (ch?.sendMessage) await ch.sendMessage(text); } }); } } }); }); console.info("[Stoat Role Bot] Subscribed to client.events 'Message'"); client.events.on("MessageReact", (payload) => { console.info("[Stoat Role Bot] MessageReact (client.events) received", payload?.id, payload?.emoji_id); handleReactionAdd(payload); }); client.events.on("MessageUnreact", (payload) => { console.info("[Stoat Role Bot] MessageUnreact (client.events) received", payload?.id, payload?.emoji_id); handleReactionRemove(payload); }); // Revolt.js may emit a generic "event" with payload.type = "MessageReact" / "MessageUnreact" client.events.on("event", (payload) => { const t = payload?.type; if (t === "MessageReact") { console.info("[Stoat Role Bot] event (MessageReact) received", payload?.id, payload?.emoji_id); handleReactionAdd(payload); } else if (t === "MessageUnreact") { console.info("[Stoat Role Bot] event (MessageUnreact) received", payload?.id, payload?.emoji_id); handleReactionRemove(payload); } }); console.info("[Stoat Role Bot] Subscribed to client.events 'MessageReact', 'MessageUnreact', and 'event'"); } }); // —— Reaction-based role assignment —— // Revolt API: MessageReact, MessageUnreact (payload: id, channel_id, user_id, emoji_id) // Like Message, these may only be emitted on client.events with capital letter async function handleReactionAdd(payload) { const messageId = payload?.id ?? payload?.message_id; const channel_id = payload?.channel_id ?? payload?.channel; const user_id = payload?.user_id ?? payload?.user; const emoji_id = payload?.emoji_id ?? payload?.emoji; if (!messageId || !channel_id || !user_id) return; if (user_id === client.user?._id) return; const channel = client.channels.get(channel_id); if (!channel?.server) return; const serverId = channel.server.id; const reactionRoles = getReactionRoles(serverId); const key = emojiKey(emoji_id); let roleName = null; for (const [msgId, mapping] of Object.entries(reactionRoles)) { if (msgId === messageId && (mapping[key] !== undefined || mapping[emoji_id] !== undefined)) { roleName = mapping[key] ?? mapping[emoji_id]; break; } } if (!roleName) return; const server = channel.server; const role = getRoleByName(server, roleName); if (!role) return; let member = server.getMember(user_id); if (!member) { try { member = await server.fetchMember(user_id); } catch { return; } } const currentRoles = [...(member.roles || [])]; if (currentRoles.includes(role.id)) return; try { await member.edit({ roles: [...currentRoles, role.id] }); console.info(`[Stoat Role Bot] Added role "${roleName}" to user in ${server.name}`); } catch (e) { console.warn(`[Stoat Role Bot] Could not add role "${roleName}":`, e.message); } } async function handleReactionRemove(payload) { const messageId = payload?.id ?? payload?.message_id; const channel_id = payload?.channel_id ?? payload?.channel; const user_id = payload?.user_id ?? payload?.user; const emoji_id = payload?.emoji_id ?? payload?.emoji; if (!messageId || !channel_id || !user_id) return; if (user_id === client.user?._id) return; const channel = client.channels.get(channel_id); if (!channel?.server) return; const serverId = channel.server.id; const reactionRoles = getReactionRoles(serverId); const key = emojiKey(emoji_id); let roleName = null; for (const [msgId, mapping] of Object.entries(reactionRoles)) { if (msgId === messageId && (mapping[key] !== undefined || mapping[emoji_id] !== undefined)) { roleName = mapping[key] ?? mapping[emoji_id]; break; } } if (!roleName) return; const server = channel.server; const role = getRoleByName(server, roleName); if (!role) return; let member = server.getMember(user_id); if (!member) { try { member = await server.fetchMember(user_id); } catch { return; } } const currentRoles = [...(member.roles || [])].filter((id) => id !== role.id); try { await member.edit({ roles: currentRoles }); console.info(`[Stoat Role Bot] Removed role "${roleName}" from user in ${server.name}`); } catch (e) { console.warn(`[Stoat Role Bot] Could not remove role "${roleName}":`, e.message); } } client.on("messageReact", (payload) => { console.info("[Stoat Role Bot] messageReact (Client) received", payload?.id, payload?.emoji_id); handleReactionAdd(payload); }); client.on("messageUnreact", (payload) => { console.info("[Stoat Role Bot] messageUnreact (Client) received", payload?.id, payload?.emoji_id); handleReactionRemove(payload); }); // —— Text commands —— // revolt.js uses camelCase: authorId, and message.server (not only channel.server) function getAuthorId(msg) { return msg.authorId ?? msg.author_id; } function getServer(msg) { return msg.server ?? msg.channel?.server; } async function handleMessage(message) { const content = message.content ?? message.contentPlain ?? ""; const isCommand = content.startsWith(PREFIX); const authorId = getAuthorId(message); if (process.env.DEBUG) { console.info(`[Stoat Role Bot] handleMessage: "${content.slice(0, 50)}" authorId=${authorId} isCmd=${isCommand} server=${!!getServer(message)}`); } if (!isCommand || authorId === client.user?._id) return; const server = getServer(message); if (!server) { if (process.env.DEBUG) console.info("[Stoat Role Bot] Ignoring: no server (DM?)"); return; } const serverId = server.id; const channel = message.channel; const args = content.slice(PREFIX.length).trim().split(/\s+/); const cmd = args[0]?.toLowerCase(); if (!cmd) return; const safeReply = async (text) => { console.info("[Stoat Role Bot] Sending reply…"); try { if (typeof message.reply === "function") { await message.reply(text); } else if (channel?.sendMessage) { await channel.sendMessage(text); } else { console.warn("[Stoat Role Bot] No reply/sendMessage available"); } } catch (e) { console.warn("[Stoat Role Bot] reply failed:", e?.message || e); try { if (channel?.sendMessage) await channel.sendMessage(text); } catch (e2) { console.warn("[Stoat Role Bot] sendMessage also failed:", e2?.message || e2); } } }; // Simple test command – if this works, the bot can send in this channel if (cmd === "ping") { await safeReply("pong"); return; } // Helper: show server ID for config/roles.json if (cmd === "serverid") { await safeReply(`This server's ID for \`config/roles.json\`:\n\`\`\`${serverId}\`\`\`\nAdd it under \`servers.\"${serverId}\"\` with \`assignable_roles\` and optionally \`reaction_roles\`.`); return; } if (cmd === "roles") { const names = getAssignableRoles(serverId); if (!names.length) { await safeReply("No self-assignable roles are configured. Ask an admin to set them in `roles.json`."); return; } const roles = names .map((n) => getRoleByName(server, n)) .filter(Boolean); if (!roles.length) { await safeReply("Configured role names don't match any server roles. Check `roles.json`."); return; } const lines = roles.map((r) => `• **${r.name}**`).join("\n"); await safeReply(`**Self-assignable roles:**\n${lines}`); return; } if (cmd === "role" && args.length >= 3) { const action = args[1].toLowerCase(); const roleName = args.slice(2).join(" "); if (!["add", "remove", "give", "take"].includes(action)) { await safeReply(`Use \`${PREFIX}role add \` or \`${PREFIX}role remove \`.`); return; } const assignable = getAssignableRoles(serverId); if (!assignable.includes(roleName)) { await safeReply(`**${roleName}** is not a self-assignable role. Use \`${PREFIX}roles\` to list them.`); return; } const role = getRoleByName(server, roleName); if (!role) { await safeReply("That role doesn't exist on this server."); return; } let member = server.getMember(authorId); if (!member) { try { member = await server.fetchMember(authorId); } catch { await safeReply("Could not find you as a member."); return; } } const currentRoles = [...(member.roles || [])]; const give = action === "add" || action === "give"; if (give) { if (currentRoles.includes(role.id)) { await safeReply(`You already have **${role.name}**.`); return; } try { await member.edit({ roles: [...currentRoles, role.id] }); await safeReply(`Added **${role.name}** to you.`); } catch (e) { await safeReply("I don't have permission to add that role."); } } else { if (!currentRoles.includes(role.id)) { await safeReply(`You don't have **${role.name}**.`); return; } try { await member.edit({ roles: currentRoles.filter((id) => id !== role.id) }); await safeReply(`Removed **${role.name}** from you.`); } catch (e) { await safeReply("I don't have permission to remove that role."); } } return; } // Admin: set reaction roles for a message if (cmd === "setreactionroles" && args.length >= 2) { const memberObj = server.getMember(authorId) ?? (await server.fetchMember(authorId).catch(() => null)); if (!memberObj?.hasPermission?.(server, "ManageRole")) { await safeReply("You need the Manage Role permission."); return; } const messageId = args[1]; const pairs = args.slice(2).join(" "); const mapping = {}; for (const part of pairs.split(/\s+/)) { const eq = part.indexOf("="); if (eq > 0) { const emoji = part.slice(0, eq).trim(); const roleName = part.slice(eq + 1).trim(); if (emoji && roleName) mapping[emoji] = roleName; } } if (Object.keys(mapping).length === 0) { await safeReply("Provide at least one pair like `emoji=RoleName`."); return; } const config = loadConfig(); config.servers = config.servers || {}; config.servers[serverId] = config.servers[serverId] || { reaction_roles: {}, assignable_roles: getAssignableRoles(serverId) }; config.servers[serverId].reaction_roles = config.servers[serverId].reaction_roles || {}; config.servers[serverId].reaction_roles[messageId] = mapping; saveConfig(config); await safeReply(`Reaction roles set for message \`${messageId}\`. Add those emojis to the message and users will get the roles.`); return; } // Admin: set assignable roles if (cmd === "setassignableroles") { const memberObj = server.getMember(authorId) ?? (await server.fetchMember(authorId).catch(() => null)); if (!memberObj?.hasPermission?.(server, "ManageRole")) { await safeReply("You need the Manage Role permission."); return; } const names = args.slice(1).join(" ").split(",").map((s) => s.trim()).filter(Boolean); const config = loadConfig(); config.servers = config.servers || {}; config.servers[serverId] = config.servers[serverId] || { reaction_roles: getReactionRoles(serverId), assignable_roles: [] }; config.servers[serverId].assignable_roles = names; saveConfig(config); await safeReply(`Assignable roles set to: ${names.length ? names.join(", ") : "(none)"}.`); } } // Revolt.js may emit "message", "messageCreate", or raw "Message" client.on("message", handleMessage); client.on("messageCreate", handleMessage); client.on("Message", (payload) => { const id = payload?.id ?? payload?._id; const msg = typeof id === "string" ? client.messages?.get(id) : payload; if (msg && typeof msg.reply === "function") handleMessage(msg); else if (msg && (msg.content != null || msg.contentPlain != null)) handleMessage(msg); }); const token = process.env.STOAT_BOT_TOKEN || process.env.REVOLT_BOT_TOKEN; if (!token) { console.error("[Stoat Role Bot] Set STOAT_BOT_TOKEN (or REVOLT_BOT_TOKEN) in the environment."); process.exit(1); } console.info("[Stoat Role Bot] Connecting to Stoat/Revolt API..."); client.loginBot(token).catch((err) => { console.error("[Stoat Role Bot] Login failed:", err?.message || err); process.exit(1); });