Skip to content
Open

Bot #192

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ POSTGRES_RESTORE_URL=
# LAVALINK_PASSWORD=youshallnotpass
# LAVALINK_SECURE=false
# LAVALINK_NAME=Main
# Default search platform for /play when no source is chosen.
# Options: ytsearch (YouTube), ytmsearch (YouTube Music), spsearch (Spotify),
# scsearch (SoundCloud), dzsearch (Deezer). URLs are always resolved directly.
LAVALINK_SEARCH_PLATFORM=ytmsearch
LAVALINK_REST_VERSION=v4

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ TitanBot offers a complete suite of tools for Discord server management and comm

### Music
- **24/7 Mode** - Play music 24/7
- **Interative Button System** - Manage music through buttons
- **Supports EVERY platform** - Supports spotify, deezer, youtube, apple music
- **Interactive Button System** - Manage music through buttons
- **YouTube Support** - Play YouTube videos, Shorts, and playlists by link, or search YouTube / YouTube Music
- **Multi-Platform** - Also plays spotify, deezer, soundcloud, and apple music links

</td>
</tr>
Expand Down Expand Up @@ -141,7 +142,7 @@ Music uses [Lavalink v4](https://github.com/lavalink-devs/Lavalink) via [Riffy](
```
Remove or rename `lavalink/nodes.json` so the bot falls back to those env vars.
3. Override nodes inline with `LAVALINK_NODES` (JSON array) or point at another file with `LAVALINK_NODES_FILE`.
4. Use `/play <song>` from a voice channel, or `/join` to connect without playing. Prefix shortcuts: `join`, `np`, `leave`, `pause`, `resume`, `skip`, `stop`, `volume <0-100>`, or `music <subcommand>`. Use `/nowplaying` and `/queue` for status; `/music` for loop, shuffle, seek, and other controls.
4. Use `/play <song>` from a voice channel, or `/join` to connect without playing. Paste any **YouTube** link (`youtube.com/watch`, `youtu.be`, `music.youtube.com`, Shorts, and playlists all work) and it plays directly. For searches, `/play` defaults to YouTube Music; use the `source` option to search **YouTube**, Spotify, SoundCloud, or Deezer instead (or type a prefix like `ytsearch:` / `ytmsearch:` directly into the query). Prefix shortcuts: `join`, `np`, `leave`, `pause`, `resume`, `skip`, `stop`, `volume <0-100>`, or `music <subcommand>`. Use `/now-playing` and `/queue` for status; `/music` for loop, shuffle, seek, and other controls.

### Using GitHub Container Registry

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function getApplicationStatusPresentation(statusValue) {

export default {
data: new SlashCommandBuilder()
.setName("app-admin")
.setName("application-admin")
.setDescription("Manage staff applications")
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((subcommand) =>
Expand Down Expand Up @@ -131,7 +131,7 @@ export default {
const selectedAppName = interaction.options.getString("application");
await appDashboard.execute(interaction, null, interaction.client, selectedAppName);
}
}, { type: 'command', commandName: 'app-admin' })
}, { type: 'command', commandName: 'application-admin' })
};

async function handleSetup(interaction) {
Expand Down Expand Up @@ -545,7 +545,7 @@ async function handleList(interaction) {
return await replyUserError(interaction, {
type: ErrorTypes.CONFIGURATION,
message: 'No applications found and no application roles configured.\n' +
'Use `/app-admin roles add` to configure application roles first.'
'Use `/application-admin setup` to configure application roles first.'
});
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/Community/modules/app_dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export default {
throw new TitanBotError(
'Applications system not set up',
ErrorTypes.CONFIGURATION,
'The applications system has not been configured yet. Please run `/app-admin setup` to create your first application.',
'The applications system has not been configured yet. Please run `/application-admin setup` to create your first application.',
);
}

Expand Down
275 changes: 275 additions & 0 deletions src/commands/Core/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import {
SlashCommandBuilder,
MessageFlags,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
} from 'discord.js';
import { createEmbed, formatDuration } from '../../utils/embeds.js';
import { logger } from '../../utils/logger.js';
import { InteractionHelper } from '../../utils/interactionHelper.js';
import { checkAllServices } from '../../utils/statusChecker.js';
import pkg from '../../../package.json' with { type: 'json' };

// Button custom id for refresh. Colon-split so the generic dispatcher in
// interactionCreate.js routes this through client.buttons correctly.
const REFRESH_ID = 'api:refresh';

// Width used to pad names inside the probes code block so the latencies line up.
const PROBES_NAME_WIDTH = 18;

function buildRefreshButton(disabled = false) {
const btn = new ButtonBuilder()
.setCustomId(REFRESH_ID)
.setLabel('Refresh Status')
.setStyle(ButtonStyle.Secondary);
// Note: setEmoji is intentionally not used with a Unicode emoji because the
// sanitizer strips them from button labels; the text label is enough.
if (disabled) btn.setDisabled(true);
return new ActionRowBuilder().addComponents(btn);
}

/**
* Convert an overall status to the headline banner text shown at the top of the
* embed, styled after the reference screenshot ("[+] All systems operational").
* ASCII markers are used because the embed sanitizer strips Unicode emojis.
*/
function overallHeadline(overall) {
switch (overall.status) {
case 'online': return '**[+] All systems operational**';
case 'issues': return '**[!] Some services are experiencing issues**';
case 'offline': return '**[X] One or more services are offline**';
default: return '**[?] Service status unknown**';
}
}

/**
* Pick an ANSI foreground color for a given status, used inside the monospace
* probes block (Discord supports ANSI inside ```ansi fences).
*/
function statusAnsi(status) {
switch (status) {
case 'online': return '\x1b[32m'; // green
case 'issues': return '\x1b[33m'; // yellow
case 'offline': return '\x1b[31m'; // red
default: return '\x1b[90m'; // gray
}
}

const ANSI_RESET = '\x1b[0m';

function statusGlyph(status) {
switch (status) {
case 'online': return '+'; // green "+" (online marker)
case 'issues': return '!'; // yellow "!" (warning)
case 'offline': return 'X'; // red "X" (offline)
default: return '?';
}
}

function statusLabelInline(status) {
switch (status) {
case 'online': return 'Online';
case 'issues': return 'Issues';
case 'offline': return 'Offline';
default: return 'Unknown';
}
}

function padRight(str, width) {
// str is plain ASCII-ish text, so String.length is good enough.
if (str.length >= width) return str.slice(0, width);
return str + ' '.repeat(width - str.length);
}

function buildProbesBlock(services) {
const lines = [];
const headerName = padRight('Service', PROBES_NAME_WIDTH);
const header = `${headerName} Latency`;
lines.push(header);
lines.push('─'.repeat(header.length));

for (const s of services) {
const color = statusAnsi(s.status);
const glyph = statusGlyph(s.status);
const name = padRight(s.name, PROBES_NAME_WIDTH);
const latency = typeof s.latency === 'number' && s.latency >= 0 ? `${s.latency}ms` : ' --';
const line = `${color}[${glyph}]${ANSI_RESET} ${name} ${padStart(latency, 8)}`;
lines.push(line);
}

return '```ansi\n' + lines.join('\n') + '\n```';
}

function padStart(str, width) {
if (str.length >= width) return str;
return ' '.repeat(width - str.length) + str;
}

function buildServicesList(services) {
return services.map((s) => {
const glyph = statusGlyph(s.status);
const label = `\`${statusLabelInline(s.status)}\``;
const latency = typeof s.latency === 'number' && s.latency >= 0
? ` — \`${s.latency}ms\``
: '';
const detail = s.detail ? `\n> ${s.detail}` : '';
return `**[${glyph}] ${s.name}** — ${label}${latency}${detail}`;
}).join('\n');
}

/**
* Build the reply payload (embed + components) for a given status result.
* Layout mirrors the reference screenshot: bold "REAL STATUS" title, headline
* banner, Product info section, Services section, and a monospace Probes
* block with per-service latencies.
*/
function buildStatusPayload(result) {
const { services, overall, checkedAt } = result;
const checkedTs = Math.floor(checkedAt.getTime() / 1000);
const bot = services.find(s => s.name === 'Discord Bot') ?? services[2];
const uptimeMs = bot?.uptimeMs ?? 0;
const wsPing = typeof bot?.latency === 'number' ? bot.latency : null;

const productLines = [
`• **Version:** \`${pkg.version}\``,
wsPing != null ? `• **Gateway Ping:** \`${wsPing}ms\`` : `• **Gateway Ping:** \`Unknown\``,
uptimeMs > 0 ? `• **Uptime:** \`${formatDuration(uptimeMs)}\`` : `• **Uptime:** \`Unknown\``,
`• **Node:** \`${process.version}\``,
].join('\n');

const description = [
overallHeadline(overall),
'',
'**Product**',
productLines,
'',
'**Services**',
buildServicesList(services),
'',
'**Probes**',
buildProbesBlock(services),
'',
`*Last checked <t:${checkedTs}:R> — use the button below to refresh*`,
].join('\n');

// Create the base embed, then force the sidebar color to match overall status.
const embed = createEmbed({
title: 'REAL STATUS',
description,
color: 'dark',
timestamp: false,
});

try {
embed.setColor(overall.color);
} catch {
// ignore
}

return {
embeds: [embed],
components: [buildRefreshButton(false)],
};
}

async function runStatusCheck(interaction) {
try {
const result = await checkAllServices(interaction.client);
return buildStatusPayload(result);
} catch (error) {
logger.error('Status check failed with unexpected error:', error);
// Fallback payload — never crash the interaction
const embed = createEmbed({
title: 'REAL STATUS',
description: '**[!] Could not complete the status check.** Please try again in a moment.',
color: 'warning',
});
return {
embeds: [embed],
components: [buildRefreshButton(false)],
};
}
}

export default {
data: new SlashCommandBuilder()
.setName('api')
.setDescription('Check the status of the bot, GitHub, and Railway services'),

async prefixExecute(interaction) {
try {
const thinkingMsg = await interaction.reply({ content: 'Checking service status…' });
const payload = await runStatusCheck(interaction);
await thinkingMsg.edit({ content: null, ...payload }).catch(() => {
interaction.channel?.send(payload).catch(() => {});
});
} catch (error) {
logger.error('API (prefix) command error:', error);
interaction.channel?.send({
embeds: [createEmbed({ title: 'REAL STATUS', description: 'Could not check service status.', color: 'error' })],
}).catch(() => {});
}
},

async execute(interaction) {
const isButton = interaction.isButton?.() && interaction.customId === REFRESH_ID;

if (isButton) {
// Button press: defer an update, then re-check and rebuild
try {
await interaction.deferUpdate();
} catch (deferError) {
logger.warn('API status refresh: deferUpdate failed:', deferError?.message);
return;
}

try {
const payload = await runStatusCheck(interaction);
await interaction.editReply(payload).catch(err => {
logger.warn('API status refresh: editReply failed:', err?.message);
});
} catch (error) {
logger.error('API status refresh error:', error);
try {
const embed = createEmbed({
title: 'REAL STATUS',
description: '**[X] Could not refresh status right now.** Try again shortly.',
color: 'error',
});
await interaction.editReply({ embeds: [embed], components: [buildRefreshButton(false)] }).catch(() => {});
} catch {
// last-resort swallow
}
}
return;
}

// Slash command — defer publicly so the status is visible to everyone
const deferSuccess = await InteractionHelper.safeDefer(interaction, {});
if (!deferSuccess) {
logger.warn(`API command: interaction defer failed`, {
userId: interaction.user.id,
guildId: interaction.guildId,
commandName: 'api',
});
return;
}

try {
const payload = await runStatusCheck(interaction);
await InteractionHelper.safeEditReply(interaction, payload);
} catch (error) {
logger.error('API command error:', error);
try {
await InteractionHelper.safeEditReply(interaction, {
embeds: [createEmbed({ title: 'REAL STATUS', description: 'Could not check service status.', color: 'error' })],
components: [],
flags: MessageFlags.Ephemeral,
});
} catch (replyError) {
logger.error('API command: failed to send error reply:', replyError);
}
}
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ async function handleSettingModalSubmit(selectInteraction, rootInteraction, sett
export default {
slashOnly: true,
data: new SlashCommandBuilder()
.setName('configwizard')
.setName('config-wizard')
.setDescription('Open the server configuration dashboard and setup wizard')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.setDMPermission(false),
Expand Down
2 changes: 1 addition & 1 deletion src/commands/Core/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export async function createInitialHelpMenu(client) {
{
name: '🚀 Getting Started',
value: [
'**1. Launch setup** — Run `/configwizard` to configure prefix, mod role, and logs.',
'**1. Launch setup** — Run `/config-wizard` to configure prefix, mod role, and logs.',
'**2. Enable systems** — Use `/commands dashboard` to turn categories on or off.', '**3. Browse commands** — Use the menu below to view categories and commands.',
].join('\n'),
inline: false,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/Core/modules/commands_dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function buildOverviewEmbed(snapshot, guild) {
description: `Manage slash and prefix commands for **${guild.name}**. Subcommands (e.g. \`birthday list\`) are listed separately.`,
color: 'info',
fields,
footer: '🔒 commands & configwizard always stay available',
footer: '🔒 commands & config-wizard always stay available',
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { getEconomyPrefix } from '../../utils/database.js';

export default {
data: new SlashCommandBuilder()
.setName("eleaderboard")
.setName("economy-leaderboard")
.setDescription("View the server's top 10 richest users.")
.setDMPermission(false),

Expand Down Expand Up @@ -85,5 +85,5 @@ export default {
});

await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
}, { command: 'eleaderboard' })
}, { command: 'economy-leaderboard' })
};
4 changes: 2 additions & 2 deletions src/commands/Fun/flip.js → src/commands/Fun/coin-flip.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js';
import { InteractionHelper } from '../../utils/interactionHelper.js';
export default {
data: new SlashCommandBuilder()
.setName("flip")
.setDescription("Flips a coin (Heads or Tails)."),
.setName("coin-flip")
.setDescription("Flips a coin (Heads or Tails)."),
category: 'Fun',

async execute(interaction, config, client) {
Expand Down
Loading