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
62 changes: 52 additions & 10 deletions config.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"ai": {
"enabled": true,
"systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\nIf asked about your own infrastructure, model, or internals \u2014 say you don't know the specifics\nand suggest asking a server admin. Don't guess or speculate about what you run on.\n\nCRITICAL RULES:\n- NEVER type @everyone or @here \u2014 these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.",
"systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\nIf asked about your own infrastructure, model, or internals say you don't know the specifics\nand suggest asking a server admin. Don't guess or speculate about what you run on.\n\nCRITICAL RULES:\n- NEVER type @everyone or @here these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.",
"channels": [],
"historyLength": 20,
"historyTTLDays": 30,
Expand Down Expand Up @@ -43,7 +43,7 @@
"welcome": {
"enabled": true,
"channelId": "1438631182379253814",
"message": "Welcome to Volvox, {user}! \ud83c\udf31 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 \u2014 we're here to help. \ud83d\udc9a",
"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",
Expand All @@ -55,6 +55,17 @@
"1446317676988465242"
],
"excludeChannels": []
},
"rulesChannel": null,
"verifiedRole": null,
"introChannel": null,
"roleMenu": {
"enabled": false,
"options": []
},
"dmSequence": {
"enabled": false,
"steps": []
}
},
"moderation": {
Expand Down Expand Up @@ -176,7 +187,12 @@
"enabled": false,
"channelId": null,
"repos": [],
"events": ["pr", "issue", "release", "push"]
"events": [
"pr",
"issue",
"release",
"push"
]
}
},
"tldr": {
Expand All @@ -193,18 +209,44 @@
"trackMessages": true,
"trackReactions": true,
"activityBadges": [
{ "days": 90, "label": "👑 Legend" },
{ "days": 30, "label": "🌳 Veteran" },
{ "days": 7, "label": "🌿 Regular" },
{ "days": 0, "label": "🌱 Newcomer" }
{
"days": 90,
"label": "👑 Legend"
},
{
"days": 30,
"label": "🌳 Veteran"
},
{
"days": 7,
"label": "🌿 Regular"
},
{
"days": 0,
"label": "🌱 Newcomer"
}
]
},
"reputation": {
"enabled": false,
"xpPerMessage": [5, 15],
"xpPerMessage": [
5,
15
],
"xpCooldownSeconds": 60,
"announceChannelId": null,
"levelThresholds": [100, 300, 600, 1000, 1500, 2500, 4000, 6000, 8500, 12000],
"levelThresholds": [
100,
300,
600,
1000,
1500,
2500,
4000,
6000,
8500,
12000
],
"roleRewards": {}
},
"challenges": {
Expand All @@ -219,4 +261,4 @@
"staleAfterDays": 7,
"xpReward": 50
}
}
}
36 changes: 36 additions & 0 deletions src/api/utils/configValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ export const CONFIG_SCHEMA = {
excludeChannels: { type: 'array' },
},
},
rulesChannel: { type: 'string', nullable: true },
verifiedRole: { type: 'string', nullable: true },
introChannel: { type: 'string', nullable: true },
roleMenu: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
options: { type: 'array', items: { type: 'object', required: ['label', 'roleId'] } },
},
},
dmSequence: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
steps: { type: 'array', items: { type: 'string' } },
},
},
},
},
spam: {
Expand Down Expand Up @@ -167,6 +184,25 @@ export function validateValue(value, schema, path) {
case 'array':
if (!Array.isArray(value)) {
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}"`);
}
}
}
}
}
}
break;
case 'object':
Expand Down
80 changes: 80 additions & 0 deletions src/commands/welcome.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { info } from '../logger.js';
import { getConfig } from '../modules/config.js';
import {
buildRoleMenuMessage,
buildRulesAgreementMessage,
normalizeWelcomeOnboardingConfig,
} from '../modules/welcomeOnboarding.js';
import { isModerator } from '../utils/permissions.js';
import { safeEditReply, safeSend } from '../utils/safeSend.js';

export const adminOnly = true;

export const data = new SlashCommandBuilder()
.setName('welcome')
.setDescription('Welcome/onboarding admin helpers')
.addSubcommand((sub) =>
sub.setName('setup').setDescription('Post rules agreement and role menu onboarding panels'),
);

export async function execute(interaction) {
await interaction.deferReply({ ephemeral: true });

const guildConfig = getConfig(interaction.guildId);
if (
!interaction.member.permissions.has(PermissionFlagsBits.Administrator) &&
!isModerator(interaction.member, guildConfig)
) {
await safeEditReply(interaction, {
content: '❌ You need moderator or administrator permissions to run this command.',
});
return;
}

const onboarding = normalizeWelcomeOnboardingConfig(guildConfig?.welcome);
const resultLines = [];

if (onboarding.rulesChannel) {
const rulesChannel =
interaction.guild.channels.cache.get(onboarding.rulesChannel) ||
(await interaction.guild.channels.fetch(onboarding.rulesChannel).catch(() => null));

if (rulesChannel?.isTextBased?.()) {
const rulesMsg = buildRulesAgreementMessage();
await safeSend(rulesChannel, rulesMsg);
resultLines.push(`✅ Posted rules agreement panel in <#${rulesChannel.id}>.`);
} else {
resultLines.push('⚠️ Could not find `welcome.rulesChannel`; rules panel not posted.');
}
} else {
resultLines.push('⚠️ `welcome.rulesChannel` is not configured.');
}

const roleMenuMsg = buildRoleMenuMessage(guildConfig?.welcome);
if (roleMenuMsg && guildConfig?.welcome?.channelId) {
const welcomeChannel =
interaction.guild.channels.cache.get(guildConfig.welcome.channelId) ||
(await interaction.guild.channels.fetch(guildConfig.welcome.channelId).catch(() => null));

if (welcomeChannel?.isTextBased?.()) {
await safeSend(welcomeChannel, roleMenuMsg);
resultLines.push(`✅ Posted role menu in <#${welcomeChannel.id}>.`);
} else {
resultLines.push('⚠️ Could not find `welcome.channelId`; role menu not posted.');
}
} else if (roleMenuMsg) {
resultLines.push('⚠️ `welcome.channelId` is not configured; role menu not posted.');
} else {
resultLines.push('⚠️ `welcome.roleMenu` is disabled or has no valid options.');
}

await safeEditReply(interaction, {
content: resultLines.join('\n'),
});

info('Welcome setup command executed', {
guildId: interaction.guildId,
userId: interaction.user.id,
});
}
70 changes: 70 additions & 0 deletions src/modules/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ import { handleReactionAdd, handleReactionRemove } from './starboard.js';
import { closeTicket, getTicketConfig, openTicket } from './ticketHandler.js';
import { accumulateMessage, evaluateNow } from './triage.js';
import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js';
import {
handleRoleMenuSelection,
handleRulesAcceptButton,
ROLE_MENU_SELECT_ID,
RULES_ACCEPT_BUTTON_ID,
} from './welcomeOnboarding.js';

/** @type {boolean} Guard against duplicate process-level handler registration */
let processHandlersRegistered = false;
Expand Down Expand Up @@ -512,6 +518,69 @@ export function registerErrorHandlers(client) {
*
* @param {Client} client - Discord client instance
*/

/**
* Register onboarding interaction handlers:
* - Rules acceptance button
* - Role selection menu
*
* @param {Client} client - Discord client instance
*/
export function registerWelcomeOnboardingHandlers(client) {
client.on(Events.InteractionCreate, async (interaction) => {
const guildId = interaction.guildId;
if (!guildId) return;

const guildConfig = getConfig(guildId);
if (!guildConfig.welcome?.enabled) return;

if (interaction.isButton() && interaction.customId === RULES_ACCEPT_BUTTON_ID) {
try {
await handleRulesAcceptButton(interaction, guildConfig);
} catch (err) {
logError('Rules acceptance handler failed', {
guildId,
userId: interaction.user?.id,
error: err?.message,
});

try {
if (!interaction.replied) {
await safeEditReply(interaction, {
content: '❌ Failed to verify. Please ping an admin.',
});
}
} catch {
// ignore
}
}
return;
}

if (interaction.isStringSelectMenu() && interaction.customId === ROLE_MENU_SELECT_ID) {
try {
await handleRoleMenuSelection(interaction, guildConfig);
} catch (err) {
logError('Role menu handler failed', {
guildId,
userId: interaction.user?.id,
error: err?.message,
});

try {
if (!interaction.replied) {
await safeEditReply(interaction, {
content: '❌ Failed to update roles. Please try again.',
});
}
} catch {
// ignore
}
}
}
});
}

export function registerChallengeButtonHandler(client) {
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isButton()) return;
Expand Down Expand Up @@ -575,6 +644,7 @@ export function registerEventHandlers(client, config, healthMonitor) {
registerTicketOpenButtonHandler(client);
registerTicketModalHandler(client);
registerTicketCloseButtonHandler(client);
registerWelcomeOnboardingHandlers(client);
registerErrorHandlers(client);
}

Expand Down
18 changes: 13 additions & 5 deletions src/modules/welcome.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { info, error as logError } from '../logger.js';
import { safeSend } from '../utils/safeSend.js';
import { isReturningMember } from './welcomeOnboarding.js';

const guildActivity = new Map();
const DEFAULT_ACTIVITY_WINDOW_MINUTES = 45;
Expand Down Expand Up @@ -137,14 +138,21 @@ export async function sendWelcomeMessage(member, client, config) {
if (!channel) return;

const useDynamic = config.welcome?.dynamic?.enabled === true;
const returningMember = isReturningMember(member);

const message = useDynamic
? buildDynamicWelcomeMessage(member, config)
: renderWelcomeMessage(
config.welcome.message || 'Welcome, {user}!',
const message = returningMember
? renderWelcomeMessage(
'Welcome back, {user}! Glad to see you again. Jump back in whenever you are ready.',
{ id: member.id, username: member.user.username },
{ name: member.guild.name, memberCount: member.guild.memberCount },
);
)
: useDynamic
? buildDynamicWelcomeMessage(member, config)
: renderWelcomeMessage(
config.welcome.message || 'Welcome, {user}!',
{ id: member.id, username: member.user.username },
{ name: member.guild.name, memberCount: member.guild.memberCount },
);

await safeSend(channel, message);
info('Welcome message sent', { user: member.user.tag, guild: member.guild.name });
Expand Down
Loading
Loading