Skip to content
Merged
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
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ AI-powered Discord bot for the Volvox community.
## Features

- **AI Chat** - Powered by Claude (via OpenClaw), responds when mentioned
- **Welcome Messages** - Greets new members in a configurable channel
- **Welcome Messages** - Dynamic, contextual onboarding (time of day, activity pulse, milestones)
- **Moderation** - Detects spam/scam patterns and alerts mods

## Requirements
Expand Down Expand Up @@ -51,7 +51,7 @@ AI-powered Discord bot for the Volvox community.

## Config

```json
```jsonc
{
"ai": {
"enabled": true,
Expand All @@ -63,7 +63,14 @@ AI-powered Discord bot for the Volvox community.
"welcome": {
"enabled": true,
"channelId": "...",
"message": "Welcome, {user}!" // placeholders: {user}, {username}, {server}, {memberCount}
"message": "Welcome, {user}!", // used when dynamic.enabled=false
"dynamic": {
"enabled": true,
"timezone": "America/New_York",
"activityWindowMinutes": 45,
"milestoneInterval": 25,
"highlightChannels": ["..."]
}
},
"moderation": {
"enabled": true,
Expand Down
14 changes: 13 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,19 @@
"welcome": {
"enabled": true,
"channelId": "1438631182379253814",
"message": "Welcome to Volvox, {user}! 🌱 You're member #{memberCount}!\n\nWe're a community of developers building cool stuff together. Feel free to introduce yourself!\n\nCheck out <#1446317676988465242> to see what we're working on, share your projects in <#1444154471704957069>, or just say hi in <#1438631182379253814>.\n\nHave questions? Just ask — we're here to help. 💚"
"message": "Welcome to Volvox, {user}! 🌱 You're member #{memberCount}!\n\nWe're a community of developers building cool stuff together. Feel free to introduce yourself!\n\nCheck out <#1446317676988465242> to see what we're working on, share your projects in <#1444154471704957069>, or just say hi in <#1438631182379253814>.\n\nHave questions? Just ask — we're here to help. 💚",
"dynamic": {
"enabled": true,
"timezone": "America/New_York",
"activityWindowMinutes": 45,
"milestoneInterval": 25,
"highlightChannels": [
"1438631182379253814",
"1444154471704957069",
"1446317676988465242"
],
"excludeChannels": []
}
},
"moderation": {
"enabled": true,
Expand Down
157 changes: 93 additions & 64 deletions src/commands/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@
*/

import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import { getConfig, setConfigValue, resetConfig, loadConfigFromFile } from '../modules/config.js';
import { getConfig, setConfigValue, resetConfig } from '../modules/config.js';

// Derived from config.json top-level keys so static slash-command choices stay in sync automatically.
const VALID_SECTIONS = Object.keys(loadConfigFromFile());

/** @type {Array<{name: string, value: string}>} Derived choices for section options */
const SECTION_CHOICES = VALID_SECTIONS.map(s => ({
name: s.charAt(0).toUpperCase() + s.slice(1).replace(/([A-Z])/g, ' $1'),
value: s,
}));
/**
* Escape backticks in user-provided strings to prevent breaking Discord inline code formatting.
* @param {string} str - Raw string to sanitize
* @returns {string} Sanitized string safe for embedding inside backtick-delimited code spans
*/
function escapeInlineCode(str) {
return String(str).replace(/`/g, '\\`');
}

export const data = new SlashCommandBuilder()
.setName('config')
Expand All @@ -27,7 +27,7 @@ export const data = new SlashCommandBuilder()
.setName('section')
.setDescription('Specific config section to view')
.setRequired(false)
.addChoices(...SECTION_CHOICES)
.setAutocomplete(true)
)
)
.addSubcommand(subcommand =>
Expand Down Expand Up @@ -57,75 +57,96 @@ export const data = new SlashCommandBuilder()
.setName('section')
.setDescription('Section to reset (omit to reset all)')
.setRequired(false)
.addChoices(...SECTION_CHOICES)
.setAutocomplete(true)
)
);

export const adminOnly = true;

/**
* Recursively collect leaf-only dot-notation paths from a config object.
* Only emits paths that point to non-object values (leaves), preventing
* autocomplete from suggesting intermediate paths whose selection would
* overwrite all nested config beneath them with a scalar.
* @param {Object} obj - Object to flatten
* @param {string} prefix - Current path prefix
* @returns {string[]} Array of dot-notation leaf paths
* Recursively collect leaf-only dot-notation paths for a config object.
* Only emits paths that point to non-object values (leaves).
* @param {*} source - Config value to traverse
* @param {string} [prefix] - Current path prefix
* @param {string[]} [paths] - Accumulator array
* @returns {string[]} Dot-notation config paths (leaf-only)
*/
function flattenConfigKeys(obj, prefix) {
const paths = [];
for (const [key, value] of Object.entries(obj)) {
const fullPath = `${prefix}.${key}`;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
paths.push(...flattenConfigKeys(value, fullPath));
function collectConfigPaths(source, prefix = '', paths = []) {
if (Array.isArray(source)) {
// Emit path for empty arrays so they're discoverable in autocomplete
if (source.length === 0 && prefix) {
paths.push(prefix);
return paths;
}
source.forEach((value, index) => {
const path = prefix ? `${prefix}.${index}` : String(index);
if (value && typeof value === 'object') {
collectConfigPaths(value, path, paths);
} else {
paths.push(path);
}
});
return paths;
}

if (!source || typeof source !== 'object') {
return paths;
}

// Emit path for empty objects so they're discoverable in autocomplete
if (Object.keys(source).length === 0 && prefix) {
paths.push(prefix);
return paths;
}

for (const [key, value] of Object.entries(source)) {
const path = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object') {
collectConfigPaths(value, path, paths);
} else {
paths.push(fullPath);
paths.push(path);
}
}

return paths;
}

/**
* Handle autocomplete for the `path` option in /config set.
*
* The `section` option (used by view/reset) uses static choices via
* addChoices(), so Discord resolves those client-side and never fires
* an autocomplete event for them. Only the `path` option is registered
* with setAutocomplete(true).
*
* Suggests dot-notation leaf paths (≥2 segments) that setConfigValue
* actually accepts.
*
* Handle autocomplete for config paths and section names
* @param {Object} interaction - Discord interaction
*/
export async function autocomplete(interaction) {
try {
const focusedValue = interaction.options.getFocused().toLowerCase();
const config = getConfig();

const paths = [];
for (const [section, value] of Object.entries(config)) {
if (typeof value === 'object' && value !== null) {
paths.push(...flattenConfigKeys(value, section));
}
}

const choices = paths
.filter(p => p.includes('.') && p.toLowerCase().includes(focusedValue))
const focusedOption = interaction.options.getFocused(true);
const focusedValue = focusedOption.value.toLowerCase().trim();
const config = getConfig();

let choices;
if (focusedOption.name === 'section') {
// Autocomplete section names from live config
choices = Object.keys(config)
.filter(s => s.toLowerCase().includes(focusedValue))
.slice(0, 25)
.map(s => ({ name: s, value: s }));
} else {
// Autocomplete dot-notation paths (leaf-only)
const paths = collectConfigPaths(config);
choices = paths
.filter(p => p.toLowerCase().includes(focusedValue))
.sort((a, b) => {
const aStarts = a.toLowerCase().startsWith(focusedValue);
const bStarts = b.toLowerCase().startsWith(focusedValue);
if (aStarts !== bStarts) return aStarts ? -1 : 1;
return a.localeCompare(b);
const aLower = a.toLowerCase();
const bLower = b.toLowerCase();
const aStartsWithFocus = aLower.startsWith(focusedValue);
const bStartsWithFocus = bLower.startsWith(focusedValue);
if (aStartsWithFocus !== bStartsWithFocus) {
return aStartsWithFocus ? -1 : 1;
}
return aLower.localeCompare(bLower);
})
.slice(0, 25)
.map(p => ({ name: p, value: p }));

await interaction.respond(choices);
} catch {
// Silently ignore — autocomplete failures (e.g. expired interaction token)
// are non-critical and must not produce unhandled promise rejections.
}

await interaction.respond(choices);
}

/**
Expand All @@ -145,6 +166,12 @@ export async function execute(interaction) {
case 'reset':
await handleReset(interaction);
break;
default:
await interaction.reply({
content: `❌ Unknown subcommand: \`${subcommand}\``,
ephemeral: true
});
break;
}
}

Expand All @@ -168,8 +195,9 @@ async function handleView(interaction) {
if (section) {
const sectionData = config[section];
if (!sectionData) {
const safeSection = escapeInlineCode(section);
return await interaction.reply({
content: `❌ Section '${section}' not found in config`,
content: `❌ Section \`${safeSection}\` not found in config`,
ephemeral: true
});
}
Expand All @@ -184,7 +212,7 @@ async function handleView(interaction) {
embed.setDescription('Current bot configuration');

// Track cumulative embed size to stay under Discord's 6000-char limit
let totalLength = embed.data.title.length + embed.data.description.length;
let totalLength = (embed.data.title?.length || 0) + (embed.data.description?.length || 0);
let truncated = false;

for (const [key, value] of Object.entries(config)) {
Expand All @@ -197,7 +225,7 @@ async function handleView(interaction) {
// Reserve space for a truncation notice
embed.addFields({
name: '⚠️ Truncated',
value: `Use \`/config view section:<name>\` to see remaining sections.`,
value: 'Use `/config view section:<name>` to see remaining sections.',
inline: false
});
truncated = true;
Expand Down Expand Up @@ -233,12 +261,13 @@ async function handleSet(interaction) {
const path = interaction.options.getString('path');
const value = interaction.options.getString('value');

// Validate section exists in live config (may include DB-added sections beyond config.json)
// Validate section exists in live config
const section = path.split('.')[0];
const liveSections = Object.keys(getConfig());
if (!liveSections.includes(section)) {
const validSections = Object.keys(getConfig());
if (!validSections.includes(section)) {
const safeSection = escapeInlineCode(section);
return await interaction.reply({
content: `❌ Invalid section '${section}'. Valid sections: ${liveSections.join(', ')}`,
content: `❌ Invalid section \`${safeSection}\`. Valid sections: ${validSections.join(', ')}`,
ephemeral: true
});
}
Expand Down
Loading