DEV Community

J3ffJessie
J3ffJessie

Posted on

Building a Multifunctional Discord Bot: A Comprehensive Technical Deep Dive

Discord has become the go-to platform for running communities, whether you’re organizing a gaming guild, a dev collective, or a professional network. It has plenty of built-in tools, but sometimes you just want to build the thing that matches how your community actually works.

This post walks through the architecture and implementation of the Torc Discord bot that does a little of everything, channel and server summaries, coffee chat pairings, a reminder system, and event integration. Along the way, it highlights patterns like rate limiting, concurrency safety, async scheduling, and resilient error handling. Note that code blocks are not the full complete functions or code blocks, that was done intentionally as then this post would be like 10 pages long. I mainly wanted to give an overview of the configuration and layout versus direct copy pasta of the entire bot file. Hopefully that doesn't detract you from reading.


Architecture Overview

The bot is built on discord.js, the go to library for working with the Discord API from Node.js. It integrates with a few external services to extend Discord’s capabilities:

  • Groq API for fast, low-latency LLM-powered conversation summarization using the Llama 3.1 8B model (I'm cheap)
  • Luma API for fetching and listing upcoming events
  • Mee6 API (optional) for gamification data such as member levels
  • node-cron for running scheduled tasks on a fixed timetable

Commands follow a message driven design and support both modern slash commands and older prefix-based commands. This keeps the bot compatible with existing usage while taking advantage of newer Discord interaction patterns. There are also a couple automated cron jobs that run as well that are not using message driven design.


Conversation Summarization

The original reason I started building this bot, was because Taylor mentioned that he couldn't keep up with the chat and if he could get a summary that was easy to read and quick it would help. So I started working on putting that together and the rest just sort of snowballed. Also, to note that I don't believe Taylor has ever used the summarize command since it has been built. ROFL!!!

For channel summaries, the bot grabs the latest 100 messages in the channel where the command is entered and sends them to Groq’s llama-3.1-8b-instant model to produce a concise recap:

async function summarizeMessages(messages) {
  try {
    const completion = await groq.chat.completions.create({
      messages: [
        {
          role: "system",
          content: "You are a friendly Discord conversation analyzer...",
        },
        {
          role: "user",
          content: `Please provide a detailed summary...:\n\n${messages}`,
        },
      ],
      model: "llama-3.1-8b-instant",
      temperature: 0.7,
      max_tokens: 1024,
    });
    return completion.choices[0].message.content;
  } catch (error) {
    await logError(error, "Error in summarization");
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Key design choices:

  • The system prompt asks for friendly, bulleted output without getting overly rigid or over-categorized.
  • A temperature of 0.7 balances consistency with natural variation in phrasing.
  • Long responses are chunked into ~1900-character segments to stay under Discord’s message limits.

  • Could the prompt be better? Probably. I haven't given it much work since I was able to get a result that I was comfortable with. I am open to criticism or input though.


Server-Wide Weekly Digests

This functionality was more of a necessity than a community ask. It gives a quick way on Monday for any user to check what happened the previous week and have some context on conversations that may come up in the chat again. A cron job runs every Monday at 10:00 UTC and kicks off a server-wide digest:

cron.schedule("0 10 * * 1", async () => {
  const summary = await gatherServerConversationsAndSummarize(guild, true);
  // Post to designated channel...
});
Enter fullscreen mode Exit fullscreen mode

The server summarization works similarly to the channel summarization. It grabs the last 100 messages from all accessible channels and chunks them together for processing and passes that off for summarization. The server summarization prompt is more structured and explicitly asks the model to pull out:

  • Facts and announcements
  • Decisions and conclusions
  • Ongoing discussions
  • Action items and follow-ups

To keep things honest, the prompt also includes strict rules such as:

  • Only summarizing what is actually present in the messages
  • Not inferring motivation, intent, or outcomes
  • Not inventing decisions, action items, or conclusions

That keeps the digest grounded in real conversation rather than guesses.


Coffee Chat Pairings

Probably the functionality I am most proud of. Most people are familiar with coffee chats. Quick little 15 minute conversations to get to know someone else and network and just have conversation. Well, what is one of the most important parts of a community? You guessed it!!! We want our members to know each other and have a strong network. It has been said by Taylor and a few others, but we want Torc especially the Discord community to feel like home. Where you can be yourself and share your wins and struggles without feeling like you will be judged, instead being understood and supported. So since I know full well that reaching out to a stranger to ask about doing a coffee chat is sort of awkward, I put together the functionality that pairs people together to have coffee chats in hopes that it gives a little nudge that makes doing the coffee chats less awkward and gets people more comfortable in reaching out to others.


The “coffee chat” system is designed to regularly pair up members who have a specific role, without constantly repeating the same matches. It can optionally factor in Mee6 leveling data to encourage pairings across different engagement levels.

Pairing With Cooldowns

The core pairing function shuffles the member list and then tries to find a partner that satisfies the cooldown and fairness rules:

function pairUpWithCooldown(members, history, cooldownMs) {
  const shuffled = shuffle(members.slice());
  const pairs = [];

  while (shuffled.length >= 2) {
    const a = shuffled.shift();
    let partnerIndex = -1;

    // 1) Try to find a partner that was not paired recently
    for (let i = 0; i < shuffled.length; i++) {
      const cand = shuffled[i];
      if (!wasRecentlyPaired(history, a.id, cand.id, cooldownMs)) {
        partnerIndex = i;
        break;
      }
    }

    // 2) If none found, prefer the partner with the fewest prior pairings
    if (partnerIndex === -1) {
      // Fallback logic to find least-matched partner...
    }

    const b = shuffled.splice(partnerIndex, 1)[0];
    pairs.push([a, b]);
  }

  return pairs;
}
Enter fullscreen mode Exit fullscreen mode

The pairing logic favors:

  • Partners who have not been matched recently (respecting a cooldown window).
  • Partners with fewer historical pairings together to increase network diversity.
  • Pairs whose last meetup was the longest time ago when there is a tie.

This keeps the experience fresh, even in smaller communities where the pool of members is limited.

Storing History and Avoiding Races

Pairing history is stored in a JSON file with per-user records, for example:

{
  "user_id": {
    "history": [
      { "partnerId": "other_user_id", "timestamp": 1704067200000 }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The bot includes migration logic so older data formats can be upgraded automatically, which avoids breaking existing deployments when the schema changes.


Reminder System

The reminder feature was requested and probably could be better than it is, but I honestly prioritized other features over it as we have way too many ways to set reminders in places. This feature show how to manage long-running tasks safely, with persistent state, time parsing, and basic concurrency control.

Flexible Time Parsing

Users can set reminders with flexible time strings like:

  • !remindme 2 weeks Take out the trash
  • !remindme 1 month 3 days Project deadline
  • !remindme 5h30m Call the dentist

A regex-based parser walks the string, extracts units, and converts everything to milliseconds: (No, I did not write my own regex. Thank you chat gippity)

function parseTime(input) {
  const regex = /(\d+(?:\.\d+)?)\s*(mo(?:nths?)?|w(?:eeks?)?|d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?|s(?:ec(?:ond)?s?)?)/gi;
  let total = 0;
  // ... parse and accumulate
  return total > 0 ? Math.round(total) : null;
}
Enter fullscreen mode Exit fullscreen mode

File Locking for Persistence

Reminders are stored as JSON on disk. Because multiple commands can be processed at the same time, the bot uses a simple file-based lock to avoid two processes reading and writing the file simultaneously:

async function acquireRemindersLock(retries = 50, delayMs = 100) {
  for (let i = 0; i < retries; i++) {
    try {
      const fd = fs.openSync(REMINDER_LOCK_FILE, "wx");
      fs.writeSync(fd, `${process.pid}\n${Date.now()}`);
      fs.closeSync(fd);
      return () => {
        try {
          fs.unlinkSync(REMINDER_LOCK_FILE);
        } catch (e) {}
      };
    } catch (err) {
      if (err && err.code === "EEXIST") {
        const stat = fs.statSync(REMINDER_LOCK_FILE);
        const ageMs = Date.now() - stat.mtimeMs;
        if (ageMs > 30000) {
          fs.unlinkSync(REMINDER_LOCK_FILE);
        }
        await new Promise((r) => setTimeout(r, delayMs));
        continue;
      }
      throw err;
    }
  }
  throw new Error("Could not acquire reminders lock");
}
Enter fullscreen mode Exit fullscreen mode

If a lock file is older than a threshold, it is treated as stale and removed, which helps the system recover from crashes without manual intervention.

Scheduling and Cancellation

Once a reminder is stored, it is scheduled via setTimeout, and the timeout IDs are tracked so they can be cancelled later:

function scheduleReminder(reminder, delay) {
  if (scheduledTimeouts.has(reminder.id)) {
    clearTimeout(scheduledTimeouts.get(reminder.id));
    scheduledTimeouts.delete(reminder.id);
  }
  const timeoutId = setTimeout(() => sendReminder(reminder), delay);
  scheduledTimeouts.set(reminder.id, timeoutId);
}
Enter fullscreen mode Exit fullscreen mode

If a user cancels a reminder, the bot clears the timeout and removes it from the map, preventing duplicate or stale notifications.


Event Management

Torc does a lot of events, both in person and virtual. Expected when the team has like 3 individual shows and also the community specific events. Previously it was connected to Guild and pulled events utilizing their API, however the team shifted to using Luma so users can subscribe the calendar and get notifications instead of having to do per event subscription.

For events, the bot connects to Luma (once someone gets me the correct API Key) and pulls in upcoming calendar entries, then sends them to the user through a DM with rich embeds that include titles, times, images, and links:

async function fetchUpcomingEvents() {
  const response = await axios.get(
    "https://public-api.luma.com/v1/calendar/list-events",
    {
      headers: {
        accept: "application/json",
        "x-luma-api-key": process.env.LUMA_API_KEY,
      },
    }
  );
  return response.data.sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
}
Enter fullscreen mode Exit fullscreen mode

Sorting by startTime ensures events are displayed in chronological order, which makes it easy for members to see what is coming up next.


Advanced Patterns

Rate Limiting

To respect Discord’s rate limits, the bot spaces out outgoing messages with a simple delay helper:

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

for (const chunk of chunks) {
  await interaction.user.send(chunk);
  await delay(1000);
}
Enter fullscreen mode Exit fullscreen mode

This pattern keeps the bot responsive while reducing the chance of hitting hard limits during bursts of activity. (There are no bursts

Safe Member Fetching

Member fetches can be slow or hang in large guilds, so the bot wraps guild.members.fetch() in a timeout using Promise.race:

async function fetchGuildMembersWithTimeout(guild, timeoutMs = 10000) {
  return Promise.race([
    guild.members.fetch(),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error("GuildMembersFetchTimeout")), timeoutMs)
    ),
  ]);
}
Enter fullscreen mode Exit fullscreen mode

If the fetch takes too long, the bot fails fast, logs the error, and can fall back to a safer behavior instead of blocking everything.

Error Logging and Admin Alerts

All major operations use a shared error logger that prints to stderr and pings an admin account without throwing additional errors on failure:

async function logError(err, context = "") {
  try {
    if (context) console.error(context, err);
    else console.error(err);
    await notifyAdmin(
      `${context ? `${context} - ` : ""}${(err && err.message) || String(err)}`
    );
  } catch (ignore) {
    // Intentionally ignore to avoid cascading failures
  }
}
Enter fullscreen mode Exit fullscreen mode

This gives me visibility into production issues while keeping the bot running even when notifications fail. Could I possibly use something awesome like Sentry for this? Possibly, but I am just a poor boy and honestly don't want anyone scolding my little bot unless it's me.

Deduplicating Message Events

Because certain events can fire more than once for the same message, the bot uses a short-lived Set to deduplicate processing:

const processedMessageIds = new Set();

if (processedMessageIds.has(message.id)) return;
processedMessageIds.add(message.id);
setTimeout(() => processedMessageIds.delete(message.id), 30 * 1000);
Enter fullscreen mode Exit fullscreen mode

This prevents double-counting messages in summaries, stats, or other handlers.


Configuration and Deployment

Environment Configuration

The bot is configured through environment variables, keeping secrets and settings out of the codebase (like a good dev is supposed to do right?):

DISCORD_TOKEN=your_bot_token
CLIENT_ID=your_client_id
GROQ_API_KEY=your_groq_key
LUMA_API_KEY=your_luma_key
GUILD_ID=your_main_guild
ADMIN_USER_ID=your_admin_id
COFFEE_ROLE_NAME="coffee chat"
COFFEE_CRON_SCHEDULE="0 9 * * 1"
COFFEE_PAIRING_COOLDOWN_DAYS=30
COFFEE_MIN_MEE6_LEVEL=0
COFFEE_FETCH_MEMBERS=true
COFFEE_FETCH_TIMEOUT_MS=10000
Enter fullscreen mode Exit fullscreen mode

This makes it straightforward to run the same code across staging and production with different credentials and schedules.

Health Checks and Hosting

A tiny HTTP server on port 3000 exposes a health endpoint:

const server = http.createServer((req, res) => {
  res.writeHead(200);
  res.end("Discord summarizer bot is running.");
});
server.listen(port, () => {
  console.log(`HTTP server listening on port ${port}`);
});
Enter fullscreen mode Exit fullscreen mode

This is handy for containerized deployments, load balancers, or platform health checks.

State and Scaling

By default, the bot uses the local filesystem:

  • reminders.json for scheduled reminders
  • coffee_pairs.json for pairing history
  • locations.log for optional location tracking

For production, especially across multiple instances, a real database such as PostgreSQL or MongoDB is a better fit for consistency and durability.

Items on my watch list as the community grows and if major if the usage increases substantially:

  • Message handling can benefit from more robust deduplication or a message queue for very high volumes.
  • Reminder scheduling may require an external job queue (for example, Bull or Bee-Queue) for thousands of timers. (highly doubt this will ever be necessary but have explored the options)
  • Coffee pairings currently run in roughly O(n²) due to cooldown checks, so if we get quite a large group of people volunteering to participate I might need more efficient data structures.

Closing Thoughts

This bot (my baby) is a product of and for the community. I have been using it as a way to stay fresh in my non ServiceNow development and keeping familiar with more modern JavaScript as well as playing with AI and ways it can be used. It has been great seeing it be used and benefit the community. I hope that continues and I can add even more features to it that benefit the community. I have a couple ideas that are already out there and just being reviewed before they are potentially implemented.

This bot pulls together several patterns in one place: external API integrations, smart scheduling, careful file operations, timeouts, and defensive error handling. The same ideas apply well beyond Discord, from other chat platforms to background services and workflow engines.

If you are planning your own Discord bot, this architecture gives you a solid starting point. Focus on reliability and observability first, then layer on features like summaries, pairings, reminders, and events as your community grows.

Have you built Discord bots before? Share your experiences in the comments – especially any patterns that worked well (or failed in interesting ways).

If you are looking for a community of like minded individuals we have something for everyone in our Discord, feel free to join in the fun Torc Discord

Top comments (0)