# Example bots

Small, complete bots for moderation, reaction roles, welcomes and tickets, ready to copy and run

Each example on this page is one complete file shaped like the [quick start](https://fluxerly.neontechspace.com/docs/nightly/quick-start.md)'s bot: It exports its options as `bot` with `defineBot` and passes them to `runBot` when Node.js runs the file. Copy it, set a few environment variables and run it. Every example logs failures in full and stops cleanly on Ctrl+C

Start with the [small bot](https://fluxerly.neontechspace.com/docs/nightly/small-bot.md) for a guided tour of commands, arguments, embeds, events, guards and cooldowns in one file. The examples below each solve one common task

## Run an example

Set up a project as in the [quick start](https://fluxerly.neontechspace.com/docs/nightly/quick-start.md): A folder with `"type": "module"` in `package.json` and the SDK installed. Save the example as `bot.js`, or as `bot.ts` to keep the TypeScript version, which Node.js 24 runs directly

A generated project's test imports `bot` from that file, so it runs the example without a token or a network. The test checks `!ping`, which most examples do not have, so change it to the example's own commands or events, as [Testing a bot](https://fluxerly.neontechspace.com/docs/nightly/testing.md) shows. Examples with required variables read them in `setup`, so a test that calls `ready()` sets them first

Put the token and the example's other variables in a `.env` file, one `KEY=value` line each, and keep that file out of version control

```text
FLUXER_BOT_TOKEN=paste-the-token-here
WELCOME_CHANNEL_ID=123456789012345678
```

Start the bot with Node.js loading that file:

```sh
node --env-file=.env bot.js
```

A missing variable stops the bot at startup with an explanation. Channels, roles and messages are configured by their decimal [IDs](https://fluxerly.neontechspace.com/docs/nightly/glossary.md#id). The [deploying guide](https://fluxerly.neontechspace.com/docs/nightly/deploying.md) shows how to keep a bot running on a server

### Give the bot its permissions

The invite in [Create a bot](https://fluxerly.neontechspace.com/docs/nightly/create-a-bot.md#3-invite-the-bot-to-a-community) grants only what the quick start needs. Each example below lists the permissions it needs, and without them its requests fail with a missing-permissions error

For a bot that is already in the community, edit the role Fluxer created for it, which has the application's name, and turn on the listed permissions in the community's role settings. For a bot that is not in the community yet, pass the listed names to the invite script instead, for example for the moderation example:

```ts
import { links, permissionBits } from "@neontechspace/fluxerly"

const applicationId = process.argv[2] ?? ""
const permissions = permissionBits.from([
    "ViewChannel",
    "SendMessages",
    "ReadMessageHistory",
    "ModerateMembers",
    "KickMembers",
    "BanMembers",
    "ManageMessages",
])
console.log(links.installation(applicationId, { permissions }))
```

Some examples also change roles or members. Fluxer allows that only when the bot's highest role ranks above them, so place the bot's role above those roles in the role settings

## Moderation commands

Five prefix commands for moderators: `!timeout @member 10m reason` stops a member from talking for 1 minute to 7 days, `!kick @member reason` removes a member from the community, `!ban @member reason` bans a member permanently, `!tempban @member 7d reason` bans a member for 1 minute to 2 years, and `!clear 20` deletes up to 20 recent messages in the channel. Each reason is optional. Each command has a [guard](https://fluxerly.neontechspace.com/docs/nightly/glossary.md#guard) that checks the moderator's own permission. The `!timeout`, `!kick`, `!ban` and `!tempban` commands also check role hierarchy through `members.fetchCanManage`, with the moderator's ID as `actorUserId`. When a command fails, a [middleware](https://fluxerly.neontechspace.com/docs/nightly/glossary.md#middleware) function tells the moderator without showing error details in the channel

- Variables: `FLUXER_BOT_TOKEN`
- Bot permissions: `ViewChannel`, `SendMessages`, `ReadMessageHistory`, `ModerateMembers`, `KickMembers`, `BanMembers` and `ManageMessages`. The bot's highest role must also rank above the members it acts on
- Moderator permissions: `ModerateMembers` for `!timeout`, `KickMembers` for `!kick`, `BanMembers` for `!ban` and `!tempban`, and `ManageMessages` for `!clear`
- Two-factor authentication: In a community that requires it for moderation, every command fails with HTTP 400 unless the account that owns the bot's application has it enabled. See [communities that require two-factor authentication](https://fluxerly.neontechspace.com/docs/nightly/guilds-and-permissions.md#communities-that-require-two-factor-authentication)

```ts
// In a server that requires two-factor authentication for moderation, Fluxer refuses these commands with
// HTTP 400 unless the account that owns the bot's application has two-factor authentication enabled
import { defineBot, describeError, format, guards, orThrow, runBot } from "@neontechspace/fluxerly"

export const bot = defineBot({
    onError: (report) => console.error(report.describe()),
    commands: {
        prefix: "!",
        use: [
            // Tell the moderator when a command fails, without showing error details in the channel.
            // The router still reports the full error to onError
            async ({ reply }, next) => {
                try {
                    await next()
                } catch (error) {
                    const sent = await reply("That command failed. The error was reported")
                    if (sent.isErr()) console.error(describeError(sent.error))
                    throw error
                }
            },
        ],
        commands: {
            timeout: {
                description: "Stop a member from talking for a while",
                guard: guards.requirePermissions(["ModerateMembers"]),
                arguments: {
                    member: { type: "member" },
                    duration: { type: "duration", min: 60_000, max: 7 * 24 * 60 * 60_000 },
                    reason: { type: "text", rest: true, optional: true },
                },
                execute: async ({ client, message, values, reply }) => {
                    // The moderator must outrank the member, so the bot never acts above the moderator's own rank
                    const moderator = { actorUserId: message.author.id }
                    if (!orThrow(await client.members.fetchCanManage(values.member, moderator)))
                        return reply("That member ranks at or above the moderator")
                    orThrow(
                        await client.members.timeout(values.member, values.duration, {
                            timeoutReason: values.reason ?? null,
                        }),
                    )
                    return reply(`Timed out ${format.userMention(values.member.userId)}`)
                },
            },
            kick: {
                description: "Remove a member from the server",
                guard: guards.requirePermissions(["KickMembers"]),
                arguments: { member: { type: "member" }, reason: { type: "text", rest: true, optional: true } },
                execute: async ({ client, message, values, reply }) => {
                    // The moderator must outrank the member, so the bot never acts above the moderator's own rank
                    const moderator = { actorUserId: message.author.id }
                    if (!orThrow(await client.members.fetchCanManage(values.member, moderator)))
                        return reply("That member ranks at or above the moderator")
                    const options = values.reason === undefined ? {} : { auditReason: values.reason }
                    orThrow(await client.members.kick(values.member, options))
                    return reply(`Kicked ${format.userMention(values.member.userId)}`)
                },
            },
            ban: {
                description: "Ban a member permanently",
                guard: guards.requirePermissions(["BanMembers"]),
                arguments: { member: { type: "member" }, reason: { type: "text", rest: true, optional: true } },
                execute: async ({ client, message, values, reply }) => {
                    // The moderator must outrank the member, so the bot never acts above the moderator's own rank
                    const moderator = { actorUserId: message.author.id }
                    if (!orThrow(await client.members.fetchCanManage(values.member, moderator)))
                        return reply("That member ranks at or above the moderator")
                    const ban = values.reason === undefined ? {} : { reason: values.reason }
                    orThrow(await client.members.ban(values.member, ban))
                    return reply(`Banned ${format.userMention(values.member.userId)}`)
                },
            },
            // A separate command keeps the duration required, so a reason word is never read as a duration
            tempban: {
                description: "Ban a member for a while, such as 7d",
                guard: guards.requirePermissions(["BanMembers"]),
                arguments: {
                    member: { type: "member" },
                    // Fluxer takes ban durations in whole seconds, from one minute up to two years
                    duration: { type: "duration", wholeSeconds: true, min: 60_000, max: 63_072_000_000 },
                    reason: { type: "text", rest: true, optional: true },
                },
                execute: async ({ client, message, values, reply }) => {
                    const moderator = { actorUserId: message.author.id }
                    if (!orThrow(await client.members.fetchCanManage(values.member, moderator)))
                        return reply("That member ranks at or above the moderator")
                    // Ban durations are milliseconds, so the bounded duration argument passes through unchanged
                    const ban = {
                        durationMs: values.duration,
                        ...(values.reason === undefined ? {} : { reason: values.reason }),
                    }
                    orThrow(await client.members.ban(values.member, ban))
                    return reply(`Banned ${format.userMention(values.member.userId)} for a while`)
                },
            },
            clear: {
                description: "Delete recent messages in this channel",
                guard: guards.requirePermissions(["ManageMessages"]),
                cooldown: { durationMs: 10_000, per: "channel" },
                arguments: { count: { type: "integer", min: 1, max: 100, default: 10 } },
                execute: async ({ client, message, values, reply }) => {
                    // The preview lists the exact messages first, and cleanup deletes only those
                    const plan = orThrow(
                        await client.messages.previewCleanup(message.channelId, {
                            filter: (candidate) => candidate.id !== message.id,
                            maxScanned: values.count + 1,
                            maxSelected: values.count,
                        }),
                    )
                    const report = orThrow(await client.messages.cleanup(plan))
                    return reply(`Requested deletion of ${report.selectedMessageIds.length} messages`)
                },
            },
        },
    },
})

// Running this file starts the bot, while importing it from a test does not.
// A failure that stops the bot, such as a rejected token, is logged and sets a failing exit code
if (import.meta.main) await runBot({ token: process.env.FLUXER_BOT_TOKEN, ...bot })
```

The `!clear` command previews the exact messages first and then deletes only those. The reply reports how many deletions were requested, because Fluxer confirms each batch rather than each message. See [the source on GitHub](https://github.com/NeonTechSpace/Fluxerly.js/tree/main/projects/sdk/examples/moderation)

## Reaction roles

Members react to one existing message to receive an opt-in role, and remove their reaction to give it back. At startup, the bot checks that it ranks above the role and adds the emoji to the message itself, so members can see which one to use

- Variables: `FLUXER_BOT_TOKEN`, `ROLE_CHANNEL_ID` and `ROLE_MESSAGE_ID` for the message, `ROLE_ID` for the role, and an optional `ROLE_EMOJI`, a Unicode emoji that defaults to ✅
- Bot permissions: `ViewChannel`, `ReadMessageHistory`, `AddReactions` and `ManageRoles`. The bot's highest role must rank above the configured role

```ts
import { defineBot, hierarchy, orThrow, runBot, type Client } from "@neontechspace/fluxerly"

function requiredEnv(name: string) {
    const value = process.env[name]
    if (!value) throw new Error(`Set the ${name} environment variable`)
    return value
}

const emoji = process.env.ROLE_EMOJI || "✅"
// Setup reads the required settings, so a test can import this file without them
let target = { channelId: "", id: "" }
let roleId = ""
let guildId = ""
let botUserId = ""

async function setRole(client: Client, userId: string, granted: boolean) {
    if (userId === botUserId) return
    const member = { guildId, userId }
    orThrow(await (granted ? client.members.addRole(member, roleId) : client.members.removeRole(member, roleId)))
}

export const bot = defineBot({
    onError: (report) => console.error(report.describe()),
    // Check the configuration once before connecting, so a missing setting or a role the bot cannot assign stops it
    // at startup
    setup: async (client) => {
        target = { channelId: requiredEnv("ROLE_CHANNEL_ID"), id: requiredEnv("ROLE_MESSAGE_ID") }
        roleId = requiredEnv("ROLE_ID")
        guildId = orThrow(await client.channels.fetch(target.channelId)).guildId
        const self = orThrow(await client.members.fetchSelf(guildId))
        botUserId = self.userId
        const roles = orThrow(await client.roles.fetchAll(guildId))
        const role = roles.find((candidate) => candidate.id === roleId)
        const botRoles = roles.filter((candidate) => self.roleIds.includes(candidate.id))
        if (!role || !botRoles.some((botRole) => hierarchy.isAbove(botRole, role)))
            throw new Error("ROLE_ID must name a role below the bot's highest role")
        // The bot's own reaction shows members which emoji to use
        orThrow(await client.messages.addReaction(target, emoji))
    },
    events: {
        messageReactionAdd: async ({ event, client }) => {
            if (event.id === target.id && event.emoji.name === emoji) await setRole(client, event.userId, true)
        },
        messageReactionRemove: async ({ event, client }) => {
            if (event.id === target.id && event.emoji.name === emoji) await setRole(client, event.userId, false)
        },
    },
})

// Running this file starts the bot, while importing it from a test does not.
// A failure that stops the bot, such as a rejected token, is logged and sets a failing exit code
if (import.meta.main) await runBot({ token: process.env.FLUXER_BOT_TOKEN, ...bot })
```

Configure only a role that members may give themselves. The bot sees only reactions made while it runs, so a member who reacted while it was offline removes the reaction and adds it again. See [the source on GitHub](https://github.com/NeonTechSpace/Fluxerly.js/tree/main/projects/sdk/examples/reaction-roles) and [communities and permissions](https://fluxerly.neontechspace.com/docs/nightly/guilds-and-permissions.md) for more about role hierarchy

## Welcome messages

The bot greets each new member in a configured channel with a mention and an embed, and can also give every new member a starter role. It ignores joins in other communities and joining bots

- Variables: `FLUXER_BOT_TOKEN`, `WELCOME_CHANNEL_ID`, and an optional `WELCOME_ROLE_ID` for the starter role
- Bot permissions: `ViewChannel`, `SendMessages` and `EmbedLinks` in the welcome channel, plus `ManageRoles` when `WELCOME_ROLE_ID` is set. The bot's highest role must then rank above the starter role

```ts
import { builders, defineBot, format, orThrow, runBot } from "@neontechspace/fluxerly"

function requiredEnv(name: string) {
    const value = process.env[name]
    if (!value) throw new Error(`Set the ${name} environment variable`)
    return value
}

// Optional role that every new member receives
const roleId = process.env.WELCOME_ROLE_ID || undefined
// Setup reads the channel, so a test can import this file without the setting
let channelId = ""
let guildId = ""

export const bot = defineBot({
    onError: (report) => console.error(report.describe()),
    // Setup runs before the bot connects, so a missing or wrong channel ID stops the bot at startup
    setup: async (client) => {
        channelId = requiredEnv("WELCOME_CHANNEL_ID")
        guildId = orThrow(await client.channels.fetch(channelId)).guildId
    },
    events: {
        guildMemberAdd: async ({ event: member, client }) => {
            if (member.guildId !== guildId || member.isBot) return
            const embed = builders
                .embed()
                .title("Welcome!")
                .description("Read the rules, pick a nickname and say hello")
                .color(0x8b7cf8)
            orThrow(
                await client.messages.send(channelId, {
                    content: `Welcome, ${format.userMention(member.userId)}!`,
                    embeds: [embed],
                    // Mentions notify nobody unless allowed explicitly
                    allowedMentions: { users: [member.userId] },
                }),
            )
            if (roleId === undefined) return
            // Returning the Result reports a failed role grant to onError
            return client.members.addRole({ guildId, userId: member.userId }, roleId)
        },
    },
})

// Running this file starts the bot, while importing it from a test does not.
// A failure that stops the bot, such as a rejected token, is logged and sets a failing exit code
if (import.meta.main) await runBot({ token: process.env.FLUXER_BOT_TOKEN, ...bot })
```

A welcome message that was sent but whose role grant failed stays posted, and the failure is logged. See [the source on GitHub](https://github.com/NeonTechSpace/Fluxerly.js/tree/main/projects/sdk/examples/welcome) and [events and collectors](https://fluxerly.neontechspace.com/docs/nightly/events-and-collectors.md) for more about member and community events

## Support tickets

A member sends `!ticket` to open a private channel visible to them, the staff role, the bot, the community owner and members with `Administrator`. Anyone in that channel sends `!close` to delete it. Each member has at most one open ticket, and a [cooldown](https://fluxerly.neontechspace.com/docs/nightly/glossary.md#cooldown) allows one `!ticket` per member per minute

- Variables: `FLUXER_BOT_TOKEN`, `TICKET_CATEGORY_ID` for the category that holds tickets, and `TICKET_STAFF_ROLE_ID` for the role that answers them
- Bot permissions: `ManageChannels` and `ManageRoles`, plus `ViewChannel`, `SendMessages` and `ReadMessageHistory` in the ticket category. Fluxer lets the bot grant only permissions it has itself

```ts
import { ChannelType, Permissions, defineBot, format, guards, orThrow, runBot } from "@neontechspace/fluxerly"

function requiredEnv(name: string) {
    const value = process.env[name]
    if (!value) throw new Error(`Set the ${name} environment variable`)
    return value
}

const access = Permissions.ViewChannel | Permissions.SendMessages | Permissions.ReadMessageHistory
// Setup reads the required settings, so a test can import this file without them
let categoryId = ""
let staffRoleId = ""

export const bot = defineBot({
    onError: (report) => console.error(report.describe()),
    // Setup runs before the bot connects, so a missing setting stops the bot at startup
    setup: () => {
        categoryId = requiredEnv("TICKET_CATEGORY_ID")
        staffRoleId = requiredEnv("TICKET_STAFF_ROLE_ID")
    },
    commands: {
        prefix: "!",
        commands: {
            ticket: {
                description: "Open a private channel with the staff",
                guard: guards.guildOnly(),
                // One attempt per member per minute, and the name check below keeps one open ticket per member
                cooldown: { durationMs: 60_000, per: "user" },
                execute: async ({ client, message, reply }) => {
                    const guildId = message.guildId
                    if (guildId === undefined) return
                    const name = `ticket-${message.author.id}`
                    const channels = orThrow(await client.channels.fetchAll(guildId))
                    const open = channels.find((channel) => channel.parentId === categoryId && channel.name === name)
                    if (open) return reply(`A ticket is already open: ${format.channelMention(open.id)}`)
                    // The bot's own account, as Fluxer reported it when the bot connected, read without a request
                    const bot = client.users.getSelf()
                    if (bot === undefined) return reply("The bot's own account is not known yet. Try again later")

                    const channel = orThrow(
                        await client.channels.create(guildId, {
                            type: ChannelType.Text,
                            name,
                            parentId: categoryId,
                            // Hide the channel from other members. The member, the staff role and the bot can see it, as can the owner and Administrators
                            permissionOverwrites: [
                                { id: guildId, type: "role", allow: 0n, deny: Permissions.ViewChannel },
                                { id: staffRoleId, type: "role", allow: access, deny: 0n },
                                { id: message.author.id, type: "member", allow: access, deny: 0n },
                                { id: bot.id, type: "member", allow: access, deny: 0n },
                            ],
                        }),
                    )
                    orThrow(
                        await client.messages.send(channel.id, {
                            content: `${format.userMention(message.author.id)} Describe the problem here. Send !close when it is solved`,
                            allowedMentions: { users: [message.author.id] },
                        }),
                    )
                    return reply(`Ticket opened: ${format.channelMention(channel.id)}`)
                },
            },
            close: {
                description: "Delete this ticket channel",
                guard: guards.guildOnly(),
                execute: async ({ client, message, reply }) => {
                    const channel = orThrow(await client.channels.fetch(message.channelId))
                    // Only channels in the ticket category that follow the ticket naming can be deleted
                    if (channel.parentId !== categoryId || !channel.name?.startsWith("ticket-"))
                        return reply("Send !close inside a ticket channel")
                    return client.channels.delete(channel.id)
                },
            },
        },
    },
})

// Running this file starts the bot, while importing it from a test does not.
// A failure that stops the bot, such as a rejected token, is logged and sets a failing exit code
if (import.meta.main) await runBot({ token: process.env.FLUXER_BOT_TOKEN, ...bot })
```

The bot reads its own user ID with `client.users.getSelf()`, which returns the account Fluxer reported when the bot connected, without a request. The `!close` command deletes only channels in the ticket category whose names start with `ticket-`. See [the source on GitHub](https://github.com/NeonTechSpace/Fluxerly.js/tree/main/projects/sdk/examples/tickets) and [commands](https://fluxerly.neontechspace.com/docs/nightly/commands.md) for more about guards and cooldowns
