Templates
12 scripts you can paste straight into the dashboard, adapted from
ones running in real servers. Each says which trigger to set. Anywhere you see
ctx.env.something_id, add that key to your guild's env record on
the dashboard's Database page.
These are on top of the 32 commands Mallard already installs with, which you can read and edit the same way.
Moderation
Mallard seeds warn, kick, ban, timeout, notes and friends on install. These go past that.
Catch image-spam automatically
const key = `imgspam.${ctx.message.authorId}`;
const now = Date.now();
const recent = JSON.parse(getKV(key) || "[]")
.filter(e => now - e.timestamp < 10000);
recent.push({
messageId: ctx.message.id,
channelId: ctx.message.channelId,
timestamp: now
});
setKV(key, JSON.stringify(recent), 60);
if (recent.length >= 3 && ctx.message.attachments.length > 0) {
deleteKV(key);
timeout(ctx.message.authorId, "Image spam (3+ attachments in 10s)", 1440);
const links = recent
.map(e => `https://discord.com/channels/${ctx.guildId}/${e.channelId}/${e.messageId}`)
.join("\n");
const author = ctx.message.authorUsername || ctx.message.authorDisplayName || ctx.message.authorId;
sendMessage(ctx.env.mod_alert_channel_id, "", [{
title: "🖼️ Image spam",
description: `${author} (${ctx.message.authorId}) timed out 24h. Please review:\n${links}`,
colorArgb: 15105570
}]);
}Counts attachments per author over a rolling ten seconds and times out anyone posting three or more, then links the offending messages for a mod to review.
Block archives and executables
const BANNED_EXT = /\.(zip|rar|7z|tar|gz|tgz|xz|iso|exe|dll|msi|scr|bat|cmd|vbs|ps1|jar|apk|deb|dmg)$/i;
const BANNED_MIME = /^application\/(zip|x-zip-compressed|x-7z-compressed|vnd\.rar|gzip|x-tar|java-archive|x-msdownload)\b/i;
const atts = (ctx.message && ctx.message.attachments) || [];
if (atts.length > 0 && !hasRole(ctx.message.authorId, ctx.env.mod_role_id)) {
const banned = atts.filter(a =>
BANNED_EXT.test(a.fileName) || BANNED_MIME.test(a.contentType));
if (banned.length > 0) {
deleteMessage(ctx.channelId, ctx.message.id);
sendDm(ctx.message.authorId,
"Your upload was removed. Archives and executables aren't allowed here, " +
"so please use a file-sharing link instead.");
const names = banned.map(a => "`" + a.fileName + "`").join(", ");
const author = ctx.message.authorUsername || ctx.message.authorDisplayName || ctx.message.authorId;
sendMessage(ctx.env.mod_alert_channel_id, "", [{
title: "📎 Attachment removed",
description: `Removed ${names} from ${author} (${ctx.message.authorId}) in <#${ctx.channelId}>.`,
colorArgb: 15105570
}]);
}
}Deletes uploads whose filename or MIME type looks like an archive or a binary, DMs the poster an explanation, and logs it. Moderators are exempt.
Alert when the mod role is pinged
const pinged = ctx.message.content.includes(`<@&${ctx.env.mod_role_id}>`);
if (pinged && !hasRole(ctx.message.authorId, ctx.env.mod_role_id)) {
sendMessage(ctx.message.channelId,
"👋 A mod has been pinged and will take a look shortly.");
const author = ctx.message.authorUsername || ctx.message.authorDisplayName || ctx.message.authorId;
sendMessage(ctx.env.mod_alert_channel_id, "", [{
title: "🔔 Mod ping",
description:
`${author} (${ctx.message.authorId}) pinged mods in <#${ctx.message.channelId}>:\n\n` +
`${ctx.message.content}\n\n` +
`https://discord.com/channels/${ctx.guildId}/${ctx.message.channelId}/${ctx.message.id}`,
colorArgb: 15105570
}]);
}Acknowledges the ping in-channel so the member knows they were heard, and mirrors it to the mod channel with a jump link.
Move a message to the right channel
if (!hasRole(ctx.actorId, ctx.env.mod_role_id)) {
editResponse("❌ Mods only.");
} else {
const source = ctx.args.source_channel || ctx.channelId;
const msg = getMessage(source, ctx.args.message_id);
if (!msg) {
editResponse("❌ Couldn't find that message. Check the ID and that it still exists.");
} else {
const files = msg.attachments.map(a => a.url).join("\n");
const actor = getMember(ctx.actorId);
const actorName = actor ? actor.username : ctx.actorId;
sendWebhookMessage(
ctx.args.target_channel,
msg.authorUsername,
msg.authorAvatarUrl,
`${msg.content}${files ? "\n" + files : ""}\n` +
`-# Redirected from <#${source}> by ${actorName} (${ctx.actorId})`);
deleteMessage(source, msg.id);
editResponse(`✅ Redirected to <#${ctx.args.target_channel}>.`);
}
}Reposts someone's message in another channel through a webhook, so it keeps their name and avatar, then deletes the original. Options: message_id, target_channel, source_channel.
Tickets and support
Private threads, opened on demand and cleaned up on close.
Open a private support ticket
const key = `ticket.${ctx.actorId}`;
const existingThreadId = JSON.parse(getKV(key) || "null");
if (existingThreadId) {
editResponse(`❌ You already have an open ticket: <#${existingThreadId}>.`);
} else {
const actor = getMember(ctx.actorId);
const actorName = actor ? actor.username : ctx.actorId;
const threadId = createThread(
ctx.env.staff_support_channel_id,
`ticket-${ctx.actorId}`,
true, // private thread
ctx.actorId, // initial members (csv)
`🎫 <@&${ctx.env.mod_role_id}> ticket opened by ${actorName} (${ctx.actorId})\n` +
`**Topic:** ${ctx.args.topic}`);
setKV(key, JSON.stringify(threadId), 0);
sendMessage(ctx.env.ticket_status_channel_id,
`🟢 Ticket opened by ${actorName} (${ctx.actorId}) in <#${threadId}>: ${ctx.args.topic}`);
editResponse("✅ Ticket opened. A private thread has been created for you.");
}Creates a private thread with the requester and the mod role in it, refusing if they already have one open. Returns the new thread ID directly to save in KV. One option: topic.
Close a ticket and tidy up
const key = `ticket.${ctx.actorId}`;
const threadId = JSON.parse(getKV(key) || "null");
const isMod = hasRole(ctx.actorId, ctx.env.mod_role_id);
if (!threadId && !isMod) {
editResponse("❌ You don't have an open ticket.");
} else {
deleteKV(key);
const actor = getMember(ctx.actorId);
const actorName = actor ? actor.username : ctx.actorId;
sendMessage(ctx.channelId, `🔒 Ticket closed by ${actorName} (${ctx.actorId}).`);
sendMessage(ctx.env.ticket_status_channel_id,
`🔴 Ticket <#${ctx.channelId}> closed by ${actorName} (${ctx.actorId}).`);
// Archiving keeps the history; deleting the thread would throw it away.
lockThread(ctx.channelId, true);
archiveThread(ctx.channelId, true);
editResponse("✅ Ticket closed.");
}Locks and archives the thread rather than deleting it, so the conversation survives, and clears the KV key that marked the ticket open.
Announcements and integrations
Native Twitch and YouTube triggers deliver instant events without polling, plus scheduled scripts for custom feeds.
Announce new YouTube uploads
// Link channels on the Integrations tab, then pick YouTube: Video Published
if (ctx.youtube.isShort) return; // Skip shorts if you only want main uploads
const targetChannel = ctx.env.yt_announce_channel_id || ctx.channelId;
sendMessage(targetChannel,
`📺 New video from **${ctx.youtube.channelTitle}**: **${ctx.youtube.title}**\n${ctx.youtube.url}`);Fires immediately when a tracked YouTube channel publishes a video, without polling RSS feeds or consuming quota. Add channels under Integrations.
Announce Twitch streams when going live
// Link your Twitch channel on the Integrations tab
const channelId = ctx.env.twitch_announce_channel_id || ctx.channelId;
sendMessage(channelId, "", [{
title: "🔴 Live now: " + ctx.twitch.title,
description: "Playing **" + (ctx.twitch.category || "Just Chatting")
+ "** at https://twitch.tv/" + ctx.twitch.broadcasterLogin,
colorArgb: 9522175 // Twitch Purple
}]);Posts a live announcement with game and stream title when your linked Twitch broadcaster starts streaming. Configure your channel under Integrations.
Auto-publish announcement posts
const channels = JSON.parse(ctx.env.announcement_channel_ids || "[]");
if (channels.includes(ctx.message.channelId)) {
publishMessage(ctx.message.channelId, ctx.message.id);
}Publishes anything posted in a configured announcement channel to every server that follows it, so nobody has to remember to press the button.
Welcome new forum threads
if (ctx.thread.isForumPost && ctx.thread.parentId === ctx.env.help_forum_id) {
sendMessage(ctx.channelId,
"Thanks for posting. While you wait for a reply, the docs may already " +
"cover it: <https://mallard.bot/docs>");
}Replies once to every new post in a specific forum channel. Useful for pointing people at documentation before a human gets there.
Community
The small things that make a server feel looked after.
Nudge a conversation onward
const topics = JSON.parse(getKV("moveon:topics") || "[]");
if (topics.length === 0) {
editResponse("❌ No topics configured. Add [{ text, credit }] to the moveon:topics record.");
} else {
const pick = topics[Math.floor(Math.random() * topics.length)];
const credit = pick.credit ? `\n-# Suggested by <@${pick.credit}>` : "";
// Fourth argument is pings=false, so the credited user is named but not notified.
editResponse("", [{
title: "Please move on to a new topic",
description: `# ${pick.text}${credit}`,
colorArgb: 16747520
}], null, false);
}Posts a random discussion prompt from a list your community suggested, crediting whoever suggested it without pinging them.
Temp-ban now, unban later
// /tempban: ban, log the expiry, and let the sweeper handle the rest
const userId = ctx.args.user;
ban(userId, ctx.args.reason, 0);
const pending = JSON.parse(getKV("mod:pending_unbans") || "[]");
pending.push({ userId: userId, unbanAt: Date.now() + (ctx.args.hours * 3600000) });
setKV("mod:pending_unbans", JSON.stringify(pending));
const target = getMember(userId);
const targetName = target ? target.username : userId;
editResponse(`✅ Banned ${targetName} (${userId}) for ${ctx.args.hours}h.`);
// ── A separate Scheduled script, running hourly ──────────────────────
const all = JSON.parse(getKV("mod:pending_unbans") || "[]");
const due = all.filter(e => e.unbanAt <= Date.now());
due.forEach(e => unban(e.userId, "Temporary ban expired"));
if (due.length > 0) {
setKV("mod:pending_unbans",
JSON.stringify(all.filter(e => e.unbanAt > Date.now())));
}Scripts never sleep, so a temporary ban records its own expiry in the key-value store and a scheduled sweeper lifts it later. The same pattern covers reminders and any other deferred action.