Skip to content

Commit bbd1a06

Browse files
committed
multi-guild support, error handling refactor, and command updates
1 parent c148ab1 commit bbd1a06

125 files changed

Lines changed: 1690 additions & 4059 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ CLIENT_ID=your_discord_client_id_here
66
GUILD_ID=your_discord_guild_id_here
77
OWNER_IDS=your_discord_id_here (optional)
88

9+
# Optional — enable slash commands in every server the bot is invited to.
10+
# Leave unset or false for single-server setup (recommended for most users).
11+
MULTI_GUILD=false
12+
913
# Bot Runtime Configuration
1014
NODE_ENV=production
1115
LOG_LEVEL=warn

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,21 @@ docker pull ghcr.io/codebymitch/titanbot:main
182182
This gives clear startup/online status messages while keeping logs simple for non-technical operators.
183183
If port `3000` is busy, the bot tries the next available ports automatically (up to `PORT_RETRY_ATTEMPTS`).
184184

185+
### Running in multiple servers (optional)
186+
187+
Most users run TitanBot on a **single server** with `GUILD_ID` set (default tutorial setup). If you want slash commands to work in **every server** the bot is invited to, opt in with:
188+
189+
```env
190+
MULTI_GUILD=true
191+
```
192+
193+
Notes for multi-server mode:
194+
- `GUILD_ID` is not used for command registration when `MULTI_GUILD=true` (you can leave it set or remove it)
195+
- Global slash commands may take up to about an hour to propagate on first deploy
196+
- Each server still has **isolated** config, economy, tickets, leveling, and other data
197+
- In the [Discord Developer Portal](https://discord.com/developers/applications), ensure your bot is not restricted to a single guild if you plan to invite it elsewhere
198+
- Generate an OAuth2 invite URL from the [Discord Developer Portal](https://discord.com/developers/applications) (OAuth2 → URL Generator, scopes: `bot` and `applications.commands`)
199+
185200
4. **Setup PostgreSQL Database** (Optional but recommended)
186201
```bash
187202
# Create database and user

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ services:
88
- DISCORD_TOKEN=${DISCORD_TOKEN}
99
- CLIENT_ID=${CLIENT_ID}
1010
- GUILD_ID=${GUILD_ID}
11+
- MULTI_GUILD=${MULTI_GUILD:-false}
1112
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
1213
- PORT=3000
1314
depends_on:

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "titanbot-custom",
3-
"version": "1.1.1",
3+
"version": "2.1.0",
44
"description": "Modular Ultimate Community Bot by Touchpoint Support",
55
"main": "src/app.js",
66
"type": "module",

src/app.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { logger, startupLog, shutdownLog } from './utils/logger.js';
1212
import { checkBirthdays } from './services/birthdayService.js';
1313
import { checkGiveaways } from './services/giveawayService.js';
1414
import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js';
15+
import pkg from '../package.json' with { type: 'json' };
1516

1617
class TitanBot extends Client {
1718
constructor() {
@@ -85,6 +86,11 @@ class TitanBot extends Client {
8586

8687
startupLog('Registering slash commands...');
8788
await this.registerCommands();
89+
if (this.config.bot.multiGuild) {
90+
startupLog('Multi-guild mode enabled — slash commands registered globally');
91+
} else if (this.config.bot.guildId) {
92+
startupLog(`Single-guild mode — slash commands registered for guild ${this.config.bot.guildId}`);
93+
}
8894
startupLog('Slash commands registration complete');
8995

9096
const databaseMode = dbStatus.isDegraded
@@ -184,7 +190,7 @@ class TitanBot extends Client {
184190
app.get('/', (req, res) => {
185191
res.status(200).json({
186192
message: 'TitanBot System Online',
187-
version: '2.0.0',
193+
version: pkg.version,
188194
timestamp: new Date().toISOString()
189195
});
190196
});
@@ -303,7 +309,8 @@ class TitanBot extends Client {
303309

304310
async registerCommands() {
305311
try {
306-
await registerSlashCommands(this, this.config.bot.guildId);
312+
const { clientId, guildId, multiGuild } = this.config.bot;
313+
await registerSlashCommands(this, { clientId, guildId, multiGuild });
307314
} catch (error) {
308315
logger.error('Error registering commands:', error);
309316
}

src/commands/Birthday/birthday.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SlashCommandBuilder, MessageFlags, ChannelType } from 'discord.js';
2-
import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js';
2+
import { createEmbed, successEmbed } from '../../utils/embeds.js';
33
import { logger } from '../../utils/logger.js';
44
import { handleInteractionError } from '../../utils/errorHandler.js';
55

@@ -93,10 +93,7 @@ export default {
9393
case 'setchannel':
9494
return await birthdaySetchannel.execute(interaction, config, client);
9595
default:
96-
return InteractionHelper.safeReply(interaction, {
97-
embeds: [errorEmbed('Error', 'Unknown subcommand')],
98-
flags: MessageFlags.Ephemeral
99-
});
96+
return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand' });
10097
}
10198
} catch (error) {
10299
logger.error('Birthday command execution failed', {

src/commands/Community/app-admin.js

Lines changed: 12 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType, LabelBuilder, RoleSelectMenuBuilder } from 'discord.js';
2-
import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js';
2+
import { createEmbed, successEmbed } from '../../utils/embeds.js';
33
import { getColor } from '../../config/bot.js';
44
import { logger } from '../../utils/logger.js';
55
import { handleInteractionError, withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
@@ -103,10 +103,7 @@ export default {
103103

104104
execute: withErrorHandling(async (interaction) => {
105105
if (!interaction.inGuild()) {
106-
return InteractionHelper.safeReply(interaction, {
107-
embeds: [errorEmbed('This command can only be used in a server.')],
108-
flags: ["Ephemeral"],
109-
});
106+
return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a server.' });
110107
}
111108

112109
const { options, guild, member } = interaction;
@@ -140,10 +137,7 @@ export default {
140137
async function handleSetup(interaction) {
141138

142139
if (interaction.deferred || interaction.replied) {
143-
return InteractionHelper.safeReply(interaction, {
144-
embeds: [errorEmbed('This interaction has already been processed. Please try the command again.')],
145-
flags: ["Ephemeral"],
146-
});
140+
return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This interaction has already been processed. Please try the command again.' });
147141
}
148142

149143
const modal = new ModalBuilder()
@@ -226,10 +220,7 @@ async function handleSetup(interaction) {
226220
const roleId = selectedRoles.first()?.id;
227221

228222
if (!roleId) {
229-
await submitted.reply({
230-
embeds: [errorEmbed('No Role Selected', 'You must select a role for the application.')],
231-
flags: ['Ephemeral'],
232-
});
223+
await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'You must select a role for the application.' });
233224
return;
234225
}
235226

@@ -241,19 +232,13 @@ async function handleSetup(interaction) {
241232

242233
const role = await interaction.guild.roles.fetch(roleId).catch(() => null);
243234
if (!role) {
244-
await submitted.reply({
245-
embeds: [errorEmbed('Invalid Role', 'The selected role could not be found.')],
246-
flags: ['Ephemeral'],
247-
});
235+
await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'The selected role could not be found.' });
248236
return;
249237
}
250238

251239
const existingRoles = await getApplicationRoles(interaction.client, interaction.guild.id);
252240
if (existingRoles.some(r => r.roleId === roleId)) {
253-
await submitted.reply({
254-
embeds: [errorEmbed('Already Configured', `The role ${role} is already configured as an application.`)],
255-
flags: ['Ephemeral'],
256-
});
241+
await replyUserError(submitted, { type: ErrorTypes.CONFIGURATION, message: 'The role ${role} is already configured as an application.' });
257242
return;
258243
}
259244

@@ -294,19 +279,11 @@ async function handleReview(interaction) {
294279
appId,
295280
);
296281
if (!application) {
297-
return InteractionHelper.safeEditReply(interaction, {
298-
embeds: [errorEmbed('Application not found.')],
299-
flags: ["Ephemeral"],
300-
});
282+
return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Application not found.' });
301283
}
302284

303285
if (application.status !== "pending") {
304-
return InteractionHelper.safeEditReply(interaction, {
305-
embeds: [
306-
errorEmbed('This application has already been processed.'),
307-
],
308-
flags: ["Ephemeral"],
309-
});
286+
return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This application has already been processed.' });
310287
}
311288

312289
const appEmbed = createEmbed({
@@ -483,10 +460,7 @@ async function handleReview(interaction) {
483460

484461
} catch (error) {
485462
logger.error('Error reviewing application:', error);
486-
await buttonInteraction.reply({
487-
embeds: [errorEmbed('Error', 'An error occurred while reviewing the application.')],
488-
flags: ["Ephemeral"],
489-
});
463+
await replyUserError(buttonInteraction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while reviewing the application.' });
490464
}
491465
});
492466

@@ -568,15 +542,7 @@ async function handleList(interaction) {
568542

569543
return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] });
570544
} else {
571-
return InteractionHelper.safeEditReply(interaction, {
572-
embeds: [
573-
errorEmbed(
574-
"No applications found and no application roles configured.\n" +
575-
"Use `/app-admin roles add` to configure application roles first."
576-
),
577-
],
578-
flags: ["Ephemeral"],
579-
});
545+
return await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: '"No applications found and no application roles configured.\\n" +\n "Use `/app-admin roles add` to configure application roles first."' });
580546
}
581547
}
582548

@@ -624,10 +590,7 @@ export async function handleApplicationReviewModal(interaction) {
624590
try {
625591
const application = await getApplication(interaction.client, interaction.guild.id, appId);
626592
if (!application) {
627-
return InteractionHelper.safeReply(interaction, {
628-
embeds: [errorEmbed('Application not found.')],
629-
flags: ["Ephemeral"]
630-
});
593+
return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Application not found.' });
631594
}
632595

633596
const status = isApprove ? 'approved' : 'denied';
@@ -703,9 +666,6 @@ export async function handleApplicationReviewModal(interaction) {
703666

704667
} catch (error) {
705668
logger.error('Error processing application review:', error);
706-
await InteractionHelper.safeEditReply(interaction, {
707-
embeds: [errorEmbed('An error occurred while processing the application.')],
708-
flags: ["Ephemeral"]
709-
});
669+
await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing the application.' });
710670
}
711671
}

src/commands/Community/apply.js

Lines changed: 10 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getColor } from '../../config/bot.js';
22
import { SlashCommandBuilder, ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js';
3-
import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js';
3+
import { createEmbed, successEmbed } from '../../utils/embeds.js';
44
import { logger } from '../../utils/logger.js';
55
import { handleInteractionError, withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
66
import ApplicationService from '../../services/applicationService.js';
@@ -72,10 +72,7 @@ export default {
7272

7373
execute: withErrorHandling(async (interaction) => {
7474
if (!interaction.inGuild()) {
75-
return InteractionHelper.safeReply(interaction, {
76-
embeds: [errorEmbed('This command can only be used in a server.')],
77-
flags: ["Ephemeral"],
78-
});
75+
return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a server.' });
7976
}
8077

8178
const { options, guild, member } = interaction;
@@ -128,19 +125,13 @@ export async function handleApplicationModal(interaction) {
128125
const applicationRole = applicationRoles.find(appRole => appRole.roleId === roleId);
129126

130127
if (!applicationRole) {
131-
return InteractionHelper.safeEditReply(interaction, {
132-
embeds: [errorEmbed('Application configuration not found.')],
133-
flags: ["Ephemeral"]
134-
});
128+
return await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: 'Application configuration not found.' });
135129
}
136130

137131
const role = interaction.guild.roles.cache.get(roleId);
138132

139133
if (!role) {
140-
return InteractionHelper.safeEditReply(interaction, {
141-
embeds: [errorEmbed('Role not found.')],
142-
flags: ["Ephemeral"]
143-
});
134+
return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Role not found.' });
144135
}
145136

146137
const answers = [];
@@ -236,9 +227,7 @@ async function handleList(interaction) {
236227
const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id);
237228

238229
if (applicationRoles.length === 0) {
239-
return InteractionHelper.safeEditReply(interaction, {
240-
embeds: [errorEmbed('No applications are currently available.')],
241-
});
230+
return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No applications are currently available.' });
242231
}
243232

244233
const embed = createEmbed({
@@ -288,15 +277,7 @@ async function handleSubmit(interaction, settings) {
288277
);
289278

290279
if (!applicationRole) {
291-
return InteractionHelper.safeEditReply(interaction, {
292-
embeds: [
293-
errorEmbed(
294-
"Application not found.",
295-
"Use `/apply list` to see available applications."
296-
),
297-
],
298-
flags: ["Ephemeral"],
299-
});
280+
return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Use `/apply list` to see available applications.' });
300281
}
301282

302283
const userApps = await getUserApplications(
@@ -307,22 +288,12 @@ async function handleSubmit(interaction, settings) {
307288
const pendingApp = userApps.find((app) => app.status === "pending");
308289

309290
if (pendingApp) {
310-
return InteractionHelper.safeEditReply(interaction, {
311-
embeds: [
312-
errorEmbed(
313-
`You already have a pending application. Please wait for it to be reviewed.`,
314-
),
315-
],
316-
flags: ["Ephemeral"],
317-
});
291+
return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You already have a pending application. Please wait for it to be reviewed.' });
318292
}
319293

320294
const role = interaction.guild.roles.cache.get(applicationRole.roleId);
321295
if (!role) {
322-
return InteractionHelper.safeEditReply(interaction, {
323-
embeds: [errorEmbed('The role for this application no longer exists.')],
324-
flags: ["Ephemeral"]
325-
});
296+
return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'The role for this application no longer exists.' });
326297
}
327298

328299
const modal = new ModalBuilder()
@@ -365,14 +336,7 @@ async function handleStatus(interaction) {
365336
);
366337

367338
if (!application || application.userId !== interaction.user.id) {
368-
return InteractionHelper.safeEditReply(interaction, {
369-
embeds: [
370-
errorEmbed(
371-
"Application not found or you do not have permission to view it.",
372-
),
373-
],
374-
flags: ["Ephemeral"],
375-
});
339+
return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Application not found or you do not have permission to view it.' });
376340
}
377341

378342
const submittedAt = application?.createdAt ? new Date(application.createdAt) : null;
@@ -397,12 +361,7 @@ async function handleStatus(interaction) {
397361
);
398362

399363
if (applications.length === 0) {
400-
return InteractionHelper.safeEditReply(interaction, {
401-
embeds: [
402-
errorEmbed('You have not submitted any applications yet.'),
403-
],
404-
flags: ["Ephemeral"],
405-
});
364+
return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You have not submitted any applications yet.' });
406365
}
407366

408367
const recentApplications = applications

0 commit comments

Comments
 (0)