# Example bots

This guide describes SDK version 1001.0.0-nightly.20261104.56

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

Each example's folder on GitHub also holds its own test, `bot.test.js` or `bot.test.ts`, to use instead of the generated project's `!ping` test. It runs the example without a token or a network, as [Testing a bot](https://fluxerly.neontechspace.com/docs/nightly/testing.md) shows. Examples with required variables read them in `setup`, so their tests set them before calling `ready()`

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

### Copy the required IDs

In Fluxer's desktop interface:

- Right-click the channel and choose **Copy channel ID**
- Right-click the target message and choose **Copy message ID**
- In the community's role settings, right-click the role and choose **Copy role ID**
- Right-click the ticket category and choose **Copy category ID**

Put each copied ID in its matching `.env` variable

### 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
- `!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 })
```

<details>
<summary>Effect version</summary>

```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 { Effect } from "effect"
import { defineBot, describeError, format, guards, runBot } from "@neontechspace/fluxerly/effect"

export const bot = defineBot({
    onError: (report) => Effect.logError(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
            ({ reply }, next) =>
                next.pipe(
                    Effect.tapError(() =>
                        reply("That command failed. The error was reported").pipe(
                            Effect.catch((error) => Effect.logError(describeError(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: ({ client, message, values, reply }) =>
                    Effect.gen(function* () {
                        // The moderator must outrank the member, so the bot never acts above the moderator's own rank
                        const moderator = { actorUserId: message.author.id }
                        if (!(yield* client.members.fetchCanManage(values.member, moderator)))
                            return yield* reply("That member ranks at or above the moderator")
                        yield* client.members.timeout(values.member, values.duration, {
                            timeoutReason: values.reason ?? null,
                        })
                        return yield* 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: ({ client, message, values, reply }) =>
                    Effect.gen(function* () {
                        // The moderator must outrank the member, so the bot never acts above the moderator's own rank
                        const moderator = { actorUserId: message.author.id }
                        if (!(yield* client.members.fetchCanManage(values.member, moderator)))
                            return yield* reply("That member ranks at or above the moderator")
                        const options = values.reason === undefined ? {} : { auditReason: values.reason }
                        yield* client.members.kick(values.member, options)
                        return yield* 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: ({ client, message, values, reply }) =>
                    Effect.gen(function* () {
                        // The moderator must outrank the member, so the bot never acts above the moderator's own rank
                        const moderator = { actorUserId: message.author.id }
                        if (!(yield* client.members.fetchCanManage(values.member, moderator)))
                            return yield* reply("That member ranks at or above the moderator")
                        const ban = values.reason === undefined ? {} : { reason: values.reason }
                        yield* client.members.ban(values.member, ban)
                        return yield* 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: ({ client, message, values, reply }) =>
                    Effect.gen(function* () {
                        const moderator = { actorUserId: message.author.id }
                        if (!(yield* client.members.fetchCanManage(values.member, moderator)))
                            return yield* 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 }),
                        }
                        yield* client.members.ban(values.member, ban)
                        return yield* 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: ({ client, message, values, reply }) =>
                    Effect.gen(function* () {
                        // The preview lists the exact messages first, and cleanup deletes only those
                        const plan = yield* client.messages.previewCleanup(message.channelId, {
                            filter: (candidate) => candidate.id !== message.id,
                            maxScanned: values.count + 1,
                            maxSelected: values.count,
                        })
                        const report = yield* client.messages.cleanup(plan)
                        return yield* 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 Effect.runPromiseExit(runBot({ token: process.env.FLUXER_BOT_TOKEN, processSignals: true, ...bot }))
```

</details>

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 = ""

// Adds and removals reach separate handlers, which can run out of order when many members react at once. So each
// member's newest reaction event decides, by its receipt, and role changes go out one at a time
const newest = new Map<string, number>()
let queue: Promise<unknown> = Promise.resolve()

function setRole(client: Client, userId: string, granted: boolean, receipt: number) {
    if (userId === botUserId || receipt < (newest.get(userId) ?? 0)) return
    newest.set(userId, receipt)
    const change = queue.then(async () => {
        // A newer reaction event from this member arrived while this one waited
        if (newest.get(userId) !== receipt) return
        const member = { guildId, userId }
        orThrow(await (granted ? client.members.addRole(member, roleId) : client.members.removeRole(member, roleId)))
    })
    queue = change.catch(() => undefined)
    return change
}

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) => {
        // Each client numbers its events from 1 again
        newest.clear()
        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, context }) => {
            if (event.id === target.id && event.emoji.name === emoji)
                await setRole(client, event.userId, true, context.receipt)
        },
        messageReactionRemove: async ({ event, client, context }) => {
            if (event.id === target.id && event.emoji.name === emoji)
                await setRole(client, event.userId, false, context.receipt)
        },
    },
})

// 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 })
```

<details>
<summary>Effect version</summary>

```ts
import { Data, Effect, Semaphore } from "effect"
import { defineBot, hierarchy, runBot, type Client } from "@neontechspace/fluxerly/effect"

// A missing or wrong setting fails setup, which stops the bot at startup with this message
class SettingError extends Data.TaggedError("SettingError")<{ readonly message: string }> {}

const requiredEnv = (name: string) =>
    Effect.suspend(() => {
        const value = process.env[name]
        return value ? Effect.succeed(value) : new SettingError({ message: `Set the ${name} environment variable` })
    })

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 = ""

// Adds and removals reach separate handlers, which can run out of order when many members react at once. So each
// member's newest reaction event decides, by its receipt, and role changes go out one at a time
const newest = new Map<string, number>()
const oneAtATime = Semaphore.makeUnsafe(1)

const setRole = (client: Client, userId: string, granted: boolean, receipt: number) =>
    Effect.gen(function* () {
        if (userId === botUserId || receipt < (newest.get(userId) ?? 0)) return
        newest.set(userId, receipt)
        yield* oneAtATime.withPermit(
            Effect.suspend(() => {
                // A newer reaction event from this member arrived while this one waited
                if (newest.get(userId) !== receipt) return Effect.void
                const member = { guildId, userId }
                return granted ? client.members.addRole(member, roleId) : client.members.removeRole(member, roleId)
            }),
        )
    })

export const bot = defineBot({
    onError: (report) => Effect.logError(report.describe()),
    // Check the configuration once before connecting, so a missing setting or a role the bot cannot assign stops it
    // at startup
    setup: (client) =>
        Effect.gen(function* () {
            // Each client numbers its events from 1 again
            newest.clear()
            target = { channelId: yield* requiredEnv("ROLE_CHANNEL_ID"), id: yield* requiredEnv("ROLE_MESSAGE_ID") }
            roleId = yield* requiredEnv("ROLE_ID")
            guildId = (yield* client.channels.fetch(target.channelId)).guildId
            const self = yield* client.members.fetchSelf(guildId)
            botUserId = self.userId
            const roles = yield* 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)))
                return yield* new SettingError({ message: "ROLE_ID must name a role below the bot's highest role" })
            // The bot's own reaction shows members which emoji to use
            yield* client.messages.addReaction(target, emoji)
        }),
    events: {
        messageReactionAdd: ({ event, client, context }) =>
            event.id === target.id && event.emoji.name === emoji
                ? setRole(client, event.userId, true, context.receipt)
                : Effect.void,
        messageReactionRemove: ({ event, client, context }) =>
            event.id === target.id && event.emoji.name === emoji
                ? setRole(client, event.userId, false, context.receipt)
                : Effect.void,
    },
})

// 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 Effect.runPromiseExit(runBot({ token: process.env.FLUXER_BOT_TOKEN, processSignals: true, ...bot }))
```

</details>

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 })
```

<details>
<summary>Effect version</summary>

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

// A missing or wrong setting fails setup, which stops the bot at startup with this message
class SettingError extends Data.TaggedError("SettingError")<{ readonly message: string }> {}

const requiredEnv = (name: string) =>
    Effect.suspend(() => {
        const value = process.env[name]
        return value ? Effect.succeed(value) : new SettingError({ message: `Set the ${name} environment variable` })
    })

// 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) => Effect.logError(report.describe()),
    // Setup runs before the bot connects, so a missing or wrong channel ID stops the bot at startup
    setup: (client) =>
        Effect.gen(function* () {
            channelId = yield* requiredEnv("WELCOME_CHANNEL_ID")
            guildId = (yield* client.channels.fetch(channelId)).guildId
        }),
    events: {
        guildMemberAdd: ({ event: member, client }) =>
            Effect.gen(function* () {
                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)
                yield* 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
                // A failed role grant fails the handler, which reports it to onError
                yield* 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 Effect.runPromiseExit(runBot({ token: process.env.FLUXER_BOT_TOKEN, processSignals: true, ...bot }))
```

</details>

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 })
```

<details>
<summary>Effect version</summary>

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

// A missing or wrong setting fails setup, which stops the bot at startup with this message
class SettingError extends Data.TaggedError("SettingError")<{ readonly message: string }> {}

const requiredEnv = (name: string) =>
    Effect.suspend(() => {
        const value = process.env[name]
        return value ? Effect.succeed(value) : new SettingError({ message: `Set the ${name} environment variable` })
    })

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) => Effect.logError(report.describe()),
    // Setup runs before the bot connects, so a missing setting stops the bot at startup
    setup: () =>
        Effect.gen(function* () {
            categoryId = yield* requiredEnv("TICKET_CATEGORY_ID")
            staffRoleId = yield* 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: ({ client, message, reply }) =>
                    Effect.gen(function* () {
                        const guildId = message.guildId
                        if (guildId === undefined) return
                        const name = `ticket-${message.author.id}`
                        const channels = yield* client.channels.fetchAll(guildId)
                        const open = channels.find(
                            (channel) => channel.parentId === categoryId && channel.name === name,
                        )
                        if (open) return yield* 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 = yield* client.users.getSelf()
                        if (bot === undefined)
                            return yield* reply("The bot's own account is not known yet. Try again later")

                        const channel = yield* 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 },
                            ],
                        })
                        yield* 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 yield* reply(`Ticket opened: ${format.channelMention(channel.id)}`)
                    }),
            },
            close: {
                description: "Delete this ticket channel",
                guard: guards.guildOnly(),
                execute: ({ client, message, reply }) =>
                    Effect.gen(function* () {
                        const channel = yield* 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 yield* reply("Send !close inside a ticket channel")
                        return yield* 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 Effect.runPromiseExit(runBot({ token: process.env.FLUXER_BOT_TOKEN, processSignals: true, ...bot }))
```

</details>

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
