Initial commit: Stoat Role Bot
Build and Push Image / build (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dawnsorrow
2026-02-17 19:45:23 -06:00
commit 09e3b6ca66
16 changed files with 1312 additions and 0 deletions
+433
View File
@@ -0,0 +1,433 @@
/**
* 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);
});