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 define and passes them to run 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
A generated project's test imports bot from that file, so it runs the example without a token or a network. The test checks !ping, which most examples do not have, so change it to the example's own commands or events, as Testing a bot shows. Examples with required variables read them in setup, so a test that calls ready() sets them first
Put the token and the example's other variables in a .env file, one KEY=value line each, and keep that file out of version control
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
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 reason stops a member from talking for 1 minute to 7 days, !kick @member reason removes a member from the community, !ban @member reason bans a member permanently, !tempban @member 7d reason bans a member for 1 minute to 2 years, and !clear 20 deletes up to 20 recent messages in the channel. Each reason is optional. Each command has a guard 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 actor. When a command fails, a middleware function tells the moderator without showing error details in the channel
- Variables:
FLUXER_BOT_ TOKEN - Bot permissions:
View,Channel Send,Messages Read,Message History Moderate,Members Kick,Members BanandMembers Manage. The bot's highest role must also rank above the members it acts onMessages - Moderator permissions:
ModerateforMembers !timeout,KickforMembers !kick,BanforMembers !banand!tempban, andManageforMessages !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 })
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_andCHANNEL_ ID ROLE_for the message,MESSAGE_ ID ROLE_for the role, and an optionalID ROLE_, a Unicode emoji that defaults to ✅EMOJI - Bot permissions:
View,Channel Read,Message History AddandReactions Manage. The bot's highest role must rank above the configured roleRoles
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 = ""
async function setRole(client, userId, granted) {
if (userId === botUserId) return
const member = { guildId, userId }
orThrow(await (granted ? client.members.addRole(member, roleId) : client.members.removeRole(member, roleId)))
}
export const bot = defineBot({
onError: (report) => console.error(report.describe()),
// Check the configuration once before connecting, so a missing setting or a role the bot cannot assign stops it
// at startup
setup: async (client) => {
target = { channelId: requiredEnv("ROLE_CHANNEL_ID"), id: requiredEnv("ROLE_MESSAGE_ID") }
roleId = requiredEnv("ROLE_ID")
guildId = orThrow(await client.channels.fetch(target.channelId)).guildId
const self = orThrow(await client.members.fetchSelf(guildId))
botUserId = self.userId
const roles = orThrow(await client.roles.fetchAll(guildId))
const role = roles.find((candidate) => candidate.id === roleId)
const botRoles = roles.filter((candidate) => self.roleIds.includes(candidate.id))
if (!role || !botRoles.some((botRole) => hierarchy.isAbove(botRole, role)))
throw new Error("ROLE_ID must name a role below the bot's highest role")
// The bot's own reaction shows members which emoji to use
orThrow(await client.messages.addReaction(target, emoji))
},
events: {
messageReactionAdd: async ({ event, client }) => {
if (event.id === target.id && event.emoji.name === emoji) await setRole(client, event.userId, true)
},
messageReactionRemove: async ({ event, client }) => {
if (event.id === target.id && event.emoji.name === emoji) await setRole(client, event.userId, false)
},
},
})
// Running this file starts the bot, while importing it from a test does not.
// A failure that stops the bot, such as a rejected token, is logged and sets a failing exit code
if (import.meta.main) await runBot({ token: process.env.FLUXER_BOT_TOKEN, ...bot })
import { defineBot, hierarchy, orThrow, runBot, type Client } from "@neontechspace/fluxerly"
function requiredEnv(name: string) {
const value = process.env[name]
if (!value) throw new Error(`Set the ${name} environment variable`)
return value
}
const emoji = process.env.ROLE_EMOJI || "✅"
// Setup reads the required settings, so a test can import this file without them
let target = { channelId: "", id: "" }
let roleId = ""
let guildId = ""
let botUserId = ""
async function setRole(client: Client, userId: string, granted: boolean) {
if (userId === botUserId) return
const member = { guildId, userId }
orThrow(await (granted ? client.members.addRole(member, roleId) : client.members.removeRole(member, roleId)))
}
export const bot = defineBot({
onError: (report) => console.error(report.describe()),
// Check the configuration once before connecting, so a missing setting or a role the bot cannot assign stops it
// at startup
setup: async (client) => {
target = { channelId: requiredEnv("ROLE_CHANNEL_ID"), id: requiredEnv("ROLE_MESSAGE_ID") }
roleId = requiredEnv("ROLE_ID")
guildId = orThrow(await client.channels.fetch(target.channelId)).guildId
const self = orThrow(await client.members.fetchSelf(guildId))
botUserId = self.userId
const roles = orThrow(await client.roles.fetchAll(guildId))
const role = roles.find((candidate) => candidate.id === roleId)
const botRoles = roles.filter((candidate) => self.roleIds.includes(candidate.id))
if (!role || !botRoles.some((botRole) => hierarchy.isAbove(botRole, role)))
throw new Error("ROLE_ID must name a role below the bot's highest role")
// The bot's own reaction shows members which emoji to use
orThrow(await client.messages.addReaction(target, emoji))
},
events: {
messageReactionAdd: async ({ event, client }) => {
if (event.id === target.id && event.emoji.name === emoji) await setRole(client, event.userId, true)
},
messageReactionRemove: async ({ event, client }) => {
if (event.id === target.id && event.emoji.name === emoji) await setRole(client, event.userId, false)
},
},
})
// Running this file starts the bot, while importing it from a test does not.
// A failure that stops the bot, such as a rejected token, is logged and sets a failing exit code
if (import.meta.main) await runBot({ token: process.env.FLUXER_BOT_TOKEN, ...bot })
Configure only a role that members may give themselves. The bot sees only reactions made while it runs, so a member who reacted while it was offline removes the reaction and adds it again. See the source on GitHub 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_, and an optionalCHANNEL_ ID WELCOME_for the starter roleROLE_ ID - Bot permissions:
View,Channel SendandMessages Embedin the welcome channel, plusLinks ManagewhenRoles WELCOME_is set. The bot's highest role must then rank above the starter roleROLE_ ID
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 })
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_for the category that holds tickets, andCATEGORY_ ID TICKET_for the role that answers themSTAFF_ ROLE_ ID - Bot permissions:
ManageandChannels Manage, plusRoles View,Channel SendandMessages Readin the ticket category. Fluxer lets the bot grant only permissions it has itselfMessage History
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 })
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