← 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,
};