diff --git a/config.json b/config.json
index 803cca06..278fefc8 100644
--- a/config.json
+++ b/config.json
@@ -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,
@@ -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",
@@ -55,6 +55,17 @@
"1446317676988465242"
],
"excludeChannels": []
+ },
+ "rulesChannel": null,
+ "verifiedRole": null,
+ "introChannel": null,
+ "roleMenu": {
+ "enabled": false,
+ "options": []
+ },
+ "dmSequence": {
+ "enabled": false,
+ "steps": []
}
},
"moderation": {
@@ -176,7 +187,12 @@
"enabled": false,
"channelId": null,
"repos": [],
- "events": ["pr", "issue", "release", "push"]
+ "events": [
+ "pr",
+ "issue",
+ "release",
+ "push"
+ ]
}
},
"tldr": {
@@ -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": {
@@ -219,4 +261,4 @@
"staleAfterDays": 7,
"xpReward": 50
}
-}
\ No newline at end of file
+}
diff --git a/src/api/utils/configValidation.js b/src/api/utils/configValidation.js
index b58d47af..a443fe72 100644
--- a/src/api/utils/configValidation.js
+++ b/src/api/utils/configValidation.js
@@ -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: {
@@ -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':
diff --git a/src/commands/welcome.js b/src/commands/welcome.js
new file mode 100644
index 00000000..4bbd0b13
--- /dev/null
+++ b/src/commands/welcome.js
@@ -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,
+ });
+}
diff --git a/src/modules/events.js b/src/modules/events.js
index b48538a8..5edd0ab7 100644
--- a/src/modules/events.js
+++ b/src/modules/events.js
@@ -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;
@@ -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;
@@ -575,6 +644,7 @@ export function registerEventHandlers(client, config, healthMonitor) {
registerTicketOpenButtonHandler(client);
registerTicketModalHandler(client);
registerTicketCloseButtonHandler(client);
+ registerWelcomeOnboardingHandlers(client);
registerErrorHandlers(client);
}
diff --git a/src/modules/welcome.js b/src/modules/welcome.js
index 58b07994..55b31203 100644
--- a/src/modules/welcome.js
+++ b/src/modules/welcome.js
@@ -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;
@@ -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 });
diff --git a/src/modules/welcomeOnboarding.js b/src/modules/welcomeOnboarding.js
new file mode 100644
index 00000000..fa4d76e3
--- /dev/null
+++ b/src/modules/welcomeOnboarding.js
@@ -0,0 +1,252 @@
+import {
+ ActionRowBuilder,
+ ButtonBuilder,
+ ButtonStyle,
+ GuildMemberFlagsBitField,
+ StringSelectMenuBuilder,
+} from 'discord.js';
+import { info } from '../logger.js';
+import { safeEditReply, safeSend } from '../utils/safeSend.js';
+
+export const RULES_ACCEPT_BUTTON_ID = 'welcome_rules_accept';
+export const ROLE_MENU_SELECT_ID = 'welcome_role_select';
+
+const MAX_ROLE_MENU_OPTIONS = 25;
+
+/**
+ * Normalize welcome onboarding settings and apply safe defaults.
+ *
+ * @param {object} welcomeConfig
+ * @returns {{
+ * rulesChannel: string|null,
+ * verifiedRole: string|null,
+ * introChannel: string|null,
+ * roleMenu: {enabled: boolean, options: Array<{label: string, roleId: string, description?: string}>},
+ * dmSequence: {enabled: boolean, steps: string[]},
+ * }}
+ */
+export function normalizeWelcomeOnboardingConfig(welcomeConfig = {}) {
+ const roleMenuOptions = Array.isArray(welcomeConfig?.roleMenu?.options)
+ ? welcomeConfig.roleMenu.options
+ .filter((opt) => opt && typeof opt === 'object')
+ .map((opt) => ({
+ label: String(opt.label || '').trim(),
+ roleId: String(opt.roleId || '').trim(),
+ ...(opt.description ? { description: String(opt.description).trim() } : {}),
+ }))
+ .filter((opt) => opt.label && opt.roleId)
+ .slice(0, MAX_ROLE_MENU_OPTIONS)
+ : [];
+
+ const dmSteps = Array.isArray(welcomeConfig?.dmSequence?.steps)
+ ? welcomeConfig.dmSequence.steps.map((step) => String(step || '').trim()).filter(Boolean)
+ : [];
+
+ return {
+ rulesChannel: typeof welcomeConfig?.rulesChannel === 'string' && welcomeConfig.rulesChannel.trim() ? welcomeConfig.rulesChannel.trim() : null,
+ verifiedRole: typeof welcomeConfig?.verifiedRole === 'string' && welcomeConfig.verifiedRole.trim() ? welcomeConfig.verifiedRole.trim() : null,
+ introChannel: typeof welcomeConfig?.introChannel === 'string' && welcomeConfig.introChannel.trim() ? welcomeConfig.introChannel.trim() : null,
+ roleMenu: {
+ enabled: welcomeConfig?.roleMenu?.enabled === true,
+ options: roleMenuOptions,
+ },
+ dmSequence: {
+ enabled: welcomeConfig?.dmSequence?.enabled === true,
+ steps: dmSteps,
+ },
+ };
+}
+
+/**
+ * Check whether a guild member is rejoining (has the DidRejoin flag).
+ *
+ * @param {import('discord.js').GuildMember} member - The guild member to check.
+ * @returns {boolean} `true` if the member has previously left and is rejoining the guild.
+ */
+export function isReturningMember(member) {
+ return member?.flags?.has?.(GuildMemberFlagsBitField.Flags.DidRejoin) === true;
+}
+
+export function buildRulesAgreementMessage() {
+ const button = new ButtonBuilder()
+ .setCustomId(RULES_ACCEPT_BUTTON_ID)
+ .setLabel('Accept Rules')
+ .setStyle(ButtonStyle.Success);
+
+ const row = new ActionRowBuilder().addComponents(button);
+
+ return {
+ content: '✅ Read the server rules, then click below to verify your access.',
+ components: [row],
+ };
+}
+
+export function buildRoleMenuMessage(welcomeConfig) {
+ const onboarding = normalizeWelcomeOnboardingConfig(welcomeConfig);
+ if (!onboarding.roleMenu.enabled || onboarding.roleMenu.options.length === 0) {
+ return null;
+ }
+
+ const select = new StringSelectMenuBuilder()
+ .setCustomId(ROLE_MENU_SELECT_ID)
+ .setPlaceholder('Choose your roles')
+ .setMinValues(0)
+ .setMaxValues(onboarding.roleMenu.options.length)
+ .addOptions(
+ onboarding.roleMenu.options.map((opt) => ({
+ label: opt.label.slice(0, 100),
+ value: opt.roleId,
+ ...(opt.description ? { description: opt.description.slice(0, 100) } : {}),
+ })),
+ );
+
+ const row = new ActionRowBuilder().addComponents(select);
+
+ return {
+ content: '🎭 Pick your roles below. You can update them anytime.',
+ components: [row],
+ };
+}
+
+async function fetchRole(guild, roleId) {
+ return guild.roles.cache.get(roleId) || (await guild.roles.fetch(roleId).catch(() => null));
+}
+
+export async function handleRulesAcceptButton(interaction, config) {
+ await interaction.deferReply({ ephemeral: true });
+ const welcome = normalizeWelcomeOnboardingConfig(config?.welcome);
+
+ if (!welcome.verifiedRole) {
+ await safeEditReply(interaction, {
+ content: '⚠️ Verified role is not configured yet. Ask an admin to set `welcome.verifiedRole`.',
+ });
+ return;
+ }
+
+ const member = interaction.member || (await interaction.guild.members.fetch(interaction.user.id));
+ const role = await fetchRole(interaction.guild, welcome.verifiedRole);
+
+ if (!role) {
+ await safeEditReply(interaction, {
+ content:
+ '❌ I cannot find the configured verified role. Ask an admin to fix onboarding config.',
+ });
+ return;
+ }
+
+ if (!role.editable) {
+ await safeEditReply(interaction, {
+ content: '❌ I cannot assign the verified role (it is above my highest role).',
+ });
+ return;
+ }
+
+ if (member.roles.cache.has(role.id)) {
+ await safeEditReply(interaction, {
+ content: '✅ You are already verified.',
+ });
+ return;
+ }
+
+ try {
+ await member.roles.add(role, 'Accepted server rules');
+ } catch (roleErr) {
+ info('Failed to assign verified role during rules acceptance', {
+ guildId: interaction.guildId,
+ userId: interaction.user.id,
+ roleId: role.id,
+ error: roleErr?.message,
+ });
+ await safeEditReply(interaction, {
+ content: '❌ Failed to assign the verified role. Please try again or contact an admin.',
+ });
+ return;
+ }
+
+ if (welcome.introChannel) {
+ const introChannel =
+ interaction.guild.channels.cache.get(welcome.introChannel) ||
+ (await interaction.guild.channels.fetch(welcome.introChannel).catch(() => null));
+
+ if (introChannel?.isTextBased?.()) {
+ await safeSend(
+ introChannel,
+ `👋 Welcome <@${member.id}>! Drop a quick intro so we can meet you.`,
+ );
+ }
+ }
+
+ if (welcome.dmSequence.enabled && welcome.dmSequence.steps.length > 0) {
+ for (const step of welcome.dmSequence.steps) {
+ try {
+ await interaction.user.send(step);
+ } catch (dmErr) {
+ info('DM delivery failed during onboarding sequence', {
+ guildId: interaction.guildId,
+ userId: interaction.user.id,
+ error: dmErr?.message,
+ });
+ break;
+ }
+ }
+ }
+
+ await safeEditReply(interaction, {
+ content: `✅ Rules accepted! You now have <@&${role.id}>.`,
+ });
+
+ info('User verified via rules button', {
+ guildId: interaction.guildId,
+ userId: interaction.user.id,
+ roleId: role.id,
+ });
+}
+
+export async function handleRoleMenuSelection(interaction, config) {
+ await interaction.deferReply({ ephemeral: true });
+ const welcome = normalizeWelcomeOnboardingConfig(config?.welcome);
+
+ if (!welcome.roleMenu.enabled || welcome.roleMenu.options.length === 0) {
+ await safeEditReply(interaction, {
+ content: '⚠️ Role menu is not configured on this server.',
+ });
+ return;
+ }
+
+ const member = interaction.member || (await interaction.guild.members.fetch(interaction.user.id));
+
+ const configuredRoleIds = [...new Set(welcome.roleMenu.options.map((opt) => opt.roleId))];
+ const selectedIds = new Set(interaction.values.filter((id) => configuredRoleIds.includes(id)));
+
+ const removable = [];
+ const addable = [];
+
+ for (const roleId of configuredRoleIds) {
+ const role = await fetchRole(interaction.guild, roleId);
+ if (!role || !role.editable) continue;
+
+ const hasRole = member.roles.cache.has(role.id);
+ if (selectedIds.has(role.id) && !hasRole) addable.push(role);
+ if (!selectedIds.has(role.id) && hasRole) removable.push(role);
+ }
+
+ if (removable.length > 0) {
+ await member.roles.remove(
+ removable.map((r) => r.id),
+ 'Updated self-assignable onboarding roles',
+ );
+ }
+ if (addable.length > 0) {
+ await member.roles.add(
+ addable.map((r) => r.id),
+ 'Updated self-assignable onboarding roles',
+ );
+ }
+
+ await safeEditReply(interaction, {
+ content:
+ addable.length === 0 && removable.length === 0
+ ? '✅ No role changes were needed.'
+ : `✅ Updated roles. Added: ${addable.length}, Removed: ${removable.length}.`,
+ });
+}
diff --git a/tests/api/routes/guilds.coverage.test.js b/tests/api/routes/guilds.coverage.test.js
index 37b8492c..8d0c5202 100644
--- a/tests/api/routes/guilds.coverage.test.js
+++ b/tests/api/routes/guilds.coverage.test.js
@@ -70,15 +70,18 @@ describe('guilds routes coverage', () => {
roles: { cache: new Map([['role1', { id: 'role1', name: 'Admin', position: 1, color: 0 }]]) },
members: {
cache: new Map([
- ['user1', {
- id: 'user1',
- user: { username: 'testuser', bot: false },
- displayName: 'Test',
- roles: { cache: new Map([['role1', { id: 'role1', name: 'Admin' }]]) },
- joinedAt: new Date('2024-01-01'),
- joinedTimestamp: new Date('2024-01-01').getTime(),
- presence: null,
- }],
+ [
+ 'user1',
+ {
+ id: 'user1',
+ user: { username: 'testuser', bot: false },
+ displayName: 'Test',
+ roles: { cache: new Map([['role1', { id: 'role1', name: 'Admin' }]]) },
+ joinedAt: new Date('2024-01-01'),
+ joinedTimestamp: new Date('2024-01-01').getTime(),
+ presence: null,
+ },
+ ],
]),
list: vi.fn().mockResolvedValue(new Map()),
},
@@ -171,11 +174,9 @@ describe('guilds routes coverage', () => {
vi.stubEnv('SESSION_SECRET', 'jwt-secret');
const jti = 'jti-actions';
sessionStore.set('user1', { accessToken: 'tok', jti });
- const token = jwt.sign(
- { userId: 'user1', username: 'user', jti },
- 'jwt-secret',
- { algorithm: 'HS256' }
- );
+ const token = jwt.sign({ userId: 'user1', username: 'user', jti }, 'jwt-secret', {
+ algorithm: 'HS256',
+ });
// Mock guild access
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
diff --git a/tests/api/utils/configValidation.test.js b/tests/api/utils/configValidation.test.js
index 2355bb28..f5c2a251 100644
--- a/tests/api/utils/configValidation.test.js
+++ b/tests/api/utils/configValidation.test.js
@@ -66,6 +66,39 @@ describe('configValidation', () => {
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('expected finite number');
});
+
+ it('should validate new welcome onboarding fields', () => {
+ expect(validateSingleValue('welcome.rulesChannel', null)).toEqual([]);
+ expect(validateSingleValue('welcome.verifiedRole', '123')).toEqual([]);
+ expect(validateSingleValue('welcome.roleMenu.enabled', true)).toEqual([]);
+ expect(validateSingleValue('welcome.dmSequence.steps', ['hi', 'there'])).toEqual([]);
+ });
+
+ it('should accept valid welcome.introChannel string', () => {
+ expect(validateSingleValue('welcome.introChannel', '123456')).toEqual([]);
+ });
+
+ it('should accept null welcome.introChannel', () => {
+ expect(validateSingleValue('welcome.introChannel', null)).toEqual([]);
+ });
+
+ it('should reject malformed roleMenu.options items', () => {
+ const errors = validateSingleValue('welcome.roleMenu', {
+ enabled: true,
+ options: [{ label: 'Test' }],
+ });
+ expect(errors.length).toBeGreaterThan(0);
+ expect(errors.some((e) => e.includes('missing required key "roleId"'))).toBe(true);
+ });
+
+ it('should reject dmSequence.steps as non-array', () => {
+ const errors = validateSingleValue('welcome.dmSequence', {
+ enabled: true,
+ steps: 'not-an-array',
+ });
+ expect(errors.length).toBeGreaterThan(0);
+ expect(errors.some((e) => e.includes('expected array'))).toBe(true);
+ });
});
describe('CONFIG_SCHEMA', () => {
diff --git a/tests/api/utils/redisClient.coverage.test.js b/tests/api/utils/redisClient.coverage.test.js
index e503fd47..1e0ecbbf 100644
--- a/tests/api/utils/redisClient.coverage.test.js
+++ b/tests/api/utils/redisClient.coverage.test.js
@@ -23,12 +23,12 @@ vi.mock('../../../src/logger.js', () => ({
info: vi.fn(),
}));
-import { error as logError, warn } from '../../../src/logger.js';
import {
_resetRedisClient,
closeRedis,
getRedisClient,
} from '../../../src/api/utils/redisClient.js';
+import { error as logError, warn } from '../../../src/logger.js';
describe('redisClient coverage', () => {
const originalRedisUrl = process.env.REDIS_URL;
@@ -59,7 +59,10 @@ describe('redisClient coverage', () => {
process.env.REDIS_URL = 'redis://localhost:6379';
const client = getRedisClient();
expect(client).not.toBeNull();
- expect(mockRedisConstructorImpl).toHaveBeenCalledWith('redis://localhost:6379', expect.any(Object));
+ expect(mockRedisConstructorImpl).toHaveBeenCalledWith(
+ 'redis://localhost:6379',
+ expect.any(Object),
+ );
});
it('returns cached client on subsequent calls (already initialized)', () => {
diff --git a/tests/modules/welcomeOnboarding.test.js b/tests/modules/welcomeOnboarding.test.js
new file mode 100644
index 00000000..3d0cfda6
--- /dev/null
+++ b/tests/modules/welcomeOnboarding.test.js
@@ -0,0 +1,159 @@
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../src/logger.js', () => ({
+ info: vi.fn(),
+ error: vi.fn(),
+ warn: vi.fn(),
+}));
+
+vi.mock('../../src/utils/safeSend.js', () => ({
+ safeSend: vi.fn(async (target, payload) => {
+ if (typeof target?.send === 'function') return target.send(payload);
+ return undefined;
+ }),
+ safeReply: vi.fn(async (target, payload) => {
+ if (typeof target?.reply === 'function') return target.reply(payload);
+ return undefined;
+ }),
+ safeEditReply: vi.fn(async () => {}),
+}));
+
+import {
+ buildRoleMenuMessage,
+ handleRoleMenuSelection,
+ handleRulesAcceptButton,
+ normalizeWelcomeOnboardingConfig,
+} from '../../src/modules/welcomeOnboarding.js';
+import { safeEditReply, safeSend } from '../../src/utils/safeSend.js';
+
+describe('welcomeOnboarding module', () => {
+ it('applies safe defaults when welcome onboarding fields are missing', () => {
+ const result = normalizeWelcomeOnboardingConfig({});
+
+ expect(result).toEqual({
+ rulesChannel: null,
+ verifiedRole: null,
+ introChannel: null,
+ roleMenu: { enabled: false, options: [] },
+ dmSequence: { enabled: false, steps: [] },
+ });
+ });
+
+ it('buildRoleMenuMessage enforces max 25 options', () => {
+ const options = Array.from({ length: 30 }, (_, i) => ({
+ label: `Role ${i + 1}`,
+ roleId: `r${i + 1}`,
+ }));
+
+ const message = buildRoleMenuMessage({ roleMenu: { enabled: true, options } });
+ const builtOptions = message?.components?.[0]?.components?.[0]?.options;
+
+ expect(builtOptions).toHaveLength(25);
+ });
+
+ it('handles rules acceptance by granting verified role and posting intro prompt', async () => {
+ const role = { id: 'verified-role', editable: true };
+ const member = {
+ id: 'member-1',
+ roles: {
+ cache: new Map(),
+ add: vi.fn(async () => {}),
+ },
+ };
+ const introChannel = {
+ id: 'intro-ch',
+ isTextBased: () => true,
+ send: vi.fn(async () => {}),
+ };
+
+ const interaction = {
+ guildId: 'guild-1',
+ user: { id: 'user-1', send: vi.fn(async () => {}) },
+ member,
+ guild: {
+ roles: {
+ cache: new Map([['verified-role', role]]),
+ fetch: vi.fn(async () => role),
+ },
+ channels: {
+ cache: new Map([['intro-ch', introChannel]]),
+ fetch: vi.fn(async () => introChannel),
+ },
+ },
+ reply: vi.fn(async () => {}),
+ deferReply: vi.fn(async () => {}),
+ editReply: vi.fn(async () => {}),
+ deferred: false,
+ replied: false,
+ };
+
+ await handleRulesAcceptButton(interaction, {
+ welcome: {
+ verifiedRole: 'verified-role',
+ introChannel: 'intro-ch',
+ dmSequence: { enabled: false, steps: [] },
+ },
+ });
+
+ expect(member.roles.add).toHaveBeenCalled();
+ expect(safeSend).toHaveBeenCalledWith(introChannel, expect.stringContaining('<@member-1>'));
+ expect(safeEditReply).toHaveBeenCalledWith(
+ interaction,
+ expect.objectContaining({ content: expect.stringContaining('Rules accepted') }),
+ );
+ });
+
+ it('updates self-assignable roles by adding selected and removing deselected', async () => {
+ const roleA = { id: 'role-a', editable: true };
+ const roleB = { id: 'role-b', editable: true };
+
+ const member = {
+ roles: {
+ cache: new Map([['role-a', roleA]]),
+ add: vi.fn(async () => {}),
+ remove: vi.fn(async () => {}),
+ },
+ };
+
+ const interaction = {
+ user: { id: 'user-2' },
+ member,
+ values: ['role-b'],
+ guild: {
+ roles: {
+ cache: new Map([
+ ['role-a', roleA],
+ ['role-b', roleB],
+ ]),
+ fetch: vi.fn(async (id) => (id === 'role-a' ? roleA : roleB)),
+ },
+ },
+ reply: vi.fn(async () => {}),
+ deferReply: vi.fn(async () => {}),
+ editReply: vi.fn(async () => {}),
+ deferred: false,
+ replied: false,
+ };
+
+ await handleRoleMenuSelection(interaction, {
+ welcome: {
+ roleMenu: {
+ enabled: true,
+ options: [
+ { label: 'Role A', roleId: 'role-a' },
+ { label: 'Role B', roleId: 'role-b' },
+ ],
+ },
+ },
+ });
+
+ expect(member.roles.remove).toHaveBeenCalledWith(
+ ['role-a'],
+ 'Updated self-assignable onboarding roles',
+ );
+ expect(member.roles.add).toHaveBeenCalledWith(
+ ['role-b'],
+ 'Updated self-assignable onboarding roles',
+ );
+ });
+});
diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx
index a54154b2..34c1cf70 100644
--- a/web/src/components/dashboard/config-editor.tsx
+++ b/web/src/components/dashboard/config-editor.tsx
@@ -37,6 +37,32 @@ function parseNumberInput(raw: string, min?: number, max?: number): number | und
return num;
}
+function parseRoleMenuOptions(raw: string) {
+ return raw
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const [label = '', roleId = '', ...descParts] = line.split('|');
+ const description = descParts.join('|').trim();
+ return {
+ label: label.trim(),
+ roleId: roleId.trim(),
+ ...(description ? { description } : {}),
+ };
+ })
+ .filter((opt) => opt.label && opt.roleId)
+ .slice(0, 25);
+}
+
+function stringifyRoleMenuOptions(
+ options: Array<{ label?: string; roleId?: string; description?: string }> = [],
+) {
+ return options
+ .map((opt) => [opt.label ?? '', opt.roleId ?? '', opt.description ?? ''].join('|'))
+ .join('\n');
+}
+
/**
* Type guard that checks whether a value is a guild configuration object returned by the API.
*
@@ -101,6 +127,10 @@ export function ConfigEditor() {
/** Working copy that the user edits. */
const [draftConfig, setDraftConfig] = useState