Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+433
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user