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), andticket-log.js(channelDelete), all using custom IDs starting withticket:and theticketscollection. - 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.
// 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:
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:
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)")
);
}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 itConventions that scale
- Custom IDs:
feature:action:arg, for exampleticket:close:123. Split withcustomId.split(":")in handlers; never collide with the reserved prefixes. - Collections: prefix or include
guildIdeverywhere; akeyfield 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
settingsdocument 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
/agentslist.
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.