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'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 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: 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 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
FLUXER_BOT_TOKEN=paste-the-token-here
WELCOME_CHANNEL_ID=123456789012345678
Start the bot with Node.js loading that file:
node --env-file=.env bot.jsnode --env-file=.env bot.tsA missing variable stops the bot at startup with an explanation. Channels, roles and messages are configured by their decimal IDs. The deploying guide 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 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:
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 }))
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 reasonstops a member from talking for 1 minute to 7 days!kick @member reasonremoves a member from the community!ban @member reasonbans a member permanently!tempban @member 7d reasonbans a member for 1 minute to 2 years!clear 20deletes up to 20 recent messages in the channel
Each reason is optional. Each command has a guard that checks the moderator's own permission. The !timeout, !kick, !ban and !tempban commands also check role hierarchy through members, with the moderator's ID as actorUserId. When a command fails, a middleware function tells the moderator without showing error details in the channel
- Variables:
FLUXER_BOT_TOKEN - Bot permissions:
ViewChannel,SendMessages,ReadMessageHistory,ModerateMembers,KickMembers,BanMembersandManageMessages. The bot's highest role must also rank above the members it acts on - Moderator permissions:
ModerateMembersfor!timeout,KickMembersfor!kick,BanMembersfor!banand!tempban, andManageMessagesfor!clear - Two-factor authentication: In a community that requires it for moderation, every command fails with HTTP
400unless the account that owns the bot's application has it enabled. See communities that require two-factor authentication
// 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 })
// 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 })
Effect Version
// 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 }))
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
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_IDandROLE_MESSAGE_IDfor the message,ROLE_IDfor the role, and an optionalROLE_EMOJI, a Unicode emoji that defaults to ✅ - Bot permissions:
ViewChannel,ReadMessageHistory,AddReactionsandManageRoles. The bot's highest role must rank above the configured role
import { defineBot, hierarchy, orThrow, runBot } from "@neontechspace/fluxerly"
function requiredEnv(name) {
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()
let queue = Promise.resolve()
function setRole(client, userId, granted, receipt) {
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 })
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 })
Effect Version
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 }))
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 and communities and permissions 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 optionalWELCOME_ROLE_IDfor the starter role - Bot permissions:
ViewChannel,SendMessagesandEmbedLinksin the welcome channel, plusManageRoleswhenWELCOME_ROLE_IDis set. The bot's highest role must then rank above the starter role
import { builders, defineBot, format, orThrow, runBot } from "@neontechspace/fluxerly"
function requiredEnv(name) {
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 })
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 })
Effect Version
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 }))
A welcome message that was sent but whose role grant failed stays posted, and the failure is logged. See the source on GitHub and events and collectors 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 allows one !ticket per member per minute
- Variables:
FLUXER_BOT_TOKEN,TICKET_CATEGORY_IDfor the category that holds tickets, andTICKET_STAFF_ROLE_IDfor the role that answers them - Bot permissions:
ManageChannelsandManageRoles, plusViewChannel,SendMessagesandReadMessageHistoryin the ticket category. Fluxer lets the bot grant only permissions it has itself
import { ChannelType, Permissions, defineBot, format, guards, orThrow, runBot } from "@neontechspace/fluxerly"
function requiredEnv(name) {
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 })
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 })
Effect Version
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 }))
The bot reads its own user ID with client, 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 and commands for more about guards and cooldowns