Mallard

Templates

11 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 31 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

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");

    sendMessage(ctx.env.mod_alert_channel_id, "", [{
        title: "🖼️ Image spam",
        description: `<@${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(", ");
        sendMessage(ctx.env.mod_alert_channel_id, "", [{
            title: "📎 Attachment removed",
            description: `Removed ${names} from <@${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.");

    sendMessage(ctx.env.mod_alert_channel_id, "", [{
        title: "🔔 Mod ping",
        description:
            `<@${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");

        sendWebhookMessage(
            ctx.args.target_channel,
            msg.authorUsername,
            msg.authorAvatarUrl,
            `${msg.content}${files ? "\n" + files : ""}\n` +
            `-# Redirected from <#${source}> by <@${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}`;

if (getKV(key)) {
    editResponse("❌ You already have an open ticket.");
} else {
    createThread(
        ctx.env.staff_support_channel_id,
        `ticket-${ctx.actorId}`,
        true,             // private thread
        ctx.actorId,      // initial members (csv)
        `🎫 Ticket opened by <@${ctx.actorId}> — <@&${ctx.env.mod_role_id}>\n` +
        `**Topic:** ${ctx.args.topic}`,
        key);             // the new thread's ID lands in this KV key

    const threadId = Number(getKV(key)) || 0;
    if (threadId !== 0) {
        sendMessage(ctx.env.ticket_status_channel_id,
            `🟢 Ticket opened by <@${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. The new thread's ID is written to a KV key so other scripts can find it. 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);

    sendMessage(ctx.channelId, `🔒 Ticket closed by <@${ctx.actorId}>.`);
    sendMessage(ctx.env.ticket_status_channel_id,
        `🔴 Ticket <#${ctx.channelId}> closed by <@${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 feeds

Scheduled scripts with fetch() give you any feed Discord doesn't natively support.

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.

Poll a YouTube channel for uploads

Scheduled
const channels = JSON.parse(getKV("yt.channels") || "[]");
let changed = false;

channels.forEach(function (ch) {
    const res = fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${ch.id}`);
    if (res.status !== 200) return;

    const latest = res.body.match(/<yt:videoId>([^<]+)/);
    if (!latest) return;

    if (latest[1] !== ch.lastVideoId) {
        sendMessage(ctx.env.yt_announce_channel_id,
            `📺 New upload: https://youtu.be/${latest[1]}`);
        ch.lastVideoId = latest[1];
        changed = true;
    }
});

if (changed) setKV("yt.channels", JSON.stringify(channels));

Reads YouTube's public RSS feed on a timer and posts anything it hasn't seen before. Store an array of channels in the yt.channels KV key.

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));
editResponse(`✅ Banned <@${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.