Scripting Guide

Lesson 13: Advanced Patterns

Multi-agent features, reaction roles, audit log monitors, webhook loggers, and conventions that keep big setups maintainable.

Everything in this lesson combines tools from earlier lessons. The unifying idea: a feature in GuildScript is usually a small team of agents sharing custom ID namespaces and database collections.

Designing multi-agent features

  • One event per agent, one namespace per feature. A ticket feature might be ticket-open.js (messageCreate), ticket-buttons.js (interactionCreate), and ticket-log.js (channelDelete), all using custom IDs starting with ticket: and the tickets collection.
  • The database is the shared memory. Agents cannot call each other or share variables; they coordinate by reading and writing the same documents.
  • Name agents after the feature in /agents (Ticket: open, Ticket: buttons) so the manager screen stays readable as you approach your plan's agent limit.

Reaction roles, complete

Two agents and zero database: the role mapping lives in the code, and Discord itself stores the reactions.

reaction-roles-add.js (event: messageReactionAdd)
// Map emoji -> role ID. Use the emoji's name for custom emojis.
const MAP = {
  "\u{1F525}": "111111111111111111", // fire emoji -> Hot Takes role
  "\u{1F3AE}": "222222222222222222", // game controller -> Gamer role
};

const MESSAGE_ID = "999999999999999999"; // the role-picker message

export async function onReact({ reaction, emoji, message, user, guild }) {
  if (message.id !== MESSAGE_ID) return;

  const roleId = MAP[emoji.name];
  if (!roleId) return;

  const member = await guild.members.fetch(user.id);
  if (!member || member.error) return;

  await member.roles.add(roleId, "Reaction role");
}

A twin agent on messageReactionRemove calls member.roles.remove(roleId) with the same map. Expected behavior: reacting on the picker message grants the mapped role instantly; removing the reaction takes it away.

Audit log monitor

guildMemberRemove cannot tell a leave from a kick by itself. The audit log can:

kick-detector.js (event: guildMemberRemove)
export async function onLeave({ member, guild }) {
  // Type 20 is MEMBER_KICK in Discord's audit log action enum.
  const entries = await guild.fetchAuditLogs({ limit: 5, type: 20 });
  if (!Array.isArray(entries)) return;

  // Find a fresh kick entry for this member (within ~10 seconds).
  const kick = entries.find(
    (e) =>
      e.targetId === member.id &&
      Date.now() - e.createdTimestamp < 10_000,
  );

  const log = await guild.channels.fetch("123456789012345678");
  if (!log || log.error) return;

  if (kick) {
    await log.send(
      member.user.username + " was kicked by " +
      (kick.executor ? kick.executor.username : "unknown") +
      " (reason: " + (kick.reason ?? "none") + ")"
    );
  } else {
    await log.send(member.user.username + " left the server.");
  }
}

Webhook logger with identity

Webhooks let log entries post under a custom name and avatar, keeping bot output visually separate:

mod-logger.js (event: messageDelete)
export async function onDelete(message, db) {
  // Reuse one webhook; store its ID once.
  const saved = await db.findOne("infra", { key: "logHook" });

  const channel = await message.guild.channels.fetch("123456789012345678");
  if (!channel || channel.error) return;

  let hook = null;
  if (saved) {
    const hooks = await channel.fetchWebhooks();
    hook = Array.isArray(hooks) ? hooks.find((h) => h.id === saved.id) : null;
  }
  if (!hook) {
    const created = await channel.createWebhook({ name: "Mod Log" });
    if (created.error) return;
    await db.updateOne(
      "infra",
      { key: "logHook" },
      { $set: { id: created.id } },
      { upsert: true },
    );
    // createWebhook returns a sendable webhook right away.
    await created.send("(log webhook created)");
    return;
  }

  // fetchWebhooks() returns data only; recreate is simplest if you need send.
  // For frequent logging, prefer channel.send from the bot itself.
  await channel.send(
    "Deleted in <#" + message.channelId + "> by " +
    (message.author ? message.author.username : "unknown") + ": " +
    (message.content || "(no text)")
  );
}
Webhook objects

Webhooks returned by createWebhook support send, editMessage, deleteMessage, edit, and delete. Webhooks listed by fetchWebhooks are data snapshots (id, name, channelId) for inspection.

State machines in the database

Multi-step flows (applications, giveaways, escalating warnings) are documents with a state field. Each agent run loads the document, decides based on state, acts, and writes the next state. Because every run is independent, the document is the flow's memory:

// giveaway document shape
// { guildId, messageId, state: "open" | "closed", entrants: [userIds], prize }

// reaction agent: only accept entries while state is "open"
// command agent: "!end-giveaway" sets state to "closed", picks a winner
//                from entrants with Math.random(), announces it

Conventions that scale

  • Custom IDs: feature:action:arg, for example ticket:close:123. Split with customId.split(":") in handlers; never collide with the reserved prefixes.
  • Collections: prefix or include guildId everywhere; a key field for singleton documents ({ key: "logHook" }).
  • Constants on top. Channel and role IDs at the top of the file make agents editable by admins who do not read the rest.
  • One source of truth for settings: the settings document pattern from Lesson 10 beats constants once more than one agent needs the value.
  • Document your agents in their names and error channels. Future you will thank present you in the /agents list.

Exercise

Build a suggestion system as a three-agent feature: !suggest <text> posts an embed with up-vote and down-vote buttons (suggest:up / suggest:down) and stores a document; the interactionCreate agent records one vote per user per suggestion in db and updates the embed's vote counts with interaction.update; and !top-suggestions lists the five highest-voted documents. You now have every tool this requires.