Mallard
Dashboard

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 ↓

No templates matched your search. Try searching for "spam", "tickets", "twitch", or a trigger name.

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(await getKV(key) || "[]")
    .filter(e => now - e.timestamp < 10000);

recent.push({
    messageId: ctx.message.id,
    channelId: ctx.message.channelId,
    timestamp: now
});
await setKV(key, JSON.stringify(recent), 60);

if (recent.length >= 3 && ctx.message.attachments.length > 0) {
    await deleteKV(key);
    const target = await ctx.guild.members.fetch(ctx.message.authorId);
    if (target) await target.timeout(1440 * 60000, "Image spam (3+ attachments in 10s)");

    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;
    await discord.channels.createMessage(ctx.env.mod_alert_channel_id, {
        embeds: [{
            title: "🖼️ Image spam",
            description: `${author} (${ctx.message.authorId}) timed out 24h. Please review:\n${links}`,
            color: 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 && !await 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) {
        await ctx.message.delete();
        await ctx.author.send(
            "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;
        await discord.channels.createMessage(ctx.env.mod_alert_channel_id, {
            embeds: [{
                title: "📎 Attachment removed",
                description: `Removed ${names} from ${author} (${ctx.message.authorId}) in <#${ctx.channelId}>.`,
                color: 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 && !await hasRole(ctx.message.authorId, ctx.env.mod_role_id)) {
    await ctx.channel.send(
        "👋 A mod has been pinged and will take a look shortly.");

    const author = ctx.message.authorUsername || ctx.message.authorDisplayName || ctx.message.authorId;
    await discord.channels.createMessage(ctx.env.mod_alert_channel_id, {
        embeds: [{
            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}`,
            color: 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 (!await hasRole(ctx.actorId, ctx.env.mod_role_id)) {
    await ctx.interaction.editReply("❌ Mods only.");
} else {
    const source = ctx.args.source_channel || ctx.channelId;
    const msg = await getMessage(source, ctx.args.message_id);

    if (!msg) {
        await ctx.interaction.editReply("❌ 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 = await ctx.guild.members.fetch(ctx.actorId);
        const actorName = actor ? actor.username : ctx.actorId;

        await sendWebhookMessage(
            ctx.args.target_channel,
            msg.authorUsername,
            msg.authorAvatarUrl,
            `${msg.content}${files ? "\n" + files : ""}\n` +
            `-# Redirected from <#${source}> by ${actorName} (${ctx.actorId})`);

        await discord.channels.deleteMessage(source, ctx.args.message_id);
        await ctx.interaction.editReply(`✅ 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(await getKV(key) || "null");

if (existingThreadId) {
    await ctx.interaction.editReply(`❌ You already have an open ticket: <#${existingThreadId}>.`);
} else {
    const actor = await ctx.guild.members.fetch(ctx.actorId);
    const actorName = actor ? actor.username : ctx.actorId;
    const threadId = await 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}`);

    await setKV(key, JSON.stringify(threadId), 0);
    await discord.channels.createMessage(ctx.env.ticket_status_channel_id,
        `🟢 Ticket opened by ${actorName} (${ctx.actorId}) in <#${threadId}>: ${ctx.args.topic}`);

    await ctx.interaction.editReply("✅ 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(await getKV(key) || "null");
const isMod = await hasRole(ctx.actorId, ctx.env.mod_role_id);

if (!threadId && !isMod) {
    await ctx.interaction.editReply("❌ You don't have an open ticket.");
} else {
    await deleteKV(key);

    const actor = await ctx.guild.members.fetch(ctx.actorId);
    const actorName = actor ? actor.username : ctx.actorId;
    await ctx.channel.send( `🔒 Ticket closed by ${actorName} (${ctx.actorId}).`);
    await discord.channels.createMessage(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.
    await discord.channels.edit(ctx.channelId, { locked: true });
    await discord.channels.edit(ctx.channelId, { archived: true });

    await ctx.interaction.editReply("✅ 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;
await discord.channels.createMessage(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;
await discord.channels.createMessage(channelId, {
    embeds: [{
        title: "🔴 Live now: " + ctx.twitch.title,
        description: "Playing **" + (ctx.twitch.category || "Just Chatting")
            + "** at https://twitch.tv/" + ctx.twitch.broadcasterLogin,
        color: 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)) {
    await discord.channels.crosspostMessage(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) {
    await ctx.channel.send(
        "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(await getKV("moveon:topics") || "[]");

if (topics.length === 0) {
    await ctx.interaction.editReply("❌ 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.
    await ctx.interaction.editReply({
        embeds: [{
            title: "Please move on to a new topic",
            description: `# ${pick.text}${credit}`,
            color: 16747520
        }]
    });
}

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;
 await ctx.guild.members.ban(userId, { reason: ctx.args.reason });

 const pending = JSON.parse(await getKV("mod:pending_unbans") || "[]");
 pending.push({ userId: userId, unbanAt: Date.now() + (ctx.args.hours * 3600000) });
 await setKV("mod:pending_unbans", JSON.stringify(pending));
 const target = await ctx.guild.members.fetch(userId);
 const targetName = target ? target.username : userId;
 await ctx.interaction.editReply(`✅ Banned ${targetName} (${userId}) for ${ctx.args.hours}h.`);

// ── A separate Scheduled script, running hourly ──────────────────────
const all = JSON.parse(await getKV("mod:pending_unbans") || "[]");
const due = all.filter(e => e.unbanAt <= Date.now());

for (const e of due) {
     await ctx.guild.members.unban(e.userId, "Temporary ban expired");
 }

if (due.length > 0) {
    await 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.