Files
stoat_role_bot/bot/index.js
T
Dawnsorrow 09e3b6ca66
Build and Push Image / build (push) Has been cancelled
Initial commit: Stoat Role Bot
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 19:45:26 -06:00

434 lines
17 KiB
JavaScript
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.
/**
* 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 <role name>\` or \`${PREFIX}role remove <role name>\`.`);
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);
});