diff --git a/bot/src/discord/commands/invite.command.js b/bot/src/discord/commands/invite.command.js index e72ca31..95cd6ad 100644 --- a/bot/src/discord/commands/invite.command.js +++ b/bot/src/discord/commands/invite.command.js @@ -4,6 +4,46 @@ const guildConfig = require('../../model/guildConfig') const inviteLog = require('../../model/inviteLog') const inviteRotator = require('../../invites/inviteRotator') +// Per-subcommand handlers, split out of execute() so the dispatch stays flat. +async function handleChannel(interaction) { + const channel = interaction.options.getChannel('channel') + if (!channel) { + const currentId = await guildConfig.getInviteChannelId(interaction.guildId) + const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.' + await interaction.reply({ content, ephemeral: true }) + return + } + await guildConfig.setInviteChannelId(interaction.guildId, channel.id) + await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true }) +} + +async function handleRotate(interaction) { + await interaction.deferReply({ ephemeral: true }) + try { + const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, { + triggeredBy: interaction.user.id, + triggeredByTag: interaction.user.tag, + }) + await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` }) + } catch (err) { + await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` }) + } +} + +async function handleLog(interaction) { + const rows = await inviteLog.list(interaction.guildId, 10) + if (rows.length === 0) { + await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true }) + return + } + const lines = rows.map((r) => { + const who = r.triggered_by_tag || 'automatic (scheduled)' + const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active' + return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})` + }) + await interaction.reply({ content: lines.join('\n'), ephemeral: true }) +} + module.exports = { data: { name: 'invite', @@ -40,46 +80,8 @@ module.exports = { }, async execute(interaction) { const sub = interaction.options.getSubcommand() - - if (sub === 'channel') { - const channel = interaction.options.getChannel('channel') - if (!channel) { - const currentId = await guildConfig.getInviteChannelId(interaction.guildId) - const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.' - await interaction.reply({ content, ephemeral: true }) - return - } - await guildConfig.setInviteChannelId(interaction.guildId, channel.id) - await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true }) - return - } - - if (sub === 'rotate') { - await interaction.deferReply({ ephemeral: true }) - try { - const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, { - triggeredBy: interaction.user.id, - triggeredByTag: interaction.user.tag, - }) - await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` }) - } catch (err) { - await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` }) - } - return - } - - if (sub === 'log') { - const rows = await inviteLog.list(interaction.guildId, 10) - if (rows.length === 0) { - await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true }) - return - } - const lines = rows.map((r) => { - const who = r.triggered_by_tag || 'automatic (scheduled)' - const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active' - return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})` - }) - await interaction.reply({ content: lines.join('\n'), ephemeral: true }) - } + if (sub === 'channel') return handleChannel(interaction) + if (sub === 'rotate') return handleRotate(interaction) + if (sub === 'log') return handleLog(interaction) }, } diff --git a/bot/src/discord/commands/schedule.command.js b/bot/src/discord/commands/schedule.command.js index bf9e3b6..25d95ae 100644 --- a/bot/src/discord/commands/schedule.command.js +++ b/bot/src/discord/commands/schedule.command.js @@ -5,6 +5,72 @@ const scheduledMessages = require('../../model/scheduledMessages') const scheduler = require('../../scheduler/scheduler') const { parseDuration } = require('../../utils/duration') +// Per-subcommand handlers, split out of execute() so the dispatch stays flat. +async function handleRecurring(interaction) { + const channel = interaction.options.getChannel('channel', true) + const cronExpr = interaction.options.getString('cron', true) + const message = interaction.options.getString('message', true) + if (!cron.validate(cronExpr)) { + await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true }) + return + } + const id = await scheduledMessages.addRecurring({ + guildId: interaction.guildId, + channelId: channel.id, + content: message, + cronExpression: cronExpr, + createdBy: interaction.user.id, + createdByTag: interaction.user.tag, + }) + await scheduler.refresh() + await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true }) +} + +async function handleOnce(interaction) { + const channel = interaction.options.getChannel('channel', true) + const inInput = interaction.options.getString('in', true) + const message = interaction.options.getString('message', true) + const ms = parseDuration(inInput) + if (!ms) { + await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true }) + return + } + const runAt = new Date(Date.now() + ms) + const id = await scheduledMessages.addOnce({ + guildId: interaction.guildId, + channelId: channel.id, + content: message, + runAt, + createdBy: interaction.user.id, + createdByTag: interaction.user.tag, + }) + await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true }) +} + +async function handleRemove(interaction) { + const id = interaction.options.getInteger('id', true) + const removed = await scheduledMessages.remove(interaction.guildId, id) + await scheduler.refresh() + await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true }) +} + +async function handleList(interaction) { + const rows = await scheduledMessages.list(interaction.guildId) + if (rows.length === 0) { + await interaction.reply({ content: 'No scheduled messages.', ephemeral: true }) + return + } + const lines = rows.map((r) => { + let kind + if (r.cron_expression) kind = `cron \`${r.cron_expression}\`` + else if (r.sent_at) kind = `sent ${new Date(r.sent_at).toLocaleString()}` + else kind = `due ${new Date(r.run_at).toLocaleString()}` + const suffix = r.enabled ? '' : ' (disabled)' + return `**#${r.id}** <#${r.channel_id}> — ${kind}${suffix}` + }) + await interaction.reply({ content: lines.join('\n'), ephemeral: true }) +} + module.exports = { data: { name: 'schedule', @@ -47,73 +113,9 @@ module.exports = { }, async execute(interaction) { const sub = interaction.options.getSubcommand() - - if (sub === 'recurring') { - const channel = interaction.options.getChannel('channel', true) - const cronExpr = interaction.options.getString('cron', true) - const message = interaction.options.getString('message', true) - if (!cron.validate(cronExpr)) { - await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true }) - return - } - const id = await scheduledMessages.addRecurring({ - guildId: interaction.guildId, - channelId: channel.id, - content: message, - cronExpression: cronExpr, - createdBy: interaction.user.id, - createdByTag: interaction.user.tag, - }) - await scheduler.refresh() - await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true }) - return - } - - if (sub === 'once') { - const channel = interaction.options.getChannel('channel', true) - const inInput = interaction.options.getString('in', true) - const message = interaction.options.getString('message', true) - const ms = parseDuration(inInput) - if (!ms) { - await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true }) - return - } - const runAt = new Date(Date.now() + ms) - const id = await scheduledMessages.addOnce({ - guildId: interaction.guildId, - channelId: channel.id, - content: message, - runAt, - createdBy: interaction.user.id, - createdByTag: interaction.user.tag, - }) - await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true }) - return - } - - if (sub === 'remove') { - const id = interaction.options.getInteger('id', true) - const removed = await scheduledMessages.remove(interaction.guildId, id) - await scheduler.refresh() - await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true }) - return - } - - if (sub === 'list') { - const rows = await scheduledMessages.list(interaction.guildId) - if (rows.length === 0) { - await interaction.reply({ content: 'No scheduled messages.', ephemeral: true }) - return - } - const lines = rows.map((r) => { - const kind = r.cron_expression - ? `cron \`${r.cron_expression}\`` - : r.sent_at - ? `sent ${new Date(r.sent_at).toLocaleString()}` - : `due ${new Date(r.run_at).toLocaleString()}` - return `**#${r.id}** <#${r.channel_id}> — ${kind}${r.enabled ? '' : ' (disabled)'}` - }) - await interaction.reply({ content: lines.join('\n'), ephemeral: true }) - } + if (sub === 'recurring') return handleRecurring(interaction) + if (sub === 'once') return handleOnce(interaction) + if (sub === 'remove') return handleRemove(interaction) + if (sub === 'list') return handleList(interaction) }, } diff --git a/bot/src/discord/discordManager.js b/bot/src/discord/discordManager.js index 49f3bae..5edc42a 100644 --- a/bot/src/discord/discordManager.js +++ b/bot/src/discord/discordManager.js @@ -50,6 +50,42 @@ async function stop() { log.info('discord client disconnected') } +// Post-login startup: register commands and start the background workers. A +// failure here leaves the client connected but flags an error status. +async function onReady() { + try { + await registerCommands(client.application.id, guildId) + await scheduler.start(client) + tempRoleSweeper.start(client) + inviteScheduler.start(client, guildId) + await inviteTracker.prime(client, guildId) + status = 'connected' + statusDetail = null + lastConnectedAt = new Date() + log.info('discord client ready', { user: client.user?.tag, guildId }) + } catch (err) { + status = 'error' + statusDetail = `startup failed: ${err.message}` + log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message }) + } +} + +// Route an interaction: role-menu handler first, then chat-input slash commands. +async function onInteractionCreate(interaction) { + if (await roleMenuHandler.handleInteraction(interaction)) return + if (!interaction.isChatInputCommand()) return + const command = commands.get(interaction.commandName) + if (!command) return + try { + await command.execute(interaction) + } catch (err) { + log.error('command execution failed', { command: interaction.commandName, message: err.message }) + const payload = { content: 'Something went wrong running that command.', ephemeral: true } + if (interaction.replied || interaction.deferred) await interaction.followUp(payload) + else await interaction.reply(payload) + } +} + // start({ token, guildId }) — (re)connects. Always stops any existing client // first so re-saving config or toggling Enabled off/on is idempotent. async function start({ token, guildId: gid }) { @@ -72,39 +108,8 @@ async function start({ token, guildId: gid }) { ], }) - client.once('ready', async () => { - try { - await registerCommands(client.application.id, guildId) - await scheduler.start(client) - tempRoleSweeper.start(client) - inviteScheduler.start(client, guildId) - await inviteTracker.prime(client, guildId) - status = 'connected' - statusDetail = null - lastConnectedAt = new Date() - log.info('discord client ready', { user: client.user?.tag, guildId }) - } catch (err) { - status = 'error' - statusDetail = `startup failed: ${err.message}` - log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message }) - } - }) - - client.on('interactionCreate', async (interaction) => { - if (await roleMenuHandler.handleInteraction(interaction)) return - if (!interaction.isChatInputCommand()) return - const command = commands.get(interaction.commandName) - if (!command) return - try { - await command.execute(interaction) - } catch (err) { - log.error('command execution failed', { command: interaction.commandName, message: err.message }) - const payload = { content: 'Something went wrong running that command.', ephemeral: true } - if (interaction.replied || interaction.deferred) await interaction.followUp(payload) - else await interaction.reply(payload) - } - }) - + client.once('ready', onReady) + client.on('interactionCreate', onInteractionCreate) client.on('messageCreate', messageFilter.handleMessageCreate) client.on('guildMemberAdd', handleGuildMemberAdd) client.on('guildMemberRemove', handleGuildMemberRemove) diff --git a/bot/src/discord/messageFilter.js b/bot/src/discord/messageFilter.js index 5c4a085..00b2540 100644 --- a/bot/src/discord/messageFilter.js +++ b/bot/src/discord/messageFilter.js @@ -66,8 +66,7 @@ function detectSpam(message) { async function isBypassed(message, cache) { if (cache.allowChannels.has(message.channelId)) return true const memberRoles = message.member ? message.member.roles.cache : null - if (memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id))) return true - return false + return Boolean(memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id))) } async function applyWarnAction(message, reason) { diff --git a/bot/src/filter/inviteFilter.js b/bot/src/filter/inviteFilter.js index 70c9925..30e198c 100644 --- a/bot/src/filter/inviteFilter.js +++ b/bot/src/filter/inviteFilter.js @@ -2,7 +2,7 @@ // current guild (anti-raid/anti-advertising). An invite that fails to resolve // (expired/invalid/vanity-only) is treated as foreign too — safer default // than silently letting an unresolvable link through. -const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi +const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-z0-9-]+)/gi // Returns the first foreign (or unresolvable) invite code found in the message, // or null if the message contains no foreign invites. Returning the code (rather diff --git a/bot/src/utils/duration.js b/bot/src/utils/duration.js index 3bfab3b..2679db0 100644 --- a/bot/src/utils/duration.js +++ b/bot/src/utils/duration.js @@ -7,7 +7,7 @@ const MAX_TIMEOUT_MS = 28 * 86_400_000 function parseDuration(input) { if (!input) return null - const match = /^(\d+)\s*(s|m|h|d)$/i.exec(input.trim()) + const match = /^(\d+)\s*([smhd])$/i.exec(input.trim()) if (!match) return null const [, amount, unit] = match return Number(amount) * UNIT_MS[unit.toLowerCase()] diff --git a/client/src/api/client.js b/client/src/api/client.js index d227a3b..b88a920 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -2,6 +2,10 @@ // same-origin API (/api/v1) — proxied to the Express server in dev. const BASE = '/api/v1' +// Prefix a non-empty query string with "?" (and nothing when it is empty), so +// callers can append it to a path without a dangling "?". +const withQs = (s) => (s ? `?${s}` : '') + class ApiError extends Error { constructor(status, message, body) { super(message) @@ -84,7 +88,7 @@ export const api = { if (opts.tag) qs.set('tag', opts.tag) if (opts.q) qs.set('q', opts.q) const s = qs.toString() - return req(`/public/wiki${s ? `?${s}` : ''}`) + return req(`/public/wiki${withQs(s)}`) }, wikiCategories: () => req('/public/wiki/categories'), wikiTags: () => req('/public/wiki/tags'), @@ -105,17 +109,22 @@ export const api = { if (opts.kind) qs.set('kind', opts.kind) if (opts.limit) qs.set('limit', opts.limit) const s = qs.toString() - return req(`/public/shard/feed${s ? `?${s}` : ''}`) + return req(`/public/shard/feed${withQs(s)}`) + }, + economy: (limit) => { + const q = limit ? `limit=${limit}` : '' + return req(`/public/shard/economy${withQs(q)}`) }, - economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`), online: () => req('/public/shard/online'), idoc: () => req('/public/shard/idoc'), champs: () => req('/public/shard/champs'), // Protocol 2.0 boards. guilds: () => req('/public/shard/guilds'), governors: () => req('/public/shard/governors'), - governorHistory: (city, limit) => - req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`), + governorHistory: (city, limit) => { + const q = limit ? `limit=${limit}` : '' + return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`) + }, presence: () => req('/public/shard/presence'), houses: () => req('/public/shard/houses'), }, @@ -129,7 +138,10 @@ export const api = { admin: { dashboard: () => req('/admin/dashboard'), setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }), - listPosts: (category) => req(`/admin/posts${category ? `?category=${category}` : ''}`), + listPosts: (category) => { + const q = category ? `category=${category}` : '' + return req(`/admin/posts${withQs(q)}`) + }, getPost: (id) => req(`/admin/posts/${id}`), createPost: (data) => req('/admin/posts', { method: 'POST', body: data }), updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }), @@ -216,7 +228,7 @@ export const api = { if (params.limit) qs.set('limit', params.limit) if (params.offset) qs.set('offset', params.offset) const s = qs.toString() - return req(`/admin/moderation/recent${s ? `?${s}` : ''}`) + return req(`/admin/moderation/recent${withQs(s)}`) }, modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`), modMembers: (params = {}) => { @@ -225,21 +237,21 @@ export const api = { if (params.limit) qs.set('limit', params.limit) if (params.offset) qs.set('offset', params.offset) const s = qs.toString() - return req(`/admin/moderation/members${s ? `?${s}` : ''}`) + return req(`/admin/moderation/members${withQs(s)}`) }, modFilterHits: (params = {}) => { const qs = new URLSearchParams() if (params.limit) qs.set('limit', params.limit) if (params.offset) qs.set('offset', params.offset) const s = qs.toString() - return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`) + return req(`/admin/moderation/filter-hits${withQs(s)}`) }, modSpamHits: (params = {}) => { const qs = new URLSearchParams() if (params.limit) qs.set('limit', params.limit) if (params.offset) qs.set('offset', params.offset) const s = qs.toString() - return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`) + return req(`/admin/moderation/spam-hits${withQs(s)}`) }, modUser: (discordId) => req(`/admin/moderation/user/${discordId}`), modUserActions: (discordId, params = {}) => { @@ -248,7 +260,7 @@ export const api = { if (params.limit) qs.set('limit', params.limit) if (params.offset) qs.set('offset', params.offset) const s = qs.toString() - return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`) + return req(`/admin/moderation/user/${discordId}/actions${withQs(s)}`) }, modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`), addModNote: (discordId, data) => @@ -261,7 +273,7 @@ export const api = { if (params.limit) qs.set('limit', params.limit) if (params.offset) qs.set('offset', params.offset) const s = qs.toString() - return req(`/admin/moderation/appeals${s ? `?${s}` : ''}`) + return req(`/admin/moderation/appeals${withQs(s)}`) }, getAppeal: (id) => req(`/admin/moderation/appeals/${id}`), claimAppeal: (id) => req(`/admin/moderation/appeals/${id}/claim`, { method: 'POST' }), diff --git a/client/src/components/CharacterStats.jsx b/client/src/components/CharacterStats.jsx index 0f7fe3b..12142ed 100644 --- a/client/src/components/CharacterStats.jsx +++ b/client/src/components/CharacterStats.jsx @@ -20,6 +20,25 @@ function Tile({ value, label }) { ) } +// Fold the settled roster results into totals. `complete` is false when any +// account's roster failed (a partial result — shown as a dash rather than a +// misleadingly low count). +function summarizeRosters(rosters) { + let chars = 0 + let online = 0 + let complete = true + for (const r of rosters) { + if (r.status !== 'fulfilled') { + complete = false + continue + } + const cs = r.value.chars || [] + chars += cs.length + online += cs.filter((c) => c.online).length + } + return { chars, online, complete } +} + export default function CharacterStats({ scope }) { const [stats, setStats] = useState(null) @@ -36,19 +55,7 @@ export default function CharacterStats({ scope }) { // Roster is a live round-trip and can be unavailable (503); tolerate a // partial result so a restarting shard doesn't blank the whole row. const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account))) - let chars = 0 - let online = 0 - let complete = true - for (const r of rosters) { - if (r.status === 'fulfilled') { - const cs = r.value.chars || [] - chars += cs.length - online += cs.filter((c) => c.online).length - } else { - complete = false - } - } - if (!cancelled) setStats({ linked, chars, online, complete }) + if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) }) } catch { if (!cancelled) setStats({ error: true }) } diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx index ff1539d..ef9d2ed 100644 --- a/client/src/components/GameAccounts.jsx +++ b/client/src/components/GameAccounts.jsx @@ -121,7 +121,8 @@ function UnlinkButton({ account, onUnlink }) { try { await onUnlink(account) } catch (err) { - setError(err.status === 403 ? 'Protected account — refused.' : err.status === 404 ? 'Not linked.' : (err.message || 'Could not unlink.')) + const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' } + setError(byStatus[err.status] || err.message || 'Could not unlink.') setBusy(false) } } diff --git a/client/src/components/HeroElement.jsx b/client/src/components/HeroElement.jsx index 75583f7..ee0f4eb 100644 --- a/client/src/components/HeroElement.jsx +++ b/client/src/components/HeroElement.jsx @@ -34,14 +34,15 @@ function TextBlock({ props }) { const align = props.align || 'center' return (