Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
deae296
feat(bot-status): add configurable rotating presence with templates
MohsinCoding Mar 14, 2026
42f462e
chore: apply lint formatting updates
MohsinCoding Mar 14, 2026
e42144e
fix: harden bot status validation and fallbacks
MohsinCoding Mar 19, 2026
7697739
fix: align bot status runtime and config scope
MohsinCoding Mar 19, 2026
23de125
chore: apply lint cleanup after merge
MohsinCoding Mar 19, 2026
2a075bf
fix(web): optional types, quote consistency, docs URL, pricing readab…
Mar 19, 2026
62c094b
fix: address review comments (interval safety, validation, string ops)
BillChirico Mar 19, 2026
0019f4a
fix: remove unused getConfig import from index.js
BillChirico Mar 19, 2026
a3a2045
fix(web): landing page lint and formatting fixes
BillChirico Mar 19, 2026
41f1cf3
fix: biome schema, JSX quotes, and line width fixes
BillChirico Mar 19, 2026
b092c86
fix: restrict botStatus to bot owners, fix streaming/presence/normalize
BillChirico Mar 19, 2026
9c4f011
test: import production SAFE_CONFIG_KEYS and cover shutdown
BillChirico Mar 19, 2026
9f6a262
fix(web): dashboard legacy compat, types, and formatting
BillChirico Mar 19, 2026
d9a2d34
Merge branch 'fix/304-b2' into mohsin/rotating-bot-status
BillChirico Mar 19, 2026
7942ada
Merge branch 'fix/304-b4' into mohsin/rotating-bot-status
BillChirico Mar 19, 2026
a887fa4
Merge branch 'fix/304-b5' into mohsin/rotating-bot-status
BillChirico Mar 19, 2026
098c686
Merge branch 'fix/304-b3' into mohsin/rotating-bot-status
BillChirico Mar 19, 2026
b466e4e
fix: biome auto-fix imports and formatting
BillChirico Mar 19, 2026
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
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
"files": {
"includes": [
"src/**/*.js",
Expand Down
22 changes: 22 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,28 @@
"flushIntervalMs": 5000
}
},
"botStatus": {
"enabled": true,
"status": "online",
"rotation": {
"enabled": true,
"intervalMinutes": 5,
"messages": [
{
"type": "Watching",
"text": "{guildCount} servers"
},
{
"type": "Listening",
"text": "to {memberCount} members"
},
{
"type": "Playing",
"text": "with /help"
}
]
}
},
"permissions": {
"enabled": true,
"adminRoleIds": [],
Expand Down
17 changes: 14 additions & 3 deletions src/api/routes/guilds.js
Original file line number Diff line number Diff line change
Expand Up @@ -727,40 +727,51 @@
* "500":
* $ref: "#/components/responses/ServerError"
*/
router.patch('/:id/config', requireGuildAdmin, validateGuild, async (req, res) => {
if (!req.body) {
return res.status(400).json({ error: 'Request body is required' });
}

const result = validateConfigPatchBody(req.body, SAFE_CONFIG_KEYS);
if (result.error) {
const response = { error: result.error };
if (result.details) response.details = result.details;
return res.status(result.status).json(response);
}

const { path, value, topLevelKey } = result;
// botStatus is global (not per-guild) — only bot owners may write to it.
const isGlobalBotStatusWrite = topLevelKey === 'botStatus';
if (isGlobalBotStatusWrite && req.authMethod === 'oauth' && !isOAuthBotOwner(req.user)) {
return res.status(403).json({ error: 'Only bot owners can update global bot status' });
}
const writeScope = isGlobalBotStatusWrite ? 'global' : req.params.id;

try {
await setConfigValue(path, value, req.params.id);
const effectiveConfig = getConfig(req.params.id);
await setConfigValue(path, value, writeScope === 'global' ? undefined : req.params.id);
const effectiveConfig = writeScope === 'global' ? getConfig() : getConfig(req.params.id);
const effectiveSection = effectiveConfig[topLevelKey] || {};
const sensitivePattern = /key|secret|token|password/i;
const logValue = sensitivePattern.test(path) ? '[REDACTED]' : value;
info('Config updated via API', { path, value: logValue, guild: req.params.id });
info('Config updated via API', {
path,
value: logValue,
guild: req.params.id,
scope: writeScope,
});
fireAndForgetWebhook('DASHBOARD_WEBHOOK_URL', {
event: 'config.updated',
guildId: req.params.id,
section: topLevelKey,
updatedKeys: [path],
timestamp: Date.now(),
});
res.json(effectiveSection);
} catch (err) {
error('Failed to update config via API', { path, error: err.message });
res.status(500).json({ error: 'Failed to update config' });
}
});

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.

/**
* @openapi
Expand Down
83 changes: 58 additions & 25 deletions src/api/utils/configValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,40 @@
retentionDays: { type: 'number', min: 1, max: 365 },
},
},
botStatus: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
status: { type: 'string', enum: ['online', 'idle', 'dnd', 'invisible'] },
activityType: {
type: 'string',
enum: ['Playing', 'Watching', 'Listening', 'Competing', 'Streaming', 'Custom'],
},
activities: { type: 'array', items: { type: 'string' } },
rotateIntervalMs: { type: 'number' },
rotation: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
intervalMinutes: { type: 'number' },
messages: {
type: 'array',
items: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['Playing', 'Watching', 'Listening', 'Competing', 'Streaming', 'Custom'],
},
text: { type: 'string', minLength: 1, pattern: '\\S' },

Check warning on line 205 in src/api/utils/configValidation.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=VolvoxLLC_volvox-bot&issues=AZ0IAguQxQIG-OxP98ir&open=AZ0IAguQxQIG-OxP98ir&pullRequest=304
},
required: ['text'],
},
},
},
},
},
},
reminders: {
type: 'object',
properties: {
Expand Down Expand Up @@ -251,12 +285,18 @@
if (typeof value !== 'string') {
errors.push(`${path}: expected string, got ${typeof value}`);
} else {
if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
errors.push(`${path}: must be at least ${schema.minLength} characters`);
}
if (schema.enum && !schema.enum.includes(value)) {
errors.push(`${path}: must be one of [${schema.enum.join(', ')}], got "${value}"`);
}
if (schema.maxLength != null && value.length > schema.maxLength) {
errors.push(`${path}: exceeds max length of ${schema.maxLength}`);
}
if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
errors.push(`${path}: does not match required pattern`);
}
}
break;
case 'number':
Expand All @@ -276,24 +316,7 @@
errors.push(`${path}: expected array, got ${typeof value}`);
} else if (schema.items) {
for (let i = 0; i < value.length; i++) {
const item = value[i];
if (schema.items.type === 'string') {
if (typeof item !== 'string') {
errors.push(`${path}[${i}]: expected string, got ${typeof item}`);
}
} else if (schema.items.type === 'object') {
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
errors.push(
`${path}[${i}]: expected object, got ${Array.isArray(item) ? 'array' : item === null ? 'null' : typeof item}`,
);
} else if (schema.items.required) {
for (const key of schema.items.required) {
if (!(key in item)) {
errors.push(`${path}[${i}]: missing required key "${key}"`);
}
}
}
}
errors.push(...validateValue(value[i], schema.items, `${path}[${i}]`));
}
}
break;
Expand All @@ -302,14 +325,24 @@
errors.push(
`${path}: expected object, got ${Array.isArray(value) ? 'array' : typeof value}`,
);
} else if (schema.properties) {
for (const [key, val] of Object.entries(value)) {
if (Object.hasOwn(schema.properties, key)) {
errors.push(...validateValue(val, schema.properties[key], `${path}.${key}`));
} else if (!schema.openProperties) {
errors.push(`${path}.${key}: unknown config key`);
} else {
if (schema.required) {
for (const key of schema.required) {
if (!Object.hasOwn(value, key)) {
errors.push(`${path}: missing required key "${key}"`);
}
}
}

if (schema.properties) {
for (const [key, val] of Object.entries(value)) {
if (Object.hasOwn(schema.properties, key)) {
errors.push(...validateValue(val, schema.properties[key], `${path}.${key}`));
} else if (!schema.openProperties) {
errors.push(`${path}.${key}: unknown config key`);
}
// openProperties: true — freeform map, unknown keys are allowed
}
// openProperties: true — freeform map, unknown keys are allowed
}
}
break;
Expand Down
4 changes: 4 additions & 0 deletions src/config-listeners.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ export function registerConfigListeners({ dbPool, config }) {
'botStatus.activityType',
'botStatus.activities',
'botStatus.rotateIntervalMs',
'botStatus.rotation',
'botStatus.rotation.enabled',
'botStatus.rotation.intervalMinutes',
'botStatus.rotation.messages',
]) {
onConfigChange(key, (_newValue, _oldValue, _path, guildId) => {
// Bot presence is global — ignore per-guild overrides here
Expand Down
5 changes: 5 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
startConversationCleanup,
stopConversationCleanup,
} from './modules/ai.js';
import { startBotStatus, stopBotStatus } from './modules/botStatus.js';
import { loadConfig } from './modules/config.js';

import { registerEventHandlers } from './modules/events.js';
Expand Down Expand Up @@ -193,6 +194,7 @@ async function gracefulShutdown(signal) {
stopWarningExpiryScheduler();
stopScheduler();
stopGithubFeed();
stopBotStatus();

// 1.5. Stop API server (drain in-flight HTTP requests before closing DB)
try {
Expand Down Expand Up @@ -379,6 +381,9 @@ async function startup() {
await loadCommands();
await client.login(token);

// Start configurable bot presence rotation after login so client.user is available
startBotStatus(client);

// Set Sentry context now that we know the bot identity (no-op if disabled)
import('./sentry.js')
.then(({ Sentry, sentryEnabled }) => {
Expand Down
Loading
Loading