← back to Norma
agents/discord-agent/lib/discord.js
417 lines
/**
* Discord.js Bot Wrapper
*
* Manages Discord bot connection, message sending, embeds, and monitoring.
* Gracefully degrades if no bot token is configured (simulation mode).
*/
let Client, GatewayIntentBits, EmbedBuilder;
try {
const discordjs = require('discord.js');
Client = discordjs.Client;
GatewayIntentBits = discordjs.GatewayIntentBits;
EmbedBuilder = discordjs.EmbedBuilder;
} catch (err) {
console.warn('[discord] discord.js not available — running in simulation mode');
}
let client = null;
let isConnected = false;
let guildsCache = [];
// Advocacy keywords for monitoring
const KEYWORDS = [
'nonprofit',
'advocacy',
'civic engagement',
'social justice',
'community organizing',
'policy change',
'petition',
'grassroots',
'public interest',
'economic justice',
'education policy',
'consumer protection',
'civic action',
'social impact',
'public benefits',
];
/**
* Check if Discord bot token is configured.
* @returns {boolean}
*/
function hasCredentials() {
return !!process.env.DISCORD_BOT_TOKEN;
}
/**
* Initialize the Discord bot client.
*
* @returns {Promise<boolean>} Whether the bot connected successfully
*/
async function initBot() {
if (!process.env.DISCORD_BOT_TOKEN) {
console.log('[discord] No bot token configured — running in simulation mode');
return false;
}
if (!Client) {
console.error('[discord] discord.js module not available');
return false;
}
if (isConnected && client) {
console.log('[discord] Bot already connected');
return true;
}
try {
client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
// Set up event handlers
client.on('ready', () => {
console.log(`[discord] Bot logged in as ${client.user.tag}`);
guildsCache = client.guilds.cache.map((g) => ({
id: g.id,
name: g.name,
memberCount: g.memberCount,
}));
console.log(`[discord] Connected to ${guildsCache.length} guilds`);
});
client.on('error', (err) => {
console.error('[discord] Client error:', err.message);
});
client.on('disconnect', () => {
console.warn('[discord] Bot disconnected');
isConnected = false;
});
await client.login(process.env.DISCORD_BOT_TOKEN);
isConnected = true;
return true;
} catch (err) {
console.error('[discord] Failed to initialize bot:', err.message);
isConnected = false;
return false;
}
}
/**
* Get the bot's current connection status.
*
* @returns {{ connected: boolean, guilds: number, user: string|null }}
*/
function getStatus() {
return {
connected: isConnected,
guilds: guildsCache.length,
user: client?.user?.tag || null,
guildList: guildsCache,
};
}
/**
* Send a plain text message to a Discord channel.
*
* @param {string} channelId - Discord channel ID
* @param {string} content - Message text
* @returns {Promise<{ id: string|null, simulated: boolean, posted: boolean }>}
*/
async function sendMessage(channelId, content) {
if (!isConnected || !client) {
return {
id: null,
simulated: true,
posted: false,
note: 'Discord bot not connected — message not sent',
draft: { channelId, content },
};
}
try {
const channel = await client.channels.fetch(channelId);
if (!channel || !channel.isTextBased()) {
throw new Error(`Channel ${channelId} not found or not text-based`);
}
const message = await channel.send(content);
return {
id: message.id,
channelId: message.channelId,
simulated: false,
posted: true,
};
} catch (err) {
console.error(`[discord] sendMessage to ${channelId} error:`, err.message);
return {
id: null,
simulated: false,
posted: false,
error: err.message,
};
}
}
/**
* Send a rich embed to a Discord channel.
*
* @param {string} channelId - Discord channel ID
* @param {Object} embedData - Embed configuration
* @param {string} embedData.title - Embed title
* @param {string} [embedData.description] - Embed description
* @param {string} [embedData.url] - Title URL
* @param {number} [embedData.color=0x2563eb] - Embed color (hex integer)
* @param {Array<{name: string, value: string, inline?: boolean}>} [embedData.fields] - Embed fields
* @param {string} [embedData.footer] - Footer text
* @param {string} [embedData.thumbnail] - Thumbnail URL
* @param {string} [embedData.image] - Image URL
* @returns {Promise<{ id: string|null, simulated: boolean, posted: boolean }>}
*/
async function sendEmbed(channelId, embedData) {
if (!isConnected || !client) {
return {
id: null,
simulated: true,
posted: false,
note: 'Discord bot not connected — embed not sent',
draft: { channelId, embedData },
};
}
try {
const channel = await client.channels.fetch(channelId);
if (!channel || !channel.isTextBased()) {
throw new Error(`Channel ${channelId} not found or not text-based`);
}
if (!EmbedBuilder) {
throw new Error('EmbedBuilder not available');
}
const embed = new EmbedBuilder()
.setTitle(embedData.title)
.setColor(embedData.color || 0x2563eb);
if (embedData.description) embed.setDescription(embedData.description);
if (embedData.url) embed.setURL(embedData.url);
if (embedData.footer) embed.setFooter({ text: embedData.footer });
if (embedData.thumbnail) embed.setThumbnail(embedData.thumbnail);
if (embedData.image) embed.setImage(embedData.image);
if (embedData.fields && Array.isArray(embedData.fields)) {
for (const field of embedData.fields) {
embed.addFields({
name: field.name,
value: field.value,
inline: field.inline || false,
});
}
}
embed.setTimestamp();
const message = await channel.send({ embeds: [embed] });
return {
id: message.id,
channelId: message.channelId,
simulated: false,
posted: true,
};
} catch (err) {
console.error(`[discord] sendEmbed to ${channelId} error:`, err.message);
return {
id: null,
simulated: false,
posted: false,
error: err.message,
};
}
}
/**
* Reply to a specific message in a channel.
*
* @param {string} channelId - Discord channel ID
* @param {string} messageId - Message ID to reply to
* @param {string} content - Reply text
* @returns {Promise<{ id: string|null, simulated: boolean, posted: boolean }>}
*/
async function replyToMessage(channelId, messageId, content) {
if (!isConnected || !client) {
return {
id: null,
simulated: true,
posted: false,
note: 'Discord bot not connected — reply not sent',
draft: { channelId, messageId, content },
};
}
try {
const channel = await client.channels.fetch(channelId);
if (!channel || !channel.isTextBased()) {
throw new Error(`Channel ${channelId} not found or not text-based`);
}
const targetMessage = await channel.messages.fetch(messageId);
if (!targetMessage) {
throw new Error(`Message ${messageId} not found in channel ${channelId}`);
}
const reply = await targetMessage.reply(content);
return {
id: reply.id,
channelId: reply.channelId,
simulated: false,
posted: true,
};
} catch (err) {
console.error(`[discord] replyToMessage in ${channelId} error:`, err.message);
return {
id: null,
simulated: false,
posted: false,
error: err.message,
};
}
}
/**
* Get recent messages from a channel.
*
* @param {string} channelId - Discord channel ID
* @param {number} [limit=50] - Max messages to fetch
* @returns {Promise<Array<{ id: string, content: string, author: string, createdAt: string }>>}
*/
async function getRecentMessages(channelId, limit = 50) {
if (!isConnected || !client) {
return [];
}
try {
const channel = await client.channels.fetch(channelId);
if (!channel || !channel.isTextBased()) {
throw new Error(`Channel ${channelId} not found or not text-based`);
}
const messages = await channel.messages.fetch({ limit: Math.min(limit, 100) });
return messages.map((msg) => ({
id: msg.id,
content: msg.content,
author: msg.author?.tag || msg.author?.username || 'unknown',
authorId: msg.author?.id || null,
createdAt: msg.createdAt.toISOString(),
isBot: msg.author?.bot || false,
}));
} catch (err) {
console.error(`[discord] getRecentMessages from ${channelId} error:`, err.message);
return [];
}
}
/**
* Create a petition embed for rich display in Discord.
*
* @param {Object} petition - Petition data
* @param {string} petition.title - Petition title
* @param {string} [petition.description] - Petition description
* @param {string} [petition.url] - Petition URL
* @param {number} [petition.signatures] - Signature count
* @param {number} [petition.goal] - Signature goal
* @param {string} [petition.target] - Who the petition targets
* @param {string} [petition.category] - Petition category
* @returns {Object} Embed data object compatible with sendEmbed
*/
function createPetitionEmbed(petition) {
const fields = [];
if (petition.target) {
fields.push({ name: 'Target', value: petition.target, inline: true });
}
if (petition.category) {
fields.push({ name: 'Category', value: petition.category, inline: true });
}
if (petition.signatures !== undefined) {
const sigText = petition.goal
? `${petition.signatures.toLocaleString()} / ${petition.goal.toLocaleString()}`
: petition.signatures.toLocaleString();
fields.push({ name: 'Signatures', value: sigText, inline: true });
}
return {
title: petition.title,
description: petition.description || 'Take action and make your voice heard.',
url: petition.url || null,
color: 0xef4444, // Red — action lane color
fields,
footer: 'Norma — Advocacy Platform',
};
}
/**
* Score a message for relevance to advocacy topics.
*
* @param {string} content - Message content
* @returns {number} Relevance score 0-100
*/
function scoreRelevance(content) {
const text = content.toLowerCase();
let score = 0;
let matchCount = 0;
for (const keyword of KEYWORDS) {
if (text.includes(keyword.toLowerCase())) {
matchCount++;
if (['nonprofit', 'advocacy', 'civic engagement'].includes(keyword.toLowerCase())) {
score += 15;
} else if (['social justice', 'petition', 'policy change', 'civic action'].includes(keyword.toLowerCase())) {
score += 12;
} else {
score += 8;
}
}
}
if (matchCount >= 3) score += 10;
if (matchCount >= 5) score += 10;
if (content.length > 200) score += 5; // Longer messages are likely more substantive
return Math.min(100, score);
}
/**
* Shutdown the bot gracefully.
*/
async function shutdown() {
if (client) {
console.log('[discord] Shutting down bot...');
client.destroy();
isConnected = false;
client = null;
}
}
module.exports = {
KEYWORDS,
hasCredentials,
initBot,
getStatus,
sendMessage,
sendEmbed,
replyToMessage,
getRecentMessages,
createPetitionEmbed,
scoreRelevance,
shutdown,
};