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(null); + /** Raw textarea strings — kept separate so partial input isn't stripped on every keystroke. */ + const [roleMenuRaw, setRoleMenuRaw] = useState(''); + const [dmStepsRaw, setDmStepsRaw] = useState(''); + const abortRef = useRef(null); const updateDraftConfig = useCallback((updater: (prev: GuildConfig) => GuildConfig) => { @@ -169,6 +199,8 @@ export function ConfigEditor() { setSavedConfig(data); setDraftConfig(structuredClone(data)); + setRoleMenuRaw(stringifyRoleMenuOptions(data.welcome?.roleMenu?.options ?? [])); + setDmStepsRaw((data.welcome?.dmSequence?.steps ?? []).join('\n')); } catch (err) { if ((err as Error).name === 'AbortError') return; const msg = (err as Error).message || 'Failed to load config'; @@ -339,6 +371,8 @@ export function ConfigEditor() { const discardChanges = useCallback(() => { if (!savedConfig) return; setDraftConfig(structuredClone(savedConfig)); + setRoleMenuRaw(stringifyRoleMenuOptions(savedConfig.welcome?.roleMenu?.options ?? [])); + setDmStepsRaw((savedConfig.welcome?.dmSequence?.steps ?? []).join('\n')); toast.success('Changes discarded.'); }, [savedConfig]); @@ -383,6 +417,48 @@ export function ConfigEditor() { [updateDraftConfig], ); + const updateWelcomeField = useCallback( + (field: string, value: unknown) => { + updateDraftConfig((prev) => { + if (!prev) return prev; + return { ...prev, welcome: { ...(prev.welcome ?? {}), [field]: value } } as GuildConfig; + }); + }, + [updateDraftConfig], + ); + + const updateWelcomeRoleMenu = useCallback( + (field: string, value: unknown) => { + updateDraftConfig((prev) => { + if (!prev) return prev; + return { + ...prev, + welcome: { + ...(prev.welcome ?? {}), + roleMenu: { ...(prev.welcome?.roleMenu ?? {}), [field]: value }, + }, + } as GuildConfig; + }); + }, + [updateDraftConfig], + ); + + const updateWelcomeDmSequence = useCallback( + (field: string, value: unknown) => { + updateDraftConfig((prev) => { + if (!prev) return prev; + return { + ...prev, + welcome: { + ...(prev.welcome ?? {}), + dmSequence: { ...(prev.welcome?.dmSequence ?? {}), [field]: value }, + }, + } as GuildConfig; + }); + }, + [updateDraftConfig], + ); + const updateModerationEnabled = useCallback( (enabled: boolean) => { updateDraftConfig((prev) => { @@ -648,7 +724,7 @@ export function ConfigEditor() { /> - +