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.

Twitch & YouTube Integrations Available

New

Native event triggers replace polling feeds with fetch(). Link channels on the Integrations tab and react in real time with ctx.twitch and ctx.youtube.

View templates

Moderation

Mallard seeds warn, kick, ban, timeout, notes and friends on install. These go past that.

Catch image-spam automatically

MessageCreate
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

MessageCreate
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

MessageCreate
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

SlashCommand
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

SlashCommand
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

SlashCommand
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

YouTubeVideoPublished
// 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

TwitchStreamOnline
// 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

MessageCreate
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

ThreadCreate
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

SlashCommand
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

SlashCommand + Scheduled
// /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.

Got one worth sharing?

The support server has a scripts hub where people post what they have built and help each other debug. Good ones end up on this page.

Join the scripts hub
An unhandled error has occurred. Reload 🗙