From edf2c76b6faaf6cbf1ec5ea2fd7d4bdde499ce9b Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 25 Jul 2026 16:58:47 +0200 Subject: [PATCH 1/2] synced to closed source --- config-generator/config.json | 9 + config-localizations/en.json | 342 +++- developer-docs/README.md | 5 +- developer-docs/commands.md | 87 +- developer-docs/configuration.md | 18 + developer-docs/intents.md | 95 ++ developer-docs/writing-a-module.md | 14 +- locales/en.json | 220 +-- main.js | 121 +- modules/admin-tools/module.json | 3 + modules/afk-system/module.json | 3 + modules/anti-ghostping/module.json | 3 + modules/betterstatus/events/botReady.js | 30 +- modules/betterstatus/module.json | 4 + modules/channel-stats/events/botReady.js | 31 +- modules/channel-stats/module.json | 4 + modules/color-me/commands/color-me.js | 1 - modules/color-me/module.json | 3 + .../commands/challenge-to-connect-four.js | 6 - modules/duel/commands/duel-context.js | 6 - modules/duel/module.json | 4 + modules/economy-system/commands/add-money.js | 4 - .../economy-system/commands/economy-system.js | 18 +- .../economy-system/commands/remove-money.js | 4 - .../economy-system/commands/set-balance.js | 4 - .../economy-system/events/messageCreate.js | 16 +- .../migrations/economy_Shop__V1.js | 28 +- modules/economy-system/models/liveMessage.js | 7 +- modules/fun/commands/hug-user.js | 6 - modules/fun/commands/kiss-user.js | 6 - modules/fun/commands/pat-user.js | 6 - modules/fun/commands/slap-user.js | 6 - modules/info-commands/commands/info.js | 21 +- modules/info-commands/commands/user-info.js | 5 - modules/info-commands/module.json | 4 + modules/levels/commands/set-user-level.js | 4 - modules/levels/commands/set-user-xp.js | 4 - modules/levels/module.json | 4 + modules/massrole/commands/add-role-to-user.js | 6 - .../commands/remove-role-from-user.js | 6 - .../message-quotes/commands/quote-message.js | 7 - .../events/voiceStateUpdate.js | 9 - .../commands/view-moderation-history.js | 5 - .../commands/view-ping-history.js | 5 - modules/ping-protection/module.json | 4 + modules/ping-protection/ping-protection.js | 27 +- modules/quiz/module.json | 3 + modules/reminders/commands/create-reminder.js | 7 - .../staff-management-system/commands/duty.js | 5 - .../commands/issue-infraction.js | 8 - .../commands/promote-user.js | 7 - .../commands/staff-status.js | 3 - .../commands/submit-review.js | 7 - .../commands/view-staff-profile.js | 5 - .../context-actions.js | 20 +- .../events/interactionCreate.js | 14 - modules/staff-management-system/module.json | 3 + .../staff-management.js | 11 - modules/starboard/commands/star-message.js | 4 - modules/starboard/handleStarboard.js | 2 +- modules/starboard/module.json | 3 + .../commands/approve-suggestion.js | 4 - .../commands/convert-to-suggestion.js | 4 - .../suggestions/commands/deny-suggestion.js | 4 - modules/suggestions/module.json | 3 + modules/team-list/events/botReady.js | 12 +- modules/team-list/module.json | 3 + .../temp-channels/commands/add-to-channel.js | 8 - .../commands/remove-from-channel.js | 7 - modules/temp-channels/events/botReady.js | 2 - .../temp-channels/events/voiceStateUpdate.js | 11 - .../temp-channels_TempChannel__V1.js | 18 +- modules/temp-channels/module.json | 3 + .../commands/challenge-to-tic-tac-toe.js | 6 - modules/tic-tak-toe/module.json | 4 + modules/tickets/commands/close-ticket.js | 4 - .../commands/create-ticket-about-message.js | 4 - modules/tickets/config.json | 7 + modules/tickets/events/interactionCreate.js | 102 +- .../tickets/migrations/tickets_Ticket__V1.js | 14 +- modules/twitch-notifications/module.json | 3 + modules/uno/commands/challenge-to-uno.js | 7 - modules/uno/commands/uno.js | 5 +- .../welcomer/commands/assign-join-roles.js | 7 - .../welcomer/commands/check-welcome-status.js | 7 - .../welcomer/commands/restore-base-roles.js | 8 - modules/welcomer/events/guildMemberRemove.js | 1 - src/cli.js | 3 +- src/commands/help.js | 1 - src/commands/reload.js | 3 +- src/events/guildDelete.js | 5 +- src/functions/commandTypes.js | 111 ++ src/functions/configuration.js | 165 +- src/functions/exitCodes.js | 96 ++ src/functions/helpers.js | 38 +- src/functions/intents.js | 98 +- src/functions/migrations/runMigrations.js | 108 +- src/functions/secure-storage/fields.js | 13 +- tests/betterstatus/botReady.test.js | 71 +- .../channel-stats/channelNameReplacer.test.js | 78 +- tests/commandTypes/commandTypes.test.js | 212 +++ tests/configuration/checkConfigFile.test.js | 1483 +++++++++++++++++ tests/configuration/checkType.test.js | 22 +- tests/economy-system/events.test.js | 138 +- tests/exitCodes/exitCodes.test.js | 186 +++ tests/info-commands/serverSubcommand.test.js | 74 +- tests/info-commands/subcommands.test.js | 28 +- tests/intents/allowlist.test.js | 190 +++ tests/intents/degradedCounts.test.js | 18 + tests/intents/intents.test.js | 59 +- .../intents/loadConfigsIntentDisable.test.js | 58 + tests/intents/optionalIntentsAudit.test.js | 30 + tests/intents/privilegedIntentUsage.test.js | 20 +- tests/locales/enConsistency.test.js | 144 ++ tests/migrations/runMigrations.test.js | 35 +- tests/team-list/buildUserString.test.js | 20 +- tests/tickets/createTicketContext.test.js | 6 +- tests/tickets/interactionCreate.test.js | 69 +- tests/uno/gameplay.test.js | 2 +- 119 files changed, 4331 insertions(+), 838 deletions(-) create mode 100644 developer-docs/intents.md create mode 100644 src/functions/commandTypes.js create mode 100644 src/functions/exitCodes.js create mode 100644 tests/commandTypes/commandTypes.test.js create mode 100644 tests/configuration/checkConfigFile.test.js create mode 100644 tests/exitCodes/exitCodes.test.js create mode 100644 tests/intents/allowlist.test.js create mode 100644 tests/intents/degradedCounts.test.js create mode 100644 tests/intents/loadConfigsIntentDisable.test.js create mode 100644 tests/intents/optionalIntentsAudit.test.js create mode 100644 tests/locales/enConsistency.test.js diff --git a/config-generator/config.json b/config-generator/config.json index 9b46816d..5c652fd0 100644 --- a/config-generator/config.json +++ b/config-generator/config.json @@ -94,6 +94,15 @@ "default": false, "description": "If enabled, module-commands will be synced to discord as global commands. They will show up on other servers, but won't work. Syncing can take up to 2 hours, so changes may not be reflected immediately.", "type": "boolean" + }, + { + "name": "allowedPrivilegedIntents", + "humanName": "Allowed privileged intents", + "default": [], + "description": "Advanced: the privileged gateway intents Discord has granted this application (GuildMembers, GuildPresences, MessageContent). Leave empty to allow all three (default). If set, any privileged intent not listed is never requested - modules that only use it for optional features keep running, and modules that require it are disabled. For operators of very large bots who cannot obtain every privileged intent.", + "hidden": true, + "type": "array", + "content": "string" } ] } diff --git a/config-localizations/en.json b/config-localizations/en.json index 67abb50a..d6fc3f7a 100644 --- a/config-localizations/en.json +++ b/config-localizations/en.json @@ -8,16 +8,6 @@ "description": "Replace this with your token", "default": "yourtokengoeshere" }, - "dmAbuseButton": { - "description": "Used to allow mass dm reporting" - }, - "scnxToken": { - "description": "Replace this with your token", - "default": "yourtokengoeshere" - }, - "scnxHostOverwirde": { - "description": "Replace this with your token" - }, "prefix": { "humanName": "Prefix of your bot", "description": "Set the prefix of your bot here", @@ -36,7 +26,7 @@ "user_presence": { "humanName": "Bot-Status", "description": "This will show up in Discord as \"Playing \"", - "default": "Change this in your Bot-Configuration on scnx.app: https://scootk.it/change-status" + "default": "your bot status" }, "logLevel": { "humanName": "Logging-Level", @@ -57,6 +47,10 @@ "syncCommandGlobally": { "humanName": "Sync module commands as global commands", "description": "If enabled, module-commands will be synced to discord as global commands. They will show up on other servers, but won't work. Syncing can take up to 2 hours, so changes may not be reflected immediately." + }, + "allowedPrivilegedIntents": { + "humanName": "Allowed privileged intents", + "description": "Advanced: the privileged gateway intents Discord has granted this application (GuildMembers, GuildPresences, MessageContent). Leave empty to allow all three (default). If set, any privileged intent not listed is never requested - modules that only use it for optional features keep running, and modules that require it are disabled. For operators of very large bots who cannot obtain every privileged intent." } } }, @@ -1722,6 +1716,10 @@ "humanName": "Use User's Tags instead of their Mention in the Leaderboard-Channel-Embed", "description": "If enabled, the bot will use the tag of users in the Leaderboard-Channel-Embed instead of their mention." }, + "enableLevelCalculator": { + "humanName": "Enable /calculate-level command", + "description": "If enabled, server members can use the /calculate-level command to calculate the XP and amount of messages required to reach a specific level based on the configured level curve and XP-per-message range." + }, "allowCheats": { "humanName": "Cheats", "description": "If enabled admins can change the XP of other users (not recommended (please leave it off if you want to have a fair levelling system!!!))" @@ -1772,6 +1770,27 @@ }, "newLevel": { "description": "New level of the user" + }, + "xpGained": { + "description": "XP gained from this action" + }, + "xpType": { + "description": "Type of XP gain (Message or Voice)" + }, + "totalXP": { + "description": "Total XP after gaining XP" + }, + "nextLevelXP": { + "description": "XP needed for the next level" + }, + "totalMessages": { + "description": "Lifetime message count" + }, + "messagesToday": { + "description": "Messages sent today (resets at midnight)" + }, + "voiceTimeToday": { + "description": "Voice time today (resets at midnight)" } } }, @@ -1797,6 +1816,27 @@ }, "role": { "description": "Mention of the role (No ping)" + }, + "xpGained": { + "description": "XP gained from this action" + }, + "xpType": { + "description": "Type of XP gain (Message or Voice)" + }, + "totalXP": { + "description": "Total XP after gaining XP" + }, + "nextLevelXP": { + "description": "XP needed for the next level" + }, + "totalMessages": { + "description": "Lifetime message count" + }, + "messagesToday": { + "description": "Messages sent today (resets at midnight)" + }, + "voiceTimeToday": { + "description": "Voice time today (resets at midnight)" } } }, @@ -1933,6 +1973,93 @@ } } }, + "message-quotes": { + "_module": { + "humanReadableName": "Message quotes", + "description": "Quotes a Discord message when a user pastes a message link." + }, + "config": { + "description": "Configure the message quoting system", + "humanName": "Configuration", + "content": { + "roles": { + "humanName": "Blacklist roles", + "description": "Roles that are excluded from quoting" + }, + "channels": { + "humanName": "Blacklist channels", + "description": "Channels that are excluded from quoting (Channels and categories are supported; a category excludes all its channels)" + }, + "withAttachments": { + "humanName": "Attach files?", + "description": "Should all attachments be quoted? (Deactivation recommended if \"Message\" components v2 are used)" + }, + "noBots": { + "humanName": "Ignore bot messages?", + "description": "Bot messages are not included in the quote when activated" + }, + "selfQuote": { + "humanName": "Allow Self-quotes?", + "description": "Can users quote their own messages?" + }, + "asReply": { + "humanName": "Reply to messages?", + "description": "Reply to the message that triggered the quote (Ignored when \"Delete trigger\" is enabled)" + }, + "deleteOrigin": { + "humanName": "Delete trigger?", + "description": "When enabled, the trigger message will be deleted" + }, + "message": { + "humanName": "Message", + "description": "Message in which the quote is returned", + "default": { + "title": "Quote from #%channelName%", + "url": "%link%", + "description": ">>> %content%", + "image": "%image%", + "color": "#2ECC71", + "author": { + "name": "%userName%", + "img": "%userAvatar%" + } + }, + "params": { + "userID": { + "description": "Id of the user" + }, + "userName": { + "description": "Username of the user" + }, + "displayName": { + "description": "Displays the user's nickname" + }, + "userAvatar": { + "description": "Avatar of the user" + }, + "channelID": { + "description": "Id of the channel from which the quote originates" + }, + "channelName": { + "description": "Name of the channel from which the quote originates" + }, + "timestamp": { + "description": "Shows when the original message was sent (Used discord timestamp)" + }, + "link": { + "description": "Message-link of the original message" + }, + "image": { + "description": "First image of the message, if available" + }, + "content": { + "description": "Message content of the quote" + } + } + } + } + } + }, "moderation": { "_module": { "humanReadableName": "Moderation & Security", @@ -2019,14 +2146,6 @@ "humanName": "Allowed invite guild IDs", "description": "Guild IDs whose invites should be allowed (in addition to this server's invites which are always allowed)." }, - "action_on_scam_link": { - "humanName": "Action on Scam-Link", - "description": "What should the bot do if someone posts an suspicious or confirmed scam link?" - }, - "scam_link_level": { - "humanName": "Level of Scam-Link-Detection", - "description": "Select the Level of Scam-Link-Filter. \"confirmed\" only contains verified Scam-Domains, while \"suspicious\" may contain not-harmful domains." - }, "whitelisted_channels_for_invite_blocking": { "humanName": "Whitelisted channels for invite-ban", "description": "Channels or categories where invite blocking is disabled" @@ -2912,12 +3031,12 @@ "description": "Configure what happens when a protected user pings themselves. Note: Automod overrides this setting meaning this setting will not apply if Automod is enabled." }, "enableAutomod": { - "humanName": "Enable automod", - "description": "If enabled, the bot will utilise Discord's native AutoMod to block the message with a ping of a protected user/role." + "humanName": "Enable AutoMod", + "description": "If enabled, the bot will utilise Discord's native AutoMod to block the message with a ping of a protected user/role. Warning: AutoMod does not support whitelisted categories due to limitations in Discord's AutoMod system - instead, it will still block the message but not log it in the history." }, "autoModLogChannel": { "humanName": "AutoMod Log Channel", - "description": "Channel where AutoMod alerts are sent." + "description": "Channel where AutoMod alerts are sent. It is recommended to keep these in a private channel." }, "autoModBlockMessage": { "humanName": "AutoMod custom message for message block", @@ -2962,6 +3081,14 @@ "humanName": "Pings to trigger moderation", "description": "The amount of pings required to trigger a moderation action." }, + "enableRolePingThresholds": { + "humanName": "Enable role-based ping thresholds", + "description": "If enabled, specific roles can have custom ping thresholds for this moderation action. This also allows specific roles to be exempted from this specific action." + }, + "rolePingThresholds": { + "humanName": "Role-based ping thresholds", + "description": "Set custom ping thresholds per role for this moderation action. If a user has multiple configured roles, the value of their highest configured role is used. Setting a role to 0 exempts that role from this action - exempted roles also override any other role's threshold." + }, "useCustomTimeframe": { "humanName": "Use a custom timeframe", "description": "If enabled, you can choose your own custom timeframe of days in which the pings must occur to trigger the moderation action." @@ -3147,6 +3274,45 @@ "wrongOptions": { "humanName": "Wrong answers", "description": "Wrong answers" + }, + "headline": { + "humanName": "Headline (optional)", + "description": "Optional embed title shown above the question. Leave empty to use the default quiz title.", + "default": "" + }, + "imageURL": { + "humanName": "Image (optional)", + "description": "Optional image displayed above the answer choices (e.g. movie scene, visual hint). Upload via the dashboard or paste an http(s) URL.", + "default": "" + } + } + } + }, + "reaction-roles": { + "_module": { + "humanReadableName": "Reaction Roles", + "description": "Let users assign roles to themselves the good old way - by adding and removing a reaction." + }, + "messages": { + "description": "Add the messages you want to add reaction roles too.", + "humanName": "Messages", + "informationBanner": { + "button": { + "url": "https://scootk.it/login-as-bot", + "text": "Open Login-As-Bot" + }, + "en": "You can have a way better user experience for your members using Button-Roles, Self-Role-Elements and other SCNX features in Login-As-Bot.", + "de": "Du kannst eine wesentlich Bessere Nutzererfahrung fΓΌr deine Mitglieder erstellen, indem du Button-Rollen, Selbst-Rollen-Erfahrungen und mehr SCNX Funktionen von Als-Bot-Anmelden verwenden." + }, + "content": { + "messageID": { + "humanName": "Message-ID", + "description": "This is the ID of the message that this configuration element should apply to", + "default": "" + }, + "reactions": { + "humanName": "Reactions", + "description": "First-Value: Reaction value, Second value: Role-ID(s), seperated with \",\". The bot will only add a reaction to these messages AFTER at least one user reacted with them." } } } @@ -3216,7 +3382,7 @@ }, "supervisorRoles": { "humanName": "Supervisor Roles", - "description": "Roles that can manage other staff members (Approve/Deny/Manage LoA's and RA's, Manage Shifts, promote and infract users)." + "description": "Roles that can manage other staff members (Approve/Deny/Manage LoA's and RA's, Manage Shifts etc.)." }, "managementRoles": { "humanName": "Management Roles", @@ -3251,9 +3417,13 @@ "humanName": "Infraction Types", "description": "These are the types of infractions that can be issued to staff members. You can customize these to fit your infractions system." }, + "infractionLogChannel": { + "humanName": "Infraction Log Channel", + "description": "Where should infractions and suspensions be announced?" + }, "enableSuspensions": { "humanName": "Enable Suspensions System", - "description": "Suspensions temporarily strip a staff member of their roles." + "description": "Suspensions temporarily strip a staff member of their roles, and give them back after the specified duration." }, "suspensionHierarchyRole": { "humanName": "Hierarchy Base Role", @@ -3312,10 +3482,6 @@ } } }, - "infractionLogChannel": { - "humanName": "Infraction Log Channel", - "description": "Where should infractions and suspensions be announced?" - }, "infractionMessage": { "humanName": "Infraction Announcement Message", "description": "The message sent to the log channel for regular infractions.", @@ -3462,7 +3628,7 @@ "content": { "enablePromotions": { "humanName": "Enable Promotions System", - "description": "If disabled, the /staff-management promote command will not work." + "description": "Enabling this allows staff members to promote users to higher ranks." }, "autoAddRole": { "humanName": "Auto-Add New Role?", @@ -3584,7 +3750,7 @@ "content": { "enableReviews": { "humanName": "Enable Reviews System", - "description": "Enabling this unlocks the staff review system, allowing users to submit ratings and feedback for staff members." + "description": "Enabling this unlocks the staff review system, allowing users to submit ratings with feedback for staff members." }, "reviewLogChannel": { "humanName": "Reviews Log Channel", @@ -3592,18 +3758,18 @@ }, "allowSelfRating": { "humanName": "Allow Self-Rating?", - "description": "If enabled, staff can review themselves. This is not recommended to keep a fair ratings system." + "description": "If enabled, staff can review themselves. This is not recommended to keep a fair review system." }, "onlyAllowStaffReview": { "humanName": "Only let users review staff", - "description": "If enabled, only staff members can review other staff members." + "description": "If enabled, users can only review staff members." }, "ratingMessage": { "humanName": "Review Message", "description": "The message sent when a review is submitted.", "default": { "_schema": "v3", - "content": "%staff%", + "content": "%staff-mention%", "embeds": [ { "title": "🌟 New Staff Rating", @@ -3864,7 +4030,7 @@ "content": { "enableActivityChecks": { "humanName": "Enable Activity Checks", - "description": "Allows admins to start an activity check to see who is active." + "description": "Allows admins to start an activity check to see who is active, and also set automatic activity checks." }, "targetRoles": { "humanName": "Roles to Check", @@ -3878,9 +4044,24 @@ "humanName": "Activity Check Embed", "description": "The message sent when an activity check starts.", "default": { - "title": "πŸ“‹ Staff Activity Check", - "description": "Please click the button below to confirm your activity before %endtime%.", - "color": "#3498db" + "_schema": "v3", + "content": "%staff-mention%", + "embeds": [ + { + "author": { + "name": "Signed, %initiator%" + }, + "title": "πŸ“‹ Staff Activity Check", + "description": "Please confirm your activity by clicking the button below before %end-time%. This activity check will stay open for %duration% hour(s), and members who do not respond before it ends may be marked as failed unless they qualify for an exception.", + "fields": [ + { + "name": "Quick info overview", + "value": "Ends at: %end-time%\nDuration: %duration% hour(s)" + } + ], + "color": "#3498db" + } + ] }, "params": { "end-time": { @@ -3888,6 +4069,65 @@ }, "duration": { "description": "The configured duration in hours." + }, + "staff-mention": { + "description": "Mention of the configured staff role(s)." + }, + "supervisor-mention": { + "description": "Mention of the configured supervisor role(s)." + }, + "management-mention": { + "description": "Mention of the configured management role(s)." + }, + "initiator": { + "description": "The user who iniated the activity check. This will show 'system' if it was an automated check." + } + } + }, + "endCheckMessage": { + "humanName": "Ended Activity Check Embed", + "description": "The message that will replace the activity check embed when it ends.", + "default": { + "_schema": "v3", + "content": "%staff-mention%", + "embeds": [ + { + "author": { + "name": "Signed, %initiator%" + }, + "title": "πŸ“‹ Staff Activity Check (ended)", + "description": "This activity check has concluded.", + "fields": [ + { + "name": "Quick info overview", + "value": "Ended at: %end-time%\nDuration: %duration% hour(s)\nTotal responses: %responded-count%" + } + ], + "color": "#FF0000" + } + ] + }, + "params": { + "end-time": { + "description": "The Discord timestamp when the check ended." + }, + "duration": { + "description": "The configured duration in hours." + }, + "staff-mention": { + "description": "Mention of the configured staff role(s)." + }, + "supervisor-mention": { + "description": "Mention of the configured supervisor role(s)." + }, + "management-mention": { + "description": "Mention of the configured management role(s)." + }, + "initiator": { + "description": "The user who iniated the activity check. This will show 'system' if it was an automated check." + }, + "responded-count": { + "description": "The number or staff members who responed to the activity check." } } }, @@ -4535,6 +4775,10 @@ "humanName": "Ticket Roles", "description": "Users who get pinged in the tickets and who can see tickets" }, + "inheritCategoryPermissions": { + "humanName": "Copy category permissions", + "description": "Opt in to copying permission overwrites from the ticket category when a ticket is created. @everyone remains denied access. Later category changes are not applied automatically." + }, "logChannel": { "humanName": "Log channel", "description": "Channel in which ticket logs should get send" @@ -4621,6 +4865,26 @@ "humanReadableName": "Twitch-Notifications", "description": "Module that sends a message to a channel, when a streamer goes live on Twitch" }, + "config": { + "description": "Twitch API credentials and polling interval. Create an app at https://dev.twitch.tv/console/apps to get your Client ID and Secret.", + "humanName": "Configuration", + "content": { + "twitchClientID": { + "humanName": "Twitch Client ID", + "description": "Client ID of your Twitch application (https://dev.twitch.tv/console/apps).", + "default": "" + }, + "clientSecret": { + "humanName": "Twitch Client Secret", + "description": "Client Secret of your Twitch application.", + "default": "" + }, + "interval": { + "humanName": "Check interval (seconds)", + "description": "How often (in seconds) the bot polls Twitch for stream updates. Must be at least 60 to stay within Twitch rate limits." + } + } + }, "streamers": { "description": "Configure here, where for what streamer which message should get send", "humanName": "Streamers", @@ -4848,6 +5112,10 @@ "humanName": "Immediately give roles, instead of waiting for rules acceptance?", "description": "If enabled, roles will be granted immediately when a user joins your server. Otherwise, no roles will be assigned to users before they complete the Discord onboarding." }, + "treat-welcome-roles-as-base-roles": { + "humanName": "Treat join roles as base roles (auto-restore)", + "description": "Guarantees every regular member has all roles configured under 'Give roles on join'. When enabled, the bot re-adds a join role if it gets removed, grants missing join roles after a member is released from quarantine or Join Gate hold, and runs a daily sweep to catch anything missed. Quarantined, held, and pending members are excluded β€” see the docs for the full list of exclusions and edge cases." + }, "not-send-messages-if-member-is-bot": { "humanName": "Ignore bots?", "description": "Should bots get ignored when they join (or leave) the server" diff --git a/developer-docs/README.md b/developer-docs/README.md index ac5b6324..a9f81981 100644 --- a/developer-docs/README.md +++ b/developer-docs/README.md @@ -9,7 +9,10 @@ Start here if you want to add a new feature as a module: - [**Writing a module**](./writing-a-module.md) - file layout, `module.json`, lifecycle, end-to-end example. - [**Events**](./events.md) - event handler shape, lifecycle gates (`botReadyAt`, `allowPartial`, `ignoreBotReadyCheck`), Discord and custom events you can listen to. -- [**Slash commands**](./commands.md) - `config` / `run` / `subcommands` / `autocomplete`, registration, options. +- [**Commands**](./commands.md) - `config` / `run` / `subcommands` / `autocomplete`, options, context-menu commands, + registration. +- [**Gateway intents**](./intents.md) - declaring `intents` / `optionalIntents` in `module.json`, degrading when a + privileged intent is not granted, the operator allowlist. - [**Database models**](./database-models.md) - Sequelize `Model.init` pattern, `models-dir`, accessing models from events. - [**Field-level encryption**](./field-encryption.md) - the secure-storage serialization layer: which columns are diff --git a/developer-docs/commands.md b/developer-docs/commands.md index eaf3f946..cab9f253 100644 --- a/developer-docs/commands.md +++ b/developer-docs/commands.md @@ -1,7 +1,11 @@ -# Slash Commands +# Commands -Commands live in a module's `commands-dir` (typically `commands/`). Each `.js` file is one slash command. The bot -collects all command files and syncs them with Discord at startup. +Commands live in a module's `commands-dir` (typically `commands/`). Each `.js` file is one command. The bot collects +all command files and syncs them with Discord at startup. + +Two kinds exist: **slash commands**, invoked by typing `/name`, and **context-menu commands**, invoked by +right-clicking a user or a message. Everything below describes slash commands unless stated otherwise; see +[Context-menu commands](#context-menu-commands) for the differences. ## Minimum command @@ -150,6 +154,76 @@ module.exports.run = async (interaction) => { }; ``` +## Context-menu commands + +A context-menu command is one a user reaches by right-clicking a **user** or a **message** and picking it under +*Apps*. It takes no options - the thing that was right-clicked is the entire input. + +```js +// modules/levels/commands/view-profile.js +const {localize} = require('../../../src/functions/localize'); +const {sendProfile} = require('./profile'); + +module.exports.config = { + name: 'View Level Profile', + type: 'USER', + contextMenu: true, + description: localize('levels', 'profile-context-description') +}; + +module.exports.run = async function (interaction) { + const member = interaction.targetMember ?? await interaction.guild.members.fetch(interaction.targetUser.id); + return sendProfile(interaction, member); +}; +``` + +- **`type`** - `'USER'` or `'MESSAGE'`. Required, and the whole command sync fails without it. +- **`contextMenu: true`** - marks the file as a context command. +- **`name`** - unlike a slash command, it may use capitals and spaces. Maximum 32 characters. +- **`description`** - used for `/help` only. Discord forbids a description on context commands, so it is stripped + before registration. Declare it anyway. +- **`options`** - not allowed. They are stripped before registration. +- **`defaultMemberPermissions`** - works exactly as for slash commands. + +Read the target off the interaction: + +| `type` | Available on `interaction` | +|-------------|-----------------------------------------------------------------| +| `'USER'` | `targetUser`, and `targetMember` when the user is in the guild | +| `'MESSAGE'` | `targetMessage` | + +`targetMember` is null when the member is not cached, so fall back to `guild.members.fetch()` as above. + +### Sharing logic with a slash command + +Most context commands are a thin wrapper over an existing slash command. Export the shared core from the slash +command file and call it from both, so the two render identically: + +```js +// in the slash command file +module.exports.sendProfile = sendProfile; +``` + +Where the slash command reads an option, hand its `run()` a proxy that supplies the context target instead: + +```js +const proxy = Object.create(interaction); +proxy.options = {getUser: () => interaction.targetUser}; +return runHug(proxy); +``` + +### Registration limits + +Discord allows **15 USER and 15 MESSAGE context commands per bot** (against 100 slash commands). Across all modules +this bot ships more than that, so if too many are enabled at once the extras are skipped and logged: + +``` +Skipping 4 USER context command(s): Discord allows at most 15 per bot. Not registered: welcomer/Assign Join Roles, ... +``` + +Which ones survive depends on module load order. Two context commands of the same type may not share a name; the +later one is skipped with a warning. + ## Localization Use `localize()` for both descriptions and replies - see [localization.md](./localization.md). Descriptions are @@ -179,6 +253,7 @@ module.exports.run = async (interaction) => { ## Where commands are registered -Commands are registered as **guild commands** for the guild configured in `config/config.json`. Global registration is -not supported - this bot is single-guild by design. Reloading happens automatically at startup; new commands appear -within seconds. To force a re-sync without restart, run `/reload`. \ No newline at end of file +Commands are registered as **guild commands** for the guild configured in `config/config.json`, which is what you want +during development: guild commands appear within seconds. Setting `syncCommandGlobally: true` registers them globally +instead - they then also show up on other servers (where they will not work), and Discord can take up to two hours to +propagate the change. Reloading happens automatically at startup; to force a re-sync without restarting, run `/reload`. \ No newline at end of file diff --git a/developer-docs/configuration.md b/developer-docs/configuration.md index bf00cbb3..8a4aa66b 100644 --- a/developer-docs/configuration.md +++ b/developer-docs/configuration.md @@ -482,6 +482,24 @@ every PR via `.github/workflows/verify-configs.yml`. The script catches: - Defaults still using the deprecated localized format. - Embed defaults that look like v3 messages but are missing `"_schema": "v3"`. +## What happens to an invalid stored value + +The checks above run against the schema you ship. At runtime the bot also validates what the **user** stored, and +what it does when that fails depends on whether the field can be verified offline: + +- **Plain types** (`string`, `integer`, `boolean`, `select`, ...) - an invalid stored value is definitively wrong, so + it is healed to the field `default`, written back to disk, and logged. The module stays enabled. +- **Fetch-backed types** (`channelID`, `roleID`, `userID`, `guildID`, and arrays of them) - validation needs a live + Discord lookup, and a failed lookup cannot be told apart from a rate limit or an outage. The stored value is + therefore **kept as-is** and only a warning is logged. Healing here would permanently destroy a valid ID during a + transient Discord problem. + +The one exception: a **required** fetch-backed field left unconfigured has an empty default that can never validate. +That rejects the file and disables the module, rather than re-failing on every boot. + +Practical consequence for module authors: do not rely on a `channelID` in your config being currently resolvable. +Handle a stale ID at the point of use. + ## Full example ```json diff --git a/developer-docs/intents.md b/developer-docs/intents.md new file mode 100644 index 00000000..da36c4d0 --- /dev/null +++ b/developer-docs/intents.md @@ -0,0 +1,95 @@ +# Gateway Intents + +Discord only sends a bot the events it asks for. The bot computes the gateway intents it needs at startup from the +**enabled modules**, so a server running three modules does not connect with the intent set of forty. + +Modules declare what they need in `module.json`; the operator decides in `config/config.json` which *privileged* +intents the bot may request at all. + +## Declaring intents in `module.json` + +```json +{ + "name": "team-list", + "intents": [ + "GuildMembers", + "GuildPresences" + ], + "optionalIntents": [ + "GuildPresences" + ], + "intentReasons": { + "GuildMembers": "Reads the member list to render which members hold each configured team role.", + "GuildPresences": "Shows an online/offline dot next to each listed member." + } +} +``` + +| Field | Purpose | +|-------------------|----------------------------------------------------------------------------------------------------------| +| `intents` | Every gateway intent the module needs. Names are `GatewayIntentBits` keys (`GuildMembers`, `GuildMessages`, ...). | +| `optionalIntents` | Subset of `intents` the module can run **without**, losing only a secondary feature. Privileged intents only. | +| `intentReasons` | `{intent: "why"}`. Used to justify a privileged-intent request to Discord and to explain it to operators. | + +Declare `intents: []` if the module needs nothing beyond the base set. `Guilds` is always requested. + +**Pairing rule:** `MessageContent` is useless without a message intent, so if your module declares `MessageContent` +but neither `GuildMessages` nor `DirectMessages`, `GuildMessages` is added automatically and a warning is logged. + +## Required vs optional + +The distinction only matters for Discord's three **privileged** intents: `GuildMembers`, `GuildPresences` and +`MessageContent`. + +- **Required** (in `intents`, not in `optionalIntents`) - the module cannot work without it. +- **Optional** (also listed in `optionalIntents`) - the module still works, minus one feature. + +`status-roles` requires `GuildPresences`: with no presence data it has nothing to react to. `team-list` only wants it +for the status dot beside each name, so it lists it as optional and renders the list without dots instead. + +Pick optional when the module has a sensible degraded mode, and then **write the code to degrade**. A count built +from an empty cache is wrong data, which is worse than absent data: + +```js +const presencesActive = (client._activeIntents || []).includes('GuildPresences'); +const online = presencesActive ? members.filter(m => m.presence).size : 'N/A'; +``` + +`src/functions/helpers.js` provides `memberCountOrFallback(guild)` and `onlineCountOrNull(client, guild)` for the two +most common cases. + +## The operator allowlist + +Large bots cannot always obtain every privileged intent from Discord. Operators list the ones they were granted in +`config/config.json`: + +```json +{ + "allowedPrivilegedIntents": ["GuildMembers", "MessageContent"] +} +``` + +An **empty array, a missing field, or a list containing no valid names means all three are allowed** - the default, +and the upgrade path for existing installs. Invalid entries are ignored with a warning. + +When a privileged intent is not allowed: + +| The module... | Result | +|--------------------------------------|---------------------------------------------------------------------------| +| **requires** it | Disabled entirely. It contributes no intents and its config is not checked. | +| lists it in **`optionalIntents`** | Stays enabled, degraded. The intent is simply not requested. | + +Disabled modules keep `userEnabled: true`, so nothing is lost in `modules.json` - re-granting the intent and +restarting brings them back. + +## Changing intents at runtime + +Intents are fixed for the lifetime of a gateway connection. Enabling a module that needs an intent the bot did not +connect with logs a warning and requires a restart; the module's features stay inert until then. The same applies in +reverse when an intent is removed from the allowlist while it is still live on the connection. + +## Reference + +- `src/functions/intents.js` - computes the intent set, applies the allowlist. +- `src/functions/configuration.js` - `applyIntentDisables()` disables modules whose required intents were denied. +- `client._activeIntents` - the intent names the running client actually connected with. diff --git a/developer-docs/writing-a-module.md b/developer-docs/writing-a-module.md index bf1b857b..5763a152 100644 --- a/developer-docs/writing-a-module.md +++ b/developer-docs/writing-a-module.md @@ -42,7 +42,13 @@ Only `module.json` is mandatory. Everything else is opt-in via the matching `mod "models-dir": "/models", "config-example-files": [ "configs/config.json" - ] + ], + "intents": [ + "GuildMembers" + ], + "intentReasons": { + "GuildMembers": "Greets members as they join, which requires the member-join event." + } } ``` @@ -59,6 +65,9 @@ Only `module.json` is mandatory. Everything else is opt-in via the matching `mod | `commands-dir` | No | Folder scanned for slash commands. Convention: `/commands`. | | `models-dir` | No | Folder scanned for Sequelize models. Convention: `/models`. | | `config-example-files` | No | Paths (relative to the module) of config schema files. See [configuration.md](./configuration.md). | +| `intents` | No | Gateway intents this module needs. See [intents.md](./intents.md). | +| `optionalIntents` | No | Privileged intents from `intents` the module can run without, losing one feature. See [intents.md](./intents.md). | +| `intentReasons` | No | `{intent: "why"}`. Justifies each privileged intent to operators and to Discord. | If you omit a `*-dir` key, that subsystem is skipped - there's no default. A module with only events doesn't need `commands-dir`. @@ -167,7 +176,8 @@ That's a working module. Run `npm run verify-configs` to confirm the config sche ## What to read next - [Events](./events.md) for handler patterns and the lifecycle gates that decide when your code runs. -- [Slash commands](./commands.md) when your module needs user-invokable commands. +- [Commands](./commands.md) when your module needs user-invokable slash or context-menu commands. +- [Gateway intents](./intents.md) if your module needs events beyond the base set. - [Database models](./database-models.md) for persistent state. - [Localization](./localization.md) for adding user-facing strings. - [Configuration files](./configuration.md) for the full config schema reference. \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index 695009ea..77df8518 100644 --- a/locales/en.json +++ b/locales/en.json @@ -56,9 +56,7 @@ "reason-option-description": "Explain why you started this session", "autoend-option-description": "If enabled, the bot will auto-end your AFK Session when your write a message (default: enabled)", "no-running-session": "You don't have any session running.", - "already-running-session": "You already have an AFK-Session running, try ending it with `/afk-system end`.", - "afk-nickname-change-audit-log": "Updated user nickname because they started an AFK-Session", - "can-not-edit-nickname": "Can not edit nickname of %u: %e" + "already-running-session": "You already have an AFK-Session running, try ending it with `/afk-system end`." }, "auto-delete": { "could-not-fetch-channel": "Could not fetch channel with ID %c", @@ -72,85 +70,18 @@ }, "betterstatus": { "command-description": "Change the bot's status", - "command-disabled": "The /status command is not enabled. An administrator needs to enable it in the betterstatus module configuration.", "text-description": "The status text to display", "activity-type-description": "The activity type (Playing, Watching, etc.)", "bot-status-description": "The bot's online status (Online, Idle, DND)", "streaming-link-description": "Streaming URL (only used when activity type is Streaming)", "status-changed": "Bot status has been changed to: %s" }, - "birthdays": { - "channel-not-found": "[birthdays] Channel not found: %c", - "sync-error": "[birthdays] %u's state was set to \"sync\", but there was no syncing candidate, so I disabled the synchronization", - "age-hover": "%a years old", - "sync-enabled-hover": "Birthday synchronized", - "verified-hover": "Birthday verified", - "no-bd-this-month": "No birthdays this month ):", - "no-birthday-set": "You don't currently have a registered birthday on this server. Set a birthday with `/birthday set`.", - "birthday-status": "Your birthday is currently set to **%dd.%mm%yyyy**%age.", - "your-age": "which means that you are **%age** years old", - "sync-on": "Your birthday is being synced via your [SC Network Account](https://sc-network.net/dashboard).", - "sync-off": "Your birthday is set locally on this server and will not be synchronized", - "no-sync-account": "It seems like you either don't have an [SC Network Account]() or you haven't entered any information about your birthday in it yet.", - "auto-sync-on": "It seems that you have autoSync in your [SC Network Account]() enabled. This means that your birthday will be synchronized all the time on every server. [Learn more]().\nYour birthday isn't showing up? It can take up to 24 hours (usually it's less than two hours) for it to be synced, so stay calm and wait just a bit longer.", - "enabled-sync": "Successfully set. The synchronization is now enabled :+1:", - "disabled-sync": "Successfully set. The synchronization is disabled, you can now change or remove your birthday from this server.", - "delete-but-sync-is-on": "You currently have sync enabled. Please disable sync to delete your birthday.", - "deleted-successfully": "Birthday deleted successfully.", - "only-sync-allowed": "This server only allows synchronization of your birthday with a [SC Network Account]()", - "invalid-date": "Invalid date provided", - "against-tos": "You have to be at least 13 years old to use Discord. Please read Discord's [Terms of Service]() and if you are under the age of 13 please [delete your account]() to comply with Discord's [Terms of Service]() and wait %waitTime (or for the age for your country, listed [here]()) years before creating a new account.", - "too-old": "It seems like you are too old to be alive", - "command-description": "View, edit and delete your birthday", - "status-command-description": "Shows the current status of your birthday", - "sync-command-description": "Manage the synchronization on this server", - "sync-command-action-description": "Action which should be performed on your synchronization", - "sync-command-action-enable-description": "Enable synchronization", - "sync-command-action-disable-description": "Disable synchronization", - "set-command-description": "Sets your birthday", - "set-command-day-description": "Day of your birthday", - "set-command-month-description": "Month of your birthday", - "set-command-year-description": "Year of your birthday", - "delete-command-description": "Deletes your birthday from this server", - "migration-happening": "Database-Schema not up-to-date. Migration database... This could take a while. Do not restart your bot to avoid data loss.", - "migration-done": "Successfully migrated database to newest version.", - "birthday-locked": "Your birthday has been locked by an admin and cannot be edited.", - "locked-indicator": "Locked by admin", - "manage-command-description": "Manage user birthdays (admin)", - "admin-set-description": "Set a user's birthday", - "admin-remove-description": "Remove a user's birthday", - "admin-lock-description": "Lock a user's birthday from editing", - "admin-unlock-description": "Unlock a user's birthday", - "admin-user-description": "The user to manage", - "admin-birthday-set": "Birthday for %u set to %dd.%mm", - "admin-no-birthday": "%u has no birthday set", - "admin-birthday-removed": "Birthday for %u has been removed", - "admin-birthday-locked": "Birthday for %u has been locked", - "admin-birthday-unlocked": "Birthday for %u has been unlocked", - "set-birthday-button": "Set your birthday", - "modal-title": "Set your birthday", - "year-placeholder": "Optional, e.g. 2000", - "upcoming-subcommand-description": "Shows upcoming birthdays in the next N days", - "upcoming-command-days-description": "How many days to look ahead", - "upcoming-embed-title": "Upcoming birthdays in the next %n days", - "no-upcoming-birthdays": "No upcoming birthdays in the next %n days.", - "upcoming-today": "today", - "upcoming-tomorrow": "tomorrow", - "upcoming-in-x-days": "in %n days", - "turning-age": "turning %a" - }, "boostTier": { "0": "None", "1": "Level 1", "2": "Level 2", "3": "Level 3" }, - "bot-feedback": { - "command-description": "Send feedback about the bot to the bot developer", - "submitted-successfully": "Thanks so much for your feedback! It has been carefully recorded and our team will review it soon. If we have any questions, we may contact you via DM (or if you are on our [Support Server]() we'll open a ticket). Thank you for making [CustomBots]() better for everyone <3\n\nYour feedback is subject to our [Terms of service]() and [Privacy Policy]().", - "failed-to-submit": "Sorry, but I couldn't send your feedback to our staff. This could be, because you got blocked or because of some server issue we are having. You can always report bugs and submit feedback in our [Feature-Board](https://features.sc-network.net). Thank you.", - "feedback-description": "Your feedback. Make sure it's neutral, constructive and helpful" - }, "channel-stats": { "audit-log-reason-interval": "Updated channel because of interval", "audit-log-reason-startup": "Updated channel because of startup", @@ -187,23 +118,10 @@ "startup": "The bot is currently starting up. Please try again in a few minutes.", "not-found": "Command not found", "used": "%tag (%id) used command /%c", - "message-used": "%tag (%id) used command %p%c", "execution-failed": "Execution of command /%c %g %s failed (Tracing: %t): %e", - "message-execution-failed": "Execution of command %p%c failed (Tracing: %t): %e", "wrong-guild": "This command is only available on the server **%g**.", "autcomplete-execution-failed": "Execution of auto-complete on command /%c %g %s with option %f failed: %e", "execution-failed-message": "## πŸ”΄ Command execution failed πŸ”΄\nThis usually happens either due to misconfiguration or due to an error on our site. Please send a screenshot of this message in #support on the [ScootKit Discord](https://scootk.it/dc-en), to resolve this.\n\n### Internal Tracing ID\n`%t`\n### Debugging-Information\n```%e```", - "error-giving-role": "An error occurred when trying to give you your roles ):\nPlease ask the server administrators to confirm that the highest role of the bot is above the role that the bot is supposed to assign.", - "role-confirm-header": "Review your role changes below, adjust the selection if needed, and submit to confirm.", - "role-confirm-no-change": "ℹ️ Your roles will not change with the current selection.", - "role-confirm-will-add": "βœ… Will be added: %r", - "role-confirm-will-remove": "❌ Will be removed: %r", - "role-confirm-result-noop": "ℹ️ Your roles were not changed.", - "role-confirm-result-added": "βœ… Added: %r", - "role-confirm-result-removed": "❌ Removed: %r", - "role-confirm-button-apply": "Confirm changes", - "role-confirm-button-cancel": "Cancel", - "role-confirm-cancelled": "ℹ️ Cancelled. Your roles were not changed.", "description-too-long": "The following command description of %c was too long to sync: %s", "module-disabled": "This command is part of the \"%m\" which is disabled. This can either be intended by the server-admins (and slash-commands haven't synced yet) or this could be caused by a configuration error. Please check (or ask the admins) to check the bot's configuration and logs for details.", "command-disabled": "This command is currently disabled by the server configuration. If you believe this is an error, please contact a server administrator.", @@ -213,7 +131,6 @@ "checking-config": "Checking configurations...", "done-with-checking": "Done with checking. Out of %totalModules modules, %enabled were enabled, %configDisabled were disabled because their configuration was wrong.", "creating-file": "Config %m/%f does not exist - I'm going to create it, please stand by...", - "checking-of-field-failed": "An error occurred while checking the content of field \"%fieldName\" in %m/%f", "saved-file": "Configuration-File %f in %m was saved successfully.", "moduleconf-regeneration": "Regenerating module configuration, no settings will be overwritten, don't worry.", "moduleconf-regeneration-success": "Module configuration regeneration successfully finished.", @@ -224,7 +141,10 @@ "role-not-found": "Role with ID \"%id\" could not be found on your server", "config-reload": "Reloading all configuration...", "intents-unknown": "⚠️ Ignoring unknown gateway intent name(s) declared in configuration: %intents", - "intents-restart-required": "⚠️ A newly enabled module needs gateway intent(s) not currently active: %intents. Restart the bot to activate its features." + "intents-restart-required": "⚠️ A newly enabled module needs gateway intent(s) not currently active: %intents. Restart the bot to activate its features.", + "intent-module-disabled": "Module %m disabled: required privileged intent(s) not granted: %intents", + "intents-restart-required-removed": "⚠️ Privileged intent(s) %intents were removed from allowedPrivilegedIntents but remain active until restart. On restart these modules will be disabled: %modules", + "allowlist-bad-entries": "Ignoring invalid entr(y/ies) in allowedPrivilegedIntents (must be one of GuildMembers, GuildPresences, MessageContent): %entries" }, "connect-four": { "tie": "It's a tie!", @@ -285,7 +205,6 @@ "work-earned-money": "The user %u gained %m %c by working", "crime-earned-money": "The user %u gained %m %c by committing a crime", "message-drop-earned-money": "The user %u gained %m %c by getting a message drop", - "rob-earned-money": "The user %u gained %m %c by robbing from %v", "weekly-earned-money": "The user %u gained %m %c by cashing in their weekly reward", "daily-earned-money": "The user %u gained %m %c by cashing in their daily reward", "admin-self-abuse": "The admin %a wanted to abuse their permissions by giving them self even more money! This can't and should not be ignored!", @@ -342,8 +261,6 @@ "destroy-cancel-reply": "You're lucky. You stopped me in the last moment before I destroyed the economy", "destroy-reply": "Ok... I'll destroy the whole economy", "destroy": "%u destroyed the economy", - "migration-happening": "Database not up-to-date. Migrating database...", - "migration-done": "Migrated database successfully.", "nothing-selected": "Select an item to buy it", "select-menu-price": "Price: %p", "price-less-than-zero": "The price can't be less or equal to zero", @@ -433,7 +350,6 @@ "stats-title": "πŸ“Š Stats", "stats-content": "Active modules: %am\nRegistered commands: %rc\nBot-Version: %v\nRunning on server %si\n[Server-Plan](https://scnx.xyz/plan): %pl\nLast restart: %lr\nLast reload: %lR", "command-description": "Show every commands", - "slash-commands-title": "Slash-Commands", "select-module-placeholder": "Select a module to view its commands", "select-module-hint": "πŸ‘‡ Use the dropdown below to browse commands by module.", "back-to-overview": "Back to overview", @@ -489,10 +405,8 @@ "voiceChannel": "Voice", "categoryChannel": "Categories", "otherChannel": "Other", - "total-invites": "Total", - "active-invites": "Active", - "left-invites": "Left", - "user-info-context-description": "Show information about a user" + "user-info-context-description": "Show information about a user", + "age-hover": "%a years old" }, "levels": { "leaderboard-channel-not-found": "Leaderboard-Channel not found or wrong type", @@ -576,13 +490,8 @@ "config-check-failed": "Configuration-Check failed. You can find more information in your log. The bot exited.", "bot-ready": "The bot initiated successfully and is now listening to commands", "no-command-permissions": "Could not update server commands. Please give us permissions to performe this critical action: %inv", - "perm-sync": "Synced permissions for /%c", - "perm-sync-failed": "Failed to synced permissions for /%c: %e", "loading-module": "Loading module %m", "hidden-module": "Module %m is hidden, meaning that it is not available. Skipping…", - "module-disabled": "Module %m is disabled", - "command-loaded": "Loaded command %d/%f", - "command-dir": "Loading commands in %d/%f", "global-command-sync": "Synced global application commands", "guild-command-sync": "Synced server application commands", "guild-command-no-sync-required": "Server application commands are up to date - no syncing required", @@ -590,7 +499,6 @@ "event-loaded": "Loaded events %d/%f", "event-dir": "Loading events in %d/%f", "model-loaded": "Loaded database model %d/%f", - "model-dir": "Loading database model in %d/%f", "loaded-cli": "Loaded API-Action %c in %p", "channel-lock": "Locked channel", "channel-unlock": "Unlocked channel", @@ -612,7 +520,12 @@ "home-guild-kicked": "Bot was removed from the configured home guild (%g). Pausing operations and waiting to be re-added.", "home-guild-rejoined": "Bot was re-added to the home guild. Reloading configuration.", "home-guild-unavailable": "Home guild (%g) is currently unavailable (likely a Discord outage). Pausing operations until it returns.", - "home-guild-available": "Home guild (%g) is available again. Resuming operations." + "home-guild-available": "Home guild (%g) is available again. Resuming operations.", + "intents-dropped": "⚠️ Not requesting privileged gateway intent(s) excluded by allowedPrivilegedIntents: %intents. Dependent features are disabled or degraded.", + "intents-degraded": "Module %m is running without optional intent(s) %intents; the related feature is unavailable until the intent is granted.", + "allowlist-bad-entries": "Ignoring invalid entr(y/ies) in allowedPrivilegedIntents (must be one of GuildMembers, GuildPresences, MessageContent): %entries", + "context-command-collision": "Skipping context command \"%n\" (%m): another %t context command already uses that name.", + "context-commands-dropped": "Skipping %c %t context command(s): Discord allows at most %limit per bot. Not registered: %n" }, "massrole": { "command-description": "Manage roles for all members", @@ -701,7 +614,6 @@ "report-reason-description": "Please describe what the user did wrong", "report-user-description": "User you want to report", "no-reason": "Not set", - "muterole-not-found": "Could not find muterole. Can not perform this action", "quarantinerole-not-found": "Could not find quarantinerole. Can not perform this action", "mute-audit-log-reason": "Got muted by %u because of \"%r\"", "unmute-audit-log-reason": "Got unmuted by %u because of \"%r\"", @@ -712,13 +624,6 @@ "unchannelmute-audit-log-reason": "The Channel-Mute got removed by %u because of \"%r\"", "unbanned-audit-log-reason": "Got unbanned by %u because of \"%r\"", "unquarantine-audit-log-reason": "Got unquarantined by %u because of \"%r\"", - "enforce-quarantine-no-roles-audit-log": "Quarantined users may not hold additional roles", - "unauthorized-quarantine-removal-audit-log": "Quarantine role was removed without /moderate unquarantine β€” re-applying", - "unauthorized-quarantine-removal-title": "⚠️ Quarantine role removed manually", - "unauthorized-quarantine-removal-description": "%user% had their quarantine role removed outside of `/moderate unquarantine`. The role has been re-applied automatically.", - "unauthorized-quarantine-removal-by": "Removed by", - "unauthorized-quarantine-removal-by-unknown": "Unknown (audit log unavailable)", - "unauthorized-quarantine-removal-original-case": "Original case", "action-expired": "Action expired", "auto-mod": "Auto-Mod", "batch-role-remove-failed": "Could not remove all roles from %i (trying to remove roles one by one): %e", @@ -759,7 +664,6 @@ "can-not-report-mod": "You can not report moderators.", "action-description-format": "%reason\nby %u on %t", "no-actions-title": "None found", - "no-actions-value": "No actions against %u found.", "actions-embed-title": "Mod-Actions against %u - Site %i", "actions-embed-description": "You can find every action against %u here.", "report-embed-title": "New report", @@ -815,49 +719,6 @@ "simple-solution-label": "Enter your answer", "verification-modal-title": "Verification" }, - "months": { - "1": "January", - "2": "February", - "3": "March", - "4": "April", - "5": "May", - "6": "June", - "7": "July", - "8": "August", - "9": "September", - "10": "October", - "11": "November", - "12": "December" - }, - "nicknames": { - "owner-cannot-be-renamed": "The owner of the server (%u) cannot be renamed.", - "nickname-error": "An error occurred while trying to change the nickname of %u: %e" - }, - "partner-list": { - "could-not-give-role": "Could not give role to user %u", - "could-not-remove-role": "Could not remove role from user %u", - "list-location": "[Partner List] The partner list is currently located here: %l. Delete the message and restart the bot, to re-send it.", - "partner-not-found": "Partner could not be found. Please check if you are using the right partner-ID. The partner-ID is not identical with the server-id of the partner. The Partner-ID can be found [here](https://gblobscdn.gitbook.com/assets%2F-MNyHzQ4T8hs4m6x1952%2F-MWDvDO9-_JwAGqtD6at%2F-MWDxIcOHB9VcWhjsWt7%2Fscreen_20210320-102628.png?alt=media&token=2f9ac1f7-1a14-445c-b34e-83057789578e) in the partner-embed.", - "successful-edit": "Edited partner-list successfully.", - "channel-not-found": "Could not find channel with ID %c or the channel has a wrong type (only text-channels supported)", - "no-partners": "There are currently no partners. This is odd, but that's how it is Β―\\_(ツ)_/Β―\n\nTo add a partner, run `/partner add` as a slash-command.", - "information": "Information", - "command-description": "Manages the partner-list on this server", - "padd-description": "Add a new partner", - "padd-name-description": "Name of the partner", - "padd-category-description": "Please select one of the categories specified in your configuration", - "padd-owner-description": "Owner of the partnered server", - "padd-inviteurl-description": "Invite to the partnered server", - "pedit-description": "Edits an existing partner", - "pedit-id-description": "ID of the partner", - "pedit-name-description": "New name of the partner", - "pedit-inviteurl-description": "New invite to this partner", - "pedit-category-description": "New category of this partner", - "pedit-owner-description": "New owner of the partner server", - "pedit-staff-description": "New designated staff member for this partner server", - "pdelete-description": "Deletes an exiting partner", - "pdelete-id-description": "ID of the partner" - }, "ping-on-vc-join": { "channel-not-found": "Notify channel %c not found", "could-not-send-pn": "Could not send PN to %m" @@ -966,7 +827,9 @@ "log-del-type": "[Ping Protection] Deleted %type data for user %target by %admin.", "log-del-all": "[Ping Protection] Deleted all stored data for user %target by %admin.", "view-moderation-history-description": "View this user's moderation history", - "view-ping-history-description": "View this user's ping history" + "view-ping-history-description": "View this user's ping history", + "log-automod-role-protection-skipped": "[Ping Protection] Skipped adding role-based protected users to the native AutoMod rule because the GuildMembers intent is not granted. Only explicitly configured protected users are included until GuildMembers is enabled.", + "no-reason": "No reason provided" }, "polls": { "what-have-i-votet": "What have I voted?", @@ -1087,35 +950,12 @@ "command-description": "Play rock-paper-scissors against the bot or someone in the chat" }, "scnx": { - "activating": "Initializing SCNX-Integration…", - "notLongerInSCNX": "Server disabled or not longer in SCNX. Exiting.", - "activated": "SCNX Integration successfully activated. Get ready to enjoy all the benefits of SCNX", - "loggedInAs": "CustomBot %b logged in as \"%u\" on server %s with version %v an plan \"%p\"", - "choose-roles": "Select roles", - "early-access-missing": "This module is currently early access, but neither the server owner nor any of the trusted admins of this server have early access unlocked.", - "early-access-unlocked": "Early Access has been unlocked", - "early-access-locked": "Early Access features are unavailable", - "select-to-run-action": "Select to run action…", - "localeUpdate": "Updated locales", - "localeUpdateSkip": "Skipped locale update", - "reportAbuse": "Report abuse", - "localeFetchFailed": "Could not fetch locales to update them: SCNX returned %s", - "issueTrackingActivated": "Activated Issue-Tracking successfully. Your bot will now report any unfixable issues to the developers.", - "newVersion": "**⬆ New version available: %v πŸŽ‰**\n\nTo apply these changes, please restart your bot in your SCNX Dashboard.\nUpdates should be applied as soon as possible, as they include bug-fixes, improvements and new features. You can find a detailed changelog in our Discord-Server.", - "freePlanExpiring-title": "⚠ **%s's free plan is going to expire in 24 hours**", - "freePlanExpiring-description": "The free plan of \"%s\" is going to expire %r (%t). Please either watch an Ad in your SCNX Dashboard or upgrade to a payed plan to keep your bot online and running.\nThank you.", - "freePlanExpiring-upgrade": "Upgrade to paid plan", - "freePlanExpiring-watchAd": "Watch advertisement", - "freePlanExpiring-footer": "Sent to this channel, because you configured SCNX this way. You can edit notification-settings in the Pricing-Panel in your SCNX Dashboard", - "pro-field-reset": "Had to reset the value of the field \"%f\" to its default value, because customization of this field is only available to the \"%p\" plan, but this server has only \"%c\".", "plan-STARTER": "Starter", "plan-ACTIVE_GUILD": "Active Guild", "plan-PRO": "Pro", "plan-UNLIMITED": "Unlimited", "plan-PROFESSIONAL": "Professional", - "plan-ENTERPRISE": "Enterprise", - "reduced-dashboard-active": "Reduced dashboard mode is active, meaning that period server data will be transmitted due allow the SCNX Dashboard to work.", - "reduced-dashboard-transmitting": "Transmitted updated server data due to reduced dashboard mode." + "plan-ENTERPRISE": "Enterprise" }, "staff-management-system": { "time-zero": "0 seconds", @@ -1538,7 +1378,8 @@ "invalid-minstars": "Invalid minimum stars %stars", "star-limit": "You've reached the hourly starboard limit of %limitEmoji on the server which is why you cannot react on the message %msgUrl .\nTry again %time!", "star-message-description": "Add this message to the starboard", - "star-message-success": "Message added to the starboard." + "star-message-success": "Message added to the starboard.", + "channel-not-found": "Could not find channel with ID %c or the channel has a wrong type (only text-channels supported)" }, "status-role": { "fulfilled": "Status-role condition is fulfilled", @@ -1595,29 +1436,23 @@ "no-added-user": "There are no users to be displayed here", "nothing-changed": "Your channel already had these settings.", "no-disconnect": "Couldn't disconnect the user from your channel. This could be due to missing permissions, or the user not being in your voice-channel", - "edit-error": "An error occurred while editing your channel. one or more of your settings couldn't be applied. This could be due to missing permissions or an invalid value.", "add-user": "Add user", "remove-user": "Remove user", "list-users": "List users", "private-channel": "Private", "public-channel": "Public", "edit-channel": "Edit channel", - "add-modal-title": "Add an user to your temp-channel", "add-modal-prompt": "The user you want to add (tag or user-id)", - "remove-modal-title": "Remove an user from your temp-channel", "remove-modal-prompt": "The user you want to remove (tag or user-id)", "edit-modal-title": "Edit your temp-channel", "edit-modal-nsfw-prompt": "Mark temp-channel as age-restricted?", - "edit-modal-nsfw-placeholder": "\"true\" (yes) or \"false\" (no)", "edit-modal-nsfw-on": "Yes (age-restricted)", "edit-modal-nsfw-off": "No (not age-restricted)", "edit-modal-bitrate-prompt": "Bitrate of your Temp-channel?", - "edit-modal-bitrate-placeholder": "A number over 8000", "edit-modal-limit-prompt": "Limit of users in your temp-channel", "edit-modal-limit-placeholder": "Number between 0 and 99; 0 = unlimited", "edit-modal-name-prompt": "How should your channel be called?", "edit-modal-name-placeholder": "A very creative channel name", - "edit-modal-username-placeholder": "Username of the user", "user-not-found": "User not found", "add-to-channel-context-description": "Add this user to your temp channel", "remove-from-channel-context-description": "Remove this user from your temp channel" @@ -1659,21 +1494,8 @@ "context-not-a-ticket": "This message is not in an open ticket channel.", "context-create-description": "Open a ticket about this message", "context-no-ticket-type": "No ticket type is configured.", - "context-create-reference": "Ticket opened about a message from %author: %url" - }, - "topgg": { - "channel-not-found": "The configured channel with the ID \"%c\" was not found", - "testvote-header": "This was a test vote", - "voterole-reached": "Voted on top.gg and received Voter-Role", - "voterole-ended": "Vote on top.gg expired and got Voter-Role removed", - "opt-in": "Enable notifications when you can vote again", - "opt-out": "Disable notifications when you can vote again", - "opted-in": "Successfully opted in into receiving notifications when you can vote again", - "opted-out": "Successfully opted out of receiving notifications when you can vote again", - "already-opted-in": "You are already opted-in and will receive notifications when you can vote again", - "already-opted-out": "You are already opted-out and will **not** receive notifications when you can vote again", - "voteamount-reached": "The user reached %k votes which resulted in this role to be given.", - "testvote-description": "This vote was triggered in the top.gg dashboard and does not count towards any votecount of anyone and won't be used for reminders." + "context-create-reference": "Ticket opened about a message from %author: %url", + "button-not-uniqe": "Two ticket types use the same button ID. Button IDs must be unique." }, "twitch-notifications": { "channel-not-found": "Channel with ID %c could not be found", diff --git a/main.js b/main.js index dbf5e3c0..f5158550 100644 --- a/main.js +++ b/main.js @@ -1,7 +1,6 @@ const Discord = require('./src/discordjs-fix'); const { ApplicationCommandOptionType, - ApplicationCommandType, ChannelType, Partials, PermissionFlagsBits, @@ -19,13 +18,15 @@ if (args[0] && args[1]) { dataDir = args[1]; } -// Compute the gateway intents required by the currently-enabled modules before constructing the client const {computeRequiredIntents} = require('./src/functions/intents'); const { flags, names, unknown, - pairingInjected + pairingInjected, + droppedPrivileged, + degradedModules, + badAllowlistEntries } = computeRequiredIntents(confDir, `${__dirname}/modules`); if (unknown.length) throw new Error(`Unknown gateway intent(s) declared in a module.json: ${unknown.join(', ')}`); @@ -45,9 +46,26 @@ client.on('shardError', (err) => { const sentryId = client.captureException ? client.captureException(err, {source: 'shard-error'}) : null; client.logger ? client.logger.error(client.sanitizePath(loc('main', 'shard-error', {e: err.stack || err})) + (sentryId ? ` [Sentry: ${sentryId}]` : '')) : console.error(err); }); -client.on('shardDisconnect', (event) => { +client.on('shardDisconnect', async (event) => { const {localize: loc} = require('./src/functions/localize'); - client.logger ? client.logger.warn(loc('main', 'shard-disconnect', {c: event ? event.code : 'unknown'})) : console.warn('Disconnected from Discord'); + const code = event ? event.code : 'unknown'; + client.logger ? client.logger.warn(loc('main', 'shard-disconnect', {c: code})) : console.warn('Disconnected from Discord'); + + /* + * discord.js emits this only for the six UNRECOVERABLE close codes β€” it has given up and will + * not reconnect, so escalate instead of lingering with a dead gateway. Transient falls back to + * legacy 1 (safe under any supervisor), the fatals to 0 to avoid a restart loop. + */ + const {disconnectExitCode, EXIT, pick} = require('./src/functions/exitCodes'); + const newCode = disconnectExitCode(code); + if (newCode === EXIT.FATAL_INVALID_TOKEN) { + if (client.scnxSetup) await require('./src/functions/scnx-integration').reportIssue(client, {type: 'CORE_FAILURE', errorDescription: 'invalid_token'}).catch(() => {}); + client.logger && client.logger.fatal(loc('main', 'login-error-token')); + } else if (newCode === EXIT.FATAL_INTENTS) { + if (client.scnxSetup) await require('./src/functions/scnx-integration').reportIssue(client, {type: 'CORE_FAILURE', errorDescription: 'disallowed_intents'}).catch(() => {}); + client.logger && client.logger.fatal(loc('main', 'login-error-intents', {url: 'https://discord.com/developers/applications/'})); + } + process.exit(pick(newCode, newCode === EXIT.CRASH_TRANSIENT ? EXIT.CRASH_TRANSIENT : 0)); }); client.on('shardReconnecting', () => { const {localize: loc} = require('./src/functions/localize'); @@ -63,6 +81,13 @@ const log4js = require('log4js'); const jsonfile = require('jsonfile'); const centra = require('centra'); const readline = require('readline'); +const {pick} = require('./src/functions/exitCodes'); +const { + resolveCommandType, + partitionCommands, + USER_LIMIT, + MESSAGE_LIMIT +} = require('./src/functions/commandTypes'); let config; let scnxSetup = false; // If enabled some other (closed-sourced) files get imported and executed @@ -122,12 +147,11 @@ log4js.configure({ const logger = log4js.getLogger(); logger.level = scnxSetup ? 'debug' : (process.env.LOGLEVEL || 'debug'); -// Loading config try { config = jsonfile.readFileSync(`${confDir}/config.json`); } catch (e) { logger.fatal('Missing config.json! Run "npm run generate-config " (Parameter ConfDir is optional) to generate it'); - process.exit(0); + process.exit(pick(78)); // config missing/unreadable: never restart } const models = {}; // Object with all models @@ -156,6 +180,14 @@ logger.info(localize('main', 'intents-loaded', { intents: names.join(', ') })); if (pairingInjected) logger.warn(localize('main', 'intents-pairing-injected')); +if (badAllowlistEntries.length) logger.warn(localize('main', 'allowlist-bad-entries', {entries: badAllowlistEntries.join(', ')})); +if (droppedPrivileged.length) logger.warn(localize('main', 'intents-dropped', {intents: droppedPrivileged.join(', ')})); +for (const d of degradedModules) { + logger.info(localize('main', 'intents-degraded', { + m: d.module, + intents: d.missingOptional.join(', ') + })); +} let moduleConf = {}; try { @@ -164,7 +196,6 @@ try { logger.info(localize('main', 'missing-moduleconf')); } -// Connecting to Database const db = new Sequelize({ dialect: 'sqlite', storage: `${dataDir}/database.sqlite`, @@ -203,27 +234,29 @@ async function startUp() { } catch (e) { logger.fatal(`[migrations] failed: ${e.stack || e}`); logger.fatal('[migrations] aborting boot to avoid running with a partially migrated schema.'); - process.exit(1); + process.exit(pick(1, 1)); } } logger.info(localize('main', 'sync-db')); if (scnxSetup) await require('./src/functions/scnx-integration').beforeInit(client); if (!client.isReady()) { await client.login(config.token).catch(async (e) => { - if (e.code === 'TokenInvalid' || e.message === 'Authentication failed') { + const {classifyLoginError, loginErrorExitCode} = require('./src/functions/exitCodes'); + const kind = classifyLoginError(e, !config.token); + if (kind === 'InvalidToken' || kind === 'MissingToken') { if (scnxSetup) await require('./src/functions/scnx-integration').reportIssue(client, { type: 'CORE_FAILURE', errorDescription: 'invalid_token' }); logger.fatal(localize('main', 'login-error-token')); - } else if (e.code === 'DisallowedIntents' || e.message === 'Used disallowed intents') { + } else if (kind === 'DisallowedIntents') { if (scnxSetup) await require('./src/functions/scnx-integration').reportIssue(client, { type: 'CORE_FAILURE', errorDescription: 'disallowed_intents' }); logger.fatal(localize('main', 'login-error-intents', {url: `https://discord.com/developers/applications/`})); } else logger.fatal(localize('main', 'login-error', {e})); - process.exit(); + process.exit(pick(loginErrorExitCode(kind))); }); } let app = {}; @@ -247,7 +280,7 @@ async function startUp() { errorData: {settingsURL: `https://discord.com/developers/applications/${client.user.id}`} }); logger.error(localize('main', 'interactions-endpoint-active', {d: `https://discord.com/developers/applications/${client.user.id}/bot`})); - process.exit(); + process.exit(pick(78)); // an interactions endpoint URL is set: the gateway never receives interactions until the user clears it } client.guild = await client.guilds.fetch(config.guildID).catch(() => { }); @@ -262,7 +295,7 @@ async function startUp() { console.log('Waiting for being added to server…'); client.once('guildCreate', () => startUp()); return; - } else process.exit(0); + } else process.exit(pick(1)); // guilds.fetch swallows every error, so "not invited" is indistinguishable from a transient failure } logger.info(localize('main', 'logged-in', {tag: formatDiscordUserName(client.user)})); loadCLIFile('/src/cli.js'); @@ -290,7 +323,7 @@ async function startUp() { if (client.logChannel) await client.logChannel.send('⚠️ ' + localize('main', 'config-check-failed')); console.log(e); logger.fatal(localize('main', 'config-check-failed')); - process.exit(0); + process.exit(pick(78)); // config failed validation: the user must fix it }); await loadCommandsInDir('./src/commands'); if (client.scnxSetup) { @@ -358,14 +391,12 @@ module.exports.migrationEnd = function () { } }; -// Starting bot db.authenticate().then(startUp).catch((e) => { logger.fatal(localize('main', 'db-connect-error', {e: e.message || e})); if (!scnxSetup) console.error(e); - process.exit(1); + process.exit(pick(1, 1)); }); -// CLI-COMMANDS const cliCommands = []; const rl = readline.createInterface({ input: process.stdin, @@ -423,7 +454,10 @@ async function syncCommandsIfNeeded() { errorData: {inviteURL: `https://discord.com/oauth2/authorize?client_id=${client.user.id}&guild_id=${config.guildID}&disable_guild_select=true&permissions=8&scope=bot%20applications.commands`} }); logger.fatal(localize('main', 'no-command-permissions', {inv: `https://discord.com/oauth2/authorize?client_id=${client.user.id}&guild_id=${config.guildID}&disable_guild_select=true&permissions=8&scope=bot%20applications.commands`})); - process.exit(0); + + // A transient Discord failure is retryable; only the guild's command cap (30032) is user-actionable. + const capHit = (e && (e.code === 30032 || e.code === '30032')) || (e && typeof e.message === 'string' && e.message.toLowerCase().includes('maximum number of application commands')); + process.exit(pick(capHit ? 78 : 1)); } @@ -483,12 +517,7 @@ async function syncCommandsIfNeeded() { function normalizeCommand(command) { const newCommand = {...command}; - if (!newCommand.type) newCommand.type = ApplicationCommandType.ChatInput; - else if (typeof newCommand.type === 'string') { - const upper = newCommand.type.toUpperCase(); - const pascal = newCommand.type.charAt(0).toUpperCase() + newCommand.type.slice(1); - newCommand.type = ApplicationCommandType[upper] || ApplicationCommandType[pascal] || newCommand.type; - } + newCommand.type = resolveCommandType(newCommand.type); if (newCommand.options) newCommand.options = newCommand.options.map(normalizeOption); if (newCommand.defaultMemberPermissions) newCommand.defaultMemberPermissions = new PermissionsBitField(newCommand.defaultMemberPermissions.map(normalizePermission)).bitfield.toString(); return newCommand; @@ -537,6 +566,28 @@ async function syncCommandsIfNeeded() { ranCommands.push(command); } + /* + * Context-menu commands must be shaped and bounded before the PUT: Discord forbids + * description/options on them and caps each type, and any violation rejects the ENTIRE sync. + * Over-cap and colliding commands are logged rather than dropped silently. + */ + const partitioned = partitionCommands(ranCommands); + for (const c of partitioned.collisions) logger.warn(localize('main', 'context-command-collision', { + n: c.name, + m: c.module || 'core', + t: c.type + })); + for (const t of ['USER', 'MESSAGE']) { + const over = partitioned.dropped.filter(d => d.type === t); + if (over.length) logger.warn(localize('main', 'context-commands-dropped', { + c: over.length, + t, + limit: t === 'USER' ? USER_LIMIT : MESSAGE_LIMIT, + n: over.map(d => `${d.module || 'core'}/${d.name}`).join(', ') + })); + } + const registrableCommands = [...partitioned.slash, ...partitioned.context]; + /** * Checks if two application commands need to be synced * @param {ApplicationCommands} oldCommands Currently synced commands @@ -553,7 +604,7 @@ async function syncCommandsIfNeeded() { break; } - if (oldCommand.description !== command.description || oldCommand.type !== command.type || (oldCommand.options || []).length !== (command.options || []).length) { + if ((oldCommand.description || '') !== (command.description || '') || oldCommand.type !== command.type || (oldCommand.options || []).length !== (command.options || []).length) { needSync = true; break; } @@ -602,8 +653,8 @@ async function syncCommandsIfNeeded() { return needSync; } - let guildCommands = config.syncCommandGlobally ? [] : ranCommands; - const globalCommands = config.syncCommandGlobally ? ranCommands : []; + let guildCommands = config.syncCommandGlobally ? [] : registrableCommands; + const globalCommands = config.syncCommandGlobally ? registrableCommands : []; if (scnxSetup) guildCommands = [...guildCommands, ...((await require('./src/functions/scnx-integration').generateCustomSlashCommands(client, guildCommands)).map(f => normalizeCommand(f)))]; if (commandsNeedSync(oldGuildCommands, guildCommands)) { await client.application.commands.set(guildCommands, config.guildID).catch(handleSyncFailure); @@ -629,10 +680,20 @@ async function loadModelsInDir(dir, moduleName = null) { await fs.readdir(`${__dirname}/${dir}`, (async (err, files) => { if (err) { logger.fatal(err); - process.exit(0); + process.exit(pick(78)); // model directory unreadable: a broken install, retrying cannot help } for await (const file of files) { const model = require(`${__dirname}/${dir}/${file}`); + + /* + * Sequelize registers models globally by class name (no model file passes an explicit + * modelName), so a duplicate name would silently replace the earlier model and db.sync() + * would never create its table. + */ + if (db.models[model.name]) { + logger.fatal(`Duplicate model class name "${model.name}" in ${dir}/${file}: already registered by another model. Model class names must be unique across all modules; rename the class.`); + process.exit(pick(78)); + } await model.init(db); if (moduleName) { if (!models[moduleName]) models[moduleName] = {}; @@ -749,6 +810,8 @@ async function loadCommandsInDir(dir, moduleName = null) { const props = require(`${__dirname}/${dir}/${f}`); commands.push({ name: props.config.name, + type: props.config.type || null, + contextMenu: props.config.contextMenu || false, forceAnonymous: props.config.forceAnonymous, description: props.config.description, restricted: props.config.restricted, diff --git a/modules/admin-tools/module.json b/modules/admin-tools/module.json index e1caaeeb..8af9da77 100644 --- a/modules/admin-tools/module.json +++ b/modules/admin-tools/module.json @@ -23,6 +23,9 @@ "intents": [ "GuildMembers" ], + "optionalIntents": [ + "GuildMembers" + ], "intentReasons": { "GuildMembers": "Looks up members across the server to apply and remove scheduled temporary roles at the right time." } diff --git a/modules/afk-system/module.json b/modules/afk-system/module.json index 61c21a7a..92b33591 100644 --- a/modules/afk-system/module.json +++ b/modules/afk-system/module.json @@ -23,6 +23,9 @@ "GuildMessages", "MessageContent" ], + "optionalIntents": [ + "MessageContent" + ], "intentReasons": { "MessageContent": "Reads messages to detect when someone mentions an AFK member and notify the sender that they are away." } diff --git a/modules/anti-ghostping/module.json b/modules/anti-ghostping/module.json index f2d6393d..2aeb0fdf 100644 --- a/modules/anti-ghostping/module.json +++ b/modules/anti-ghostping/module.json @@ -20,6 +20,9 @@ "GuildMessages", "MessageContent" ], + "optionalIntents": [ + "MessageContent" + ], "intentReasons": { "MessageContent": "Reads message content so a deleted ghost-ping can be surfaced with its original mention text." } diff --git a/modules/betterstatus/events/botReady.js b/modules/betterstatus/events/botReady.js index 5773e573..ec76f5df 100644 --- a/modules/betterstatus/events/botReady.js +++ b/modules/betterstatus/events/botReady.js @@ -1,4 +1,4 @@ -const {formatDiscordUserName} = require('../../../src/functions/helpers'); +const {formatDiscordUserName, memberCountOrFallback} = require('../../../src/functions/helpers'); const {ActivityType} = require('discord.js'); const activityTypes = { @@ -41,19 +41,33 @@ module.exports.run = async function (client) { /** * @private - * Replace status variables + * Replace status variables. GuildMembers and GuildPresences are both optional for this module, + * so every placeholder derived from the member cache / presence data degrades to a placeholder + * instead of a false count built from an empty cache. * @param statusString String to run the replacer on * @returns {Promise} */ async function replaceStatusString(statusString) { if (!statusString) return 'Invalid status'; const members = client.guild.members.cache; - const randomOnline = members.filter(m => ['online', 'dnd'].includes(m.presence?.status) && !m.user.bot).random(); - const random = members.filter(m => !m.user.bot).random(); - return statusString.replaceAll('%memberCount%', client.guild.memberCount) - .replaceAll('%onlineMemberCount%', members.filter(m => m.presence && !m.user.bot).size) - .replaceAll('%randomOnlineMemberTag%', randomOnline ? formatDiscordUserName(randomOnline.user) : formatDiscordUserName(client.user)) - .replaceAll('%randomMemberTag%', `${random.user.username}#${random.user.discriminator}`) + const membersActive = (client._activeIntents || []).includes('GuildMembers'); + const presencesActive = (client._activeIntents || []).includes('GuildPresences'); + const placeholder = 'N/A'; + + const random = membersActive ? members.filter(m => !m.user.bot).random() : null; + + // Needs presences too: without them every member looks offline, so this would near-always + // fall through to the bot's own tag and read as "a member is online" when we don't know. + const randomOnline = (membersActive && presencesActive) + ? members.filter(m => ['online', 'dnd'].includes(m.presence?.status) && !m.user.bot).random() + : null; + + return statusString.replaceAll('%memberCount%', memberCountOrFallback(client.guild)) + .replaceAll('%onlineMemberCount%', presencesActive ? members.filter(m => m.presence && !m.user.bot).size : placeholder) + .replaceAll('%randomOnlineMemberTag%', randomOnline + ? formatDiscordUserName(randomOnline.user) + : (presencesActive ? formatDiscordUserName(client.user) : placeholder)) + .replaceAll('%randomMemberTag%', random ? `${random.user.username}#${random.user.discriminator}` : placeholder) .replaceAll('%channelCount%', client.guild.channels.cache.size) .replaceAll('%roleCount%', (await client.guild.roles.fetch()).size); } diff --git a/modules/betterstatus/module.json b/modules/betterstatus/module.json index 8f8569b0..416adf83 100644 --- a/modules/betterstatus/module.json +++ b/modules/betterstatus/module.json @@ -21,6 +21,10 @@ "GuildMembers", "GuildPresences" ], + "optionalIntents": [ + "GuildMembers", + "GuildPresences" + ], "intentReasons": { "GuildMembers": "Tracks members joining and leaving to keep member-count placeholders in the bot's status accurate.", "GuildPresences": "Reads members' online status to show live online-member counts in the bot's status." diff --git a/modules/channel-stats/events/botReady.js b/modules/channel-stats/events/botReady.js index 19002340..66ff53a6 100644 --- a/modules/channel-stats/events/botReady.js +++ b/modules/channel-stats/events/botReady.js @@ -1,5 +1,5 @@ const {ChannelType} = require('discord.js'); -const {formatDate} = require('../../../src/functions/helpers'); +const {formatDate, memberCountOrFallback, onlineCountOrNull} = require('../../../src/functions/helpers'); const {localize} = require('../../../src/functions/localize'); module.exports.run = async (client) => { @@ -43,6 +43,13 @@ async function channelNameReplacer(client, channel, input) { const users = client.guild.members.cache; const members = users.filter(u => !u.user.bot); + // Presence data is only reliable with GuildPresences; role membership and the non-bot subset + // are enumerated from the member cache, which is near-empty without GuildMembers. Both degrade + // to a placeholder rather than silently under-reporting. + const presencesActive = (client._activeIntents || []).includes('GuildPresences'); + const membersActive = (client._activeIntents || []).includes('GuildMembers'); + const presencePlaceholder = 'N/A'; + /** * Replaces the first member-with-role-count parameters of the input * @private @@ -51,30 +58,31 @@ async function channelNameReplacer(client, channel, input) { if (input.includes('%userWithRoleCount-')) { const id = input.split('%userWithRoleCount-')[1].split('%')[0]; if (input.includes(`%userWithRoleCount-${id}%`)) { - input = input.replaceAll(`%userWithRoleCount-${id}%`, users.filter(f => f.roles.cache.has(id)).size.toString()); + input = input.replaceAll(`%userWithRoleCount-${id}%`, membersActive ? users.filter(f => f.roles.cache.has(id)).size.toString() : presencePlaceholder); replaceFirst(); } } if (input.includes('%onlineUserWithRoleCount-')) { const id = input.split('%onlineUserWithRoleCount-')[1].split('%')[0]; if (input.includes(`%onlineUserWithRoleCount-${id}%`)) { - input = input.replaceAll(`%onlineUserWithRoleCount-${id}%`, users.filter(f => f.roles.cache.has(id) && f.presence && (f.presence || {}).status !== 'offline').size.toString()); + input = input.replaceAll(`%onlineUserWithRoleCount-${id}%`, (membersActive && presencesActive) ? users.filter(f => f.roles.cache.has(id) && f.presence && (f.presence || {}).status !== 'offline').size.toString() : presencePlaceholder); replaceFirst(); } } } replaceFirst(); - return input.split('%userCount%').join(users.size) - .split('%memberCount%').join(members.size) - .split('%onlineUserCount%').join(users.filter(u => u.presence && (u.presence || {}).status !== 'offline').size) - .split('%onlineMemberCount%').join(members.filter(u => u.presence && (u.presence || {}).status !== 'offline').size) + const onlineUserCount = onlineCountOrNull(client, client.guild); + return input.split('%userCount%').join(memberCountOrFallback(client.guild)) + .split('%memberCount%').join(membersActive ? members.size : presencePlaceholder) + .split('%onlineUserCount%').join(onlineUserCount === null ? presencePlaceholder : onlineUserCount) + .split('%onlineMemberCount%').join(presencesActive ? members.filter(u => u.presence && (u.presence || {}).status !== 'offline').size : presencePlaceholder) .split('%channelCount%').join(channel.guild.channels.cache.size) .split('%roleCount%').join(channel.guild.roles.cache.size) - .split('%botCount%').join(users.filter(m => m.user.bot).size) - .split('%dndCount%').join(members.filter(u => u.presence && (u.presence || {}).status === 'dnd').size) - .split('%awayCount%').join(members.filter(m => m.presence && (m.presence || {}).status === 'idle').size) - .split('%offlineCount%').join(members.filter(m => !m.presence || (m.presence || {}).status === 'offline').size) + .split('%botCount%').join(membersActive ? users.filter(m => m.user.bot).size : presencePlaceholder) + .split('%dndCount%').join(presencesActive ? members.filter(u => u.presence && (u.presence || {}).status === 'dnd').size : presencePlaceholder) + .split('%awayCount%').join(presencesActive ? members.filter(m => m.presence && (m.presence || {}).status === 'idle').size : presencePlaceholder) + .split('%offlineCount%').join(presencesActive ? members.filter(m => !m.presence || (m.presence || {}).status === 'offline').size : presencePlaceholder) .split('%guildBoosts%').join(channel.guild.premiumSubscriptionCount || '0') .split('%boostLevel%').join(localize('boostTier', channel.guild.premiumTier)) .split('%boosterCount%').join(members.filter(m => !!m.premiumSinceTimestamp).size) @@ -82,5 +90,4 @@ async function channelNameReplacer(client, channel, input) { .split('%currentTime%').join(formatDate(new Date(), true)).trim(); } -// Exported for unit testing of the placeholder-replacement logic. module.exports.channelNameReplacer = channelNameReplacer; diff --git a/modules/channel-stats/module.json b/modules/channel-stats/module.json index 4571f00e..cd5d7af7 100644 --- a/modules/channel-stats/module.json +++ b/modules/channel-stats/module.json @@ -20,6 +20,10 @@ "GuildMembers", "GuildPresences" ], + "optionalIntents": [ + "GuildMembers", + "GuildPresences" + ], "intentReasons": { "GuildMembers": "Counts the full membership to keep total member-count stat channels up to date.", "GuildPresences": "Reads members' online status to power online, idle, do-not-disturb and offline count channels." diff --git a/modules/color-me/commands/color-me.js b/modules/color-me/commands/color-me.js index 92ebc2d9..0cdd8a1d 100644 --- a/modules/color-me/commands/color-me.js +++ b/modules/color-me/commands/color-me.js @@ -251,7 +251,6 @@ async function color(interaction, moduleStrings) { }; } -// Exported for unit testing of the colour-validation logic. module.exports.color = color; /** diff --git a/modules/color-me/module.json b/modules/color-me/module.json index a1f84be7..500ae320 100644 --- a/modules/color-me/module.json +++ b/modules/color-me/module.json @@ -22,6 +22,9 @@ "intents": [ "GuildMembers" ], + "optionalIntents": [ + "GuildMembers" + ], "intentReasons": { "GuildMembers": "Reacts to member updates to grant or remove the custom color role when boost status changes." } diff --git a/modules/connect-four/commands/challenge-to-connect-four.js b/modules/connect-four/commands/challenge-to-connect-four.js index 3291c762..030ed84d 100644 --- a/modules/connect-four/commands/challenge-to-connect-four.js +++ b/modules/connect-four/commands/challenge-to-connect-four.js @@ -9,12 +9,6 @@ module.exports.config = { description: localize('connect-four', 'challenge-to-connect-four-context-description') }; -/* - * Thin adapter: /connect-four run() resolves its opponent via interaction.options.getMember('user') - * and an optional field_size via getInteger('field_size') (defaulting to 7). We reuse run() - * unchanged by handing it the real interaction with those option reads overridden so the - * challenge and game flow is identical, against the right-clicked user with the default field size. - */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/duel/commands/duel-context.js b/modules/duel/commands/duel-context.js index fa6d2190..e699fbe4 100644 --- a/modules/duel/commands/duel-context.js +++ b/modules/duel/commands/duel-context.js @@ -9,12 +9,6 @@ module.exports.config = { description: localize('duel', 'duel-context-description') }; -/* - * Thin adapter: /duel run() resolves its opponent via interaction.options.getMember('user', true). - * We reuse run() unchanged by handing it the real interaction with getMember overridden to return - * the right-clicked member, so the challenge -> accept -> duel flow is identical against that user. - * The self-challenge guard inside run() applies unchanged. - */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/duel/module.json b/modules/duel/module.json index c22008b2..489c9fde 100644 --- a/modules/duel/module.json +++ b/modules/duel/module.json @@ -20,6 +20,10 @@ "GuildMembers", "GuildPresences" ], + "optionalIntents": [ + "GuildMembers", + "GuildPresences" + ], "intentReasons": { "GuildMembers": "Looks up members to pick random opponents and resolve player names in duels.", "GuildPresences": "Reads members' online status so offline members are skipped when choosing a random opponent." diff --git a/modules/economy-system/commands/add-money.js b/modules/economy-system/commands/add-money.js index 17998d50..139ab25a 100644 --- a/modules/economy-system/commands/add-money.js +++ b/modules/economy-system/commands/add-money.js @@ -15,10 +15,6 @@ module.exports.config = { description: localize('economy-system', 'add-money-context-description') }; -/* - * /economy add adapter: runs the slash admin guard, then opens the amount modal (customId encodes - * action + target) handled in events/interactionCreate.js. showModal must be first, so no defer. - */ module.exports.run = async function (interaction) { interaction.str = interaction.client.configurations['economy-system']['strings']; interaction.config = interaction.client.configurations['economy-system']['config']; diff --git a/modules/economy-system/commands/economy-system.js b/modules/economy-system/commands/economy-system.js index b1eac1d2..8f657ae4 100644 --- a/modules/economy-system/commands/economy-system.js +++ b/modules/economy-system/commands/economy-system.js @@ -30,13 +30,11 @@ async function cooldown (command, duration, userId, client) { } }); if (cooldownModel) { - // check cooldown duration if (cooldownModel.timestamp.getTime() + duration > Date.now()) return false; cooldownModel.timestamp = new Date(); await cooldownModel.save(); return true; } else { - // create the model await model.create({ userId: userId, command: command, @@ -99,9 +97,7 @@ async function adminGuard(interaction, user) { return true; } -/* - * Shared rob core: robs `user` on behalf of interaction.user. interaction.str/config must already be set. - */ +// Shared rob core: robs `user` on behalf of interaction.user. interaction.str/config must already be set. async function robUser(interaction, user) { const robbedUser = await interaction.client.models['economy-system']['Balance'].findOne({ where: { @@ -133,9 +129,7 @@ async function robUser(interaction, user) { })); } -/* - * Shared "add money" core: adds `amount` to `user`'s wallet. Assumes the admin guard has passed. - */ +// Shared "add money" core: adds `amount` to `user`'s wallet. Assumes the admin guard has passed. async function addMoney(interaction, user, amount) { await editBalance(interaction.client, user.id, 'add', parseInt(amount)); respond(interaction, { @@ -160,9 +154,7 @@ async function addMoney(interaction, user, amount) { })); } -/* - * Shared "remove money" core: removes `amount` from `user`'s wallet. Assumes the admin guard has passed. - */ +// Shared "remove money" core: removes `amount` from `user`'s wallet. Assumes the admin guard has passed. async function removeMoney(interaction, user, amount) { await editBalance(interaction.client, user.id, 'remove', parseInt(amount)); respond(interaction, { @@ -187,9 +179,7 @@ async function removeMoney(interaction, user, amount) { })); } -/* - * Shared "set balance" core: sets `user`'s wallet to `amount`. Assumes the admin guard has passed. - */ +// Shared "set balance" core: sets `user`'s wallet to `amount`. Assumes the admin guard has passed. async function setMoney(interaction, user, amount) { await editBalance(interaction.client, user.id, 'set', parseInt(amount)); respond(interaction, { diff --git a/modules/economy-system/commands/remove-money.js b/modules/economy-system/commands/remove-money.js index 4b219c58..aa61c886 100644 --- a/modules/economy-system/commands/remove-money.js +++ b/modules/economy-system/commands/remove-money.js @@ -15,10 +15,6 @@ module.exports.config = { description: localize('economy-system', 'remove-money-context-description') }; -/* - * /economy remove adapter: runs the slash admin guard, then opens the amount modal (customId encodes - * action + target) handled in events/interactionCreate.js. showModal must be first, so no defer. - */ module.exports.run = async function (interaction) { interaction.str = interaction.client.configurations['economy-system']['strings']; interaction.config = interaction.client.configurations['economy-system']['config']; diff --git a/modules/economy-system/commands/set-balance.js b/modules/economy-system/commands/set-balance.js index 3251bfed..ebd5d6c2 100644 --- a/modules/economy-system/commands/set-balance.js +++ b/modules/economy-system/commands/set-balance.js @@ -15,10 +15,6 @@ module.exports.config = { description: localize('economy-system', 'set-balance-context-description') }; -/* - * /economy set adapter: runs the slash admin guard, then opens the balance modal (customId encodes - * action + target) handled in events/interactionCreate.js. showModal must be first, so no defer. - */ module.exports.run = async function (interaction) { interaction.str = interaction.client.configurations['economy-system']['strings']; interaction.config = interaction.client.configurations['economy-system']['config']; diff --git a/modules/economy-system/events/messageCreate.js b/modules/economy-system/events/messageCreate.js index aeef0ea6..b69a8668 100644 --- a/modules/economy-system/events/messageCreate.js +++ b/modules/economy-system/events/messageCreate.js @@ -1,6 +1,11 @@ const {editBalance} = require('../economy-system'); const {localize} = require('../../../src/functions/localize'); -const {formatDiscordUserName} = require('../../../src/functions/helpers'); +const { + formatDiscordUserName, + embedType, + randomElementFromArray, + randomIntFromInterval +} = require('../../../src/functions/helpers'); module.exports.run = async function (client, message) { if (!client.botReadyAt) return; @@ -12,8 +17,8 @@ module.exports.run = async function (client, message) { if (config['messageDrops'] === 0) return; if (config['msgDropsIgnoredChannels'].includes(message.channel.id)) return; - if (Math.floor(Math.random() * config['messageDrops']) !== 1) return; - const toAdd = Math.floor(Math.random() * (config['messageDropsMax'] - config['messageDropsMin'])) + config['messageDropsMin']; + if (randomIntFromInterval(1, config['messageDrops']) !== 1) return; + const toAdd = randomIntFromInterval(parseInt(config['messageDropsMin']), parseInt(config['messageDropsMax'])); await editBalance(client, message.author.id, 'add', toAdd); const sendMsg = await client.models['economy-system']['dropMsg'].findOne({ where: { @@ -21,7 +26,10 @@ module.exports.run = async function (client, message) { } }); if (!sendMsg) { - const msg = await message.reply({content: localize('economy-system', 'message-drop', {m: toAdd, c: config['currencySymbol']})}); + const dropMessage = randomElementFromArray(client.configurations['economy-system']['strings']['msgDropMsg'] || []); + const msg = await message.reply(dropMessage + ? embedType(dropMessage, {'%earned%': `${toAdd} ${config['currencySymbol']}`}) + : {content: localize('economy-system', 'message-drop', {m: toAdd, c: config['currencySymbol']})}); setTimeout(() => { msg.delete(); }, 5000); diff --git a/modules/economy-system/migrations/economy_Shop__V1.js b/modules/economy-system/migrations/economy_Shop__V1.js index 415efc7d..f53c5d37 100644 --- a/modules/economy-system/migrations/economy_Shop__V1.js +++ b/modules/economy-system/migrations/economy_Shop__V1.js @@ -1,25 +1,8 @@ const TABLE = 'economy_shop'; /* - * V1 commit (98e3b4f4, Oct 2024) changed the primary key from `name` to a new `id` - * column. The pre-V1 schema had no `id` column at all: `name` was the STRING PK. - * The current model is `id STRING PRIMARY KEY, name STRING, price INTEGER, role TEXT`. - * - * The old inline V1 did `findAll β†’ sync({force:true}) β†’ re-insert with i++` to perform - * this PK swap. Customers who ran that inline V1 have the new schema and their existing - * rows received sequential integer-as-string ids. Customers who never ran it (e.g. they - * upgraded straight from pre-V1 code to this new Umzug-based code) still have the old - * `name`-as-PK table; their shop queries by `id` would silently fail. - * - * SQLite has no `ALTER TABLE ... DROP PRIMARY KEY`, so this migration uses the canonical - * SQLite table-rebuild pattern: create a new table with the right schema, copy the rows - * across, drop the old, rename the new. For data that came from the pre-V1 `name`-as-PK - * schema, we use `name` itself as the new `id` value β€” that's the stablest mapping - * (it's already unique, and operator-facing identifiers tend to reference items by - * name in the bot's config). - * - * Idempotent: if `id` already exists in the table description (post-V1, fresh install, - * or already-migrated), the body is a no-op. + * Moves the primary key from `name` to `id`. SQLite has no `ALTER TABLE ... DROP PRIMARY KEY`, so + * this rebuilds the table; pre-V1 rows reuse `name` as their `id`. No-ops when `id` already exists. */ module.exports = { tables: [TABLE], @@ -50,11 +33,6 @@ module.exports = { }); }, down: async () => { - - /* - * No-op: reverting from `id`-PK back to `name`-PK is not a meaningful rollback - * once the runtime code expects `id`. Operators should restore from a backup - * (`migration-backups/__economy_Shop__V1__economy_shop.json`) instead. - */ + // No-op: restore from migration-backups/ instead. } }; \ No newline at end of file diff --git a/modules/economy-system/models/liveMessage.js b/modules/economy-system/models/liveMessage.js index 44d4ba1a..d8a6ed5e 100644 --- a/modules/economy-system/models/liveMessage.js +++ b/modules/economy-system/models/liveMessage.js @@ -3,7 +3,12 @@ const { Model } = require('sequelize'); -module.exports = class LiveMessage extends Model { +/* + * Named EconomyLiveMessage (not LiveMessage) because sequelize registers models globally by class + * name: a second class with the same name would replace this one and db.sync() would never create + * this table. + */ +module.exports = class EconomyLiveMessage extends Model { static init(sequelize) { return super.init({ type: { diff --git a/modules/fun/commands/hug-user.js b/modules/fun/commands/hug-user.js index aa18cbdd..1297eac0 100644 --- a/modules/fun/commands/hug-user.js +++ b/modules/fun/commands/hug-user.js @@ -9,12 +9,6 @@ module.exports.config = { description: localize('fun', 'hug-context-description') }; -/* - * Thin adapter: the /hug run() reads its recipient from interaction.options.getUser('user'). - * We delegate to that exact run() with a proxy whose options.getUser returns the context-menu - * targetUser, so the rendered hug (message + gif) is identical to the slash command, including - * the self-target guard the original enforces. - */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/fun/commands/kiss-user.js b/modules/fun/commands/kiss-user.js index fc6d6b97..4a66a828 100644 --- a/modules/fun/commands/kiss-user.js +++ b/modules/fun/commands/kiss-user.js @@ -9,12 +9,6 @@ module.exports.config = { description: localize('fun', 'kiss-context-description') }; -/* - * Thin adapter: the /kiss run() reads its recipient from interaction.options.getUser('user'). - * We delegate to that exact run() with a proxy whose options.getUser returns the context-menu - * targetUser, so the rendered kiss (message + gif) is identical to the slash command, including - * the self-target guard the original enforces. - */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/fun/commands/pat-user.js b/modules/fun/commands/pat-user.js index 9d145a1e..318206b9 100644 --- a/modules/fun/commands/pat-user.js +++ b/modules/fun/commands/pat-user.js @@ -9,12 +9,6 @@ module.exports.config = { description: localize('fun', 'pat-context-description') }; -/* - * Thin adapter: the /pat run() reads its recipient from interaction.options.getUser('user'). - * We delegate to that exact run() with a proxy whose options.getUser returns the context-menu - * targetUser, so the rendered pat (message + gif) is identical to the slash command, including - * the self-target guard the original enforces. - */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/fun/commands/slap-user.js b/modules/fun/commands/slap-user.js index 74c36427..6a7e6fda 100644 --- a/modules/fun/commands/slap-user.js +++ b/modules/fun/commands/slap-user.js @@ -9,12 +9,6 @@ module.exports.config = { description: localize('fun', 'slap-context-description') }; -/* - * Thin adapter: the /slap run() reads its recipient from interaction.options.getUser('user'). - * We delegate to that exact run() with a proxy whose options.getUser returns the context-menu - * targetUser, so the rendered slap (message + gif) is identical to the slash command, including - * the self-target guard the original enforces. - */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/info-commands/commands/info.js b/modules/info-commands/commands/info.js index 31ab25dd..988a5bc1 100644 --- a/modules/info-commands/commands/info.js +++ b/modules/info-commands/commands/info.js @@ -7,7 +7,9 @@ const { formatNumber, parseEmbedColor, safeSetFooter, - moduleEnabled + moduleEnabled, + memberCountOrFallback, + onlineCountOrNull } = require('../../../src/functions/helpers'); const {ChannelType, MessageEmbed} = require('discord.js'); const {AgeFromDate} = require('age-calculator'); @@ -63,7 +65,14 @@ module.exports.subcommands = { embed.addField(moduleStrings.serverinfo.banCount, bans.size.toString(), true); embed.addField(moduleStrings.serverinfo.createdAt, ``, true); const members = interaction.guild.members.cache; - embed.addField(moduleStrings.serverinfo.members, `\`\`\`| ${localize('info-commands', 'userCount')} | ${localize('info-commands', 'memberCount')} | Online |\n| ${pufferStringToSize(members.size, localize('info-commands', 'userCount').length)} | ${pufferStringToSize(members.filter(m => !m.user.bot).size, localize('info-commands', 'memberCount').length)} | ${pufferStringToSize(members.filter(m => m.presence && (m.presence || {}).status !== 'offline').size, localize('info-commands', 'onlineCount').length)} |\`\`\``); + const onlineMemberCount = onlineCountOrNull(interaction.client, interaction.guild); + const onlineDisplay = onlineMemberCount === null ? 'N/A' : onlineMemberCount; + + // members.cache is near-empty without GuildMembers, so the human-count column would silently + // under-report rather than showing the same placeholder as the columns beside it. + const membersIntentActive = (interaction.client._activeIntents || []).includes('GuildMembers'); + const humanCountDisplay = membersIntentActive ? members.filter(m => !m.user.bot).size : 'N/A'; + embed.addField(moduleStrings.serverinfo.members, `\`\`\`| ${localize('info-commands', 'userCount')} | ${localize('info-commands', 'memberCount')} | Online |\n| ${pufferStringToSize(memberCountOrFallback(interaction.guild), localize('info-commands', 'userCount').length)} | ${pufferStringToSize(humanCountDisplay, localize('info-commands', 'memberCount').length)} | ${pufferStringToSize(onlineDisplay, localize('info-commands', 'onlineCount').length)} |\`\`\``); embed.addField(moduleStrings.serverinfo.channels, `\`\`\`| ${localize('info-commands', 'textChannel')} | ${localize('info-commands', 'voiceChannel')} | ${localize('info-commands', 'categoryChannel')} | ${localize('info-commands', 'otherChannel')} |\n| ${pufferStringToSize(interaction.guild.channels.cache.filter(c => c.type === ChannelType.GuildText).size.toString(), localize('info-commands', 'textChannel').length)} | ${pufferStringToSize(interaction.guild.channels.cache.filter(c => c.type === ChannelType.GuildVoice).size.toString(), localize('info-commands', 'voiceChannel').length)} | ${pufferStringToSize(interaction.guild.channels.cache.filter(c => c.type === ChannelType.GuildCategory).size.toString(), localize('info-commands', 'categoryChannel').length)} | ${pufferStringToSize(interaction.guild.channels.cache.filter(c => c.type !== ChannelType.GuildVoice && c.type !== ChannelType.GuildText && c.type !== ChannelType.GuildCategory).size.toString(), localize('info-commands', 'otherChannel').length)} |\`\`\``); let featuresstring = ''; interaction.guild.features.forEach(f => { @@ -123,9 +132,11 @@ module.exports.subcommands = { safeSetFooter(embed, interaction.client); if (!interaction.client.strings.disableFooterTimestamp) embed.setTimestamp(); if (role.color) embed.addField(moduleStrings.roleInfo.color, role.hexColor, true); + // role.members is derived from the member cache, near-empty without GuildMembers. + const membersIntentActive = (interaction.client._activeIntents || []).includes('GuildMembers'); if (role.members) { - embed.addField(moduleStrings.roleInfo.memberWithThisRoleCount, role.members.size.toString(), true); - if (role.members.size <= 10 && role.members.size !== 0) { + embed.addField(moduleStrings.roleInfo.memberWithThisRoleCount, membersIntentActive ? role.members.size.toString() : 'N/A', true); + if (membersIntentActive && role.members.size <= 10 && role.members.size !== 0) { let memberstring = ''; role.members.forEach(m => { memberstring = memberstring + `<@${m.id}>, `; @@ -204,7 +215,7 @@ async function sendUserInfo(interaction, member) { let dateString = `${birthday.day}.${birthday.month}${birthday.year ? `.${birthday.year}` : ''}`; if (birthday.year) { const age = new AgeFromDate(new Date(birthday.year, birthday.month - 1, birthday.day)).age; - dateString = `[${dateString}](https://scnx.xyz/${interaction.client.locale === 'de' ? 'de/' : ''}custom-bot/age-calculator?age=${age} "${localize('birthdays', 'age-hover', {a: age})}")`; + dateString = `[${dateString}](https://scnx.xyz/${interaction.client.locale === 'de' ? 'de/' : ''}custom-bot/age-calculator?age=${age} "${localize('info-commands', 'age-hover', {a: age})}")`; } embed.addField(moduleStrings.userinfo.birthday, dateString, true); } diff --git a/modules/info-commands/commands/user-info.js b/modules/info-commands/commands/user-info.js index 8ec848a3..3b4d901e 100644 --- a/modules/info-commands/commands/user-info.js +++ b/modules/info-commands/commands/user-info.js @@ -8,11 +8,6 @@ module.exports.config = { description: localize('info-commands', 'user-info-context-description') }; -/* - * Thin adapter: defer (sendUserInfo replies via editReply, normally deferred by beforeSubcommand) - * and hand the target member off to the shared sendUserInfo core so the output is identical to - * /info user. - */ module.exports.run = async function (interaction) { await interaction.deferReply({ephemeral: true}); let member = interaction.targetMember; diff --git a/modules/info-commands/module.json b/modules/info-commands/module.json index 37af134c..c5f15031 100644 --- a/modules/info-commands/module.json +++ b/modules/info-commands/module.json @@ -21,6 +21,10 @@ "GuildMembers", "GuildVoiceStates" ], + "optionalIntents": [ + "GuildMembers", + "GuildPresences" + ], "intentReasons": { "GuildPresences": "Reads members' online status to show online counts and a member's status in info commands.", "GuildMembers": "Reads the member list to show accurate member and online counts in server and user info commands." diff --git a/modules/levels/commands/set-user-level.js b/modules/levels/commands/set-user-level.js index 854b4921..d8f77781 100644 --- a/modules/levels/commands/set-user-level.js +++ b/modules/levels/commands/set-user-level.js @@ -14,10 +14,6 @@ module.exports.config = { description: localize('levels', 'set-level-context-description') }; -/* - * "Set User Level" admin context command: enforces the allowCheats gate (like /manage-levels edit-level set), - * then opens the level modal (customId encodes the target) handled in events/interactionCreate.js. showModal first. - */ module.exports.run = async function (interaction) { if (!interaction.client.configurations['levels']['config']['allowCheats']) return interaction.reply({ ephemeral: true, diff --git a/modules/levels/commands/set-user-xp.js b/modules/levels/commands/set-user-xp.js index 7229fe8f..5817ba6e 100644 --- a/modules/levels/commands/set-user-xp.js +++ b/modules/levels/commands/set-user-xp.js @@ -14,10 +14,6 @@ module.exports.config = { description: localize('levels', 'set-xp-context-description') }; -/* - * "Set User XP" admin context command: enforces the allowCheats gate (like /manage-levels edit-xp set), - * then opens the XP modal (customId encodes the target) handled in events/interactionCreate.js. showModal first. - */ module.exports.run = async function (interaction) { if (!interaction.client.configurations['levels']['config']['allowCheats']) return interaction.reply({ ephemeral: true, diff --git a/modules/levels/module.json b/modules/levels/module.json index 65883bfc..7a5eb29c 100644 --- a/modules/levels/module.json +++ b/modules/levels/module.json @@ -27,6 +27,10 @@ "MessageContent", "GuildMembers" ], + "optionalIntents": [ + "GuildMembers", + "MessageContent" + ], "intentReasons": { "MessageContent": "Reads message text to award activity XP while excluding prefix commands.", "GuildMembers": "Resets levels when a member leaves and resolves names for the leaderboard." diff --git a/modules/massrole/commands/add-role-to-user.js b/modules/massrole/commands/add-role-to-user.js index 23b18f03..7b1dd32b 100644 --- a/modules/massrole/commands/add-role-to-user.js +++ b/modules/massrole/commands/add-role-to-user.js @@ -12,12 +12,6 @@ module.exports.config = { description: localize('massrole', 'add-role-to-user-context-description') }; -/* - * Thin adapter for the massrole add logic, applied to a SINGLE target member. Discord modals - * cannot contain select menus, so we reply ephemerally with a role select whose customId encodes - * the action + target user id (massrole-ctx:add:). The select is handled in - * events/interactionCreate.js, which calls the shared applyRoleToMember core. - */ module.exports.run = async function (interaction) { if (interaction.member.roles.cache.filter(m => interaction.client.configurations['massrole']['config'].adminRoles.includes(m.id)).size === 0) { return interaction.reply({ diff --git a/modules/massrole/commands/remove-role-from-user.js b/modules/massrole/commands/remove-role-from-user.js index a335d133..1702a7b3 100644 --- a/modules/massrole/commands/remove-role-from-user.js +++ b/modules/massrole/commands/remove-role-from-user.js @@ -12,12 +12,6 @@ module.exports.config = { description: localize('massrole', 'remove-role-from-user-context-description') }; -/* - * Thin adapter for the massrole remove logic, applied to a SINGLE target member. Discord modals - * cannot contain select menus, so we reply ephemerally with a role select whose customId encodes - * the action + target user id (massrole-ctx:remove:). The select is handled in - * events/interactionCreate.js, which calls the shared applyRoleToMember core. - */ module.exports.run = async function (interaction) { if (interaction.member.roles.cache.filter(m => interaction.client.configurations['massrole']['config'].adminRoles.includes(m.id)).size === 0) { return interaction.reply({ diff --git a/modules/message-quotes/commands/quote-message.js b/modules/message-quotes/commands/quote-message.js index 63f4a589..9cb7295f 100644 --- a/modules/message-quotes/commands/quote-message.js +++ b/modules/message-quotes/commands/quote-message.js @@ -9,13 +9,6 @@ module.exports.config = { description: localize('message-quotes', 'quote-message-description') }; -/* - * Builds the quote with the exact same renderer the auto-quote event uses (buildQuoteMessage, - * shared in renderQuote.js) and posts it into the current channel. The link is reconstructed - * from the target message's guild/channel/id. The quoter is the command user, so the - * selfQuote=false config still suppresses quoting your own message. Replies ephemerally when - * the quote is suppressed by config (noBots / selfQuote). - */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/ping-on-vc-join/events/voiceStateUpdate.js b/modules/ping-on-vc-join/events/voiceStateUpdate.js index 8eb529ce..cd685971 100644 --- a/modules/ping-on-vc-join/events/voiceStateUpdate.js +++ b/modules/ping-on-vc-join/events/voiceStateUpdate.js @@ -8,7 +8,6 @@ exports.run = async (client, oldState, newState) => { if (!client.botReadyAt) return; const roleConfig = client.configurations['ping-on-vc-join']['actual-config']; - // Ignore bots for role assignment if (roleConfig.assignRoleToUsersInVoiceChannels && roleConfig.voiceRoles.length !== 0 && !newState.member.user.bot) { if (oldState.channel && !newState.channel) newState.member.roles.remove(roleConfig.voiceRoles); if (!oldState.channel && newState.channel) newState.member.roles.add(roleConfig.voiceRoles); @@ -25,21 +24,17 @@ exports.run = async (client, oldState, newState) => { const member = await client.guild.members.fetch(newState.id); if (member.user.bot) return; - // Check cooldown based on configuration const cooldownEnabled = configElement['cooldownEnabled'] || false; if (cooldownEnabled) { - // Per-channel cooldown const cooldownKey = `${channel.id}`; const now = Date.now(); const cooldownEnd = channelCooldown.get(cooldownKey); if (cooldownEnd && now < cooldownEnd) { - // Still in cooldown, don't send message return; } } else { - // Legacy per-user cooldown if (userCooldown.has(member.user.id)) return; } @@ -56,16 +51,13 @@ exports.run = async (client, oldState, newState) => { '%mention%': `<@${member.user.id}>` })); - // Set cooldown after sending message if (cooldownEnabled) { - // Per-channel cooldown const cooldownMinutes = configElement['cooldownMinutes'] || 5; const cooldownMs = cooldownMinutes * 60 * 1000; const cooldownKey = `${channel.id}`; channelCooldown.set(cooldownKey, Date.now() + cooldownMs); - // Clean up expired cooldowns periodically setTimeout(() => { const now = Date.now(); if (channelCooldown.get(cooldownKey) <= now) { @@ -73,7 +65,6 @@ exports.run = async (client, oldState, newState) => { } }, cooldownMs); } else { - // Legacy per-user cooldown userCooldown.add(member.user.id); setTimeout(() => { userCooldown.delete(member.user.id); diff --git a/modules/ping-protection/commands/view-moderation-history.js b/modules/ping-protection/commands/view-moderation-history.js index 468f80c8..20954816 100644 --- a/modules/ping-protection/commands/view-moderation-history.js +++ b/modules/ping-protection/commands/view-moderation-history.js @@ -10,11 +10,6 @@ module.exports.config = { description: localize('ping-protection', 'view-moderation-history-description') }; -/* - * Thin adapter: build the same payload the /ping-protection user actions-history slash - * subcommand produces by reusing generateActionsResponse, then reply ephemerally with it so - * the output (embed + pagination buttons) is identical for the targeted user. - */ module.exports.run = async function (interaction) { const payload = await generateActionsResponse(interaction.client, interaction.targetUser.id, 1); return interaction.reply({ diff --git a/modules/ping-protection/commands/view-ping-history.js b/modules/ping-protection/commands/view-ping-history.js index 6d6b76fa..db1f226b 100644 --- a/modules/ping-protection/commands/view-ping-history.js +++ b/modules/ping-protection/commands/view-ping-history.js @@ -10,11 +10,6 @@ module.exports.config = { description: localize('ping-protection', 'view-ping-history-description') }; -/* - * Thin adapter: build the same payload the /ping-protection user history slash subcommand - * produces by reusing generateHistoryResponse, then reply ephemerally with it so the output - * (embed + pagination buttons) is identical for the targeted user. - */ module.exports.run = async function (interaction) { const payload = await generateHistoryResponse(interaction.client, interaction.targetUser.id, 1); return interaction.reply({ diff --git a/modules/ping-protection/module.json b/modules/ping-protection/module.json index 028391be..7b485630 100644 --- a/modules/ping-protection/module.json +++ b/modules/ping-protection/module.json @@ -26,6 +26,10 @@ "MessageContent", "AutoModerationExecution" ], + "optionalIntents": [ + "GuildMembers", + "MessageContent" + ], "intentReasons": { "GuildMembers": "Enumerates the holders of protected roles to detect unwanted mentions.", "MessageContent": "Scans message content and mentions to catch pings of protected roles and users." diff --git a/modules/ping-protection/ping-protection.js b/modules/ping-protection/ping-protection.js index 01a5e120..1ea0a1d2 100644 --- a/modules/ping-protection/ping-protection.js +++ b/modules/ping-protection/ping-protection.js @@ -625,11 +625,19 @@ async function syncNativeAutoMod(client) { const protectedIdsSet = new Set(config.protectedUsers || []); if (config.protectAllUsersWithProtectedRole && config.protectedRoles && config.protectedRoles.length > 0) { - guild.members.cache.forEach(member => { - if (member.roles.cache.some(r => config.protectedRoles.includes(r.id))) { - protectedIdsSet.add(member.id); - } - }); + + // Without GuildMembers the member cache is near-empty, so enumerating it would seed the + // native AutoMod rule with an incomplete protected-user list. Skip and warn instead; the + // real-time mention-based detection does not use the member cache and is unaffected. + if ((guild.client._activeIntents || []).includes('GuildMembers')) { + guild.members.cache.forEach(member => { + if (member.roles.cache.some(r => config.protectedRoles.includes(r.id))) { + protectedIdsSet.add(member.id); + } + }); + } else { + client.logger.warn(localize('ping-protection', 'log-automod-role-protection-skipped')); + } } protectedIdsSet.forEach(id => { @@ -650,7 +658,6 @@ async function syncNativeAutoMod(client) { keywords.splice(1000); } - // AutoMod rule data const actions = []; const blockMetadata = {}; if (config.autoModBlockMessage) { @@ -699,7 +706,6 @@ async function syncNativeAutoMod(client) { } } -// Makes the history embed async function generateHistoryResponse(client, userId, page = 1) { const storageConfig = client.configurations['ping-protection']['storage']; const limit = 5; @@ -795,7 +801,6 @@ async function generateHistoryResponse(client, userId, page = 1) { }; } -// Makes the moderation actions history embed async function generateActionsResponse(client, userId, page = 1) { const moderationConfig = client.configurations['ping-protection']['moderation']; const limit = 5; @@ -866,7 +871,6 @@ async function generateActionsResponse(client, userId, page = 1) { }; } -// Handles data deletion async function deleteAllUserData(client, userId) { await executeDataDeletion(client, userId, 'del_all'); client.logger.info(localize('ping-protection', 'log-data-deletion', { @@ -887,7 +891,6 @@ async function markUserAsRejoined(client, userId) { }); } -// Enforces data retention async function enforceRetention(client) { const storageConfig = client.configurations['ping-protection']['storage']; if (!storageConfig) return; @@ -942,11 +945,9 @@ async function enforceRetention(client) { } } -// Executes moderation action async function executeAction(client, member, rule, reason, storageConfig, originChannel = null, stats = {}) { const actionType = rule.actionType; - // Sends action log if enabled const sendActionLog = async () => { if (!rule.enableActionLogging || !originChannel) return; @@ -974,7 +975,6 @@ async function executeAction(client, member, rule, reason, storageConfig, origin } }; - // Sends error message if action fails const sendErrorLog = async (error) => { if (!originChannel) return; @@ -1074,7 +1074,6 @@ async function executeAction(client, member, rule, reason, storageConfig, origin return false; } -// Processes a ping event async function processPing(client, userId, targetId, isRole, messageUrl, originChannel, memberToPunish) { const config = client.configurations['ping-protection']['configuration']; const storageConfig = client.configurations['ping-protection']['storage']; diff --git a/modules/quiz/module.json b/modules/quiz/module.json index f22cbef6..03471f79 100644 --- a/modules/quiz/module.json +++ b/modules/quiz/module.json @@ -23,6 +23,9 @@ "intents": [ "GuildMembers" ], + "optionalIntents": [ + "GuildMembers" + ], "intentReasons": { "GuildMembers": "Resolves member names for quiz participants and the scoreboard." } diff --git a/modules/reminders/commands/create-reminder.js b/modules/reminders/commands/create-reminder.js index 0fea06cf..52ac67e4 100644 --- a/modules/reminders/commands/create-reminder.js +++ b/modules/reminders/commands/create-reminder.js @@ -14,13 +14,6 @@ module.exports.config = { description: localize('reminders', 'context-create-description') }; -/* - * Open a modal collecting WHEN (a duration like "10m"/"2h"). The modal's customId encodes the - * targeted message as create-reminder:: so the modal-submit handler in - * events/interactionCreate.js can reconstruct the message, build a reminder whose content is - * the message jump link and run the existing planReminder() flow. showModal must be the first - * response, so we must NOT deferReply before it. - */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/staff-management-system/commands/duty.js b/modules/staff-management-system/commands/duty.js index d2897d3f..0736f8bf 100644 --- a/modules/staff-management-system/commands/duty.js +++ b/modules/staff-management-system/commands/duty.js @@ -644,7 +644,6 @@ async function buildDutyAdminPayload(client, targetMember, requestingMember) { }; } -// ----- Button handlers ----- async function handleDutyStartButton(client, interaction) { const parts = interaction.customId.split('_'); const userId = parts[2]; @@ -896,7 +895,6 @@ async function handleDutyLbPageButton(client, interaction) { return interaction.editReply(payload); } -// ----- Admin handler ----- async function handleDutyAdminForceEnd(client, interaction) { const permCheck = checkDutyAdminPermission(client, interaction); if (permCheck) return permCheck; @@ -1205,7 +1203,6 @@ async function handleDutyAdminAddTimeSubmit(client, interaction) { }); } -// ----- Dropdown handler ----- async function handleDutyDropdown(client, interaction, action, selectedType) { if (action === 'manage') { const payload = await buildDutyManagePayload(client, interaction.user.id, selectedType); @@ -1541,7 +1538,6 @@ module.exports.config = { ] }; -// Export handlers module.exports.buttonHandlers = { handleDutyStartButton, handleDutyAdminAddTimeButton, @@ -1557,7 +1553,6 @@ module.exports.buttonHandlers = { handleDutyAdminAddTimeSubmit }; -// Exported for unit testing of the pure duty helpers. module.exports._test = { getLookbackDate, canUseDutyAdmin, diff --git a/modules/staff-management-system/commands/issue-infraction.js b/modules/staff-management-system/commands/issue-infraction.js index bd6c1492..39991cc0 100644 --- a/modules/staff-management-system/commands/issue-infraction.js +++ b/modules/staff-management-system/commands/issue-infraction.js @@ -13,14 +13,6 @@ module.exports.config = { description: localize('staff-management-system', 'issue-infraction-context-description') }; -/* - * Thin adapter for the /staff-management infraction issue subcommand. MANAGE_GUILD is only a - * coarse Discord gate; the real gate is the module's runtime SUPERVISOR check, enforced here - * before a modal is shown (and again inside issueInfraction). The modal collects the same fields - * the slash flow does (type / reason / optional expiry); its customId encodes the target user id - * so the submit handler in events/interactionCreate.js can run the shared issueInfraction core. - * showModal must be the first response, so we must NOT defer. - */ module.exports.run = async function (interaction) { if (!isSupervisor(interaction.client, interaction.member)) return interaction.reply({ content: localize('staff-management-system', 'err-gen-no-perm'), diff --git a/modules/staff-management-system/commands/promote-user.js b/modules/staff-management-system/commands/promote-user.js index ce8d34ec..b69b268a 100644 --- a/modules/staff-management-system/commands/promote-user.js +++ b/modules/staff-management-system/commands/promote-user.js @@ -13,13 +13,6 @@ module.exports.config = { description: localize('staff-management-system', 'promote-user-context-description') }; -/* - * Thin adapter for the /staff-management promote subcommand. MANAGE_GUILD is only a coarse Discord - * gate; the real gate is the module's runtime SUPERVISOR check, enforced here before the select is - * shown (and again inside promoteUser). The slash flow picks the new rank as a ROLE option, so we - * reply ephemerally with a role select whose customId encodes the target user id; the select - * submit in events/interactionCreate.js runs the shared promoteUser core with the chosen role. - */ module.exports.run = async function (interaction) { if (!isSupervisor(interaction.client, interaction.member)) return interaction.reply({ content: localize('staff-management-system', 'err-gen-no-perm'), diff --git a/modules/staff-management-system/commands/staff-status.js b/modules/staff-management-system/commands/staff-status.js index 9eb2b69c..5deeaa81 100644 --- a/modules/staff-management-system/commands/staff-status.js +++ b/modules/staff-management-system/commands/staff-status.js @@ -21,7 +21,6 @@ const { checkStaffPermissions } = require('../staff-management'); -// ---------- Status DM's and logging ---------- async function sendStatusDm(user, type, dmType, data = {}) { const label = type === 'LOA' ? 'LoA' @@ -33,7 +32,6 @@ async function sendStatusDm(user, type, dmType, data = {}) { ? `` : ''; - // These messages use the locales key to be easily used later const messages = { approved: { title: 'dm-appr-title', @@ -171,7 +169,6 @@ async function logStatusChange(client, type, action, data) { } } -// ----- Status ----- const getStatusMeta = (type) => ({ isLoa: type === 'LOA', label: type === 'LOA' diff --git a/modules/staff-management-system/commands/submit-review.js b/modules/staff-management-system/commands/submit-review.js index 73a81765..380ff8d4 100644 --- a/modules/staff-management-system/commands/submit-review.js +++ b/modules/staff-management-system/commands/submit-review.js @@ -8,13 +8,6 @@ module.exports.config = { description: localize('staff-management-system', 'submit-review-context-description') }; -/* - * Thin adapter for the /staff-management review submit subcommand. Open to everyone; the - * onlyAllowStaffReview restriction (target must be staff) is enforced at runtime inside the shared - * submitReview core, exactly as for the slash flow. The modal collects the same fields the slash - * flow does (stars 1-5 / comment); its customId encodes the target user id so the submit handler - * in events/interactionCreate.js runs submitReview. showModal must be first, so we must NOT defer. - */ module.exports.run = async function (interaction) { return interaction.showModal(buildReviewModal(interaction.targetUser.id)); }; diff --git a/modules/staff-management-system/commands/view-staff-profile.js b/modules/staff-management-system/commands/view-staff-profile.js index 186e822a..16b19a0b 100644 --- a/modules/staff-management-system/commands/view-staff-profile.js +++ b/modules/staff-management-system/commands/view-staff-profile.js @@ -9,11 +9,6 @@ module.exports.config = { description: localize('staff-management-system', 'view-staff-profile-description') }; -/* - * Thin adapter: defer ephemerally (handleProfileView responds via editReply) and hand off to - * the shared handleProfileView core, exactly like the /staff-management profile view subcommand, - * so the rendered staff profile is identical for the targeted user. - */ module.exports.run = async function (interaction) { await interaction.deferReply({ flags: MessageFlags.Ephemeral diff --git a/modules/staff-management-system/context-actions.js b/modules/staff-management-system/context-actions.js index 3527f39c..ba468ec9 100644 --- a/modules/staff-management-system/context-actions.js +++ b/modules/staff-management-system/context-actions.js @@ -17,13 +17,9 @@ const { } = require('./staff-management'); /* - * Shared core for the staff-management USER context-menu commands. Each command is a thin adapter - * that shows a modal/select; the submitted data is then routed back here and handed to the same - * issueInfraction / promoteUser / submitReview cores the slash subcommands call, so the output is - * identical. The cores call interaction.deferReply() and editReply() themselves, so the run() - * adapters must NOT defer before showing the modal/select, and the submit handlers must NOT defer - * before invoking the core. Supervisor gating for infract/promote is enforced both up-front here - * (so unauthorized users never see a modal) and again inside the cores. + * Shared core for the staff-management USER context-menu commands, routing submitted modal/select + * data into the same cores the slash subcommands call. Those cores defer and editReply themselves, + * so neither the run() adapters nor the submit handlers may defer first. */ const SUPERVISOR = 'supervisor'; @@ -33,12 +29,7 @@ function isSupervisor(client, member) { return checkStaffPermissions(member, getConfig(client, 'configuration'), SUPERVISOR); } -/* - * Discord modal interactions have no interaction.options; promoteUser reads the optional - * announcement-channel override via interaction.options.getChannel('channel'). The context flow - * has no such option, so we wrap the submit interaction with an options shim returning null, - * leaving every other property delegated to the real interaction. - */ +// Modal interactions have no interaction.options, but promoteUser reads getChannel('channel'). function withOptionsShim(interaction) { return new Proxy(interaction, { get(target, prop) { @@ -49,7 +40,6 @@ function withOptionsShim(interaction) { }); } -// ----- Issue Infraction (modal) ----- function buildInfractionModal(client, userId) { const types = getConfig(client, 'infractions')?.infractionTypes || []; const selectable = types.filter(infractionType => infractionType.toLowerCase() !== 'suspension'); @@ -112,7 +102,6 @@ async function handleInfractionModal(client, interaction, userId) { return issueInfraction(client, interaction, targetMember, type, reason, expiry || null); } -// ----- Promote User (role select) ----- function buildPromoteSelect(userId) { return { flags: MessageFlags.Ephemeral, @@ -147,7 +136,6 @@ async function handlePromoteSelect(client, interaction, userId) { return promoteUser(client, withOptionsShim(interaction), targetMember, role, null); } -// ----- Submit Review (modal) ----- function buildReviewModal(userId) { const modal = new ModalBuilder() .setCustomId(`staff-mgmt_ctx-review_${userId}`) diff --git a/modules/staff-management-system/events/interactionCreate.js b/modules/staff-management-system/events/interactionCreate.js index 4febea1c..7f076769 100644 --- a/modules/staff-management-system/events/interactionCreate.js +++ b/modules/staff-management-system/events/interactionCreate.js @@ -39,7 +39,6 @@ module.exports.run = async (client, interaction) => { const parts = interaction.customId.split('_'); const action = parts[1]; - // ----- USER context-menu submits (Issue Infraction / Promote User / Submit Review) ----- if (action === 'ctx-infract' && interaction.isModalSubmit()) { return require('../context-actions').handleInfractionModal(client, interaction, parts[2]); } @@ -50,7 +49,6 @@ module.exports.run = async (client, interaction) => { return require('../context-actions').handleReviewModal(client, interaction, parts[2]); } - // ----- Duty manage handlers ----- if (interaction.customId.startsWith('duty-mgmt_')) { const dutyAction = parts[1]; @@ -77,7 +75,6 @@ module.exports.run = async (client, interaction) => { return; } - // ----- Review history pagination ----- if (action === 'rev-page') { await interaction.deferUpdate(); const targetUser = await client.users.fetch(parts[2]).catch(() => null); @@ -91,7 +88,6 @@ module.exports.run = async (client, interaction) => { return interaction.editReply(payload); } - // ----- LOA/RA handlers ----- const loaActions = ['loa-end', 'loa-end-submit', 'loa-extend', 'loa-extend-submit', 'loa-hist']; const raActions = ['ra-end', 'ra-end-submit', 'ra-extend', 'ra-extend-submit', 'ra-hist']; @@ -106,7 +102,6 @@ module.exports.run = async (client, interaction) => { if (base === 'hist') return handleStatusHistPage(client, interaction, type); } - // ----- Promotion history pagination ----- if (action === 'prom-hist') { await interaction.deferUpdate(); const targetUser = await client.users.fetch(parts[2]).catch(() => null); @@ -120,7 +115,6 @@ module.exports.run = async (client, interaction) => { return interaction.editReply(payload); } - // ----- Infraction history pagination ----- if (action === 'inf-hist') { await interaction.deferUpdate(); const targetUser = await client.users.fetch(parts[2]).catch(() => null); @@ -134,7 +128,6 @@ module.exports.run = async (client, interaction) => { return interaction.editReply(payload); } - // ----- User panel dropdown ----- if (interaction.customId.startsWith('staff-mgmt_panel-menu_')) { const targetId = interaction.customId.split('_')[2]; await interaction.deferUpdate(); @@ -158,7 +151,6 @@ module.exports.run = async (client, interaction) => { return interaction.editReply(payload); } - // ----- User panel deletion dropdown ----- if (interaction.customId.startsWith('staff-mgmt_delete-menu_')) { const targetId = interaction.customId.split('_')[2]; const selection = interaction.values[0]; @@ -200,7 +192,6 @@ module.exports.run = async (client, interaction) => { return interaction.showModal(modal); } - // ----- Data deletion modal submission ----- if (interaction.isModalSubmit() && interaction.customId.startsWith('staff-mgmt_del-confirm_')) { await interaction.deferReply({flags: MessageFlags.Ephemeral}); const configuration = getConfig(client, 'configuration'); @@ -326,7 +317,6 @@ module.exports.run = async (client, interaction) => { }); } - // ----- User panel buttons ----- if (interaction.customId.startsWith('staff-mgmt_panel-')) { const parts = interaction.customId.split('_'); const targetId = parts[2]; @@ -353,7 +343,6 @@ module.exports.run = async (client, interaction) => { } } - // ----- Status buttons ----- const LoARequest = client.models['staff-management-system']['LoaRequest']; const StaffProfile = client.models['staff-management-system']['StaffProfile']; const config = client.configurations['staff-management-system']['configuration']; @@ -444,7 +433,6 @@ module.exports.run = async (client, interaction) => { } } - // ----- Deny modal submission ----- if (interaction.isModalSubmit() && action === 'loa-deny') { const configuration = getConfig(client, 'configuration'); @@ -513,7 +501,6 @@ module.exports.run = async (client, interaction) => { }); } - // ----- Profile edit submission ----- if (interaction.isModalSubmit() && action === 'profile-edit') { const nickname = interaction.fields.getTextInputValue('nickname'); const intro = interaction.fields.getTextInputValue('intro'); @@ -530,7 +517,6 @@ module.exports.run = async (client, interaction) => { }); } - // ----- Activity checks button ----- if (action === 'ac-respond') { const ActivityCheck = client.models['staff-management-system']['ActivityCheck']; const ActivityCheckResponse = client.models['staff-management-system']['ActivityCheckResponse']; diff --git a/modules/staff-management-system/module.json b/modules/staff-management-system/module.json index 65330dc9..773125bd 100644 --- a/modules/staff-management-system/module.json +++ b/modules/staff-management-system/module.json @@ -29,6 +29,9 @@ "GuildMembers", "GuildPresences" ], + "optionalIntents": [ + "GuildPresences" + ], "intentReasons": { "GuildMembers": "Restores suspended-staff roles on startup and tracks staff membership.", "GuildPresences": "Displays each staff member's current online status in their profile." diff --git a/modules/staff-management-system/staff-management.js b/modules/staff-management-system/staff-management.js index 18483c53..4e6f159b 100644 --- a/modules/staff-management-system/staff-management.js +++ b/modules/staff-management-system/staff-management.js @@ -9,7 +9,6 @@ const schedule = require('node-schedule'); const {embedTypeV2, safeSetFooter, dateToDiscordTimestamp} = require('../../src/functions/helpers'); const { localize } = require('../../src/functions/localize'); -// --- Local helpers --- const getConfig = (client, file) => client.configurations['staff-management-system'][file]; const getSafeChannelId = (val) => Array.isArray(val) && val.length > 0 // Helper to get safe channel ID from config ? val[0] @@ -111,7 +110,6 @@ function formatDuration(seconds) { return parts.join(', ') || localize('staff-management-system', 'time-zero'); } -// ---------- Infractions ---------- async function issueInfraction(client, interaction, targetMember, type, reason, expiryInput) { await interaction.deferReply({ephemeral: true}); const config = getConfig(client, 'infractions'); @@ -223,7 +221,6 @@ async function issueInfraction(client, interaction, targetMember, type, reason, }); } -// ---------- Suspensions ---------- async function issueSuspension(client, interaction, targetMember, durationInput, reason) { await interaction.deferReply({ephemeral: true}); const config = getConfig(client, 'infractions'); @@ -385,7 +382,6 @@ async function resolveInfractionReference(client, reference) { } } -// ----- Infractions voiding ----- async function voidInfraction(client, interaction, reference) { await interaction.deferReply({ephemeral: true}); const config = getConfig(client, 'infractions'); @@ -438,7 +434,6 @@ async function voidInfraction(client, interaction, reference) { }); } -// ----- Generates infractions history embed ----- async function generateInfractionHistoryResponse(client, targetUser, page = 1) { const limit = 5; const offset = (page - 1) * limit; @@ -494,7 +489,6 @@ async function generateInfractionHistoryResponse(client, targetUser, page = 1) { return { embeds: [embed.toJSON()], components: [row.toJSON()] }; } -// ----- Gets infraction history ----- async function getInfractionHistory(client, interaction, targetUser) { await interaction.deferReply({ephemeral: true}); const response = await generateInfractionHistoryResponse(client, targetUser, 1); @@ -504,7 +498,6 @@ async function getInfractionHistory(client, interaction, targetUser) { }); } -// ---------- Promotions ---------- async function promoteUser(client, interaction, targetMember, newRole, reason) { await interaction.deferReply({ephemeral: true}); const config = getConfig(client, 'promotions'); @@ -636,7 +629,6 @@ async function promoteUser(client, interaction, targetMember, newRole, reason) { }); } -// ----- Generates promotion history & embed ----- async function generatePromotionHistoryResponse(client, targetUser, page = 1) { const Promotion = client.models['staff-management-system']['Promotion']; const limit = 5; @@ -693,7 +685,6 @@ async function getPromotionHistory(client, interaction, targetUser) { }); } -// ---------- User Panel ---------- async function generatePanelSubpage(client, targetUser, type, page) { if (type === 'infractions') return await generatePanelInfractions(client, targetUser, page); if (type === 'promotions') return await generatePanelPromotions(client, targetUser, page); @@ -1344,7 +1335,6 @@ async function executeDataDeletion(client, targetId, dataType) { } } -// ---------- Activity Checks ---------- async function startActivityCheck(client, interactionOrChannel, isAutomated = false) { const config = getConfig(client, 'activity-checks'); const ActivityCheck = client.models['staff-management-system']['ActivityCheck']; @@ -1606,7 +1596,6 @@ function initActivityCheckAutomation(client) { }); } -// ---------- Reviews ---------- async function submitReview(client, interaction, targetUser, stars, comment) { await interaction.deferReply({ephemeral: true}); const config = getConfig(client, 'reviews'); diff --git a/modules/starboard/commands/star-message.js b/modules/starboard/commands/star-message.js index 0a3dc74d..57b58109 100644 --- a/modules/starboard/commands/star-message.js +++ b/modules/starboard/commands/star-message.js @@ -8,10 +8,6 @@ module.exports.config = { description: localize('starboard', 'star-message-description') }; -/* - * Force-stars the right-clicked message by reusing handleStarboard() with options.force (bypasses the - * self-star removal and minStars threshold). Synthesizes the minimal msgReaction handleStarboard reads. - */ module.exports.run = async function (interaction) { const target = interaction.targetMessage; const starConfig = interaction.client.configurations['starboard']['config']; diff --git a/modules/starboard/handleStarboard.js b/modules/starboard/handleStarboard.js index c973b547..6e7107ed 100644 --- a/modules/starboard/handleStarboard.js +++ b/modules/starboard/handleStarboard.js @@ -25,7 +25,7 @@ module.exports = async (client, msgReaction, user, isReactionRemove = false, opt if (isNaN(starConfig.minStars)) return disableModule('starboard', localize('starboard', 'invalid-minstars', {stars: starConfig.minStars})); const channel = client.channels.cache.get(starConfig.channelId); - if (!channel) return disableModule('starboard', localize('partner-list', 'channel-not-found', {c: starConfig.channelId})); + if (!channel) return disableModule('starboard', localize('starboard', 'channel-not-found', {c: starConfig.channelId})); if ((msg.channel.nsfw && !channel.nsfw) || starConfig.excludedChannels.includes(msg.channel.id) || starConfig.excludedRoles.some(r => msg.member?.roles.cache.has(r))) return; if (!force && !starConfig.selfStar && user.id === msg.author.id) return msgReaction.users.remove(user.id).catch(() => { }); diff --git a/modules/starboard/module.json b/modules/starboard/module.json index 3210e426..473c4ded 100644 --- a/modules/starboard/module.json +++ b/modules/starboard/module.json @@ -23,6 +23,9 @@ "GuildMessages", "MessageContent" ], + "optionalIntents": [ + "MessageContent" + ], "intentReasons": { "MessageContent": "Reads a starred message's text and attachments to render it on the starboard." } diff --git a/modules/suggestions/commands/approve-suggestion.js b/modules/suggestions/commands/approve-suggestion.js index e13bf754..9ae12dd6 100644 --- a/modules/suggestions/commands/approve-suggestion.js +++ b/modules/suggestions/commands/approve-suggestion.js @@ -14,10 +14,6 @@ module.exports.config = { description: localize('suggestions', 'approve-suggestion-description') }; -/* - * Resolves the Suggestion by its stored messageID, then opens an optional-comment modal (customId encodes - * action + suggestion id) whose submit handler reuses applySuggestionDecision. showModal first, so no defer. - */ module.exports.run = async function (interaction) { const suggestion = await interaction.client.models['suggestions']['Suggestion'].findOne({ where: {messageID: interaction.targetMessage.id} diff --git a/modules/suggestions/commands/convert-to-suggestion.js b/modules/suggestions/commands/convert-to-suggestion.js index c811bdd3..f6b6c53e 100644 --- a/modules/suggestions/commands/convert-to-suggestion.js +++ b/modules/suggestions/commands/convert-to-suggestion.js @@ -10,10 +10,6 @@ module.exports.config = { description: localize('suggestions', 'convert-to-suggestion-description') }; -/* - * Converts the right-clicked message into a suggestion via the shared createSuggestion(), attributed to - * the original author (not the staff member converting it). - */ module.exports.run = async function (interaction) { const target = interaction.targetMessage; await interaction.deferReply({ephemeral: true}); diff --git a/modules/suggestions/commands/deny-suggestion.js b/modules/suggestions/commands/deny-suggestion.js index a1d6ff71..22133b5a 100644 --- a/modules/suggestions/commands/deny-suggestion.js +++ b/modules/suggestions/commands/deny-suggestion.js @@ -14,10 +14,6 @@ module.exports.config = { description: localize('suggestions', 'deny-suggestion-description') }; -/* - * Resolves the Suggestion by its stored messageID, then opens an optional-reason modal (customId encodes - * action + suggestion id) whose submit handler reuses applySuggestionDecision. showModal first, so no defer. - */ module.exports.run = async function (interaction) { const suggestion = await interaction.client.models['suggestions']['Suggestion'].findOne({ where: {messageID: interaction.targetMessage.id} diff --git a/modules/suggestions/module.json b/modules/suggestions/module.json index a2ca172a..70aeaa40 100644 --- a/modules/suggestions/module.json +++ b/modules/suggestions/module.json @@ -22,6 +22,9 @@ "GuildMessages", "MessageContent" ], + "optionalIntents": [ + "MessageContent" + ], "intentReasons": { "MessageContent": "Reads message text to turn it into a suggestion entry." } diff --git a/modules/team-list/events/botReady.js b/modules/team-list/events/botReady.js index d3b22428..2cf2ad26 100644 --- a/modules/team-list/events/botReady.js +++ b/modules/team-list/events/botReady.js @@ -47,16 +47,22 @@ const statusIcons = { */ function buildUserString(membersWithRole, role, channelConfig, listedUserIDs) { let userString = ''; - for (const member of membersWithRole) { + const members = Array.from(membersWithRole); + + // Without GuildPresences every member reads as offline, so drop the status column entirely + // rather than rendering everyone as offline. + const showStatus = Boolean(channelConfig.includeStatus) && + Boolean(members[0]?.client?._activeIntents?.includes('GuildPresences')); + for (const member of members) { if (listedUserIDs.includes(member.user.id) && channelConfig.onlineShowHighestRole) continue; listedUserIDs.push(member.user.id); const status = (member.presence || {status: 'offline'}).status; - userString = userString + (channelConfig.includeStatus + userString = userString + (showStatus ? `* ${member.user.toString()}: ${statusIcons[status]} ${localize('team-list', status)}\n` : `${member.user.toString()}, `); } if (userString === '') userString = localize('team-list', 'no-users-with-role', {r: role.toString()}); - else if (!channelConfig.includeStatus) userString = userString.substring(0, userString.length - 2); + else if (!showStatus) userString = userString.substring(0, userString.length - 2); return userString; } diff --git a/modules/team-list/module.json b/modules/team-list/module.json index ee1eb1ff..a690d5fe 100644 --- a/modules/team-list/module.json +++ b/modules/team-list/module.json @@ -21,6 +21,9 @@ "GuildMembers", "GuildPresences" ], + "optionalIntents": [ + "GuildPresences" + ], "intentReasons": { "GuildMembers": "Lists every member holding each staff role in the always up-to-date embed.", "GuildPresences": "Shows each listed staff member's current online status." diff --git a/modules/temp-channels/commands/add-to-channel.js b/modules/temp-channels/commands/add-to-channel.js index 8ebf1213..4d78451f 100644 --- a/modules/temp-channels/commands/add-to-channel.js +++ b/modules/temp-channels/commands/add-to-channel.js @@ -12,14 +12,6 @@ module.exports.config = { description: localize('temp-channels', 'add-to-channel-context-description') }; -/* - * Thin adapter for the temp-channels "add user" flow. Everyone can invoke it, but it is - * creator-only: resolveOwnedTempChannel matches the channel the menu was invoked in against the - * invoker as creatorID, so a non-creator (or a non-temp channel) gets the notInChannel reply and - * we never touch the channel. On success we hand off to the shared userAdd core with the - * 'context' caller, which reads interaction.targetUser and produces output identical to the - * /temp-channel add-user subcommand and the add-user button/select flows. - */ module.exports.run = async function (interaction) { await interaction.deferReply({ephemeral: true}); const vc = await resolveOwnedTempChannel(interaction, 'context'); diff --git a/modules/temp-channels/commands/remove-from-channel.js b/modules/temp-channels/commands/remove-from-channel.js index fcdb39a2..ce1e8f78 100644 --- a/modules/temp-channels/commands/remove-from-channel.js +++ b/modules/temp-channels/commands/remove-from-channel.js @@ -12,13 +12,6 @@ module.exports.config = { description: localize('temp-channels', 'remove-from-channel-context-description') }; -/* - * Thin adapter for the temp-channels "remove user" flow. Everyone can invoke it, but it is - * creator-only via resolveOwnedTempChannel (channel the menu was invoked in must be a temp - * channel owned by the invoker, otherwise notInChannel). On success we hand off to the shared - * userRemove core with the 'context' caller, which reads interaction.targetUser and produces - * output identical to the /temp-channel remove-user subcommand and the remove-user flows. - */ module.exports.run = async function (interaction) { await interaction.deferReply({ephemeral: true}); const vc = await resolveOwnedTempChannel(interaction, 'context'); diff --git a/modules/temp-channels/events/botReady.js b/modules/temp-channels/events/botReady.js index c0966297..fe8cb7c0 100644 --- a/modules/temp-channels/events/botReady.js +++ b/modules/temp-channels/events/botReady.js @@ -8,7 +8,6 @@ module.exports.run = async function () { const moduleConfig = client.configurations['temp-channels']['config']; const settingsChannel = client.channels.cache.get(moduleConfig['settingsChannel']); - // Cleanup orphaned temp channels on startup const tempChannels = await client.models['temp-channels']['TempChannel'].findAll(); let cleanedCount = 0; for (const tempChannel of tempChannels) { @@ -38,7 +37,6 @@ module.exports.run = async function () { client.logger.info(`[temp-channels] Cleaned up ${cleanedCount} empty or orphaned temp channel(s) on startup`); } - // Schedule archive cleanup job (every hour) if (moduleConfig.enableArchiving && moduleConfig.archiveDeleteAfterHours > 0) { const archiveCleanupJob = scheduleJob('0 * * * *', async () => { const cutoff = new Date(Date.now() - moduleConfig.archiveDeleteAfterHours * 3600000); diff --git a/modules/temp-channels/events/voiceStateUpdate.js b/modules/temp-channels/events/voiceStateUpdate.js index 98ce0574..11ed9fd0 100644 --- a/modules/temp-channels/events/voiceStateUpdate.js +++ b/modules/temp-channels/events/voiceStateUpdate.js @@ -9,7 +9,6 @@ module.exports.run = async function (client, oldState, newState) { if (!client.botReadyAt) return; const moduleConfig = client.configurations['temp-channels']['config']; - // Handle channel leave β€” delete or archive if (oldState.channel) { const oldChannel = await client.models['temp-channels']['TempChannel'].findOne({ where: {id: oldState.channel.id} @@ -20,7 +19,6 @@ module.exports.run = async function (client, oldState, newState) { const dcOldChannel = await client.channels.fetch(oldChannel.id).catch(() => null); if (dcOldChannel && dcOldChannel.members.size === 0) { if (moduleConfig.enableArchiving && moduleConfig.archiveCategory) { - // Archive: move to archive category, strip permissions await dcOldChannel.setParent(moduleConfig.archiveCategory, { lockPermissions: false, reason: '[temp-channels] Archiving empty temp channel' @@ -60,7 +58,6 @@ module.exports.run = async function (client, oldState, newState) { oldChannel.archivedAt = new Date(); await oldChannel.save(); } else { - // Delete channel if (oldChannel.noMicChannel) { const noMicChannel = await client.channels.fetch(oldChannel.noMicChannel).catch(() => null); if (noMicChannel) await noMicChannel.delete(`[temp-channels] ${localize('temp-channels', 'removed-audit-log-reason')}`).catch(() => { @@ -80,7 +77,6 @@ module.exports.run = async function (client, oldState, newState) { } } - // No-mic channel visibility sync if (moduleConfig['create_no_mic_channel']) { const possibleExistingChannel = await client.models['temp-channels']['TempChannel'].findOne({ where: { @@ -101,13 +97,11 @@ module.exports.run = async function (client, oldState, newState) { if (!newState.channel) return; if (newState.channel.id === moduleConfig['channelID']) { - // Check for existing channel (active or archived) const existingChannel = await client.models['temp-channels']['TempChannel'].findOne({ where: {creatorID: newState.member.user.id} }); if (existingChannel) { - // Restore from archive if needed if (existingChannel.archivedAt) { const dcChannel = await client.channels.fetch(existingChannel.id).catch(() => null); if (dcChannel) { @@ -116,7 +110,6 @@ module.exports.run = async function (client, oldState, newState) { reason: '[temp-channels] Restoring archived channel' }).catch(() => { }); - // Re-apply permissions based on saved mode if (!existingChannel.isPublic) { await dcChannel.permissionOverwrites.create(dcChannel.guild.roles.everyone, { 'CONNECT': false, @@ -175,7 +168,6 @@ module.exports.run = async function (client, oldState, newState) { await existingChannel.destroy(); } } else { - // Active channel exists, move user there return newState.setChannel(existingChannel.id, '[temp-channels] ' + localize('temp-channels', 'move-audit-log-reason')).catch(() => { newState.setChannel(null, '[temp-channels] ' + localize('temp-channels', 'disconnect-audit-log-reason')); existingChannel.destroy(); @@ -183,7 +175,6 @@ module.exports.run = async function (client, oldState, newState) { } } - // Channel limit check if (moduleConfig.enableMaxActiveChannels && moduleConfig.maxActiveChannels > 0) { const activeCount = await client.models['temp-channels']['TempChannel'].count({where: {archivedAt: null}}); if (activeCount >= moduleConfig.maxActiveChannels) { @@ -197,7 +188,6 @@ module.exports.run = async function (client, oldState, newState) { } } - // Create new channel const n = await client.models['temp-channels']['TempChannel'].count({}) + 1; const newChannel = await newState.guild.channels.create({ name: moduleConfig['channelname_format'] @@ -237,7 +227,6 @@ module.exports.run = async function (client, oldState, newState) { if (moduleConfig['useNoMic']) await sendMessage(noMicChannel); } - // Apply private permissions if default is private if (!moduleConfig['publicChannels']) { await newChannel.permissionOverwrites.create(newState.guild.roles.everyone, { 'CONNECT': false, diff --git a/modules/temp-channels/migrations/temp-channels_TempChannel__V1.js b/modules/temp-channels/migrations/temp-channels_TempChannel__V1.js index 8444920b..f2d0e73c 100644 --- a/modules/temp-channels/migrations/temp-channels_TempChannel__V1.js +++ b/modules/temp-channels/migrations/temp-channels_TempChannel__V1.js @@ -1,18 +1,7 @@ const OLD_TABLE = 'temp-channel_TempChannels'; const NEW_TABLE = 'temp-channel_TempChannelsv2'; -/* - * Replaces the old `migrate('temp-channels', 'TempChannelV1', 'TempChannel')` call - * (which used the now-deprecated row-by-row JavaScript helper in src/functions/helpers.js) - * with a SQL-level INSERT INTO ... SELECT inside a transaction. - * - * The legacy helper was not transactional and ran one create+destroy per row, so a - * crash mid-loop could leave the source table partially drained while the destination - * already had the copied rows. This version is atomic. - * - * Idempotent: if the old V1 table no longer exists (already migrated under the legacy - * helper, or fresh install where the V1 schema was never present), the body is a no-op. - */ +// Copies V1 rows into the V2 table in one transaction. No-ops when the old table is absent. module.exports = { tables: [OLD_TABLE, NEW_TABLE], up: async ({ @@ -38,9 +27,6 @@ module.exports = { }, down: async () => { - /* - * No-op: copying rows back to a now-empty V1 schema is not a meaningful - * rollback, and the old helper had no down path either. - */ + // No-op: copying rows back to a now-empty V1 schema is not a meaningful rollback. } }; \ No newline at end of file diff --git a/modules/temp-channels/module.json b/modules/temp-channels/module.json index 96f81214..e6906333 100644 --- a/modules/temp-channels/module.json +++ b/modules/temp-channels/module.json @@ -22,6 +22,9 @@ "GuildVoiceStates", "GuildMembers" ], + "optionalIntents": [ + "GuildMembers" + ], "intentReasons": { "GuildMembers": "Looks up members by username to add or remove them from a temporary channel." } diff --git a/modules/tic-tak-toe/commands/challenge-to-tic-tac-toe.js b/modules/tic-tak-toe/commands/challenge-to-tic-tac-toe.js index f2bee491..e492bbf2 100644 --- a/modules/tic-tak-toe/commands/challenge-to-tic-tac-toe.js +++ b/modules/tic-tak-toe/commands/challenge-to-tic-tac-toe.js @@ -9,12 +9,6 @@ module.exports.config = { description: localize('tic-tac-toe', 'challenge-to-tic-tac-toe-context-description') }; -/* - * Thin adapter: /tic-tac-toe run() resolves its opponent via - * interaction.options.getMember('user', true). We reuse run() unchanged by handing it the real - * interaction with getMember overridden to return the right-clicked member, so the challenge and - * game flow is identical against that user. The self-challenge guard inside run() applies unchanged. - */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/tic-tak-toe/module.json b/modules/tic-tak-toe/module.json index b90d4d4e..a12123c5 100644 --- a/modules/tic-tak-toe/module.json +++ b/modules/tic-tak-toe/module.json @@ -25,6 +25,10 @@ "GuildMembers", "GuildPresences" ], + "optionalIntents": [ + "GuildMembers", + "GuildPresences" + ], "intentReasons": { "GuildMembers": "Resolves member names for opponents and the scoreboard.", "GuildPresences": "Filters out offline members when picking a random opponent." diff --git a/modules/tickets/commands/close-ticket.js b/modules/tickets/commands/close-ticket.js index 9889b007..0f2ac18e 100644 --- a/modules/tickets/commands/close-ticket.js +++ b/modules/tickets/commands/close-ticket.js @@ -9,10 +9,6 @@ module.exports.config = { description: localize('tickets', 'context-close-description') }; -/* - * "close-ticket" button-flow adapter: resolves the open Ticket for interaction.channel and hands it to - * the shared closeTicket() core. Replies ephemerally if the channel is not a ticket channel. - */ module.exports.run = async function (interaction) { const client = interaction.client; const ticket = await client.models['tickets']['Ticket'].findOne({ diff --git a/modules/tickets/commands/create-ticket-about-message.js b/modules/tickets/commands/create-ticket-about-message.js index 0fef1c75..7284b6ae 100644 --- a/modules/tickets/commands/create-ticket-about-message.js +++ b/modules/tickets/commands/create-ticket-about-message.js @@ -8,10 +8,6 @@ module.exports.config = { description: localize('tickets', 'context-create-description') }; -/* - * "create-ticket" button-flow adapter: reuses the shared createTicket() core for the first configured - * ticket type, passing a reference to the targeted message so the new ticket links back to it. - */ module.exports.run = async function (interaction) { const client = interaction.client; const config = client.configurations['tickets']['config']; diff --git a/modules/tickets/config.json b/modules/tickets/config.json index 3c995c4f..b9255a42 100644 --- a/modules/tickets/config.json +++ b/modules/tickets/config.json @@ -43,6 +43,13 @@ "type": "array", "content": "roleID" }, + { + "name": "inheritCategoryPermissions", + "humanName": "Copy category permissions", + "default": false, + "description": "Opt in to copying permission overwrites from the ticket category when a ticket is created. @everyone remains denied access. Later category changes are not applied automatically.", + "type": "boolean" + }, { "name": "logChannel", "humanName": "Log channel", diff --git a/modules/tickets/events/interactionCreate.js b/modules/tickets/events/interactionCreate.js index 1cf7d828..cebf213a 100644 --- a/modules/tickets/events/interactionCreate.js +++ b/modules/tickets/events/interactionCreate.js @@ -1,5 +1,10 @@ const {localize} = require('../../../src/functions/localize'); -const {MessageEmbed} = require('discord.js'); +const { + MessageEmbed, + OverwriteType, + PermissionFlagsBits, + PermissionsBitField +} = require('discord.js'); const { lockChannel, messageLogToStringToPaste, @@ -104,15 +109,85 @@ async function createTicket(client, interaction, element, typeIndex, reference = existingTicket.open = false; await existingTicket.save(); } - const overwrites = []; - element.ticketRoles.forEach(rID => { - overwrites.push( - { - id: rID, - type: 'ROLE', - allow: ['SEND_MESSAGES', 'VIEW_CHANNEL', 'READ_MESSAGE_HISTORY'] - } + const category = element.inheritCategoryPermissions === true + ? await interaction.guild.channels.fetch(element['ticket-create-category']) + : null; + const overwrites = category?.permissionOverwrites?.cache + ? Array.from(category.permissionOverwrites.cache.values(), overwrite => ({ + id: overwrite.id, + type: overwrite.type, + allow: overwrite.allow.bitfield, + deny: overwrite.deny.bitfield + })) + : []; + + /** + * Merges ticket access into an existing overwrite (or creates one) instead of replacing it, so + * inherited category permissions survive. + * @param {String} id Role or member id + * @param {Number} type OverwriteType + */ + function grantTicketAccess(id, type) { + const index = overwrites.findIndex(overwrite => overwrite.id === id); + const overwrite = index === -1 ? { + id, + type, + allow: 0n, + deny: 0n + } : overwrites[index]; + const allow = new PermissionsBitField(overwrite.allow).add( + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.AttachFiles, + PermissionFlagsBits.EmbedLinks, + PermissionFlagsBits.ReadMessageHistory ); + const deny = new PermissionsBitField(overwrite.deny).remove( + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.AttachFiles, + PermissionFlagsBits.EmbedLinks, + PermissionFlagsBits.ReadMessageHistory + ); + const merged = { + ...overwrite, + type, + allow: allow.bitfield, + deny: deny.bitfield + }; + if (index === -1) overwrites.push(merged); + else overwrites[index] = merged; + } + + /** + * Hides the ticket from a role. Only ViewChannel is denied; every other inherited category + * permission is kept intact, which is the point of the opt-in inherit mode. + * @param {String} id Role id + */ + function denyEveryoneAccess(id) { + const index = overwrites.findIndex(overwrite => overwrite.id === id); + const overwrite = index === -1 ? { + id, + type: OverwriteType.Role, + allow: 0n, + deny: 0n + } : overwrites[index]; + const allow = new PermissionsBitField(overwrite.allow).remove(PermissionFlagsBits.ViewChannel); + const deny = new PermissionsBitField(overwrite.deny).add(PermissionFlagsBits.ViewChannel); + const merged = { + ...overwrite, + type: OverwriteType.Role, + allow: allow.bitfield, + deny: deny.bitfield + }; + if (index === -1) overwrites.push(merged); + else overwrites[index] = merged; + } + + denyEveryoneAccess(interaction.guild.id); // Discord guarantees @everyone's role id is the guild id + grantTicketAccess(interaction.member.id, OverwriteType.Member); + element.ticketRoles.forEach(rID => { + grantTicketAccess(rID, OverwriteType.Role); }); let topic = `Ticket created by ${interaction.user.toString()} by clicking on a message in ${interaction.channel.toString()}`; if (reference) topic = reference; @@ -121,14 +196,7 @@ async function createTicket(client, interaction, element, typeIndex, reference = parent: element['ticket-create-category'], topic: topic, reason: localize('tickets', 'ticket-created-audit-log', {u: formatDiscordUserName(interaction.user)}), - permissionOverwrites: [{ - id: interaction.guild.roles.cache.find(r => r.name === '@everyone'), - deny: ['SEND_MESSAGES', 'VIEW_CHANNEL', 'READ_MESSAGE_HISTORY'] - }, - { - id: interaction.member, - allow: ['SEND_MESSAGES', 'VIEW_CHANNEL', 'READ_MESSAGE_HISTORY'] - }, ...overwrites] + permissionOverwrites: overwrites }); const ticket = await client.models['tickets']['Ticket'].create({ open: true, diff --git a/modules/tickets/migrations/tickets_Ticket__V1.js b/modules/tickets/migrations/tickets_Ticket__V1.js index 927c2fc4..f0a11466 100644 --- a/modules/tickets/migrations/tickets_Ticket__V1.js +++ b/modules/tickets/migrations/tickets_Ticket__V1.js @@ -2,13 +2,8 @@ const OLD_TABLE = 'ticket_Ticketv1'; const NEW_TABLE = 'ticket_Ticketv2'; /* - * Replaces `migrate('tickets', 'TicketV1', 'Ticket')` (the legacy row-by-row helper - * in src/functions/helpers.js) with a SQL-level INSERT INTO ... SELECT inside a - * transaction. The new Ticket schema adds a `type` column; existing V1 rows have no - * value for it, so it defaults to NULL. - * - * Idempotent: if either table is missing (already migrated under the legacy helper, - * or a fresh install where the V1 schema was never present), the body is a no-op. + * Copies V1 rows into the V2 table in one transaction; the new `type` column defaults to NULL. + * No-ops when either table is absent. */ module.exports = { tables: [OLD_TABLE, NEW_TABLE], @@ -36,9 +31,6 @@ module.exports = { }, down: async () => { - /* - * No-op: copying rows back to a now-empty V1 schema is not a meaningful - * rollback, and the old helper had no down path either. - */ + // No-op: copying rows back to a now-empty V1 schema is not a meaningful rollback. } }; \ No newline at end of file diff --git a/modules/twitch-notifications/module.json b/modules/twitch-notifications/module.json index 864a3a86..4ec154ff 100644 --- a/modules/twitch-notifications/module.json +++ b/modules/twitch-notifications/module.json @@ -21,6 +21,9 @@ "intents": [ "GuildMembers" ], + "optionalIntents": [ + "GuildMembers" + ], "intentReasons": { "GuildMembers": "Resolves streamer members from the cache to grant the live role." } diff --git a/modules/uno/commands/challenge-to-uno.js b/modules/uno/commands/challenge-to-uno.js index 58347443..dad96338 100644 --- a/modules/uno/commands/challenge-to-uno.js +++ b/modules/uno/commands/challenge-to-uno.js @@ -9,13 +9,6 @@ module.exports.config = { description: localize('uno', 'challenge-to-uno-context-description') }; -/* - * Thin adapter: /uno run() opens an open join-lobby and reads no opponent from options, so we - * reuse it unchanged to produce the identical lobby. Uno has no single opponent, so to honour the - * right-clicked user as the challenged player we additionally ping them with a follow-up inviting - * them to join the lobby. Self-targeting is harmless (the host is already in the lobby), so we skip - * the ping in that case rather than block the game. - */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/uno/commands/uno.js b/modules/uno/commands/uno.js index 2f700030..12628712 100644 --- a/modules/uno/commands/uno.js +++ b/modules/uno/commands/uno.js @@ -447,7 +447,7 @@ module.exports.run = async function (interaction) { i.deferUpdate(); } else if (i.customId === 'uno-deck') { const player = game.players.find(p => p.id === i.user.id); - if (!player) return i.reply({content: localize('uno', 'not-in-game'), ephemeral: true}); + if (!player) return i.reply({content: localize('uno', 'not-ingame'), ephemeral: true}); console.log(player); const m = await i.reply({ components: buildDeck(player, game).map(c => c.toJSON()), @@ -460,7 +460,7 @@ module.exports.run = async function (interaction) { }).on('collect', int => perPlayerHandler(int, player, game)); } else if (i.customId === 'uno-uno') { const player = game.players.find(p => p.id === i.user.id); - if (!player) return i.reply({content: localize('uno', 'not-in-game'), ephemeral: true}); + if (!player) return i.reply({content: localize('uno', 'not-ingame'), ephemeral: true}); if (player.cards.length === 2) { player.uno = true; @@ -483,7 +483,6 @@ module.exports.config = { defaultPermission: true }; -// Exposed for unit testing of the pure game rules. module.exports.__test = { canUseCard, nextPlayer, diff --git a/modules/welcomer/commands/assign-join-roles.js b/modules/welcomer/commands/assign-join-roles.js index fee2f799..27fbcd05 100644 --- a/modules/welcomer/commands/assign-join-roles.js +++ b/modules/welcomer/commands/assign-join-roles.js @@ -10,13 +10,6 @@ module.exports.config = { description: localize('welcomer', 'assign-join-roles-context-description') }; -/* - * Staff-only (MANAGE_ROLES) adapter that runs the automatic join-role assignment on demand for a - * single member. evaluateMember() returns {skip, missingRoleIDs} exactly as the base-role sync - * uses it: skip means the member is in a holding state (pending/quarantine/...) and must not be - * given roles, otherwise we add the missing configured join roles - the same roles.add() the - * automatic flow performs, with the same audit reason - so the result is identical. - */ module.exports.run = async function (interaction) { await interaction.deferReply({flags: MessageFlags.Ephemeral}); const client = interaction.client; diff --git a/modules/welcomer/commands/check-welcome-status.js b/modules/welcomer/commands/check-welcome-status.js index 4c9b75f6..f9e51b1c 100644 --- a/modules/welcomer/commands/check-welcome-status.js +++ b/modules/welcomer/commands/check-welcome-status.js @@ -16,13 +16,6 @@ module.exports.config = { description: localize('welcomer', 'check-welcome-status-context-description') }; -/* - * Staff-only (MANAGE_GUILD) read-only adapter: reports a member's welcomer state using the same - * primitives the automatic base-role sync uses. isInHoldingState() decides whether the member is - * currently withheld from welcome roles (pending onboarding, quarantine, join-gate hold, etc.), - * and evaluateMember() reports which configured join roles the member is missing - identical - * logic to runSync(), surfaced as an ephemeral embed instead of mutating roles. - */ module.exports.run = async function (interaction) { await interaction.deferReply({flags: MessageFlags.Ephemeral}); const client = interaction.client; diff --git a/modules/welcomer/commands/restore-base-roles.js b/modules/welcomer/commands/restore-base-roles.js index 26928485..f9f3fe7d 100644 --- a/modules/welcomer/commands/restore-base-roles.js +++ b/modules/welcomer/commands/restore-base-roles.js @@ -10,14 +10,6 @@ module.exports.config = { description: localize('welcomer', 'restore-base-roles-context-description') }; -/* - * Staff-only (MANAGE_ROLES) adapter for the base-role re-add path. Requires the base-roles - * feature (treat-welcome-roles-as-base-roles) to be on - the automatic re-add in - * handleRoleRemoval()/handleHoldingRelease() is gated the same way - otherwise it replies - * ephemerally. It mirrors handleHoldingRelease exactly: skip if the member is still in a holding - * state, then roles.add() the missing join roles with the shared base-role-audit-reason, so the - * restored state is identical to the automatic flow. - */ module.exports.run = async function (interaction) { await interaction.deferReply({flags: MessageFlags.Ephemeral}); const client = interaction.client; diff --git a/modules/welcomer/events/guildMemberRemove.js b/modules/welcomer/events/guildMemberRemove.js index 0b386494..6cb4ffb8 100644 --- a/modules/welcomer/events/guildMemberRemove.js +++ b/modules/welcomer/events/guildMemberRemove.js @@ -81,7 +81,6 @@ async function timer(client, userId) { } }); if (timeModel) { - // check timer duration return timeModel.timestamp.getTime() + 604800000 >= Date.now(); } } \ No newline at end of file diff --git a/src/cli.js b/src/cli.js index 11e8bb23..8f4dc75a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,5 +1,6 @@ const fs = require('fs'); const {reloadConfig} = require('./functions/configuration'); +const {pick} = require('./functions/exitCodes'); const {syncCommandsIfNeeded} = require('../main'); module.exports.commands = [ @@ -36,7 +37,7 @@ module.exports.commands = [ }).catch(async () => { if (inputElement.client.logChannel) await inputElement.client.logChannel.send('⚠️️ Configuration reloaded failed. Bot shutting down'); console.log('Reload failed. Exiting'); - process.exit(0); + process.exit(pick(1)); // reload failure is retryable ; }); } diff --git a/src/commands/help.js b/src/commands/help.js index e16f817b..804579f3 100644 --- a/src/commands/help.js +++ b/src/commands/help.js @@ -54,7 +54,6 @@ module.exports.run = async function (interaction) { modules[command.module || 'none'].push(command); } - // Add custom slash commands as their own module group const customCommands = (interaction.client.config || {}).customCommands || []; const enabledCustomCommands = customCommands.filter(c => c.type === 'COMMAND' && c.enabled && c.slashCommandName && c.slashCommandDescription); if (enabledCustomCommands.length > 0) { diff --git a/src/commands/reload.js b/src/commands/reload.js index 6e8c7ba0..3b49ae6f 100644 --- a/src/commands/reload.js +++ b/src/commands/reload.js @@ -2,6 +2,7 @@ const {reloadConfig} = require('../functions/configuration'); const {syncCommandsIfNeeded} = require('../../main'); const {localize} = require('../functions/localize'); const {formatDiscordUserName} = require('../functions/helpers'); +const {pick} = require('../functions/exitCodes'); module.exports.run = async function (interaction) { await interaction.reply({ @@ -14,7 +15,7 @@ module.exports.run = async function (interaction) { if (interaction.client.logChannel) interaction.client.logChannel.send('⚠️️ ' + localize('reload', 'reload-failed')).catch(() => { }); await interaction.editReply({content: localize('reload', 'reload-failed-message', {r: reason})}); - process.exit(0); + process.exit(pick(1)); // reload failure is retryable ; })).then(async (res) => { if (interaction.client.logChannel) interaction.client.logChannel.send('βœ… ' + localize('reload', 'reloaded-config', res)).catch(() => { diff --git a/src/events/guildDelete.js b/src/events/guildDelete.js index 7f3d095c..d8ed3316 100644 --- a/src/events/guildDelete.js +++ b/src/events/guildDelete.js @@ -1,4 +1,5 @@ const {localize} = require('../functions/localize'); +const {pick} = require('../functions/exitCodes'); module.exports.run = async (client, guild) => { if (guild.id !== client.config.guildID) return; @@ -16,7 +17,7 @@ module.exports.run = async (client, guild) => { client.logger.fatal(localize('main', 'not-invited', { inv: `https://discord.com/oauth2/authorize?client_id=${client.user.id}&guild_id=${client.config.guildID}&disable_guild_select=true&permissions=8&scope=bot%20applications.commands` })); - return process.exit(0); + return process.exit(pick(66)); // kicked from home guild: never restart } // Eager teardown so in-flight intervals/jobs stop immediately. reloadConfig() will @@ -40,7 +41,7 @@ module.exports.run = async (client, guild) => { client.logger.fatal(localize('main', 'config-check-failed')); const sentryId = client.captureException ? client.captureException(e, {source: 'guild-rejoin-reload'}) : null; client.logger.error(client.sanitizePath(`${e.stack || e}${sentryId ? ` [Sentry: ${sentryId}]` : ''}`)); - process.exit(0); + process.exit(pick(1)); // rejoin reload failure is retryable } }; client.on('guildCreate', onGuildCreate); diff --git a/src/functions/commandTypes.js b/src/functions/commandTypes.js new file mode 100644 index 00000000..001029e0 --- /dev/null +++ b/src/functions/commandTypes.js @@ -0,0 +1,111 @@ +/** + * Command-type resolution and the registration rules Discord enforces on application commands. + * @module CommandTypes + */ +const {ApplicationCommandType} = require('discord.js'); + +// Discord's per-application registration caps for context-menu commands. +const USER_LIMIT = 15; +const MESSAGE_LIMIT = 15; + +/** + * Resolves a module's declared `type` to the numeric ApplicationCommandType the REST API requires. + * Accepts any casing ('USER'/'User'/'user'), maps the 'slash' alias modules declare to ChatInput + * (the enum member is named ChatInput, not Slash), defaults a missing type to ChatInput and passes + * an already-numeric type through. Sending a string type, or defaulting a context command to + * ChatInput, is a 400 that aborts the whole command sync, so this must never return a string for a + * known type. + * @param {String|Number} type Declared command type + * @returns {Number|String} Numeric ApplicationCommandType, or the input for an unknown type + */ +function resolveCommandType(type) { + if (!type) return ApplicationCommandType.ChatInput; + if (typeof type !== 'string') return type; + const upper = type.toUpperCase(); + if (upper === 'SLASH' || upper === 'CHAT_INPUT' || upper === 'CHATINPUT') return ApplicationCommandType.ChatInput; + const pascal = upper.charAt(0) + upper.slice(1).toLowerCase(); + return ApplicationCommandType[pascal] || type; +} + +/** + * The context-menu kind of an already-normalized command, or null for a slash command. + * @param {Object} command Command carrying a numeric `type` + * @returns {String|null} 'USER', 'MESSAGE' or null + */ +function contextTypeOf(command) { + if (command.type === ApplicationCommandType.User) return 'USER'; + if (command.type === ApplicationCommandType.Message) return 'MESSAGE'; + return null; +} + +/** + * Splits normalized commands into the slash and context sets that can actually be registered. + * + * Context commands are shaped and bounded here because Discord rejects the whole sync otherwise: + * `description` and `options` are not allowed on USER/MESSAGE commands, duplicate names within a + * type are a 400, and each type is capped. Anything over the cap or colliding is reported rather + * than silently discarded, so the caller can log it. Slash commands pass through untouched. + * @param {Object[]} commands Normalized commands (numeric `type`) + * @returns {{slash: Object[], context: Object[], dropped: Object[], collisions: Object[]}} + */ +function partitionCommands(commands) { + const slash = []; + const context = []; + const dropped = []; + const collisions = []; + const counts = { + USER: 0, + MESSAGE: 0 + }; + const limits = { + USER: USER_LIMIT, + MESSAGE: MESSAGE_LIMIT + }; + const seen = new Set(); + + for (const command of commands) { + const type = contextTypeOf(command); + if (!type) { + slash.push(command); + continue; + } + const key = `${type}:${command.name.toLowerCase()}`; + if (seen.has(key)) { + collisions.push({ + name: command.name, + module: command.module, + type + }); + continue; + } + if (counts[type] >= limits[type]) { + dropped.push({ + name: command.name, + module: command.module, + type + }); + continue; + } + seen.add(key); + counts[type]++; + const resolved = {...command}; + delete resolved.description; + delete resolved.options; + context.push(resolved); + } + + return { + slash, + context, + dropped, + collisions + }; +} + +module.exports = { + USER_LIMIT, + MESSAGE_LIMIT, + resolveCommandType, + contextTypeOf, + partitionCommands +}; diff --git a/src/functions/configuration.js b/src/functions/configuration.js index af21a3ab..1666f5f3 100644 --- a/src/functions/configuration.js +++ b/src/functions/configuration.js @@ -11,14 +11,15 @@ const { client } = require('../../main'); const {localize} = require('./localize'); +const {pick} = require('./exitCodes'); const isEqual = require('is-equal'); const path = require('path'); const { computeRequiredIntents, - diffIntents + diffIntents, + PRIVILEGED_INTENTS } = require('./intents'); -// Config localization: load external translation files (cached) const configLocalizationCache = {}; function loadConfigLocalization(locale) { @@ -47,6 +48,56 @@ const channelTypeMap = { GUILD_STAGE_VOICE: ChannelType.GuildStageVoice }; +/* + * Types whose validity depends on a live Discord fetch. checkType returns false for these on BOTH a + * genuine not-found AND a transient blip (rate-limit / 5xx / network), which are indistinguishable + * at the call site, so an invalid stored value must NEVER be healed to the field default. + */ +const FETCH_BACKED_TYPES = new Set(['channelID', 'roleID', 'userID', 'guildID']); + +/** + * True when a field's validity is backed by a live Discord fetch, directly or as an array element. + * @param {ConfigField} field + * @returns {Boolean} + */ +function isFetchBackedType(field) { + if (FETCH_BACKED_TYPES.has(field.type)) return true; + return field.type === 'array' && FETCH_BACKED_TYPES.has(field.content); +} + +/** + * Disables every enabled module requiring a privileged intent the operator's allowlist does not + * grant. Idempotent: reloadConfig resets `enabled` from modules.json first, so this must re-run on + * every boot AND reload. Returns the disabled module names so the caller can skip their config check. + * @param {Client} client + * @param {String} [modulesDir] + * @returns {Promise>} + */ +async function applyIntentDisables(client, modulesDir) { + const dir = modulesDir || path.join(__dirname, '..', '..', 'modules'); + const {disabledModules} = computeRequiredIntents(client.configDir, dir); + const disabled = new Set(); + for (const d of disabledModules) { + disabled.add(d.module); + const mod = client.modules[d.module]; + if (!mod) continue; + mod.enabled = false; + client.logger.warn(localize('config', 'intent-module-disabled', { + m: d.module, + intents: d.missingRequired.join(', ') + })); + if (client.scnxSetup) await require('./scnx-integration').reportIssue(client, { + type: 'MODULE_FAILURE', + errorDescription: 'module_disabled', + module: d.module, + errorData: {reason: 'Required privileged intent(s) not granted: ' + d.missingRequired.join(', ')} + }); + } + return disabled; +} + +module.exports.applyIntentDisables = applyIntentDisables; + /** * Check every (including module) configuration and load them * @author Simon Csaba @@ -66,8 +117,10 @@ async function loadAllConfigs(client) { } }); + const intentDisabled = await applyIntentDisables(client); for (const moduleName in client.modules) { if (!client.modules[moduleName].userEnabled) continue; + if (intentDisabled.has(moduleName)) continue; // already disabled + reported above await checkModuleConfig(moduleName, client.modules[moduleName]['config']['on-checked-config-event'] ? require(`./modules/${moduleName}/${client.modules[moduleName]['config']['on-checked-config-event']}`) : null) .catch(async (e) => { client.modules[moduleName].enabled = false; @@ -109,19 +162,51 @@ async function checkConfigFile(file, moduleName) { const locScope = builtIn ? '_core' : moduleName; const locFileName = exampleFile.filename.replace('.json', ''); + /** + * Detaches a default value from the shared example/localization caches. Object and array + * defaults are returned by reference from module-cached JSON, so materialized configs alias + * them and any in-place mutation would corrupt the cache for every later load. + * @param {*} value Default value to detach + * @returns {*} A cache-independent copy; primitives pass through + */ + function detachDefault(value) { + if (value !== null && typeof value === 'object') return structuredClone(value); + return value; + } + function resolveDefault(field) { if (isLocalizedObject(field.default)) { - return field.default[client.locale] || field.default['en']; + return detachDefault(field.default[client.locale] || field.default['en']); } if (['string', 'emoji', 'imgURL'].includes(field.type) && client.locale && client.locale !== 'en') { const locData = loadConfigLocalization(client.locale); const fileLocData = locData[locScope] && locData[locScope][locFileName]; if (fileLocData && fileLocData.content && fileLocData.content[field.name] && fileLocData.content[field.name].default !== undefined) { - return fileLocData.content[field.name].default; + return detachDefault(fileLocData.content[field.name].default); } } - return field.default; + return detachDefault(field.default); + } + + /** + * Resolves a field's dependsOn chain transitively: a field is only enabled when its + * dependsOn target is truthy AND that target's own chain is satisfied, so a field stays + * hidden when a hidden ancestor disables it even if the intermediate still holds a stale + * truthy value. + * @param {Object} field Field whose chain is being resolved + * @param {Object} source Config object to read current values from + * @param {Set} [seen] Cycle guard + * @returns {Boolean} + */ + function dependsOnChainSatisfied(field, source, seen = new Set()) { + if (!field.dependsOn || seen.has(field.name)) return true; + seen.add(field.name); + const parent = exampleFile.content.find(f => f.name === field.dependsOn); + if (!parent) return true; // a missing dependsOn target is rejected separately + const parentValue = typeof source[parent.name] === 'undefined' ? resolveDefault(parent) : source[parent.name]; + if (!parentValue) return false; + return dependsOnChainSatisfied(parent, source, seen); } let forceOverwrite = false; @@ -152,7 +237,7 @@ async function checkConfigFile(file, moduleName) { const dependsOnNotField = field.dependsOnNot ? exampleFile.content.find(f => f.name === field.dependsOnNot) : null; if (field.dependsOn && !dependsOnField) return reject(`Depends-On-Field ${field.dependsOn} does not exist.`); if (field.dependsOnNot && !dependsOnNotField) return reject(`Depends-On-Field ${field.dependsOnNotField} does not exist.`); - if (dependsOnField && !(typeof object[dependsOnField.name] === 'undefined' ? resolveDefault(dependsOnField) : object[dependsOnField.name])) { + if (dependsOnField && !dependsOnChainSatisfied(field, object)) { objectData[field.name] = configData[field.name] || resolveDefault(field); // Otherwise disabled fields may be overwritten continue; } @@ -179,7 +264,7 @@ async function checkConfigFile(file, moduleName) { } const dependsOnField = field.dependsOn ? exampleFile.content.find(f => f.name === field.dependsOn) : null; if (field.dependsOn && !dependsOnField) return reject(`Depends-On-Field ${field.dependsOn} does not exist.`); - if (dependsOnField && !(typeof configData[dependsOnField.name] === 'undefined' ? resolveDefault(dependsOnField) : configData[dependsOnField.name])) { + if (dependsOnField && !dependsOnChainSatisfied(field, configData)) { newConfig[field.name] = configData[field.name] || resolveDefault(field); // Otherwise disabled fields may be overwritten continue; } @@ -207,7 +292,7 @@ async function checkConfigFile(file, moduleName) { } if (isLocalizedObject(field.default)) { // Old format: {en: ..., de: ...} β€” backwards compatible - field.default = field.default[client.locale] || field.default['en']; + field.default = detachDefault(field.default[client.locale] || field.default['en']); } else { // New format: plain value β€” resolve locale from external file field.default = resolveDefault(field); @@ -218,6 +303,25 @@ async function checkConfigFile(file, moduleName) { } else if (field.type === 'keyed' && field.disableKeyEdits) for (const key in field.default) if (fieldValue[key] == null) fieldValue[key] = field.default[key]; if (field.allowNull && field.type !== 'boolean' && !fieldValue) return res(fieldValue); if (!await checkType(field, fieldValue)) { + if (isFetchBackedType(field)) { + + /* + * checkType-false on a fetch-backed type is ambiguous: the ID may be gone OR a + * transient Discord failure rejected the fetch. Healing to the default here + * would permanently destroy a valid ID (or empty a valid array) on a blip. + */ + if (!fieldValue && field.default === '') { + + // A required fetch-backed field left unconfigured has an empty default that + // never validates; healing would re-fail every boot, so disable the module. + return rej(`Required field "${field.name}" in ${exampleFile.filename}${moduleName ? ` (module ${moduleName})` : ''} is not configured (empty default cannot be validated).`); + } + logger.warn(`[CONFIGURATION] Field "${field.name}" in ${exampleFile.filename}${moduleName ? ` (module ${moduleName})` : ''} could not be verified (stored value ${JSON.stringify(fieldValue)}); keeping the stored value without healing.`); + return res(fieldValue); + } + + // Non-fetch types: an invalid stored value is definitively wrong, so heal to the + // default (persisted by the write-back below) instead of disabling the module. if (client.scnxSetup) await require('./scnx-integration').reportIssue(client, { type: 'CONFIGURATION_ISSUE', module: moduleName, @@ -225,16 +329,8 @@ async function checkConfigFile(file, moduleName) { configFile: exampleFile.filename.replaceAll('.json', ''), errorDescription: 'field_check_failed' }); - logger.error(localize('config', 'checking-of-field-failed', { - fieldName: field.name, - m: moduleName, - f: exampleFile.filename - })); - rej(localize('config', 'checking-of-field-failed', { - fieldName: field.name, - m: moduleName, - f: exampleFile.filename - })); + logger.warn(`[CONFIGURATION] Field "${field.name}" in ${exampleFile.filename}${moduleName ? ` (module ${moduleName})` : ''} had an invalid stored value (${JSON.stringify(fieldValue)}); healed to default (${JSON.stringify(field.default)}).`); + return res(field.default); } if (field.disableKeyEdits && field.type === 'keyed') { for (const key in fieldValue) { @@ -377,8 +473,8 @@ async function checkType(field, value) { return typeof value === 'boolean'; default: logger.error(`Unknown type: ${field.type}`); - process.exit(0); - + process.exit(pick(78)); // a config field declares an unknown type: invalid config, never restart + ; } } @@ -404,17 +500,37 @@ function computeReloadIntentChange(client, modulesDir, logWarnings = true) { const dir = modulesDir || path.join(__dirname, '..', '..', 'modules'); const { names: required, - unknown + unknown, + allowedPrivileged, + disabledModules, + badAllowlistEntries } = computeRequiredIntents(client.configDir, dir); if (logWarnings && unknown.length) client.logger.warn(localize('config', 'intents-unknown', {intents: unknown.join(', ')})); + if (logWarnings && badAllowlistEntries.length) client.logger.warn(localize('config', 'allowlist-bad-entries', {entries: badAllowlistEntries.join(', ')})); const missingIntents = diffIntents(client._activeIntents || [], required); - const requiresRestart = missingIntents.length > 0; - if (logWarnings && requiresRestart) { + const allowAll = allowedPrivileged.length === 0; + + /* + * Still live on the connected gateway but no longer permitted by the on-disk allowlist. A live + * intent cannot be dropped without a reconnect, so this needs a restart too β€” on which + * applyIntentDisables will disable the modules that require it. + */ + const removedIntents = (client._activeIntents || []).filter(i => + PRIVILEGED_INTENTS.includes(i) && !allowAll && !allowedPrivileged.includes(i)); + const requiresRestart = missingIntents.length > 0 || removedIntents.length > 0; + if (logWarnings && missingIntents.length) { client.logger.warn(localize('config', 'intents-restart-required', {intents: missingIntents.join(', ')})); } + if (logWarnings && removedIntents.length) { + client.logger.warn(localize('config', 'intents-restart-required-removed', { + intents: removedIntents.join(', '), + modules: disabledModules.map(d => d.module).join(', ') || 'none' + })); + } return { requiresRestart, - missingIntents + missingIntents, + removedIntents }; } @@ -440,7 +556,6 @@ module.exports.reloadConfig = async function (client) { } client.jobs = []; - // Reload module configuration const moduleConf = jsonfile.readFileSync(`${client.configDir}/modules.json`); for (const moduleName in client.modules) { client.modules[moduleName].enabled = !!moduleConf[moduleName]; diff --git a/src/functions/exitCodes.js b/src/functions/exitCodes.js new file mode 100644 index 00000000..445cdc6b --- /dev/null +++ b/src/functions/exitCodes.js @@ -0,0 +1,96 @@ +/* + * Exit codes a supervisor reads to decide whether to restart: 0 = intentional stop, 1 = transient, + * 6x/78 = user-actionable fatals that must never be restarted. When in doubt, exit 1. Opt-in via + * exitCodesEnabled(), so the historical "always 0" behaviour is kept until a supervisor opts in. + */ +const EXIT = { + CLEAN_STOP: 0, + CRASH_TRANSIENT: 1, // retryable: network, 5xx, timeout, unknown crash + FATAL_INVALID_TOKEN: 64, + FATAL_INTENTS: 65, + FATAL_REMOVED: 66, // bot removed from its guild (confirmed, not a transient fetch failure) + FATAL_CONFIG: 78 +}; + +/** + * Classify a Discord login failure. + * @param {Error} e Error thrown by client.login + * @param {boolean} tokenMissing True when no token was configured at all + * @returns {string} MissingToken | InvalidToken | DisallowedIntents | Network | RateLimited | Unknown + */ +function classifyLoginError(e, tokenMissing) { + if (tokenMissing) return 'MissingToken'; + const code = e && e.code ? String(e.code) : ''; + const name = e && e.name ? String(e.name) : ''; + const msg = e && e.message ? String(e.message) : ''; + const haystack = `${code} ${name} ${msg}`.toLowerCase(); + if (haystack.includes('disallowed intent')) return 'DisallowedIntents'; + if (code === 'TokenInvalid' || haystack.includes('invalid token') || haystack.includes('an invalid token was provided')) return 'InvalidToken'; + if (['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET'].includes(code)) return 'Network'; + if (haystack.includes('getaddrinfo') || haystack.includes('network') || haystack.includes('fetch failed')) return 'Network'; + if (haystack.includes('rate limit') || haystack.includes('ratelimit') || haystack.includes('429')) return 'RateLimited'; + return 'Unknown'; +} + +const LOGIN_EXIT = { + MissingToken: EXIT.FATAL_INVALID_TOKEN, + InvalidToken: EXIT.FATAL_INVALID_TOKEN, + DisallowedIntents: EXIT.FATAL_INTENTS, + Network: EXIT.CRASH_TRANSIENT, + RateLimited: EXIT.CRASH_TRANSIENT, + Unknown: EXIT.CRASH_TRANSIENT +}; + +/** + * Exit code for a classifyLoginError class. An unrecognised class is transient, never fatal. + * @param {string} kind + * @returns {number} + */ +function loginErrorExitCode(kind) { + return Object.prototype.hasOwnProperty.call(LOGIN_EXIT, kind) ? LOGIN_EXIT[kind] : EXIT.CRASH_TRANSIENT; +} + +// Only the six UNRECOVERABLE close codes reach here; 4010/4011/4012 can't occur single-sharded. +const DISCONNECT_EXIT = { + 4004: EXIT.FATAL_INVALID_TOKEN, + 4013: EXIT.FATAL_INTENTS, + 4014: EXIT.FATAL_INTENTS +}; + +/** + * Exit code for a runtime gateway close code. Anything unmapped is transient. + * @param {number|string} code + * @returns {number} + */ +function disconnectExitCode(code) { + return Object.prototype.hasOwnProperty.call(DISCONNECT_EXIT, code) ? DISCONNECT_EXIT[code] : EXIT.CRASH_TRANSIENT; +} + +/** + * Whether the new exit-code convention is active. Evaluated lazily so it can be toggled in tests. + * @returns {boolean} + */ +function exitCodesEnabled() { + return process.argv.includes('--scnx-exit-codes') || process.env.SCNX_EXIT_CODES === '1'; +} + +/** + * New-convention code when the opt-in is present, legacy code otherwise. + * @param {number} newCode + * @param {number} [legacyCode=0] What this site exited before the convention existed + * @returns {number} + */ +function pick(newCode, legacyCode = 0) { + return exitCodesEnabled() ? newCode : legacyCode; +} + +module.exports = { + EXIT, + classifyLoginError, + loginErrorExitCode, + LOGIN_EXIT, + disconnectExitCode, + DISCONNECT_EXIT, + exitCodesEnabled, + pick +}; diff --git a/src/functions/helpers.js b/src/functions/helpers.js index 5c627a55..120ad9c2 100644 --- a/src/functions/helpers.js +++ b/src/functions/helpers.js @@ -127,7 +127,6 @@ function safeSetFooter(embed, client, customText = null, customIconURL = null) { const footerText = customText || (client.strings && client.strings.footer) || null; const footerIconURL = customIconURL || (client.strings && client.strings.footerImgUrl) || null; - // Only set footer if we have valid text (Discord.js requires text to be non-empty) if (footerText && footerText.trim().length > 0) { embed.setFooter({ text: footerText, @@ -263,7 +262,6 @@ function embedType(input, args = {}, optionsToKeep = {}, mergeComponentsRows = [ if (!embedData.footer?.disabled) { const footerText = inputReplacer(args, embedData.footer?.text, true) || (client.strings && client.strings.footer); const footerIconURL = (embedData.footer?.iconURL || (client.strings && client.strings.footerImgUrl) || '').trim() || undefined; - // Only create footer object if we have valid text if (footerText && footerText.trim().length > 0) { footer = { text: footerText, @@ -338,7 +336,6 @@ function embedTypeSchemaV2(input, args = {}, optionsToKeep = {}, mergeComponents if (!client.strings.disableFooterTimestamp && !input.embedTimestamp) emb.setTimestamp(); if (input.embedTimestamp) emb.setTimestamp(input.embedTimestamp); - // Safely set footer with null checks const footerText = input.footer ? inputReplacer(args, input.footer) : (client.strings && client.strings.footer); const footerIconURL = (input.footerImgUrl || (client.strings && client.strings.footerImgUrl) || '').trim() || undefined; if (footerText && footerText.trim().length > 0) { @@ -562,12 +559,10 @@ function buildV4Component(comp, args, counters) { const row = new ActionRowBuilder(); const firstChild = comp.components[0]; if (firstChild && firstChild.type === 3) { - // String select menu (max 1 per row) const select = buildV4StringSelect(firstChild, args, counters); if (!select) return null; row.addComponents(select); } else { - // Buttons (max 5 per row) const buttons = []; for (const btnComp of comp.components.slice(0, 5)) { if (btnComp.type !== 2) continue; @@ -714,7 +709,6 @@ function embedTypeSchemaV4(input, args = {}, optionsToKeep = {}, mergeComponents } } - // Check if a dynamicImage sentinel exists anywhere (including inside containers) if ((input.components || []).some(function findSentinel(c) { return c.type === 'dynamicImage' || (Array.isArray(c.components) && c.components.some(findSentinel)); })) optionsToKeep._hasDynamicImagePlaceholder = true; @@ -1008,7 +1002,6 @@ async function postToSCNetworkPaste(content, opts = { module.exports.postToSCNetworkPaste = postToSCNetworkPaste; module.exports.PasteUploadError = PasteUploadError; -// Internal building blocks exposed for unit tests; not part of the public bot API. module.exports.__test = { base58Encode, encryptPrivatebinPaste, @@ -1219,9 +1212,7 @@ module.exports.checkForUpdates = checkForUpdates; * @returns {number} Random integer */ function randomIntFromInterval(min, max) { - // Cryptographically secure, unbiased integer in [min, max] inclusive. - // crypto.randomInt does rejection sampling internally (no modulo bias) and is - // unpredictable, unlike Math.random. Tolerant of swapped args / non-integers. + // crypto.randomInt rejection-samples internally, so no modulo bias (unlike Math.random). const lo = Math.ceil(Math.min(min, max)); const hi = Math.floor(Math.max(min, max)); return hi > lo ? crypto.randomInt(lo, hi + 1) : lo; @@ -1505,4 +1496,29 @@ module.exports.memberCanSendInChannel = function (member, channel) { if (!perms.has(PermissionFlagsBits.ViewChannel)) return false; const isThread = typeof channel.isThread === 'function' && channel.isThread(); return perms.has(isThread ? PermissionFlagsBits.SendMessagesInThreads : PermissionFlagsBits.SendMessages); -}; \ No newline at end of file +}; + +/** + * Total member count, correct with or without the GuildMembers intent (guild.memberCount is + * maintained by Discord independently of the member cache). + * @param {Guild} guild + * @returns {Number} + */ +function memberCountOrFallback(guild) { + return typeof guild.memberCount === 'number' ? guild.memberCount : guild.members.cache.size; +} + +/** + * Online-member count, or null when GuildPresences is inactive, so callers can omit the figure + * instead of rendering a misleading 0 built from an empty presence cache. + * @param {Client} client + * @param {Guild} guild + * @returns {Number|null} + */ +function onlineCountOrNull(client, guild) { + if (!(client._activeIntents || []).includes('GuildPresences')) return null; + return guild.members.cache.filter(m => m.presence && ['online', 'idle', 'dnd'].includes(m.presence.status)).size; +} + +module.exports.memberCountOrFallback = memberCountOrFallback; +module.exports.onlineCountOrNull = onlineCountOrNull; \ No newline at end of file diff --git a/src/functions/intents.js b/src/functions/intents.js index 5ed40449..44e0370b 100644 --- a/src/functions/intents.js +++ b/src/functions/intents.js @@ -74,15 +74,62 @@ function customCommandIntents(confDir) { return [...new Set(needed)]; } -// Union the enabled modules' declared intents with the base set, apply the pairing rule, then resolve. +const PRIVILEGED_INTENTS = ['GuildMembers', 'GuildPresences', 'MessageContent']; + +// Operator-declared allowlist from config.json. Missing/non-array => [] (= all allowed). +function readAllowedPrivilegedIntents(confDir) { + let config; + try { + config = jsonfile.readFileSync(path.join(confDir, 'config.json')); + } catch { + return []; + } + return Array.isArray(config.allowedPrivilegedIntents) ? config.allowedPrivilegedIntents : []; +} + +// Splits a raw allowlist into valid privileged names (deduped, order-preserved) and invalid entries. +function partitionAllowlist(list) { + const allowed = []; + const bad = []; + for (const name of (Array.isArray(list) ? list : [])) { + if (PRIVILEGED_INTENTS.includes(name)) { + if (!allowed.includes(name)) allowed.push(name); + } else bad.push(name); + } + return { + allowed, + bad + }; +} + +/** + * Unions the enabled modules' declared intents with the base set, applies the pairing rule, then + * validates/resolves. Privileged intents are gated by the operator allowlist in config.json: a + * module whose REQUIRED privileged intent is not allowed is disabled entirely, one for which it is + * only OPTIONAL is degraded (stays active, the intent is dropped). + * @param {String} confDir Directory containing modules.json and custom-commands.json + * @param {String} modulesDir Directory containing module subfolders + * @returns {{names: String[], flags: Number[], unknown: String[], pairingInjected: Boolean, + * allowedPrivileged: String[], disabledModules: Object[], degradedModules: Object[], + * droppedPrivileged: String[], badAllowlistEntries: String[]}} + */ function computeRequiredIntents(confDir, modulesDir) { + const { + allowed: allowedPrivileged, + bad: badAllowlistEntries + } = partitionAllowlist(readAllowedPrivilegedIntents(confDir)); + const allowAll = allowedPrivileged.length === 0; // empty OR all-invalid => everything allowed + const isAllowed = (intent) => !PRIVILEGED_INTENTS.includes(intent) || allowAll || allowedPrivileged.includes(intent); + let moduleConf = {}; try { moduleConf = jsonfile.readFileSync(path.join(confDir, 'modules.json')); } catch { moduleConf = {}; } - const declared = []; + const disabledModules = []; + const degradedModules = []; + const declared = []; // only from ACTIVE modules for (const name of Object.keys(moduleConf)) { if (!moduleConf[name]) continue; let moduleJson; @@ -91,22 +138,42 @@ function computeRequiredIntents(confDir, modulesDir) { } catch { continue; } - if (Array.isArray(moduleJson.intents)) declared.push(...moduleJson.intents); + const intents = Array.isArray(moduleJson.intents) ? moduleJson.intents : []; + const optional = Array.isArray(moduleJson.optionalIntents) ? moduleJson.optionalIntents : []; + const privileged = intents.filter(i => PRIVILEGED_INTENTS.includes(i)); + const missingRequired = privileged.filter(i => !optional.includes(i) && !isAllowed(i)); + if (missingRequired.length) { + disabledModules.push({ + module: name, + missingRequired + }); + continue; + } + const missingOptional = privileged.filter(i => optional.includes(i) && !isAllowed(i)); + if (missingOptional.length) degradedModules.push({ + module: name, + missingOptional + }); + declared.push(...intents); } declared.push(...customCommandIntents(confDir)); const { names: paired, injected } = applyPairingRule([...new Set(declared)]); - const resolved = resolveIntents(paired); + const droppedPrivileged = [...new Set(paired.filter(i => PRIVILEGED_INTENTS.includes(i) && !isAllowed(i)))]; + const resolved = resolveIntents(paired.filter(isAllowed)); return { ...resolved, - pairingInjected: injected + pairingInjected: injected, + allowedPrivileged, + disabledModules, + degradedModules, + droppedPrivileged, + badAllowlistEntries }; } -const PRIVILEGED_INTENTS = ['GuildMembers', 'GuildPresences', 'MessageContent']; - // Per privileged intent, the enabled modules requiring it with each module's declared reason. function privilegedIntentUsage(confDir, modulesDir = path.join(__dirname, '..', '..', 'modules')) { const out = {}; @@ -116,6 +183,10 @@ function privilegedIntentUsage(confDir, modulesDir = path.join(__dirname, '..', out[intent].push(entry); } + const {allowed: allowedPrivileged} = partitionAllowlist(readAllowedPrivilegedIntents(confDir)); + const allowAll = allowedPrivileged.length === 0; + const granted = (intent) => allowAll || allowedPrivileged.includes(intent); + let moduleConf = {}; try { moduleConf = jsonfile.readFileSync(path.join(confDir, 'modules.json')); @@ -132,12 +203,15 @@ function privilegedIntentUsage(confDir, modulesDir = path.join(__dirname, '..', } const intents = Array.isArray(moduleJson.intents) ? moduleJson.intents : []; const reasons = (moduleJson.intentReasons && typeof moduleJson.intentReasons === 'object') ? moduleJson.intentReasons : {}; + const optional = Array.isArray(moduleJson.optionalIntents) ? moduleJson.optionalIntents : []; for (const intent of PRIVILEGED_INTENTS) { if (!intents.includes(intent)) continue; add(intent, { module: name, name: moduleJson.humanReadableName || name, - reason: reasons[intent] || null + reason: reasons[intent] || null, + granted: granted(intent), + optional: optional.includes(intent) }); } } @@ -145,7 +219,9 @@ function privilegedIntentUsage(confDir, modulesDir = path.join(__dirname, '..', add('MessageContent', { module: 'custom-commands', name: 'Custom commands', - reason: 'Message-trigger auto-responders read message text to decide when to reply.' + reason: 'Message-trigger auto-responders read message text to decide when to reply.', + granted: granted('MessageContent'), + optional: false }); } return out; @@ -161,5 +237,7 @@ module.exports = { applyPairingRule, customCommandIntents, computeRequiredIntents, - privilegedIntentUsage + privilegedIntentUsage, + readAllowedPrivilegedIntents, + partitionAllowlist }; diff --git a/src/functions/migrations/runMigrations.js b/src/functions/migrations/runMigrations.js index 20be5ae9..5a8a7439 100644 --- a/src/functions/migrations/runMigrations.js +++ b/src/functions/migrations/runMigrations.js @@ -80,52 +80,66 @@ function buildUmzug(client, dir, options = {}) { getModel: () => client.models['DatabaseSchemeVersion'] }); const {writtenThisBoot} = options; - return new Umzug({ - migrations: { - glob: path.join(dir, '*.js'), - resolve: ({ - name, - path: filePath, - context - }) => { - const stripped = name.replace(/\.js$/u, ''); - return { - name: stripped, - up: async () => { - const mig = await loadMigrationFile(filePath); - if (Array.isArray(mig.tables) && mig.tables.length > 0) { - try { - const written = await backupTables(client, context.sequelize, stripped, mig.tables); - if (writtenThisBoot) for (const p of written) writtenThisBoot.add(path.basename(p)); - } catch (backupErr) { - const e = new Error( - `[migrations] Cannot take pre-migration backup for ${stripped}: ${backupErr.message}. ` + - 'Free disk space (or fix permissions on the migration-backups directory) and retry.' - ); - e.cause = backupErr; - throw e; - } - } - return mig.up({ - name: stripped, - context - }); - }, - down: async () => { - const mig = await loadMigrationFile(filePath); - return mig.down({ - name: stripped, - context - }); + const context = { + sequelize, + queryInterface: sequelize.getQueryInterface(), + client + }; + + const resolveMigration = (filePath, name) => { + const stripped = name.replace(/\.js$/u, ''); + return { + name: stripped, + up: async () => { + const mig = await loadMigrationFile(filePath); + if (Array.isArray(mig.tables) && mig.tables.length > 0) { + try { + const written = await backupTables(client, context.sequelize, stripped, mig.tables); + if (writtenThisBoot) for (const p of written) writtenThisBoot.add(path.basename(p)); + } catch (backupErr) { + const e = new Error( + `[migrations] Cannot take pre-migration backup for ${stripped}: ${backupErr.message}. ` + + 'Free disk space (or fix permissions on the migration-backups directory) and retry.' + ); + e.cause = backupErr; + throw e; } - }; + } + return mig.up({ + name: stripped, + context + }); + }, + down: async () => { + const mig = await loadMigrationFile(filePath); + return mig.down({ + name: stripped, + context + }); } - }, - context: { - sequelize, - queryInterface: sequelize.getQueryInterface(), - client - }, + }; + }; + + /* + * When the caller passes an explicit list of absolute paths (runAllMigrations always does), + * build the migrations array directly. Umzug's glob options are version- and platform-dependent + * (backslash paths on Windows; `{name}.js` is a literal without a comma), and both silently drop + * or re-include files β€” an explicit array removes that matching entirely. Sorted by basename so + * V1 runs before V2. Direct callers passing no list fall back to the directory glob. + */ + const migrations = Array.isArray(options.files) + ? options.files + .slice() + .sort((a, b) => (path.basename(a) < path.basename(b) ? -1 : 1)) + .map(filePath => resolveMigration(filePath, path.basename(filePath))) + : { + glob: path.join(dir, '*.js'), + resolve: ({name, path: filePath}) => resolveMigration(filePath, name) + }; + + return new Umzug({ + migrations, + context, storage, logger: { info: (m) => client.logger.info(typeof m === 'string' ? m : JSON.stringify(m)), @@ -158,7 +172,11 @@ async function runAllMigrations(client, hooks = {}) { const fileNames = migrationFileNames(dir); if (fileNames.length === 0) continue; - const umzug = buildUmzug(client, dir, {writtenThisBoot}); + // Hand umzug the exact absolute file list rather than a glob (see buildUmzug). + const umzug = buildUmzug(client, dir, { + writtenThisBoot, + files: fileNames.map(name => path.join(dir, `${name}.js`)) + }); const pending = await umzug.pending(); if (pending.length === 0) { client.logger.debug(`[migrations:${moduleName}] up to date`); diff --git a/src/functions/secure-storage/fields.js b/src/functions/secure-storage/fields.js index ef9ac46d..a7f699e8 100644 --- a/src/functions/secure-storage/fields.js +++ b/src/functions/secure-storage/fields.js @@ -1,13 +1,8 @@ /* - * The registry of columns protected by the secure-storage layer. These columns are declared TEXT and - * the hooks (de)serialize their JSON/int values into that text. On the managed backend the same - * columns are additionally encrypted at rest; locally the encryption is a no-op. - * - * module : module folder name, or null for a core model under src/models/ - * model : the registration key (config.name) under client.models[module][key] - * file : model filename without extension, when it differs from `model` - * name : the live Sequelize model.name; pinned so an accidental class rename fails a test - * fields : { fieldName: 'string' | 'json' | 'int' } drives the (de)serialization + * Registry of columns protected by the secure-storage layer. Declared TEXT; the hooks (de)serialize + * their JSON/int values into that text. Entry keys: `module` (folder, null for a core model), + * `model` (registration key), `file` (filename when it differs), `name` (live model.name, pinned so + * a class rename fails a test), `fields` ({name: 'string'|'json'|'int'}). */ const VALID_TYPES = ['string', 'json', 'int']; diff --git a/tests/betterstatus/botReady.test.js b/tests/betterstatus/botReady.test.js index 6168e259..c55a5229 100644 --- a/tests/betterstatus/botReady.test.js +++ b/tests/betterstatus/botReady.test.js @@ -14,11 +14,11 @@ const {Collection} = require('discord.js'); const botReady = require('../../modules/betterstatus/events/botReady'); function makeMember({ - bot = false, - status = 'online', - username = 'user', - discriminator = '0' - } = {}) { + bot = false, + status = 'online', + username = 'user', + discriminator = '0' +} = {}) { return { presence: status ? {status} : null, user: { @@ -33,7 +33,8 @@ function makeClient(config, { members = [], roleCount = 3, channelCount = 4, - memberCount = 10 + memberCount = 10, + activeIntents = ['GuildMembers', 'GuildPresences'] } = {}) { const cache = new Collection(); members.forEach((m, i) => cache.set(String(i), m)); @@ -41,6 +42,7 @@ function makeClient(config, { intervals: [], config: {user_presence: 'Hi %memberCount%'}, configurations: {betterstatus: {config}}, + _activeIntents: activeIntents, guild: { memberCount, members: {cache}, @@ -108,6 +110,63 @@ test('replaces %randomMemberTag% using the username#discriminator form', async ( expect(client.user.setActivity.mock.calls[0][0]).toBe('T:alice#1234'); }); +describe('degraded rendering (GuildMembers/GuildPresences withheld - both are optional for this module)', () => { + test('without GuildPresences, %onlineMemberCount% is the neutral N/A token, never a false 0', async () => { + const client = makeClient(baseConf(), { + members: [makeMember({status: 'online'}), makeMember({status: 'dnd'})], + memberCount: 42, + activeIntents: ['GuildMembers'] // GuildPresences withheld + }); + client.config.user_presence = 'O:%onlineMemberCount%'; + await botReady.run(client); + expect(client.user.setActivity.mock.calls[0][0]).toBe('O:N/A'); + }); + + test('without GuildMembers, %randomMemberTag% is the neutral N/A token and does not throw', async () => { + const client = makeClient(baseConf(), { + members: [makeMember({username: 'alice', discriminator: '1234'})], + activeIntents: ['GuildPresences'] // GuildMembers withheld + }); + client.config.user_presence = 'T:%randomMemberTag%'; + await expect(botReady.run(client)).resolves.toBeUndefined(); + expect(client.user.setActivity.mock.calls[0][0]).toBe('T:N/A'); + }); + + test('without GuildMembers, %memberCount% still reports the correct total via guild.memberCount (not the empty cache)', async () => { + const client = makeClient(baseConf(), { + members: [], + memberCount: 250, + activeIntents: ['GuildPresences'] + }); + client.config.user_presence = 'M:%memberCount%'; + await botReady.run(client); + expect(client.user.setActivity.mock.calls[0][0]).toBe('M:250'); + }); + + test('without GuildPresences, %randomOnlineMemberTag% is the neutral N/A token (not the bot\'s own tag)', async () => { + const client = makeClient(baseConf(), { + members: [makeMember({username: 'alice', discriminator: '1234'})], + activeIntents: ['GuildMembers'] // GuildPresences withheld + }); + client.config.user_presence = 'R:%randomOnlineMemberTag%'; + await botReady.run(client); + expect(client.user.setActivity.mock.calls[0][0]).toBe('R:N/A'); + }); + + test('happy path (both intents active) is unchanged', async () => { + const client = makeClient(baseConf(), { + members: [makeMember({status: 'online'}), makeMember({status: 'dnd'}), makeMember({status: null})], + roleCount: 7, + channelCount: 5, + memberCount: 42, + activeIntents: ['GuildMembers', 'GuildPresences'] + }); + client.config.user_presence = 'M:%memberCount% O:%onlineMemberCount% C:%channelCount% R:%roleCount%'; + await botReady.run(client); + expect(client.user.setActivity.mock.calls[0][0]).toBe('M:42 O:2 C:5 R:7'); + }); +}); + test('registers an interval when enableInterval is set and clamps below 5s', async () => { jest.useFakeTimers(); const setIntervalSpy = jest.spyOn(global, 'setInterval'); diff --git a/tests/channel-stats/channelNameReplacer.test.js b/tests/channel-stats/channelNameReplacer.test.js index d8d6cebe..ad961f41 100644 --- a/tests/channel-stats/channelNameReplacer.test.js +++ b/tests/channel-stats/channelNameReplacer.test.js @@ -12,11 +12,11 @@ const {Collection} = require('discord.js'); const {channelNameReplacer} = require('../../modules/channel-stats/events/botReady'); function member({ - bot = false, - status = null, - roles = [], - premium = false - } = {}) { + bot = false, + status = null, + roles = [], + premium = false +} = {}) { return { user: {bot}, presence: status ? {status} : null, @@ -25,7 +25,7 @@ function member({ }; } -function buildClient(members) { +function buildClient(members, activeIntents = ['Guilds', 'GuildMembers', 'GuildPresences']) { const cache = new Collection(); members.forEach((m, i) => cache.set(String(i), m)); const guild = { @@ -36,7 +36,10 @@ function buildClient(members) { premiumTier: 2 }; return { - client: {guild: {members: {cache}}}, + client: { + guild: {members: {cache}}, + _activeIntents: activeIntents + }, channel: {guild} }; } @@ -84,6 +87,30 @@ test('online member count excludes bots', async () => { expect(await channelNameReplacer(client, channel, '%onlineMemberCount%')).toBe('1'); }); +test('renders N/A (not a false 0) for %onlineMemberCount% / %onlineUserCount% when GuildPresences intent is inactive', async () => { + const { + client, + channel + } = buildClient([ + member({status: 'online'}) + ], ['Guilds', 'GuildMembers']); + expect(await channelNameReplacer(client, channel, '%onlineMemberCount%')).toBe('N/A'); + expect(await channelNameReplacer(client, channel, '%onlineUserCount%')).toBe('N/A'); +}); + +test('renders N/A (not a false/under-reported count) for %memberCount% (non-bot) and %botCount% when GuildMembers intent is inactive', async () => { + const { + client, + channel + } = buildClient([ + member(), member({bot: true}) + ], ['Guilds', 'GuildPresences']); + // %userCount% (the guild total) stays correct via memberCountOrFallback regardless of GuildMembers. + expect(await channelNameReplacer(client, channel, 'U:%userCount%')).not.toBe('N/A'); + expect(await channelNameReplacer(client, channel, 'M:%memberCount%')).toBe('M:N/A'); + expect(await channelNameReplacer(client, channel, 'B:%botCount%')).toBe('B:N/A'); +}); + test('role-scoped counts resolve a specific role id', async () => { const { client, @@ -100,6 +127,43 @@ test('role-scoped counts resolve a specific role id', async () => { expect(await channelNameReplacer(client, channel, '%onlineUserWithRoleCount-role-a%')).toBe('1'); }); +test('renders N/A (not a false 0) for %userWithRoleCount-% when GuildMembers intent is inactive', async () => { + const { + client, + channel + } = buildClient([ + member({roles: ['role-a']}), + member({roles: ['role-a']}) + ], ['Guilds', 'GuildPresences']); + expect(await channelNameReplacer(client, channel, '%userWithRoleCount-role-a%')).toBe('N/A'); +}); + +test('renders N/A for %onlineUserWithRoleCount-% when GuildPresences intent is inactive', async () => { + const { + client, + channel + } = buildClient([ + member({ + roles: ['role-a'], + status: 'online' + }) + ], ['Guilds', 'GuildMembers']); + expect(await channelNameReplacer(client, channel, '%onlineUserWithRoleCount-role-a%')).toBe('N/A'); +}); + +test('renders N/A for %onlineUserWithRoleCount-% when GuildMembers intent is inactive', async () => { + const { + client, + channel + } = buildClient([ + member({ + roles: ['role-a'], + status: 'online' + }) + ], ['Guilds', 'GuildPresences']); + expect(await channelNameReplacer(client, channel, '%onlineUserWithRoleCount-role-a%')).toBe('N/A'); +}); + test('replaces multiple distinct role placeholders recursively', async () => { const { client, diff --git a/tests/commandTypes/commandTypes.test.js b/tests/commandTypes/commandTypes.test.js new file mode 100644 index 00000000..9b700302 --- /dev/null +++ b/tests/commandTypes/commandTypes.test.js @@ -0,0 +1,212 @@ +/* + * Tests for src/functions/commandTypes.js β€” the command registration rules Discord enforces: + * - resolveCommandType maps a module's declared `type` string to the numeric enum. Sending a + * string type, or defaulting a context command to ChatInput, is a 400 that aborts the whole sync. + * - partitionCommands splits slash from context commands, strips the fields Discord forbids on + * USER/MESSAGE, and enforces the per-type registration caps. + */ +const {ApplicationCommandType} = require('discord.js'); +const { + USER_LIMIT, + MESSAGE_LIMIT, + resolveCommandType, + contextTypeOf, + partitionCommands +} = require('../../src/functions/commandTypes'); + +// Discord's CHAT_INPUT name rule; context-menu names are exempt (mixed case + spaces allowed). +const CHAT_INPUT_NAME = /^[-_'\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u; + +describe('resolveCommandType', () => { + test('a missing type defaults to ChatInput', () => { + expect(resolveCommandType(undefined)).toBe(ApplicationCommandType.ChatInput); + expect(resolveCommandType(null)).toBe(ApplicationCommandType.ChatInput); + expect(resolveCommandType('')).toBe(ApplicationCommandType.ChatInput); + }); + + test('resolves the ALL-CAPS forms modules actually declare', () => { + expect(resolveCommandType('USER')).toBe(ApplicationCommandType.User); + expect(resolveCommandType('MESSAGE')).toBe(ApplicationCommandType.Message); + }); + + test('is case-insensitive', () => { + for (const t of ['user', 'User', 'uSeR']) expect(resolveCommandType(t)).toBe(ApplicationCommandType.User); + for (const t of ['message', 'Message']) expect(resolveCommandType(t)).toBe(ApplicationCommandType.Message); + }); + + test('maps the slash aliases to ChatInput (the enum member is named ChatInput, not Slash)', () => { + for (const t of ['SLASH', 'slash', 'CHAT_INPUT', 'ChatInput', 'CHATINPUT']) { + expect(resolveCommandType(t)).toBe(ApplicationCommandType.ChatInput); + } + }); + + test('passes an already-numeric type through unchanged', () => { + expect(resolveCommandType(ApplicationCommandType.User)).toBe(ApplicationCommandType.User); + expect(resolveCommandType(ApplicationCommandType.ChatInput)).toBe(ApplicationCommandType.ChatInput); + }); + + test('an unknown type string is returned unchanged (caller/Discord surfaces it)', () => { + expect(resolveCommandType('SOMETHING_NEW')).toBe('SOMETHING_NEW'); + }); + + test('never returns a string for a known type', () => { + for (const t of ['USER', 'MESSAGE', 'SLASH', 'CHAT_INPUT']) { + expect(typeof resolveCommandType(t)).toBe('number'); + } + }); +}); + +describe('contextTypeOf', () => { + test('classifies normalized commands by their numeric type', () => { + expect(contextTypeOf({type: ApplicationCommandType.User})).toBe('USER'); + expect(contextTypeOf({type: ApplicationCommandType.Message})).toBe('MESSAGE'); + expect(contextTypeOf({type: ApplicationCommandType.ChatInput})).toBeNull(); + expect(contextTypeOf({})).toBeNull(); + }); +}); + +describe('partitionCommands', () => { + const ctx = (name, type) => ({ + name, + type, + description: 'a description', + options: [{name: 'opt'}], + module: 'm' + }); + + test('separates slash from context commands', () => { + const {slash, context} = partitionCommands([ + { + name: 'ping', + type: ApplicationCommandType.ChatInput, + description: 'd' + }, + ctx('View Level Profile', ApplicationCommandType.User) + ]); + expect(slash.map(c => c.name)).toEqual(['ping']); + expect(context.map(c => c.name)).toEqual(['View Level Profile']); + }); + + test('strips description and options from context commands (Discord forbids both)', () => { + const {context} = partitionCommands([ctx('Close Ticket', ApplicationCommandType.Message)]); + expect(context[0]).not.toHaveProperty('description'); + expect(context[0]).not.toHaveProperty('options'); + expect(context[0].name).toBe('Close Ticket'); + expect(context[0].type).toBe(ApplicationCommandType.Message); + }); + + test('leaves slash commands untouched', () => { + const command = { + name: 'ping', + type: ApplicationCommandType.ChatInput, + description: 'd', + options: [{name: 'o'}] + }; + const {slash} = partitionCommands([command]); + expect(slash[0]).toEqual(command); + }); + + test('caps USER context commands at the Discord limit and reports the rest', () => { + const input = Array.from({length: USER_LIMIT + 4}, (_, i) => ctx(`User Cmd ${i}`, ApplicationCommandType.User)); + const {context, dropped} = partitionCommands(input); + expect(context).toHaveLength(USER_LIMIT); + expect(dropped).toHaveLength(4); + expect(dropped.every(d => d.type === 'USER')).toBe(true); + }); + + test('caps MESSAGE context commands independently of USER', () => { + const input = [ + ...Array.from({length: MESSAGE_LIMIT + 2}, (_, i) => ctx(`Msg Cmd ${i}`, ApplicationCommandType.Message)), + ...Array.from({length: 3}, (_, i) => ctx(`User Cmd ${i}`, ApplicationCommandType.User)) + ]; + const {context, dropped} = partitionCommands(input); + expect(context.filter(c => c.type === ApplicationCommandType.Message)).toHaveLength(MESSAGE_LIMIT); + expect(context.filter(c => c.type === ApplicationCommandType.User)).toHaveLength(3); + expect(dropped).toHaveLength(2); + }); + + test('slash commands are never capped by the context limits', () => { + const input = Array.from({length: USER_LIMIT + 10}, (_, i) => ({ + name: `cmd-${i}`, + type: ApplicationCommandType.ChatInput, + description: 'd' + })); + const {slash, dropped} = partitionCommands(input); + expect(slash).toHaveLength(USER_LIMIT + 10); + expect(dropped).toEqual([]); + }); + + test('drops a duplicate context name for the same type (Discord 400s on duplicates)', () => { + const {context, collisions} = partitionCommands([ + ctx('Report', ApplicationCommandType.User), + ctx('report', ApplicationCommandType.User) + ]); + expect(context).toHaveLength(1); + expect(collisions).toHaveLength(1); + expect(collisions[0].name).toBe('report'); + }); + + test('the same name under different context types is not a collision', () => { + const {context, collisions} = partitionCommands([ + ctx('Report', ApplicationCommandType.User), + ctx('Report', ApplicationCommandType.Message) + ]); + expect(context).toHaveLength(2); + expect(collisions).toEqual([]); + }); +}); + +describe('every shipped context command survives the real pipeline', () => { + const fs = require('fs'); + const path = require('path'); + const modulesDir = path.join(__dirname, '..', '..', 'modules'); + + /* + * Reads each command file's declared config WITHOUT requiring it (the files pull in main.js, + * which boots the bot). Regression guard for the 50035 incident: a context command that loses + * its type is submitted as CHAT_INPUT, and its mixed-case, space-containing name then fails + * Discord's slash-name regex, aborting the entire command sync. + */ + function declaredConfigs() { + const out = []; + for (const moduleName of fs.readdirSync(modulesDir)) { + const commandsDir = path.join(modulesDir, moduleName, 'commands'); + if (!fs.existsSync(commandsDir)) continue; + for (const file of fs.readdirSync(commandsDir).filter(f => f.endsWith('.js'))) { + const src = fs.readFileSync(path.join(commandsDir, file), 'utf8'); + if (!/contextMenu:\s*true/u.test(src)) continue; + const name = /name:\s*'((?:[^'\\]|\\.)*)'/u.exec(src); + const type = /type:\s*'([A-Za-z_]+)'/u.exec(src); + out.push({ + file: `${moduleName}/${file}`, + name: name && name[1], + type: type && type[1] + }); + } + } + return out; + } + + const configs = declaredConfigs(); + + test('there is at least one context command to check', () => { + expect(configs.length).toBeGreaterThan(0); + }); + + test.each(configs.map(c => [c.file, c]))('%s resolves to a context type, never ChatInput', (_file, config) => { + expect(config.type).toBeTruthy(); + const resolved = resolveCommandType(config.type); + expect(typeof resolved).toBe('number'); + expect(resolved).not.toBe(ApplicationCommandType.ChatInput); + expect(contextTypeOf({type: resolved})).not.toBeNull(); + }); + + test('context command names would be rejected as slash names (why the type must survive)', () => { + const wouldFail = configs.filter(c => !CHAT_INPUT_NAME.test(c.name) || c.name !== c.name.toLowerCase()); + expect(wouldFail.length).toBeGreaterThan(0); + }); + + test('every context name is within Discord\'s 32-character limit', () => { + for (const c of configs) expect(c.name.length).toBeLessThanOrEqual(32); + }); +}); diff --git a/tests/configuration/checkConfigFile.test.js b/tests/configuration/checkConfigFile.test.js new file mode 100644 index 00000000..d53fc975 --- /dev/null +++ b/tests/configuration/checkConfigFile.test.js @@ -0,0 +1,1483 @@ +// Tests for the configuration loader's stateful, fs/require-backed functions, +// exercised exclusively through the module's PUBLIC surface: +// - loadAllConfigs(client) -> drives checkConfigFile (builtIn) + checkModuleConfig +// - reloadConfig(client) -> drives loadAllConfigs + the scnx hooks +// +// checkConfigFile and checkModuleConfig are not exported, so they are reached +// indirectly: builtIn files via fs.readdir(config-generator), module files via +// each enabled module's module.json `config-example-files` list. +// +// checkType / isLocalizedObject / loadConfigLocalization are covered elsewhere +// (checkType.test.js, pure.test.js) and are NOT re-tested here. +// +// Strategy: configuration.js dynamically require()s its example config files by +// absolute path (`${__dirname}/../../config-generator/` or the module +// equivalent), its module.json files, and `./scnx-integration`. We mock those +// via jest.doMock on the RESOLVED absolute paths, mock `jsonfile` and `fs`, and +// run each case inside jest.isolateModulesAsync so mocks are fresh and the +// module-load-time destructure of `logger`/`client` from the main stub picks up +// our per-test client. + +const path = require('path'); + +// configuration.js destructures `logger`/`client` from the main stub at require +// time. Because each case runs inside an isolated module registry, the stub is +// configured there (see withConfig) rather than via a top-level reference. + +const SRC_DIR = path.resolve(__dirname, '../../src/functions'); +const ROOT = path.resolve(__dirname, '../..'); + +// Paths built EXACTLY as configuration.js builds them via string concatenation +// with its own __dirname (note the unnormalized `../..`). jest.doMock keys must +// match the require() request string, so we reproduce it verbatim here. +const generatorPath = (file) => `${SRC_DIR}/../../config-generator/${file}`; +const modulePath = (moduleName, file) => `${SRC_DIR}/../../modules/${moduleName}/${file}`; +// module.json is required relatively (`../../modules//module.json`) from +// configuration.js, which resolves to this absolute path. +const moduleJsonPath = (moduleName) => path.join(ROOT, 'modules', moduleName, 'module.json'); +// Extensionless: scnx-integration.js ships only with the managed backend, so this is a VIRTUAL +// mock and jest keys it by the exact request string configuration.js uses (`./scnx-integration`). +const scnxPath = path.join(SRC_DIR, 'scnx-integration'); + +// Let the not-yet-awaited fs.readdir callback in loadAllConfigs drain its +// awaits (real checkType promises + sync jsonfile) before assertions run. +const flush = () => new Promise((r) => setImmediate(r)); + +function makeLogger() { + return { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + }; +} + +function makeClient(over = {}) { + const logger = makeLogger(); + return { + logger, + locale: 'en', + configDir: '/cfg', + scnxSetup: false, + modules: {}, + configurations: {}, + intervals: [], + jobs: [], + config: {}, + emit: jest.fn(), + ...over + }; +} + +// Run `driver(cfg)` inside a freshly-isolated module registry with a configured +// main stub and mocked jsonfile/fs/scnx-integration + registered example files. +// +// The driver MUST run inside the isolation: configuration.js re-requires +// `../../main` at call time (`const {client} = require('../../main')`), and once +// isolateModulesAsync exits that require would resolve from the outer registry, +// returning a stub instance whose `.client` is not the one we configured. +// +// Returns { result, mocks } where mocks = {jsonfileMock, fsMock, scnxMock}. +async function withConfig({ + client, + jsonfile = {}, + fs = {}, + scnx = {}, + mocks = [], // [{ path, content }] β€” virtual require()d modules + driver +} = {}) { + let out = { + result: undefined, + mocks: undefined + }; + await jest.isolateModulesAsync(async () => { + // Inside an isolated registry the ../../main stub is a *fresh* instance, + // so configure that one (not the top-level reference) before requiring + // configuration, which destructures logger/client from it at load time. + const isolatedMain = require('../__stubs__/main'); + isolatedMain.client = client; + isolatedMain.logger = client.logger; + + const jsonfileMock = { + readFileSync: jest.fn(() => { + throw new Error('ENOENT'); + }), + writeFileSync: jest.fn(), + ...jsonfile + }; + const fsMock = { + readdir: jest.fn((dir, cb) => cb(null, [])), + existsSync: jest.fn().mockReturnValue(true), + mkdirSync: jest.fn(), + readFileSync: jest.fn(() => { + throw new Error('no-loc-file'); + }), + ...fs + }; + const scnxMock = { + reportIssue: jest.fn().mockResolvedValue(undefined), + setFieldValue: jest.fn((c, f, v) => v), + verifyLimitedConfigElementFile: jest.fn((c, e, d) => d), + beforeInit: jest.fn().mockResolvedValue(undefined), + init: jest.fn().mockResolvedValue(undefined), + verifyCustomCommands: jest.fn().mockResolvedValue(undefined), + ...scnx + }; + + jest.doMock('jsonfile', () => jsonfileMock); + jest.doMock('fs', () => fsMock); + jest.doMock(scnxPath, () => scnxMock, {virtual: true}); + + for (const m of mocks) { + // eslint-disable-next-line no-loop-func + jest.doMock(m.path, () => m.content, {virtual: true}); + } + + const cfg = require('../../src/functions/configuration'); + out.mocks = { + jsonfileMock, + fsMock, + scnxMock + }; + if (driver) out.result = await driver(cfg); + // Drain the not-yet-awaited fs.readdir callback in loadAllConfigs. + await flush(); + }); + return out; +} + +// Read back the single config object/array that was last written. +function lastWrite(mocks) { + const calls = mocks.jsonfileMock.writeFileSync.mock.calls; + return calls.length ? calls[calls.length - 1][1] : undefined; +} + +afterEach(() => { + jest.resetModules(); + jest.restoreAllMocks(); +}); + + +// --------------------------------------------------------------------------- +// checkConfigFile β€” driven through loadAllConfigs(client) with a single builtIn +// file returned by fs.readdir, and no enabled modules. +// --------------------------------------------------------------------------- + +// Drive a single builtIn config file through loadAllConfigs and return +// {result, mocks, written} where `written` is the last writeFileSync payload. +async function runBuiltIn(client, file, example, opts = {}) { + const {mocks} = await withConfig({ + client, + jsonfile: opts.jsonfile, + fs: {readdir: jest.fn((dir, cb) => cb(null, [file])), ...(opts.fs || {})}, + scnx: opts.scnx, + mocks: [{ + path: generatorPath(file), + content: example + }, ...(opts.mocks || [])], + driver: (cfg) => cfg.loadAllConfigs(client) + }); + return { + mocks, + written: lastWrite(mocks) + }; +} + +describe('checkConfigFile (builtIn) - plain object file', () => { + test('creates the file (forceOverwrite) when read throws and writes resolved defaults', async () => { + const client = makeClient(); + const example = { + filename: 'demo.json', + content: [ + { + name: 'a', + type: 'string', + default: 'hello' + }, + { + name: 'b', + type: 'boolean', + default: true + } + ] + }; + const { + mocks, + written + } = await runBuiltIn(client, 'demo.json', example, { + jsonfile: { + readFileSync: jest.fn(() => { + throw new Error('ENOENT'); + }) + } + }); + expect(mocks.jsonfileMock.writeFileSync).toHaveBeenCalled(); + expect(written).toEqual({ + a: 'hello', + b: true + }); + // "creating-file" info log emitted on the forceOverwrite path + expect(client.logger.info).toHaveBeenCalledWith(expect.stringContaining('config.creating-file')); + }); + + test('does not rewrite when existing config already equals computed config', async () => { + const client = makeClient(); + const example = { + filename: 'demo.json', + content: [{ + name: 'a', + type: 'string', + default: 'hello' + }] + }; + const {mocks} = await runBuiltIn(client, 'demo.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({a: 'hello'}))} + }); + expect(mocks.jsonfileMock.writeFileSync).not.toHaveBeenCalled(); + }); + + test('uses the stored value over the default when present and valid', async () => { + const client = makeClient(); + const example = { + filename: 'demo.json', + content: [ + { + name: 'a', + type: 'string', + default: 'def' + }, + // a second field absent from the stored config forces a rewrite, + // so the written payload can be inspected. + { + name: 'b', + type: 'string', + default: 'fromDefault' + } + ] + }; + const {written} = await runBuiltIn(client, 'demo.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({a: 'stored'}))} + }); + // stored value kept for `a`; default filled in for the missing `b` + expect(written).toEqual({ + a: 'stored', + b: 'fromDefault' + }); + }); +}); + +describe('checkConfigFile (builtIn) - example file not found', () => { + test('rejects (and loadAllConfigs reports the error) when example file cannot be required', async () => { + const client = makeClient(); + // readdir returns a file we did NOT register, so require() throws. + await withConfig({ + client, + fs: {readdir: jest.fn((dir, cb) => cb(null, ['missing.json']))}, + driver: (cfg) => cfg.loadAllConfigs(client) + }); + expect(client.logger.error).toHaveBeenCalledWith(expect.stringContaining('Not found config example file')); + }); +}); + +describe('checkConfigFile (builtIn) - configElements array file', () => { + test('processes each element through its content fields', async () => { + const client = makeClient(); + const example = { + filename: 'list.json', + configElements: true, + // second field (absent from stored objects) forces a rewrite so the + // written payload is observable. + content: [ + { + name: 'label', + type: 'string', + default: 'x' + }, + { + name: 'extra', + type: 'string', + default: 'E' + } + ] + }; + const {written} = await runBuiltIn(client, 'list.json', example, { + jsonfile: {readFileSync: jest.fn(() => [{label: 'one'}, {label: 'two'}])} + }); + expect(written).toEqual([ + { + label: 'one', + extra: 'E' + }, + { + label: 'two', + extra: 'E' + } + ]); + }); + + test('coerces a non-array existing file into a single-element array', async () => { + const client = makeClient(); + const example = { + filename: 'list.json', + configElements: true, + content: [ + { + name: 'label', + type: 'string', + default: 'd' + }, + { + name: 'extra', + type: 'string', + default: 'E' + } + ] + }; + const {written} = await runBuiltIn(client, 'list.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({label: 'solo'}))} + }); + expect(client.logger.warn).toHaveBeenCalled(); + expect(written).toEqual([{ + label: 'solo', + extra: 'E' + }]); + }); + + test('a non-object existing file becomes an empty config-element array (no write when already empty)', async () => { + const client = makeClient(); + const example = { + filename: 'list.json', + configElements: true, + content: [{ + name: 'label', + type: 'string', + default: 'd' + }] + }; + // 'a-string' is neither array nor object -> configData becomes [], and the + // computed config is also [] -> identical, so nothing is written. + const {mocks} = await runBuiltIn(client, 'list.json', example, { + jsonfile: {readFileSync: jest.fn(() => 'a-string')} + }); + expect(mocks.jsonfileMock.writeFileSync).not.toHaveBeenCalled(); + }); + + // C1 regression guard: a channelID whose fetch REJECTS is indistinguishable from a + // transient Discord blip, so the stored value must be KEPT (not healed+persisted to the + // default). A second, plain field forces a write so we can prove `chan` kept 'bad-id' + // rather than being overwritten with 'd'. + test('a fetch-backed field whose fetch rejects keeps its stored value (not healed to default)', async () => { + const client = makeClient({ + guildID: 'g1', + channels: {fetch: jest.fn().mockRejectedValue(new Error('not found'))} + }); + const example = { + filename: 'els.json', + configElements: true, + content: [ + { + name: 'chan', + type: 'channelID', + default: 'd' + }, + { + name: 'extra', + type: 'string', + default: 'E' + } + ] + }; + const {mocks} = await runBuiltIn(client, 'els.json', example, { + jsonfile: {readFileSync: jest.fn(() => [{chan: 'bad-id'}])} + }); + // Kept the stored id verbatim; only the absent `extra` was defaulted. + expect(lastWrite(mocks)).toEqual([{chan: 'bad-id', extra: 'E'}]); + expect(client.logger.warn).toHaveBeenCalledWith(expect.stringContaining('keeping the stored value')); + // The destructive heal-to-default log must NOT fire for a fetch-backed type. + expect(client.logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('healed to default')); + }); +}); + +describe('checkConfigFile - keyed disableKeyEdits', () => { + // The disableKeyEdits cleanup mutates the stored value in place, so the write + // diff vanishes for builtIn files. Drive it through a module so the resolved + // config is observable on client.configurations[module][file]. + test('drops unknown keys and back-fills missing keys from the default', async () => { + const client = makeClient({ + modules: { + km: { + userEnabled: true, + enabled: true, + config: {} + } + }, + configurations: {km: {}} + }); + const example = { + filename: 'keyed.json', + content: [ + { + name: 'map', + type: 'keyed', + disableKeyEdits: true, + content: { + key: 'string', + value: 'string' + }, + default: { + a: 'A', + b: 'B' + } + } + ] + }; + // stored has an extra key (c) to drop and is missing b to back-fill. + await withConfig({ + client, + jsonfile: { + readFileSync: jest.fn(() => ({ + map: { + a: 'kept', + c: 'remove' + } + })) + }, + fs: {readdir: jest.fn((dir, cb) => cb(null, []))}, + mocks: [ + { + path: moduleJsonPath('km'), + content: {'config-example-files': ['keyed.json']} + }, + { + path: modulePath('km', 'keyed.json'), + content: example + } + ], + driver: (cfg) => cfg.loadAllConfigs(client) + }); + expect(client.configurations.km.keyed.map).toEqual({ + a: 'kept', + b: 'B' + }); + }); +}); + +describe('checkConfigFile (builtIn) - skipContentCheck', () => { + test('passes existing config through untouched (no rewrite when identical)', async () => { + const client = makeClient(); + const existing = { + anything: [1, 2, 3], + nested: {x: true} + }; + const example = { + filename: 'raw.json', + skipContentCheck: true, + content: [] + }; + const {mocks} = await runBuiltIn(client, 'raw.json', example, { + jsonfile: {readFileSync: jest.fn(() => existing)} + }); + expect(mocks.jsonfileMock.writeFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('checkConfigFile (builtIn) - dependsOn / dependsOnNot', () => { + test('dependsOn=false short-circuits without type-checking the dependent field', async () => { + const client = makeClient({ + // would be called if the channelID field were actually checked + channels: {fetch: jest.fn().mockRejectedValue(new Error('should-not-be-called'))} + }); + const example = { + filename: 'dep.json', + content: [ + { + name: 'enabled', + type: 'boolean', + default: false + }, + { + name: 'channel', + type: 'channelID', + default: 'ignored', + dependsOn: 'enabled' + } + ] + }; + const {written} = await runBuiltIn(client, 'dep.json', example, { + jsonfile: { + readFileSync: jest.fn(() => { + throw new Error('new'); + }) + } + }); + expect(written.enabled).toBe(false); + expect(written.channel).toBe('ignored'); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + test('dependsOn resolves transitively: a hidden ancestor short-circuits the whole chain', async () => { + const client = makeClient({ + // rejects if the gated channelID field is ever type-checked + channels: {fetch: jest.fn().mockRejectedValue(new Error('should-not-be-called'))} + }); + const example = { + filename: 'chain.json', + content: [ + {name: 'master', type: 'boolean', default: false}, + {name: 'toggle', type: 'boolean', default: false, dependsOn: 'master'}, + {name: 'channel', type: 'channelID', default: 'ignored', dependsOn: 'toggle'}, + {name: 'extra', type: 'string', default: 'x'} // absent from input -> forces a write + ] + }; + // master OFF but the intermediate toggle holds a stale truthy value - the + // gated channel field must still be treated as disabled (type check skipped). + const {written} = await runBuiltIn(client, 'chain.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({master: false, toggle: true, channel: 'ignored'}))} + }); + expect(written.channel).toBe('ignored'); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + test('configElements dependsOn resolves transitively through a stale-truthy intermediate', async () => { + const client = makeClient(); + const example = { + filename: 'chainel.json', + configElements: true, + content: [ + {name: 'master', type: 'boolean', default: false}, + {name: 'toggle', type: 'boolean', default: false, dependsOn: 'master'}, + {name: 'value', type: 'string', default: 'fallback', dependsOn: 'toggle'} + ] + }; + const {written} = await runBuiltIn(client, 'chainel.json', example, { + jsonfile: {readFileSync: jest.fn(() => [{master: false, toggle: true, value: 'whatever'}])} + }); + // master off -> chain unsatisfied -> the gated field is treated as disabled + // (its incoming 'whatever' is NOT accepted; it resets to the default). + expect(written[0].value).toBe('fallback'); + }); + + test('rejects when dependsOn references a missing field', async () => { + const client = makeClient(); + const example = { + filename: 'baddep.json', + content: [{ + name: 'x', + type: 'string', + default: 'v', + dependsOn: 'nope' + }] + }; + const {mocks} = await runBuiltIn(client, 'baddep.json', example, { + jsonfile: { + readFileSync: jest.fn(() => { + throw new Error('new'); + }) + } + }); + expect(client.logger.error).toHaveBeenCalledWith(expect.stringContaining('Depends-On-Field nope')); + expect(mocks.jsonfileMock.writeFileSync).not.toHaveBeenCalled(); + }); + + test('configElements dependsOnNot=true keeps the default for the gated field', async () => { + const client = makeClient(); + const example = { + filename: 'depnot.json', + configElements: true, + content: [ + { + name: 'disabled', + type: 'boolean', + default: true + }, + { + name: 'value', + type: 'string', + default: 'kept', + dependsOnNot: 'disabled' + } + ] + }; + const {written} = await runBuiltIn(client, 'depnot.json', example, { + jsonfile: { + readFileSync: jest.fn(() => [{ + disabled: true, + value: 'whatever' + }]) + } + }); + expect(written[0].value).toBe('kept'); + }); + + test('configElements dependsOn=false keeps the default for the gated field', async () => { + const client = makeClient(); + const example = { + filename: 'depel.json', + configElements: true, + content: [ + { + name: 'on', + type: 'boolean', + default: false + }, + { + name: 'value', + type: 'string', + default: 'fallback', + dependsOn: 'on' + } + ] + }; + const {written} = await runBuiltIn(client, 'depel.json', example, { + jsonfile: {readFileSync: jest.fn(() => [{on: false}])} + }); + expect(written[0].value).toBe('fallback'); + }); +}); + +describe('checkConfigFile (builtIn) - elementToggle false branch', () => { + test('when toggle is off, fields are copied/defaulted and overwrite is skipped', async () => { + const client = makeClient(); + const example = { + filename: 'toggle.json', + content: [ + { + name: 'on', + type: 'boolean', + default: false, + elementToggle: true + }, + { + name: 'inner', + type: 'string', + default: 'def' + } + ] + }; + const {mocks} = await runBuiltIn(client, 'toggle.json', example, { + jsonfile: { + readFileSync: jest.fn(() => ({ + on: false, + inner: 'stored' + })) + } + }); + // skipOverwrite true (toggle off) and not forceOverwrite => no write + expect(mocks.jsonfileMock.writeFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('checkConfigFile (builtIn) - logChannelID special case', () => { + test('swallows a SCHEMA-level rejection and sets null when file === "config"', async () => { + const client = makeClient({guildID: 'g1'}); + // Value errors now heal inside checkField, so the logChannelID catch only fires for schema-level rejections; a field missing its default rejects at the schema level, exercising the preserved special case. + const example = { + filename: 'config.json', + content: [{ + name: 'logChannelID', + type: 'channelID' + }] + }; + // The readdir file name must be exactly 'config' for the special case. + const {written} = await runBuiltIn(client, 'config', example, { + jsonfile: {readFileSync: jest.fn(() => ({logChannelID: 'bad-id'}))} + }); + expect(written.logChannelID).toBeNull(); + }); + + test('a non-config file with a SCHEMA-level failure still rejects (no swallow)', async () => { + const client = makeClient({guildID: 'g1'}); + // Missing default => schema-level reject, which (outside the logChannelID/config special case) still fails the whole file: logged, nothing written. + const example = { + filename: 'other.json', + content: [{ + name: 'someChannel', + type: 'channelID' + }] + }; + const {mocks} = await runBuiltIn(client, 'other.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({someChannel: 'bad-id'}))} + }); + // loadAllConfigs swallows the rejection but logs it; nothing is written. + expect(client.logger.error).toHaveBeenCalled(); + expect(mocks.jsonfileMock.writeFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('checkConfigFile (builtIn) - allowNull and missing default', () => { + test('allowNull non-boolean empty value is accepted as-is', async () => { + const client = makeClient(); + const example = { + filename: 'nul.json', + content: [ + { + name: 'opt', + type: 'string', + default: 'd', + allowNull: true + }, + // forces a rewrite (missing from the stored config) so the + // allowNull passthrough for `opt` is observable in the payload. + { + name: 'other', + type: 'string', + default: 'o' + } + ] + }; + const {written} = await runBuiltIn(client, 'nul.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({opt: ''}))} + }); + // empty string passed through untouched despite a non-empty default + expect(written.opt).toBe(''); + }); + + test('a field missing its default value causes a reject (logged, no write)', async () => { + const client = makeClient(); + const example = { + filename: 'nodef.json', + content: [{ + name: 'x', + type: 'string' + }] + }; + const {mocks} = await runBuiltIn(client, 'nodef.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({x: 'present'}))} + }); + expect(client.logger.error).toHaveBeenCalledWith(expect.stringContaining('Missing default value')); + expect(mocks.jsonfileMock.writeFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('checkConfigFile (builtIn) - localized default resolution', () => { + test('legacy {en, de} default resolves to the client locale', async () => { + const client = makeClient({locale: 'de'}); + const example = { + filename: 'loc.json', + content: [{ + name: 'greeting', + type: 'string', + default: { + en: 'Hello', + de: 'Hallo' + } + }] + }; + const {written} = await runBuiltIn(client, 'loc.json', example, { + jsonfile: { + readFileSync: jest.fn(() => { + throw new Error('new'); + }) + } + }); + expect(written.greeting).toBe('Hallo'); + }); + + test('resolveDefault falls back to en for a localized elementToggle default when the locale is absent', async () => { + // The elementToggle gate's default is resolved via resolveDefault (not + // checkField), so a localized {en: ...} default with a locale that has no + // entry exercises resolveDefault's isLocalizedObject + en-fallback branch. + const client = makeClient({locale: 'fr'}); + const example = { + filename: 'gate.json', + content: [ + { + name: 'gate', + type: 'boolean', + default: {en: false}, + elementToggle: true + }, + { + name: 'inner', + type: 'string', + default: 'x' + } + ] + }; + const {mocks} = await runBuiltIn(client, 'gate.json', example, { + // stored config absent -> gate value comes from resolveDefault (en: false) + jsonfile: {readFileSync: jest.fn(() => ({}))} + }); + // gate resolved to the en fallback (false) -> toggle off -> overwrite skipped + expect(mocks.jsonfileMock.writeFileSync).not.toHaveBeenCalled(); + }); + + test('external locale file supplies the default for a string field', async () => { + const client = makeClient({locale: 'fr'}); + const example = { + filename: 'loc.json', + content: [{ + name: 'greeting', + type: 'string', + default: 'Hello' + }] + }; + const locFile = JSON.stringify({ + _core: {loc: {content: {greeting: {default: 'Bonjour'}}}} + }); + const {written} = await runBuiltIn(client, 'loc.json', example, { + jsonfile: { + readFileSync: jest.fn(() => { + throw new Error('new'); + }) + }, + fs: {readFileSync: jest.fn(() => locFile)} + }); + expect(written.greeting).toBe('Bonjour'); + }); +}); + +// --------------------------------------------------------------------------- +// scnxSetup integration hooks inside checkField +// --------------------------------------------------------------------------- + +describe('checkConfigFile (builtIn) - scnxSetup integration hooks', () => { + test('wraps a passing field value through setFieldValue', async () => { + const client = makeClient({scnxSetup: true}); + const example = { + filename: 'config.json', + content: [{ + name: 'greeting', + type: 'string', + default: 'hi' + }] + }; + const setFieldValue = jest.fn((c, f, v) => `wrapped:${v}`); + const {written} = await runBuiltIn(client, 'config.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({greeting: 'hey'}))}, + scnx: {setFieldValue} + }); + expect(setFieldValue).toHaveBeenCalled(); + expect(written.greeting).toBe('wrapped:hey'); + }); + + // C1: a fetch-backed field that fails to verify (possibly a transient blip) must NOT + // report a CONFIGURATION_ISSUE β€” that would spam the dashboard every boot for a value we + // are deliberately keeping. The report is reserved for definitively-invalid non-fetch + // values (see the select-field test below). Here the stored value is kept, not healed. + test('a failing fetch-backed field does NOT report a CONFIGURATION_ISSUE and keeps its value', async () => { + const client = makeClient({ + scnxSetup: true, + guildID: 'g1', + channels: {fetch: jest.fn().mockRejectedValue(new Error('x'))} + }); + const example = { + filename: 'config.json', + content: [ + { + name: 'chan', + type: 'channelID', + default: 'fallback' + }, + // absent field forces a write so the kept value is observable + { + name: 'extra', + type: 'string', + default: 'E' + } + ] + }; + const reportIssue = jest.fn().mockResolvedValue(undefined); + const {written} = await runBuiltIn(client, 'config.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({chan: 'stored-id'}))}, + scnx: {reportIssue} + }); + expect(reportIssue).not.toHaveBeenCalledWith(client, expect.objectContaining({type: 'CONFIGURATION_ISSUE'})); + // stored id kept verbatim, never healed to 'fallback' + expect(written.chan).toBe('stored-id'); + }); +}); + +// Stronger defaults: invalid STORED VALUES heal to the field default instead of rejecting the whole file (which would disable the module). +describe('checkConfigFile - invalid stored values heal to the default', () => { + // Real-world trigger: a select field whose schema evolved from boolean to a 3-way select; a legacy stored boolean `false` is no longer valid and must heal to the 'inherit' default rather than disabling the module. + test('a select field with a legacy boolean stored value heals to the default and persists', async () => { + const client = makeClient(); + const example = { + filename: 'demo.json', + content: [{ + name: 'mode', + type: 'select', + content: [{value: 'inherit'}, {value: 'on'}, {value: 'off'}], + default: 'inherit' + }] + }; + const {written} = await runBuiltIn(client, 'demo.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({mode: false}))} + }); + // Healed to the default... + expect(written.mode).toBe('inherit'); + // ...and the warn log names the field, invalid value and the healed default. + expect(client.logger.warn).toHaveBeenCalledWith(expect.stringContaining('healed to default')); + // No hard error / whole-file rejection occurred. + expect(client.logger.error).not.toHaveBeenCalled(); + }); + + test('reports a CONFIGURATION_ISSUE (dashboard visibility) while still healing under scnxSetup', async () => { + const client = makeClient({scnxSetup: true}); + const example = { + filename: 'demo.json', + content: [{ + name: 'mode', + type: 'select', + content: [{value: 'inherit'}, {value: 'on'}, {value: 'off'}], + default: 'inherit' + }] + }; + const { + mocks, + written + } = await runBuiltIn(client, 'demo.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({mode: false}))} + }); + expect(mocks.scnxMock.reportIssue).toHaveBeenCalledWith(client, expect.objectContaining({ + type: 'CONFIGURATION_ISSUE', + field: 'mode', + errorDescription: 'field_check_failed' + })); + expect(written.mode).toBe('inherit'); + }); + + test('a SCHEMA-level failure inside a config element still rejects the file', async () => { + const client = makeClient(); + // Missing default is a schema error (not a value error), so it is NOT healed: checkField rejects and the configElements loop propagates the rejection. + const example = { + filename: 'els.json', + configElements: true, + content: [{ + name: 'x', + type: 'string' + }] + }; + await runBuiltIn(client, 'els.json', example, { + jsonfile: {readFileSync: jest.fn(() => [{x: 'present'}])} + }); + expect(client.logger.error).toHaveBeenCalledWith(expect.stringContaining('Missing default value')); + }); + + test('a healed flat-config value lands in newConfig and is persisted', async () => { + const client = makeClient(); + // integer field with a stored non-numeric string heals to the numeric default. + const example = { + filename: 'demo.json', + content: [{ + name: 'count', + type: 'integer', + default: 5 + }] + }; + const { + mocks, + written + } = await runBuiltIn(client, 'demo.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({count: 'not-a-number'}))} + }); + expect(mocks.jsonfileMock.writeFileSync).toHaveBeenCalled(); + expect(written).toEqual({count: 5}); + }); + + test('heals an invalid value in a MODULE config (warn names the module) and the module stays loaded', async () => { + const client = makeClient({ + modules: { + mymod: { + userEnabled: true, + enabled: true, + config: {} + } + }, + configurations: {mymod: {}} + }); + const example = { + filename: 'settings.json', + content: [{ + name: 'count', + type: 'integer', + default: 5 + }] + }; + await withConfig({ + client, + jsonfile: {readFileSync: jest.fn(() => ({count: 'not-a-number'}))}, + fs: {readdir: jest.fn((dir, cb) => cb(null, []))}, + mocks: [ + { + path: moduleJsonPath('mymod'), + content: {'config-example-files': ['settings.json']} + }, + { + path: modulePath('mymod', 'settings.json'), + content: example + } + ], + driver: (cfg) => cfg.loadAllConfigs(client) + }); + // Module not disabled by a whole-file rejection; healed value materialized. + expect(client.modules.mymod.enabled).toBe(true); + expect(client.configurations.mymod.settings).toEqual({count: 5}); + expect(client.logger.warn).toHaveBeenCalledWith(expect.stringContaining('module mymod')); + }); + + // C1: a whole ARRAY of fetch-backed ids must not be emptied to the default when one element + // fails to fetch (a transient blip on a single role should never wipe the list). + test('an array of fetch-backed ids keeps its stored value when an element fails to fetch', async () => { + const client = makeClient({ + guildID: 'g1', + guilds: {fetch: jest.fn().mockResolvedValue({roles: {fetch: jest.fn().mockResolvedValue(null)}})} + }); + const example = { + filename: 'demo.json', + content: [ + { + name: 'roles', + type: 'array', + content: 'roleID', + default: [] + }, + { + name: 'extra', + type: 'string', + default: 'E' + } + ] + }; + const {written} = await runBuiltIn(client, 'demo.json', example, { + jsonfile: {readFileSync: jest.fn(() => ({roles: ['r1', 'r2']}))} + }); + // Array kept verbatim, NOT emptied to []. + expect(written.roles).toEqual(['r1', 'r2']); + expect(client.logger.warn).toHaveBeenCalledWith(expect.stringContaining('keeping the stored value')); + }); + + // M3: a REQUIRED (non-allowNull) fetch-backed field with an empty-string default that is left + // unconfigured must NOT heal-to-'' every boot (issue spam + enabled-but-broken). It routes to + // the reject-to-disable path: the module is disabled and a single MODULE_FAILURE is reported, + // with NO per-boot CONFIGURATION_ISSUE. + test('a required empty-default fetch-backed field disables the module instead of heal-thrashing', async () => { + const client = makeClient({ + scnxSetup: true, + guildID: 'g1', + channels: {fetch: jest.fn().mockRejectedValue(new Error('unconfigured'))}, + modules: { + mod: { + userEnabled: true, + enabled: true, + config: {} + } + }, + configurations: {mod: {}} + }); + const reportIssue = jest.fn().mockResolvedValue(undefined); + await withConfig({ + client, + jsonfile: {readFileSync: jest.fn(() => ({logchannel: ''}))}, + fs: {readdir: jest.fn((dir, cb) => cb(null, []))}, + scnx: {reportIssue}, + mocks: [ + { + path: moduleJsonPath('mod'), + content: {'config-example-files': ['config.json']} + }, + { + path: modulePath('mod', 'config.json'), + content: { + filename: 'config.json', + content: [{ + name: 'logchannel', + type: 'channelID', + default: '' + }] + } + } + ], + driver: (cfg) => cfg.loadAllConfigs(client) + }); + // Reject-to-disable path taken: module disabled, single MODULE_FAILURE, no CONFIGURATION_ISSUE spam. + expect(client.modules.mod.enabled).toBe(false); + expect(reportIssue).toHaveBeenCalledWith(client, expect.objectContaining({type: 'MODULE_FAILURE'})); + expect(reportIssue).not.toHaveBeenCalledWith(client, expect.objectContaining({type: 'CONFIGURATION_ISSUE'})); + }); +}); + +// --------------------------------------------------------------------------- +// checkConfigFile (module path) β€” driven through loadAllConfigs' module loop. +// --------------------------------------------------------------------------- + +describe('checkConfigFile (module) - non-builtIn path', () => { + test('writes into configurations[moduleName] and creates the module dir when missing', async () => { + const client = makeClient({ + modules: { + mymod: { + userEnabled: true, + enabled: true, + config: {} + } + }, + configurations: {mymod: {}} + }); + const example = { + filename: 'settings.json', + content: [{ + name: 'foo', + type: 'string', + default: 'bar' + }] + }; + const {mocks} = await withConfig({ + client, + jsonfile: { + readFileSync: jest.fn(() => { + throw new Error('new'); + }) + }, + fs: { + readdir: jest.fn((dir, cb) => cb(null, [])), + existsSync: jest.fn().mockReturnValue(false) + }, + mocks: [ + { + path: moduleJsonPath('mymod'), + content: {'config-example-files': ['settings.json']} + }, + { + path: modulePath('mymod', 'settings.json'), + content: example + } + ], + driver: (cfg) => cfg.loadAllConfigs(client) + }); + expect(mocks.fsMock.mkdirSync).toHaveBeenCalledWith('/cfg/mymod'); + expect(client.configurations.mymod.settings).toEqual({foo: 'bar'}); + }); +}); + +// --------------------------------------------------------------------------- +// loadAllConfigs +// --------------------------------------------------------------------------- + +describe('loadAllConfigs', () => { + test('checks generator files + enabled modules and returns the summary shape', async () => { + const client = makeClient({ + modules: { + modA: { + userEnabled: true, + enabled: true, + config: {} + }, + modB: { + userEnabled: false, + enabled: false, + config: {} + } + }, + configurations: {modA: {}} + }); + const genExample = { + filename: 'g.json', + content: [{ + name: 'k', + type: 'string', + default: 'v' + }] + }; + const modExample = { + filename: 'm.json', + content: [{ + name: 'mk', + type: 'string', + default: 'mv' + }] + }; + + const {result: data} = await withConfig({ + client, + jsonfile: { + readFileSync: jest.fn(() => { + throw new Error('new'); + }) + }, + fs: {readdir: jest.fn((dir, cb) => cb(null, ['g.json']))}, + mocks: [ + { + path: generatorPath('g.json'), + content: genExample + }, + { + path: moduleJsonPath('modA'), + content: {'config-example-files': ['m.json']} + }, + { + path: modulePath('modA', 'm.json'), + content: modExample + } + ], + driver: (cfg) => cfg.loadAllConfigs(client) + }); + expect(data).toEqual({ + totalModules: 2, + enabled: 1, + configDisabled: 0, + userEnabled: 0 + }); + expect(client.configurations.modA.m).toEqual({mk: 'mv'}); + }); + + test('skips disabled modules (userEnabled false)', async () => { + const client = makeClient({ + modules: { + off: { + userEnabled: false, + enabled: false, + config: {} + } + }, + configurations: {off: {}} + }); + const {result: data} = await withConfig({ + client, + fs: {readdir: jest.fn((dir, cb) => cb(null, []))}, + driver: (cfg) => cfg.loadAllConfigs(client) + }); + expect(data.totalModules).toBe(1); + // module.json never required because the module was skipped + expect(client.configurations.off).toEqual({}); + }); + + test('disables a module internally and reports an issue when its config check fails under scnxSetup', async () => { + const client = makeClient({ + scnxSetup: true, + modules: { + broken: { + userEnabled: true, + enabled: true, + config: {} + } + }, + configurations: {broken: {}} + }); + const reportIssue = jest.fn().mockResolvedValue(undefined); + await withConfig({ + client, + fs: {readdir: jest.fn((dir, cb) => cb(null, []))}, + scnx: {reportIssue}, + mocks: [{ + path: moduleJsonPath('broken'), + content: {'config-example-files': ['missing.json']} + }], + driver: (cfg) => cfg.loadAllConfigs(client) + }); + expect(client.modules.broken.enabled).toBe(false); + expect(client.logger.error).toHaveBeenCalled(); + expect(reportIssue).toHaveBeenCalledWith(client, expect.objectContaining({type: 'MODULE_FAILURE'})); + }); + + // Covers configuration.js:107 (the `intentDisabled.has(moduleName)` skip) together with + // the applyIntentDisables scnxSetup report branch (configuration.js:73). A module whose + // module.json declares a REQUIRED privileged intent that config.json's allowlist excludes + // must be disabled + reported EXACTLY ONCE by applyIntentDisables, and loadAllConfigs must + // `continue` past it rather than also running its own (here: guaranteed-to-fail) config + // check - proven by asserting reportIssue was only invoked once and logger.error (which the + // config-check failure path would also trigger) was never called. + test('skips the module-level config check for a module already disabled by the privileged-intent allowlist', async () => { + const client = makeClient({ + scnxSetup: true, + modules: { + presencemod: { + userEnabled: true, + enabled: true, + config: {} + } + }, + configurations: {presencemod: {}} + }); + const reportIssue = jest.fn().mockResolvedValue(undefined); + const moduleJsonRealPath = path.join(ROOT, 'modules', 'presencemod', 'module.json'); + await withConfig({ + client, + jsonfile: { + readFileSync: jest.fn((p) => { + if (p === '/cfg/modules.json') return {presencemod: true}; + if (p === '/cfg/config.json') return {allowedPrivilegedIntents: ['GuildMembers']}; + if (p === moduleJsonRealPath) return {intents: ['GuildPresences']}; + throw new Error('ENOENT'); + }) + }, + fs: {readdir: jest.fn((dir, cb) => cb(null, []))}, + scnx: {reportIssue}, + mocks: [{ + // Real `require()`-based module.json used by checkModuleConfig. Declares a + // config-example-file that is NOT mocked, so if checkModuleConfig were (wrongly) + // reached for this module, checkConfigFile would reject and loadAllConfigs would + // report a SECOND (different) MODULE_FAILURE and log an error. + path: moduleJsonPath('presencemod'), + content: {'config-example-files': ['settings.json']} + }], + driver: (cfg) => cfg.loadAllConfigs(client) + }); + expect(client.modules.presencemod.enabled).toBe(false); + expect(client.modules.presencemod.userEnabled).toBe(true); + expect(reportIssue).toHaveBeenCalledTimes(1); + expect(reportIssue).toHaveBeenCalledWith(client, expect.objectContaining({ + type: 'MODULE_FAILURE', + errorDescription: 'module_disabled', + module: 'presencemod', + errorData: expect.objectContaining({reason: expect.stringContaining('GuildPresences')}) + })); + // The module-level config-check catch path (which also disables + reports + logs.error) + // never ran, proving the intentDisabled `continue` skip fired. + expect(client.logger.error).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// checkModuleConfig (reached via loadAllConfigs, asserted on its effects) +// --------------------------------------------------------------------------- + +describe('checkModuleConfig', () => { + test('a module with no config-example-files contributes no configuration writes', async () => { + const client = makeClient({ + modules: { + empty: { + userEnabled: true, + enabled: true, + config: {} + } + }, + configurations: {empty: {}} + }); + const {mocks} = await withConfig({ + client, + fs: {readdir: jest.fn((dir, cb) => cb(null, []))}, + mocks: [{ + path: moduleJsonPath('empty'), + content: {'config-example-files': []} + }], + driver: (cfg) => cfg.loadAllConfigs(client) + }); + expect(mocks.jsonfileMock.writeFileSync).not.toHaveBeenCalled(); + expect(client.configurations.empty).toEqual({}); + }); + + test('checks each configured example file for the module', async () => { + const client = makeClient({ + modules: { + mod: { + userEnabled: true, + enabled: true, + config: {} + } + }, + configurations: {mod: {}} + }); + const example = { + filename: 's.json', + content: [{ + name: 'x', + type: 'string', + default: 'y' + }] + }; + await withConfig({ + client, + jsonfile: { + readFileSync: jest.fn(() => { + throw new Error('new'); + }) + }, + fs: {readdir: jest.fn((dir, cb) => cb(null, []))}, + mocks: [ + { + path: moduleJsonPath('mod'), + content: {'config-example-files': ['s.json']} + }, + { + path: modulePath('mod', 's.json'), + content: example + } + ], + driver: (cfg) => cfg.loadAllConfigs(client) + }); + expect(client.configurations.mod.s).toEqual({x: 'y'}); + }); +}); + +// --------------------------------------------------------------------------- +// reloadConfig +// --------------------------------------------------------------------------- + +describe('reloadConfig', () => { + test('clears intervals/jobs, emits events, reapplies modules.json and returns the summary', async () => { + const cancel = jest.fn(); + const client = makeClient({ + intervals: ['iv'], + jobs: [{cancel}, null], + modules: { + m1: { + userEnabled: false, + enabled: false, + config: {} + } + }, + configurations: {m1: {}} + }); + + const clearIntervalSpy = jest.spyOn(global, 'clearInterval').mockImplementation(() => { + }); + const {result: res} = await withConfig({ + client, + jsonfile: { + readFileSync: jest.fn((p) => { + if (p.endsWith('modules.json')) return {m1: true}; + throw new Error('new'); + }) + }, + fs: {readdir: jest.fn((dir, cb) => cb(null, []))}, + mocks: [{ + path: moduleJsonPath('m1'), + content: {'config-example-files': []} + }], + driver: (cfg) => cfg.reloadConfig(client) + }); + clearIntervalSpy.mockRestore(); + + expect(client.emit).toHaveBeenCalledWith('configReload'); + expect(client.emit).toHaveBeenCalledWith('botReady'); + expect(cancel).toHaveBeenCalledTimes(1); + expect(client.intervals).toEqual([]); + expect(client.jobs).toEqual([]); + expect(client.modules.m1.enabled).toBe(true); + expect(client.modules.m1.userEnabled).toBe(true); + expect(client.botReadyAt).toBeInstanceOf(Date); + expect(res).toHaveProperty('totalModules', 1); + }); + + test('runs the scnx hooks and reloads custom commands when scnxSetup is true', async () => { + const client = makeClient({ + scnxSetup: true, + modules: {}, + configurations: {} + }); + const beforeInit = jest.fn().mockResolvedValue(undefined); + const init = jest.fn().mockResolvedValue(undefined); + const verifyCustomCommands = jest.fn().mockResolvedValue(undefined); + await withConfig({ + client, + jsonfile: { + readFileSync: jest.fn((p) => { + if (p.endsWith('modules.json')) return {}; + if (p.endsWith('custom-commands.json')) return [{name: 'cc'}]; + throw new Error('new'); + }) + }, + fs: {readdir: jest.fn((dir, cb) => cb(null, []))}, + scnx: { + beforeInit, + init, + verifyCustomCommands + }, + driver: (cfg) => cfg.reloadConfig(client) + }); + expect(beforeInit).toHaveBeenCalledWith(client); + expect(init).toHaveBeenCalledWith(client, true); + expect(verifyCustomCommands).toHaveBeenCalledWith(client); + expect(client.config.customCommands).toEqual([{name: 'cc'}]); + }); +}); \ No newline at end of file diff --git a/tests/configuration/checkType.test.js b/tests/configuration/checkType.test.js index 947b2bee..e3ef0257 100644 --- a/tests/configuration/checkType.test.js +++ b/tests/configuration/checkType.test.js @@ -20,8 +20,20 @@ const {checkType} = require('../../src/functions/configuration'); const baseClient = main.client; +/* + * The new exit-code convention is a start-time opt-in (src/functions/exitCodes.js): enable it per + * test (the unknown-type test asserts the NEW code) and restore the environment afterwards. + */ +let prevScnxExitCodesEnv; +beforeEach(() => { + prevScnxExitCodesEnv = process.env.SCNX_EXIT_CODES; + process.env.SCNX_EXIT_CODES = '1'; +}); + afterEach(() => { jest.restoreAllMocks(); + if (typeof prevScnxExitCodesEnv === 'undefined') delete process.env.SCNX_EXIT_CODES; + else process.env.SCNX_EXIT_CODES = prevScnxExitCodesEnv; }); describe('checkType - integer', () => { @@ -359,9 +371,17 @@ describe('checkType - guildID', () => { }); describe('checkType - unknown type', () => { - test('logs and calls process.exit(0)', async () => { + test('logs and calls process.exit(78) (FATAL_CONFIG)', async () => { const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined); await checkType({type: 'totally-unknown'}, 'x'); + expect(exitSpy).toHaveBeenCalledWith(78); + }); + + test('WITHOUT the opt-in flag it exits 0 (legacy, byte-identical to pre-convention)', async () => { + delete process.env.SCNX_EXIT_CODES; // flag-off mode; afterEach restores it + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { + }); + await checkType({type: 'totally-unknown'}, 'x'); expect(exitSpy).toHaveBeenCalledWith(0); }); }); \ No newline at end of file diff --git a/tests/economy-system/events.test.js b/tests/economy-system/events.test.js index 5b328e70..af1325d7 100644 --- a/tests/economy-system/events.test.js +++ b/tests/economy-system/events.test.js @@ -4,7 +4,9 @@ * messageCreate (random money drops): the early-return guards (not ready, no * guild, bot author, wrong guild), the messageDrops==0 / ignored-channel * short-circuits, the random-roll gate, the credited amount range, and that a - * drop notice is sent only when the author has not opted out. + * drop notice is sent only when the author has not opted out. The notice comes + * from the configured msgDropMsg strings, falling back to the localized text + * when that config is missing or empty. * interactionCreate: only the shop select-menu in the right guild buys an item. * botReady: redraws shop+leaderboard and schedules the daily refresh job. * shop command: permission gating on add/delete/edit, and that buy/list don't @@ -31,9 +33,26 @@ jest.mock('../../modules/economy-system/economy-system', () => ({ updateShopItem: (...a) => mockUpdateShopItem(...a) })); -jest.mock('../../src/functions/helpers', () => ({ - formatDiscordUserName: (u) => (u && u.tag) || 'user' -})); +// Real RNG (so the genuine drop-chance / payout maths runs); embedType is a +// passthrough that renders the placeholders so assertions read as plain text. +// mockRandomIntFromInterval lets a single test force a deterministic roll. +let mockRandomIntFromInterval = null; +jest.mock('../../src/functions/helpers', () => { + const actual = jest.requireActual('../../src/functions/helpers'); + return { + ...actual, + randomIntFromInterval: (...a) => (mockRandomIntFromInterval || actual.randomIntFromInterval)(...a), + embedType: (input, args, opts) => { + let content = input; + for (const [k, v] of Object.entries(args || {})) content = content.split(k).join(v); + return { + content, + ...opts + }; + }, + formatDiscordUserName: (u) => (u && u.tag) || 'user' + }; +}); const mockSchedule = jest.fn(() => ({})); jest.mock('node-schedule', () => ({scheduleJob: (...a) => mockSchedule(...a)})); @@ -44,13 +63,16 @@ beforeEach(() => { mockShopMsg.mockClear(); mockCreateLeaderboard.mockClear(); mockSchedule.mockClear(); - jest.spyOn(Math, 'random').mockRestore?.(); + mockRandomIntFromInterval = null; }); describe('messageCreate money drops', () => { const handler = require('../../modules/economy-system/events/messageCreate'); - function makeClient(config = {}, {dropOptOut = null} = {}) { + function makeClient(config = {}, { + dropOptOut = null, + strings = {} + } = {}) { return { botReadyAt: Date.now(), config: {guildID: 'g1'}, @@ -65,6 +87,10 @@ describe('messageCreate money drops', () => { messageDropsMax: 6, currencySymbol: '$', ...config + }, + strings: { + msgDropMsg: ['Message-Drop: You earned %earned% simply by chatting!'], + ...strings } } }, @@ -115,17 +141,25 @@ describe('messageCreate money drops', () => { }); test('does nothing when the random roll misses (drop chance not hit)', async () => { - jest.spyOn(Math, 'random').mockReturnValue(0.0); // floor(0*1)=0 !== 1 -> miss + mockRandomIntFromInterval = () => 2; // roll !== 1 -> miss await handler.run(makeClient({messageDrops: 5}), makeMessage()); expect(mockEditBalance).not.toHaveBeenCalled(); - Math.random.mockRestore(); + }); + + test('a chance of 1 drops on every single message', async () => { + // REGRESSION: floor(random()*1) is always 0 and never 1, so a configured + // 1/1 (100%) chance used to drop NEVER. The roll is now a 1/N interval, + // so N=1 always hits. 25 consecutive messages must all drop. + for (let i = 0; i < 25; i++) { + mockEditBalance.mockClear(); + await handler.run(makeClient({messageDrops: 1}), makeMessage()); + expect(mockEditBalance).toHaveBeenCalled(); + } }); test('credits a drop and replies when the author has not opted out', async () => { - // messageDrops:1 -> floor(random*1)=0 ... need ===1; with messageDrops:2, random in [0.5,1) -> floor=1 - jest.spyOn(Math, 'random').mockReturnValue(0.5); const client = makeClient({ - messageDrops: 2, + messageDrops: 1, messageDropsMin: 10, messageDropsMax: 11 }, {dropOptOut: null}); @@ -133,17 +167,83 @@ describe('messageCreate money drops', () => { await handler.run(client, msg); expect(mockEditBalance).toHaveBeenCalledWith(client, 'u1', 'add', expect.any(Number)); expect(msg.reply).toHaveBeenCalled(); - Math.random.mockRestore(); + }); + + test('replies with the configured msgDropMsg, not a hardcoded string', async () => { + const client = makeClient({ + messageDrops: 1, + messageDropsMin: 10, + messageDropsMax: 10 + }, {strings: {msgDropMsg: ['CUSTOM %earned%']}}); + const msg = makeMessage(); + await handler.run(client, msg); + expect(msg.reply).toHaveBeenCalledWith(expect.objectContaining({content: 'CUSTOM 10 $'})); + }); + + test('picks a random element when msgDropMsg holds multiple entries', async () => { + // 3 entries, 60 drops: P(some entry never picked) < 3*(2/3)^60 ~ 1e-10. + const seen = new Set(); + for (let i = 0; i < 60; i++) { + const client = makeClient({ + messageDrops: 1, + messageDropsMin: 10, + messageDropsMax: 10 + }, { + strings: { + msgDropMsg: [ + 'A %earned%', + 'B %earned%', + 'C %earned%' + ] + } + }); + const msg = makeMessage(); + await handler.run(client, msg); + seen.add(msg.reply.mock.calls[0][0].content); + } + expect([...seen].sort()).toEqual([ + 'A 10 $', + 'B 10 $', + 'C 10 $' + ]); + }); + + test.each([ + ['missing', undefined], + ['empty', []] + ])('falls back to the localized drop notice when msgDropMsg is %s', async (_label, msgDropMsg) => { + // randomElementFromArray([]) returns null and embedType(null) would throw, + // so configs predating msgDropMsg (or with a cleared array) must still send. + const client = makeClient({messageDrops: 1}, {strings: {msgDropMsg}}); + const msg = makeMessage(); + await handler.run(client, msg); + expect(msg.reply).toHaveBeenCalledWith({content: expect.any(String)}); + expect(msg.reply.mock.calls[0][0].content).not.toBe(''); + }); + + test('the credited amount spans the full inclusive [min,max]', async () => { + // REGRESSION: floor(random()*(max-min))+min excluded max entirely. The roll + // is now inclusive on both ends. min=5,max=6 => 2 outcomes; over 200 drops + // P(an endpoint never appears) = 2*(1/2)^200 ~ 1e-60, so this cannot flake. + const seen = new Set(); + for (let i = 0; i < 200; i++) { + mockEditBalance.mockClear(); + await handler.run(makeClient({messageDrops: 1}), makeMessage()); + const amount = mockEditBalance.mock.calls[0][3]; + expect(amount).toBeGreaterThanOrEqual(5); + expect(amount).toBeLessThanOrEqual(6); + expect(Number.isInteger(amount)).toBe(true); + seen.add(amount); + } + expect([...seen].sort()).toEqual([5, 6]); }); test('does not send a reply when the author opted out of drop messages', async () => { - jest.spyOn(Math, 'random').mockReturnValue(0.5); - const client = makeClient({messageDrops: 2}, {dropOptOut: {id: 'u1'}}); + const client = makeClient({messageDrops: 1}, {dropOptOut: {id: 'u1'}}); const msg = makeMessage(); await handler.run(client, msg); expect(mockEditBalance).toHaveBeenCalled(); expect(msg.reply).not.toHaveBeenCalled(); - Math.random.mockRestore(); }); }); @@ -211,10 +311,10 @@ describe('shop command permission gating', () => { const shop = require('../../modules/economy-system/commands/shop'); function makeInteraction({ - userId = 'u', - shopManagers = [], - botOperators = [] - } = {}) { + userId = 'u', + shopManagers = [], + botOperators = [] + } = {}) { return { user: {id: userId}, reply: jest.fn().mockResolvedValue(), diff --git a/tests/exitCodes/exitCodes.test.js b/tests/exitCodes/exitCodes.test.js new file mode 100644 index 00000000..e85c2440 --- /dev/null +++ b/tests/exitCodes/exitCodes.test.js @@ -0,0 +1,186 @@ +/* + * Tests for src/functions/exitCodes.js: + * - classifyLoginError taxonomy + * - loginErrorExitCode / disconnectExitCode mapping (64/65/1) + * - the start-time opt-in gate: pick(newCode, legacyCode) returns the new-convention code only + * when --scnx-exit-codes / SCNX_EXIT_CODES=1 is present, else the legacy code (default 0). + * Environment toggling is deterministic: saved before and restored after every test. + */ + +const {EXIT, classifyLoginError, loginErrorExitCode, LOGIN_EXIT, exitCodesEnabled, pick, disconnectExitCode, DISCONNECT_EXIT} = require('../../src/functions/exitCodes'); + +let prevScnxExitCodesEnv; +beforeEach(() => { + prevScnxExitCodesEnv = process.env.SCNX_EXIT_CODES; + delete process.env.SCNX_EXIT_CODES; +}); +afterEach(() => { + if (typeof prevScnxExitCodesEnv === 'undefined') delete process.env.SCNX_EXIT_CODES; + else process.env.SCNX_EXIT_CODES = prevScnxExitCodesEnv; +}); + +describe('classifyLoginError - taxonomy', () => { + test('MissingToken when the token was absent (short-circuits before inspecting the error)', () => { + expect(classifyLoginError(new Error('anything'), true)).toBe('MissingToken'); + expect(classifyLoginError(null, true)).toBe('MissingToken'); + }); + + test('InvalidToken via discord.js error code', () => { + expect(classifyLoginError({code: 'TokenInvalid'}, false)).toBe('InvalidToken'); + }); + + test('InvalidToken via message text', () => { + expect(classifyLoginError(new Error('An invalid token was provided'), false)).toBe('InvalidToken'); + expect(classifyLoginError(new Error('invalid token'), false)).toBe('InvalidToken'); + }); + + test('DisallowedIntents via message text', () => { + expect(classifyLoginError(new Error('Privileged message content intent is disallowed intent'), false)).toBe('DisallowedIntents'); + }); + + test('DisallowedIntents takes priority over an invalid-token substring', () => { + // "disallowed intent" is checked before the token check. + expect(classifyLoginError(new Error('disallowed intent (invalid token)'), false)).toBe('DisallowedIntents'); + }); + + test('Network via connection error codes', () => { + for (const code of ['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET']) { + expect(classifyLoginError({code}, false)).toBe('Network'); + } + }); + + test('Network via message text', () => { + expect(classifyLoginError(new Error('getaddrinfo failed'), false)).toBe('Network'); + expect(classifyLoginError(new Error('fetch failed'), false)).toBe('Network'); + }); + + test('RateLimited via message text', () => { + expect(classifyLoginError(new Error('You are being rate limited'), false)).toBe('RateLimited'); + expect(classifyLoginError(new Error('429 Too Many Requests'), false)).toBe('RateLimited'); + }); + + test('Unknown for anything unrecognized', () => { + expect(classifyLoginError(new Error('some other failure'), false)).toBe('Unknown'); + expect(classifyLoginError(null, false)).toBe('Unknown'); + }); +}); + +describe('loginErrorExitCode - mapping to systemd exit codes', () => { + test.each([ + ['MissingToken', 64], + ['InvalidToken', 64], + ['DisallowedIntents', 65], + ['Network', 1], + ['RateLimited', 1], + ['Unknown', 1] + ])('%s -> %i', (kind, code) => { + expect(loginErrorExitCode(kind)).toBe(code); + expect(LOGIN_EXIT[kind]).toBe(code); + }); + + test('defaults to 1 (transient) for an unrecognized class', () => { + expect(loginErrorExitCode('SomethingNew')).toBe(1); + }); + + test('EXIT constants carry the documented values', () => { + expect(EXIT).toEqual({ + CLEAN_STOP: 0, + CRASH_TRANSIENT: 1, + FATAL_INVALID_TOKEN: 64, + FATAL_INTENTS: 65, + FATAL_REMOVED: 66, + FATAL_CONFIG: 78 + }); + }); +}); + +describe('disconnectExitCode - runtime gateway disconnect close-code mapping', () => { + test.each([ + [4004, 64], // AuthenticationFailed -> invalid token + [4013, 65], // InvalidIntents + [4014, 65] // DisallowedIntents + ])('unrecoverable close code %i -> exit %i', (closeCode, exit) => { + expect(disconnectExitCode(closeCode)).toBe(exit); + expect(DISCONNECT_EXIT[closeCode]).toBe(exit); + }); + + test('other unrecoverable codes (invalid shard / sharding required / invalid api version) -> transient 1', () => { + for (const closeCode of [4010, 4011, 4012]) { + expect(disconnectExitCode(closeCode)).toBe(EXIT.CRASH_TRANSIENT); + } + }); + + test('unknown / missing close codes default to transient 1', () => { + expect(disconnectExitCode(1006)).toBe(EXIT.CRASH_TRANSIENT); + expect(disconnectExitCode('unknown')).toBe(EXIT.CRASH_TRANSIENT); + expect(disconnectExitCode()).toBe(EXIT.CRASH_TRANSIENT); // no code passed + }); + + test('end-to-end through the gate: token/intents fatals gate to legacy 0, transient stays 1', () => { + + /* + * A runtime disconnect never exited before, so the transient case passes legacyCode 1 (safe + * under both old PM2 and the new supervisor) while fatals fall back to 0 to avoid a restart loop. + */ + function legacyFor(newCode) { + return newCode === EXIT.CRASH_TRANSIENT ? EXIT.CRASH_TRANSIENT : 0; + } + // flag OFF + expect(pick(disconnectExitCode(4004), legacyFor(disconnectExitCode(4004)))).toBe(0); + expect(pick(disconnectExitCode(4014), legacyFor(disconnectExitCode(4014)))).toBe(0); + expect(pick(disconnectExitCode(1006), legacyFor(disconnectExitCode(1006)))).toBe(1); + // flag ON + process.env.SCNX_EXIT_CODES = '1'; + expect(pick(disconnectExitCode(4004), legacyFor(disconnectExitCode(4004)))).toBe(64); + expect(pick(disconnectExitCode(4013), legacyFor(disconnectExitCode(4013)))).toBe(65); + expect(pick(disconnectExitCode(1006), legacyFor(disconnectExitCode(1006)))).toBe(1); + }); +}); + +describe('opt-in gate (exitCodesEnabled / pick)', () => { + test('disabled by default: pick returns the legacy code (default 0)', () => { + expect(exitCodesEnabled()).toBe(false); + expect(pick(66)).toBe(0); + expect(pick(78)).toBe(0); + expect(pick(64)).toBe(0); + expect(pick(1)).toBe(0); + }); + + test('disabled: an explicit legacyCode is honoured (pre-convention exit-1 sites stay 1)', () => { + expect(pick(1, 1)).toBe(1); + }); + + test('SCNX_EXIT_CODES=1 enables the new codes (lazily, at call time)', () => { + expect(pick(66)).toBe(0); // before the toggle + process.env.SCNX_EXIT_CODES = '1'; + expect(exitCodesEnabled()).toBe(true); + expect(pick(66)).toBe(66); + expect(pick(78)).toBe(78); + expect(pick(1, 1)).toBe(1); + }); + + test('SCNX_EXIT_CODES with any other value does not enable', () => { + process.env.SCNX_EXIT_CODES = 'true'; + expect(exitCodesEnabled()).toBe(false); + expect(pick(66)).toBe(0); + }); + + test('--scnx-exit-codes argv flag enables the new codes', () => { + process.argv.push('--scnx-exit-codes'); + try { + expect(exitCodesEnabled()).toBe(true); + expect(pick(65)).toBe(65); + } finally { + process.argv.pop(); + } + }); + + test('end-to-end, both modes: login classifier through the gate', () => { + const kind = classifyLoginError({code: 'TokenInvalid'}, false); + expect(pick(loginErrorExitCode(kind))).toBe(0); // flag off -> legacy 0 + process.env.SCNX_EXIT_CODES = '1'; + expect(pick(loginErrorExitCode(kind))).toBe(64); // flag on -> new code + expect(pick(loginErrorExitCode(classifyLoginError({code: 'ETIMEDOUT'}, false)))).toBe(1); + expect(pick(loginErrorExitCode(classifyLoginError(new Error('disallowed intent'), false)))).toBe(65); + }); +}); diff --git a/tests/info-commands/serverSubcommand.test.js b/tests/info-commands/serverSubcommand.test.js index 7251d674..e1b21811 100644 --- a/tests/info-commands/serverSubcommand.test.js +++ b/tests/info-commands/serverSubcommand.test.js @@ -13,7 +13,12 @@ jest.mock('../../src/functions/helpers', () => ({ formatNumber: (n) => String(n), parseEmbedColor: (c) => c, safeSetFooter: jest.fn(), - moduleEnabled: () => false + moduleEnabled: () => false, + memberCountOrFallback: (guild) => typeof guild.memberCount === 'number' ? guild.memberCount : guild.members.cache.size, + onlineCountOrNull: (client, guild) => { + if (!(client._activeIntents || []).includes('GuildPresences')) return null; + return guild.members.cache.filter(m => m.presence && ['online', 'idle', 'dnd'].includes(m.presence.status)).size; + } })); jest.mock('discord.js', () => { const actual = jest.requireActual('discord.js'); @@ -163,6 +168,73 @@ test('builds the overview with owner, bans and member/channel tables', async () expect(embed.fields.find(f => f.name === 'Bans').value).toBe('3'); }); +test('renders N/A (not a false 0) for the online count when GuildPresences intent is inactive', async () => { + const guild = makeGuild(); + const interaction = makeInteraction(guild); + interaction.client._activeIntents = ['Guilds', 'GuildMembers']; + await info.subcommands.server(interaction); + const embed = interaction.editReply.mock.calls[0][0].embeds[0]; + const members = embed.fields.find(f => f.name === 'Members'); + expect(members.value).toContain('N/A'); +}); + +test('renders the real online count when GuildPresences intent is active', async () => { + const guild = makeGuild(); + const interaction = makeInteraction(guild); + interaction.client._activeIntents = ['Guilds', 'GuildMembers', 'GuildPresences']; + await info.subcommands.server(interaction); + const embed = interaction.editReply.mock.calls[0][0].embeds[0]; + const members = embed.fields.find(f => f.name === 'Members'); + expect(members.value).not.toContain('N/A'); +}); + +test('renders N/A (not a wrong low count) for the non-bot member count when GuildMembers intent is inactive', async () => { + const guild = makeGuild({ + members: { + cache: channels([{ + user: {bot: false}, + presence: {status: 'online'} + }, { + user: {bot: false}, + presence: null + }, { + user: {bot: true}, + presence: null + }]) + } + }); + const interaction = makeInteraction(guild); + interaction.client._activeIntents = ['Guilds']; + await info.subcommands.server(interaction); + const embed = interaction.editReply.mock.calls[0][0].embeds[0]; + const members = embed.fields.find(f => f.name === 'Members'); + expect(members.value).toContain('N/A'); + expect(members.value).not.toContain('2'); +}); + +test('renders the real non-bot member count when GuildMembers intent is active', async () => { + const guild = makeGuild({ + members: { + cache: channels([{ + user: {bot: false}, + presence: {status: 'online'} + }, { + user: {bot: false}, + presence: null + }, { + user: {bot: true}, + presence: null + }]) + } + }); + const interaction = makeInteraction(guild); + interaction.client._activeIntents = ['Guilds', 'GuildMembers']; + await info.subcommands.server(interaction); + const embed = interaction.editReply.mock.calls[0][0].embeds[0]; + const members = embed.fields.find(f => f.name === 'Members'); + expect(members.value).toContain('2'); +}); + test('includes optional afk/description/rules/system fields when present', async () => { const guild = makeGuild({ afkChannel: {}, diff --git a/tests/info-commands/subcommands.test.js b/tests/info-commands/subcommands.test.js index 58950ee4..0369dc4a 100644 --- a/tests/info-commands/subcommands.test.js +++ b/tests/info-commands/subcommands.test.js @@ -4,11 +4,10 @@ * listing. * - role: permission rendering (ADMINISTRATOR shorthand vs explicit list), * small-member listing, and the hoist/mentionable/managed feature flags. - * - user: the levels enrichment block and the administrator permission - * shorthand. + * - user: the levels enrichment block and the administrator permission shorthand. * - server: owner/ban/member-table assembly. * MessageEmbed + helpers are mocked so we can assert on the field set; the - * cross-module helpers (messageCreate) load via the curve config. + * cross-module helpers (messageCreate/guildMemberJoin) load via the curve config. */ const mainStub = require('../__stubs__/main'); @@ -137,7 +136,8 @@ function clientBase(modules = {}) { configurations: conf, modules, strings: {disableFooterTimestamp: true}, - locale: 'en' + locale: 'en', + _activeIntents: ['Guilds', 'GuildMembers', 'GuildPresences'] }; } @@ -261,6 +261,26 @@ describe('role subcommand', () => { expect(who.value).toContain('<@b>'); }); + test('renders N/A (not a false 0) for the role-member count when GuildMembers intent is inactive', async () => { + const members = { + size: 2, + forEach: (fn) => { + fn({id: 'a'}); + fn({id: 'b'}); + } + }; + const client = clientBase(); + client._activeIntents = ['Guilds', 'GuildPresences']; + const interaction = baseInteraction(client); + interaction.options = {getRole: () => role({members})}; + await info.subcommands.role(interaction); + const count = interaction.editReply.mock.calls[0][0].embeds[0].fields.find(f => f.name === 'Count'); + expect(count.value).toBe('N/A'); + // The (near-empty, cache-derived) member list must also be omitted, not rendered wrong. + const who = interaction.editReply.mock.calls[0][0].embeds[0].fields.find(f => f.name === 'Who'); + expect(who).toBeUndefined(); + }); + test('feature flags surface in the description', async () => { const interaction = baseInteraction(clientBase()); interaction.options = { diff --git a/tests/intents/allowlist.test.js b/tests/intents/allowlist.test.js new file mode 100644 index 00000000..afcb201d --- /dev/null +++ b/tests/intents/allowlist.test.js @@ -0,0 +1,190 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const {computeRequiredIntents, partitionAllowlist, readAllowedPrivilegedIntents, privilegedIntentUsage} = require('../../src/functions/intents'); + +function fixture({modules = {}, enabled = {}, allowlist, customCommands} = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-')); + fs.mkdirSync(path.join(root, 'config')); + fs.mkdirSync(path.join(root, 'modules')); + for (const [m, json] of Object.entries(modules)) { + fs.mkdirSync(path.join(root, 'modules', m)); + fs.writeFileSync(path.join(root, 'modules', m, 'module.json'), JSON.stringify(json)); + } + fs.writeFileSync(path.join(root, 'config', 'modules.json'), JSON.stringify(enabled)); + const config = {}; + if (typeof allowlist !== 'undefined') config.allowedPrivilegedIntents = allowlist; + fs.writeFileSync(path.join(root, 'config', 'config.json'), JSON.stringify(config)); + if (customCommands) fs.writeFileSync(path.join(root, 'config', 'custom-commands.json'), JSON.stringify(customCommands)); + return {confDir: path.join(root, 'config'), modulesDir: path.join(root, 'modules')}; +} + +describe('partitionAllowlist', () => { + test('splits valid privileged names from invalid/non-privileged ones and dedupes', () => { + const {allowed, bad} = partitionAllowlist(['GuildMembers', 'GuildMembers', 'Guilds', 'Bogus']); + expect(allowed).toEqual(['GuildMembers']); + expect(bad).toEqual(['Guilds', 'Bogus']); + }); + + test('tolerates a non-array argument', () => { + expect(partitionAllowlist(undefined)).toEqual({allowed: [], bad: []}); + }); +}); + +describe('computeRequiredIntents allowlist', () => { + const modA = {intents: ['GuildPresences'], optionalIntents: ['GuildPresences']}; // cosmetic presence + const statusRoles = {intents: ['GuildPresences']}; // required (no optionalIntents) + + test('empty allowlist = all allowed: no disables/degrades/drops, presence still requested', () => { + const {confDir, modulesDir} = fixture({ + modules: {a: modA, sr: statusRoles}, enabled: {a: true, sr: true}, allowlist: [] + }); + const r = computeRequiredIntents(confDir, modulesDir); + expect(r.names).toContain('GuildPresences'); + expect(r.disabledModules).toEqual([]); + expect(r.degradedModules).toEqual([]); + expect(r.droppedPrivileged).toEqual([]); + expect(r.badAllowlistEntries).toEqual([]); + }); + + test('absent config.json = all allowed (no crash)', () => { + const {confDir, modulesDir} = fixture({modules: {sr: statusRoles}, enabled: {sr: true}}); + fs.rmSync(path.join(confDir, 'config.json')); + const r = computeRequiredIntents(confDir, modulesDir); + expect(r.names).toContain('GuildPresences'); + expect(r.allowedPrivileged).toEqual([]); + }); + + test('allowlist without GuildPresences: required consumer disabled, optional consumer degraded, intent dropped', () => { + const {confDir, modulesDir} = fixture({ + modules: {a: modA, sr: statusRoles}, + enabled: {a: true, sr: true}, + allowlist: ['GuildMembers', 'MessageContent'] + }); + const r = computeRequiredIntents(confDir, modulesDir); + expect(r.names).not.toContain('GuildPresences'); + expect(r.droppedPrivileged).toEqual(['GuildPresences']); + expect(r.disabledModules).toEqual([{module: 'sr', missingRequired: ['GuildPresences']}]); + expect(r.degradedModules).toEqual([{module: 'a', missingOptional: ['GuildPresences']}]); + }); + + test('intent required by one active module and optional in another is still requested; optional module not degraded', () => { + const req = {intents: ['GuildMembers']}; // required + const opt = {intents: ['GuildMembers'], optionalIntents: ['GuildMembers']}; // optional + const {confDir, modulesDir} = fixture({ + modules: {req, opt}, enabled: {req: true, opt: true}, allowlist: ['GuildMembers'] + }); + const r = computeRequiredIntents(confDir, modulesDir); + expect(r.names).toContain('GuildMembers'); + expect(r.disabledModules).toEqual([]); + expect(r.degradedModules).toEqual([]); + }); + + test('non-privileged intents never gated', () => { + const {confDir, modulesDir} = fixture({ + modules: {m: {intents: ['GuildVoiceStates']}}, enabled: {m: true}, allowlist: ['GuildMembers'] + }); + const r = computeRequiredIntents(confDir, modulesDir); + expect(r.names).toContain('GuildVoiceStates'); + expect(r.disabledModules).toEqual([]); + }); + + test('invalid allowlist entries reported and (when all-invalid) treated as all-allowed', () => { + const {confDir, modulesDir} = fixture({ + modules: {sr: statusRoles}, enabled: {sr: true}, allowlist: ['Bogus', 'Guilds'] + }); + const r = computeRequiredIntents(confDir, modulesDir); + expect(r.badAllowlistEntries).toEqual(['Bogus', 'Guilds']); + expect(r.names).toContain('GuildPresences'); // all-invalid => fail open to all-allowed + expect(r.disabledModules).toEqual([]); + }); + + test('module with optionalIntents but no intents is tolerated', () => { + const {confDir, modulesDir} = fixture({ + modules: {m: {optionalIntents: ['GuildMembers']}}, enabled: {m: true}, allowlist: ['MessageContent'] + }); + const r = computeRequiredIntents(confDir, modulesDir); + expect(r.disabledModules).toEqual([]); + expect(r.degradedModules).toEqual([]); + }); + + test('MessageContent needed by a custom command but not allowed is dropped (no crash)', () => { + const {confDir, modulesDir} = fixture({ + modules: {}, enabled: {}, allowlist: ['GuildMembers'], + customCommands: [{type: 'MESSAGE', enabled: true, matchType: 'contains', matchString: 'hi'}] + }); + const r = computeRequiredIntents(confDir, modulesDir); + expect(r.names).not.toContain('MessageContent'); + expect(r.droppedPrivileged).toContain('MessageContent'); + expect(r.names).toContain('GuildMessages'); // pairing still injected, non-privileged + }); + + test('config.json present but allowedPrivilegedIntents field absent = all allowed (upgrade path)', () => { + const {confDir, modulesDir} = fixture({ + modules: {sr: {intents: ['GuildPresences']}}, enabled: {sr: true} + // no allowlist arg => config.json is written as {} (field absent) + }); + const r = computeRequiredIntents(confDir, modulesDir); + expect(r.allowedPrivileged).toEqual([]); + expect(r.names).toContain('GuildPresences'); + expect(r.disabledModules).toEqual([]); + }); + + test('non-array allowedPrivilegedIntents is treated as empty (all allowed)', () => { + const {confDir, modulesDir} = fixture({ + modules: {sr: {intents: ['GuildPresences']}}, enabled: {sr: true}, allowlist: 'GuildPresences' + }); + const r = computeRequiredIntents(confDir, modulesDir); + expect(r.allowedPrivileged).toEqual([]); + expect(r.names).toContain('GuildPresences'); + }); +}); + +describe('privilegedIntentUsage allowlist annotations', () => { + test('marks intents dropped by the allowlist as not granted', () => { + const {confDir, modulesDir} = fixture({ + modules: {sr: {intents: ['GuildPresences'], intentReasons: {GuildPresences: 'status roles'}}}, + enabled: {sr: true}, + allowlist: ['GuildMembers'] + }); + const usage = privilegedIntentUsage(confDir, modulesDir); + const entry = usage.GuildPresences.find(e => e.module === 'sr'); + expect(entry.granted).toBe(false); + expect(entry.optional).toBe(false); + }); + + test('marks a module\'s optional intent as optional, and granted follows the allowlist', () => { + const {confDir, modulesDir} = fixture({ + modules: {a: {intents: ['GuildPresences'], optionalIntents: ['GuildPresences']}}, + enabled: {a: true}, + allowlist: ['GuildMembers'] + }); + const usage = privilegedIntentUsage(confDir, modulesDir); + const entry = usage.GuildPresences.find(e => e.module === 'a'); + expect(entry.granted).toBe(false); + expect(entry.optional).toBe(true); + }); + + test('empty allowlist grants everything', () => { + const {confDir, modulesDir} = fixture({ + modules: {a: {intents: ['GuildPresences'], optionalIntents: ['GuildPresences']}}, + enabled: {a: true}, + allowlist: [] + }); + const usage = privilegedIntentUsage(confDir, modulesDir); + const entry = usage.GuildPresences.find(e => e.module === 'a'); + expect(entry.granted).toBe(true); + expect(entry.optional).toBe(true); + }); + + test('custom-commands MessageContent entry is annotated with granted', () => { + const {confDir, modulesDir} = fixture({ + modules: {}, enabled: {}, allowlist: ['GuildMembers'], + customCommands: [{type: 'MESSAGE', enabled: true, matchType: 'contains', matchString: 'hi'}] + }); + const usage = privilegedIntentUsage(confDir, modulesDir); + const entry = usage.MessageContent.find(e => e.module === 'custom-commands'); + expect(entry.granted).toBe(false); + expect(entry.optional).toBe(false); + }); +}); diff --git a/tests/intents/degradedCounts.test.js b/tests/intents/degradedCounts.test.js new file mode 100644 index 00000000..09243d3a --- /dev/null +++ b/tests/intents/degradedCounts.test.js @@ -0,0 +1,18 @@ +const {memberCountOrFallback, onlineCountOrNull} = require('../../src/functions/helpers'); + +test('memberCountOrFallback uses guild.memberCount regardless of cache', () => { + const guild = {memberCount: 40000, members: {cache: {size: 3}}}; + expect(memberCountOrFallback(guild)).toBe(40000); +}); + +test('onlineCountOrNull returns null when GuildPresences is not active', () => { + const client = {_activeIntents: ['Guilds']}; + const guild = {members: {cache: {filter: () => ({size: 5})}}}; + expect(onlineCountOrNull(client, guild)).toBeNull(); +}); + +test('onlineCountOrNull counts online members when GuildPresences is active', () => { + const client = {_activeIntents: ['Guilds', 'GuildPresences']}; + const guild = {members: {cache: {filter: (fn) => ({size: 5})}}}; + expect(onlineCountOrNull(client, guild)).toBe(5); +}); diff --git a/tests/intents/intents.test.js b/tests/intents/intents.test.js index 8820113d..92b30644 100644 --- a/tests/intents/intents.test.js +++ b/tests/intents/intents.test.js @@ -243,6 +243,7 @@ describe('computeRequiredIntents', () => { const {names} = computeRequiredIntents(confDir, modulesDir); expect(names).toEqual(['Guilds']); }); + }); describe('resolveIntents β€” numeric-enum hardening', () => { @@ -406,20 +407,72 @@ describe('privilegedIntentUsage', () => { { module: 'moderation', name: 'Moderation', - reason: 'Anti-raid and captcha' + reason: 'Anti-raid and captcha', + granted: true, + optional: false }, { module: 'welcomer', name: 'Welcomer', - reason: null + reason: null, + granted: true, + optional: false } // no intentReasons -> reason null, name fallback ])); expect(usage.MessageContent).toEqual([{ module: 'moderation', name: 'Moderation', - reason: 'Spam/phishing filtering' + reason: 'Spam/phishing filtering', + granted: true, + optional: false }]); expect(usage.GuildPresences).toBeUndefined(); // statusRoles disabled expect(usage.GuildMessageReactions).toBeUndefined(); // non-privileged intents excluded }); + + test('an enabled module with no module.json on disk is skipped', () => { + const { + confDir, + modulesDir + } = makeFixture({real: {intents: ['GuildMembers'], humanReadableName: 'Real'}}, { + real: true, + ghost: true // enabled but no folder/module.json + }); + const usage = privilegedIntentUsage(confDir, modulesDir); + expect(usage.GuildMembers).toEqual([{module: 'real', name: 'Real', reason: null, granted: true, optional: false}]); + }); + + test('attributes MessageContent to custom commands when a MESSAGE autoresponder is enabled', () => { + const { + confDir, + modulesDir + } = makeFixture({}, {}, [{ + type: 'MESSAGE', + enabled: true, + matchType: 'everyMessage' + }]); + const usage = privilegedIntentUsage(confDir, modulesDir); + expect(usage.MessageContent).toEqual([{ + module: 'custom-commands', + name: 'Custom commands', + reason: 'Message-trigger auto-responders read message text to decide when to reply.', + granted: true, + optional: false + }]); + }); + + test('falls back to the dir name when a module has no humanReadableName, and tolerates non-array intents', () => { + const { + confDir, + modulesDir + } = makeFixture({ + noName: {intents: ['GuildPresences']}, // privileged intent, no humanReadableName -> name fallback + badIntents: {intents: 'not-an-array'} // intents not an array -> contributes nothing + }, { + noName: true, + badIntents: true + }); + const usage = privilegedIntentUsage(confDir, modulesDir); + expect(usage.GuildPresences).toEqual([{module: 'noName', name: 'noName', reason: null, granted: true, optional: false}]); + }); }); \ No newline at end of file diff --git a/tests/intents/loadConfigsIntentDisable.test.js b/tests/intents/loadConfigsIntentDisable.test.js new file mode 100644 index 00000000..796ddc32 --- /dev/null +++ b/tests/intents/loadConfigsIntentDisable.test.js @@ -0,0 +1,58 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const {applyIntentDisables} = require('../../src/functions/configuration'); + +function fixture(enabled, moduleIntents, allowlist) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'disable-')); + fs.mkdirSync(path.join(root, 'config')); + fs.mkdirSync(path.join(root, 'modules')); + for (const [m, json] of Object.entries(moduleIntents)) { + fs.mkdirSync(path.join(root, 'modules', m)); + fs.writeFileSync(path.join(root, 'modules', m, 'module.json'), JSON.stringify(json)); + } + fs.writeFileSync(path.join(root, 'config', 'modules.json'), JSON.stringify(enabled)); + fs.writeFileSync(path.join(root, 'config', 'config.json'), JSON.stringify({allowedPrivilegedIntents: allowlist})); + const modules = {}; + for (const m of Object.keys(moduleIntents)) modules[m] = {enabled: !!enabled[m], userEnabled: !!enabled[m]}; + const client = { + configDir: path.join(root, 'config'), modules, scnxSetup: false, + logger: {warn: () => {}, info: () => {}, error: () => {}} + }; + return {client, modulesDir: path.join(root, 'modules')}; +} + +test('disables a module missing a required privileged intent, leaving userEnabled true', async () => { + const {client, modulesDir} = fixture( + {sr: true}, {sr: {intents: ['GuildPresences']}}, ['GuildMembers']); + const disabled = await applyIntentDisables(client, modulesDir); + expect([...disabled]).toEqual(['sr']); + expect(client.modules.sr.enabled).toBe(false); + expect(client.modules.sr.userEnabled).toBe(true); +}); + +test('re-applying after a reset (reload) keeps the module disabled (C1 regression)', async () => { + const {client, modulesDir} = fixture( + {sr: true}, {sr: {intents: ['GuildPresences']}}, ['GuildMembers']); + await applyIntentDisables(client, modulesDir); + client.modules.sr.enabled = true; // simulate reloadConfig reset (configuration.js:459-462) + client.modules.sr.userEnabled = true; + await applyIntentDisables(client, modulesDir); + expect(client.modules.sr.enabled).toBe(false); +}); + +test('tolerates a disabled-list module with no client.modules entry (hidden module)', async () => { + const {client, modulesDir} = fixture( + {sr: true}, {sr: {intents: ['GuildPresences']}}, ['GuildMembers']); + delete client.modules.sr; + const disabled = await applyIntentDisables(client, modulesDir); + expect([...disabled]).toEqual(['sr']); // reported, but no throw +}); + +test('empty allowlist disables nothing', async () => { + const {client, modulesDir} = fixture( + {sr: true}, {sr: {intents: ['GuildPresences']}}, []); + const disabled = await applyIntentDisables(client, modulesDir); + expect([...disabled]).toEqual([]); + expect(client.modules.sr.enabled).toBe(true); +}); diff --git a/tests/intents/optionalIntentsAudit.test.js b/tests/intents/optionalIntentsAudit.test.js new file mode 100644 index 00000000..6631c19f --- /dev/null +++ b/tests/intents/optionalIntentsAudit.test.js @@ -0,0 +1,30 @@ +const fs = require('fs'); +const path = require('path'); +const {PRIVILEGED_INTENTS} = require('../../src/functions/intents'); + +const modulesDir = path.join(__dirname, '..', '..', 'modules'); + +describe('optionalIntents audit well-formedness', () => { + const dirs = fs.readdirSync(modulesDir).filter(d => fs.existsSync(path.join(modulesDir, d, 'module.json'))); + for (const d of dirs) { + test(`${d}: optionalIntents is a subset of declared privileged intents`, () => { + const j = JSON.parse(fs.readFileSync(path.join(modulesDir, d, 'module.json'))); + const optional = j.optionalIntents || []; + expect(Array.isArray(optional)).toBe(true); + for (const o of optional) { + expect(PRIVILEGED_INTENTS).toContain(o); // only privileged intents are gatable + expect(j.intents || []).toContain(o); // must actually be declared + } + }); + } + + test('status-roles keeps GuildPresences required (regression anchor)', () => { + const j = JSON.parse(fs.readFileSync(path.join(modulesDir, 'status-roles', 'module.json'))); + expect(j.optionalIntents || []).not.toContain('GuildPresences'); + }); + + test('team-list marks GuildPresences optional (cosmetic status dots)', () => { + const j = JSON.parse(fs.readFileSync(path.join(modulesDir, 'team-list', 'module.json'))); + expect(j.optionalIntents).toContain('GuildPresences'); + }); +}); diff --git a/tests/intents/privilegedIntentUsage.test.js b/tests/intents/privilegedIntentUsage.test.js index 9793a2ba..e4700634 100644 --- a/tests/intents/privilegedIntentUsage.test.js +++ b/tests/intents/privilegedIntentUsage.test.js @@ -36,7 +36,13 @@ describe('privilegedIntentUsage', () => { }); writeModule(modulesDir, 'b', {intents: ['GuildPresences']}); const out = privilegedIntentUsage(confDir, modulesDir); - expect(out.GuildMembers).toEqual([{module: 'a', name: 'Module A', reason: 'needs members'}]); + expect(out.GuildMembers).toEqual([{ + module: 'a', + name: 'Module A', + reason: 'needs members', + granted: true, + optional: false + }]); expect(out.GuildPresences).toBeUndefined(); }); @@ -54,7 +60,13 @@ describe('privilegedIntentUsage', () => { fs.writeFileSync(path.join(confDir, 'modules.json'), JSON.stringify({a: true})); writeModule(modulesDir, 'a', {intents: ['MessageContent', 'GuildMessages']}); const out = privilegedIntentUsage(confDir, modulesDir); - expect(out.MessageContent).toEqual([{module: 'a', name: 'a', reason: null}]); + expect(out.MessageContent).toEqual([{ + module: 'a', + name: 'a', + reason: null, + granted: true, + optional: false + }]); }); test('attributes a custom-command message trigger to a synthetic entry', () => { @@ -68,7 +80,9 @@ describe('privilegedIntentUsage', () => { expect(out.MessageContent).toEqual([{ module: 'custom-commands', name: 'Custom commands', - reason: 'Message-trigger auto-responders read message text to decide when to reply.' + reason: 'Message-trigger auto-responders read message text to decide when to reply.', + granted: true, + optional: false }]); }); diff --git a/tests/locales/enConsistency.test.js b/tests/locales/enConsistency.test.js new file mode 100644 index 00000000..959ae9ce --- /dev/null +++ b/tests/locales/enConsistency.test.js @@ -0,0 +1,144 @@ +/* + * Keeps locales/en.json in sync with the code that reads it. + * + * localize() THROWS on a missing key (src/functions/localize.js), so a typo is a runtime crash + * rather than a fallback - and the unit tests cannot catch it, because they mock localize to echo + * `section.key`. This suite reads the real sources instead and cross-checks both directions: + * - every statically resolvable localize('section', 'key') has an entry + * - every section and key in en.json is still referenced by shipped code + * + * Arguments that are not a plain string literal (`localize('boostTier', guild.premiumTier)`, + * `localize('emoji-quiz', 'point' + suffix)`) cannot be resolved here, so the section they belong to + * is exempt from the unused-key check rather than being reported as missing. + */ +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const SKIP_DIRS = new Set(['node_modules', '.git', 'coverage']); + +function walk(dir, out = []) { + if (!fs.existsSync(dir)) return out; + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) walk(p, out); + } else if (entry.name.endsWith('.js')) out.push(p); + } + return out; +} + +/** + * Reads one argument starting at `i`. Returns the literal's value only when the argument is exactly + * one string literal followed by `,` or `)` - so a concatenation or an identifier yields null. + * @param {String} src Source text + * @param {Number} i Index of the first character of the argument + * @returns {{value: String|null, end: Number}} Literal value (null when not static) and the index of the delimiter + */ +function readArgument(src, i) { + while (i < src.length && /\s/u.test(src[i])) i++; + const quote = src[i]; + if (quote !== '\'' && quote !== '"') return { + value: null, + end: i + }; + let j = i + 1; + let value = ''; + while (j < src.length) { + if (src[j] === '\\') { + value += src[j + 1]; + j += 2; + continue; + } + if (src[j] === quote) break; + value += src[j]; + j++; + } + if (j >= src.length) return { + value: null, + end: j + }; + let k = j + 1; + while (k < src.length && /\s/u.test(src[k])) k++; + // Anything other than a delimiter here means the literal was part of a larger expression. + if (src[k] !== ',' && src[k] !== ')') return { + value: null, + end: k + }; + return { + value, + end: k + }; +} + +// `localize` is aliased to `loc` in main.js. +const CALL = /\b(?:localize|loc)\s*\(/gu; + +function scanSources() { + const used = new Map(); + const dynamicKeySections = new Set(); + const files = [ + ...walk(path.join(ROOT, 'src')), + ...walk(path.join(ROOT, 'modules')), + path.join(ROOT, 'main.js') + ]; + for (const file of files) { + // localize.js declares the function; its own `localize(file, string)` is not a call site. + if (file.endsWith(path.join('functions', 'localize.js'))) continue; + const src = fs.readFileSync(file, 'utf8'); + CALL.lastIndex = 0; + let m; + while ((m = CALL.exec(src)) !== null) { + const section = readArgument(src, m.index + m[0].length); + if (section.value === null || src[section.end] !== ',') continue; + if (!used.has(section.value)) used.set(section.value, new Set()); + const key = readArgument(src, section.end + 1); + if (key.value === null) dynamicKeySections.add(section.value); + else used.get(section.value).add(key.value); + } + } + return { + used, + dynamicKeySections + }; +} + +const en = JSON.parse(fs.readFileSync(path.join(ROOT, 'locales', 'en.json'), 'utf8')); +const { + used, + dynamicKeySections +} = scanSources(); + +describe('locales/en.json covers every string the code asks for', () => { + test('no localize() call references a missing section', () => { + const missing = [...used.keys()].filter(s => !en[s]).sort(); + expect(missing).toEqual([]); + }); + + test('no localize() call references a missing key', () => { + const missing = []; + for (const [section, keys] of used) { + if (!en[section]) continue; + for (const key of keys) if (!(key in en[section])) missing.push(`${section}.${key}`); + } + expect(missing.sort()).toEqual([]); + }); +}); + +describe('locales/en.json carries no strings this repo cannot use', () => { + test('every section is referenced by shipped code', () => { + const orphans = Object.keys(en).filter(s => !used.has(s)).sort(); + expect(orphans).toEqual([]); + }); + + test('every key in a statically resolvable section is referenced', () => { + const unused = []; + for (const [section, entries] of Object.entries(en)) { + if (!used.has(section) || dynamicKeySections.has(section)) continue; + for (const key of Object.keys(entries)) { + if (!used.get(section).has(key)) unused.push(`${section}.${key}`); + } + } + expect(unused.sort()).toEqual([]); + }); +}); diff --git a/tests/migrations/runMigrations.test.js b/tests/migrations/runMigrations.test.js index 13ab1a24..c54842c7 100644 --- a/tests/migrations/runMigrations.test.js +++ b/tests/migrations/runMigrations.test.js @@ -352,4 +352,37 @@ describe('Umzug + DatabaseSchemeVersionStorage end-to-end against the real level await sequelize.close(); }); -}); \ No newline at end of file +}); +describe('buildUmzug explicit file list', () => { + const tempChannelsDir = path.join(__dirname, '..', '..', 'modules', 'temp-channels', 'migrations'); + + /* + * runAllMigrations always hands buildUmzug the exact absolute paths it wants run, rather than a + * glob: umzug's glob/ignore matching is version- and platform-dependent (backslash paths on + * Windows, brace patterns treated as literals) and can silently drop or re-include files. + */ + test('drives from the given paths, sorted by basename, ignoring other files in the directory', async () => { + const { + DatabaseSchemeVersion, + sequelize + } = makeMarkerModel(); + await sequelize.sync(); + + // Passed out of order on purpose: V1 must still resolve before V2. + const files = [ + path.join(tempChannelsDir, 'temp-channels_TempChannel__V2.js'), + path.join(tempChannelsDir, 'temp-channels_TempChannel__V1.js') + ]; + const umzug = buildUmzug(fakeClient(DatabaseSchemeVersion), tempChannelsDir, {files}); + const names = (await umzug.migrations()).map(m => m.name); + expect(names).toEqual(['temp-channels_TempChannel__V1', 'temp-channels_TempChannel__V2']); + + // A single-entry list must not pull in its sibling. + const one = buildUmzug(fakeClient(DatabaseSchemeVersion), tempChannelsDir, { + files: [path.join(tempChannelsDir, 'temp-channels_TempChannel__V2.js')] + }); + expect((await one.migrations()).map(m => m.name)).toEqual(['temp-channels_TempChannel__V2']); + + await sequelize.close(); + }); +}); diff --git a/tests/team-list/buildUserString.test.js b/tests/team-list/buildUserString.test.js index d5896b8b..620310f7 100644 --- a/tests/team-list/buildUserString.test.js +++ b/tests/team-list/buildUserString.test.js @@ -15,13 +15,14 @@ const role = { toString: () => '<@&r1>' }; -function member(id, status) { +function member(id, status, activeIntents = ['GuildPresences']) { return { user: { id, toString: () => `<@${id}>` }, - presence: status ? {status} : null + presence: status ? {status} : null, + client: {_activeIntents: activeIntents} }; } @@ -73,4 +74,19 @@ test('accumulates listed user ids across calls', () => { buildUserString([member('a')], role, {includeStatus: false}, listed); buildUserString([member('b')], role, {includeStatus: false}, listed); expect(listed).toEqual(['a', 'b']); +}); + +test('hides the status indicator when GuildPresences is not an active intent, even with includeStatus on', () => { + const members = [member('a', 'online', []), member('b', 'dnd', [])]; + const out = buildUserString(members, role, {includeStatus: true}, []); + expect(out).toBe('<@a>, <@b>'); + expect(out).not.toContain('🟒'); + expect(out).not.toContain('⚫'); +}); + +test('shows the status indicator when includeStatus is on and GuildPresences is active', () => { + const members = [member('a', 'online', ['GuildPresences']), member('b', 'dnd', ['GuildPresences'])]; + const out = buildUserString(members, role, {includeStatus: true}, []); + expect(out).toContain('<@a>: 🟒 team-list.online'); + expect(out).toContain('<@b>: πŸ”΄ team-list.dnd'); }); \ No newline at end of file diff --git a/tests/tickets/createTicketContext.test.js b/tests/tickets/createTicketContext.test.js index 2a09c53d..b823b730 100644 --- a/tests/tickets/createTicketContext.test.js +++ b/tests/tickets/createTicketContext.test.js @@ -14,9 +14,9 @@ const {createTicket} = require('../../modules/tickets/events/interactionCreate') const command = require('../../modules/tickets/commands/create-ticket-about-message'); function makeInteraction({ - config = [{name: 'Support'}], - content = 'hello world' - } = {}) { + config = [{name: 'Support'}], + content = 'hello world' +} = {}) { return { client: {configurations: {tickets: {config}}}, targetMessage: { diff --git a/tests/tickets/interactionCreate.test.js b/tests/tickets/interactionCreate.test.js index 6b5abedf..78c5673c 100644 --- a/tests/tickets/interactionCreate.test.js +++ b/tests/tickets/interactionCreate.test.js @@ -12,6 +12,7 @@ jest.mock('../../src/functions/localize', () => ({localize: (file, key) => `${fi const mainStub = require('../__stubs__/main'); const handler = require('../../modules/tickets/events/interactionCreate'); +const {OverwriteType, PermissionFlagsBits, PermissionsBitField} = require('discord.js'); function makeElement() { return { @@ -78,9 +79,11 @@ function makeInteraction(customId) { id: 'g1', channels: { create: jest.fn().mockResolvedValue(channel), - fetch: jest.fn().mockResolvedValue(null) + fetch: jest.fn().mockResolvedValue({ + permissionOverwrites: {cache: new Map()} + }) }, - roles: {cache: {find: () => ({id: 'everyone'})}} + roles: {cache: {find: () => ({id: 'g1'})}} }, deferReply: jest.fn().mockResolvedValue(), reply: jest.fn().mockResolvedValue(), @@ -127,4 +130,64 @@ describe('tickets create-ticket interaction', () => { // reply() on an already-acknowledged interaction throws "already acknowledged". expect(interaction.reply).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + test('copies category permissions and merges ticket-specific access when enabled', async () => { + const client = makeClient(); + client.configurations.tickets.config[0].ticketRoles = ['staff']; + client.configurations.tickets.config[0].inheritCategoryPermissions = true; + const interaction = makeInteraction('create-ticket-0'); + interaction.guild.channels.fetch.mockResolvedValue({ + permissionOverwrites: { + cache: new Map([ + ['g1', { + id: 'g1', + type: OverwriteType.Role, + allow: new PermissionsBitField([ + PermissionFlagsBits.AttachFiles, + PermissionFlagsBits.ViewChannel + ]), + deny: new PermissionsBitField() + }], + ['staff', { + id: 'staff', + type: OverwriteType.Role, + allow: new PermissionsBitField([ + PermissionFlagsBits.AttachFiles, + PermissionFlagsBits.EmbedLinks + ]), + deny: new PermissionsBitField(PermissionFlagsBits.SendMessages) + }] + ]) + } + }); + + await handler.run(client, interaction); + + const {permissionOverwrites} = interaction.guild.channels.create.mock.calls[0][0]; + const everyone = permissionOverwrites.find(overwrite => overwrite.id === 'g1'); + const staff = permissionOverwrites.find(overwrite => overwrite.id === 'staff'); + const creator = permissionOverwrites.find(overwrite => overwrite.id === 'u1'); + expect(new PermissionsBitField(everyone.allow).has(PermissionFlagsBits.AttachFiles)).toBe(true); + expect(new PermissionsBitField(everyone.allow).has(PermissionFlagsBits.ViewChannel)).toBe(false); + expect(new PermissionsBitField(everyone.deny).has(PermissionFlagsBits.ViewChannel)).toBe(true); + expect(new PermissionsBitField(staff.allow).has([ + PermissionFlagsBits.AttachFiles, + PermissionFlagsBits.EmbedLinks, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ViewChannel + ])).toBe(true); + expect(new PermissionsBitField(staff.deny).has(PermissionFlagsBits.SendMessages)).toBe(false); + expect(new PermissionsBitField(creator.allow).has(PermissionFlagsBits.ViewChannel)).toBe(true); + }); + + test('does not copy category permissions unless the option is explicitly enabled', async () => { + const client = makeClient(); + const interaction = makeInteraction('create-ticket-0'); + + await handler.run(client, interaction); + + expect(interaction.guild.channels.fetch).not.toHaveBeenCalled(); + const {permissionOverwrites} = interaction.guild.channels.create.mock.calls[0][0]; + expect(permissionOverwrites.map(overwrite => overwrite.id)).toEqual(['g1', 'u1']); + }); +}); diff --git a/tests/uno/gameplay.test.js b/tests/uno/gameplay.test.js index 792de0d6..4901a729 100644 --- a/tests/uno/gameplay.test.js +++ b/tests/uno/gameplay.test.js @@ -457,6 +457,6 @@ describe('run lobby', () => { await uno.run(interaction); const i = lobbyClick('uno-uno', 'stranger'); await collector.handlers.collect(i); - expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: 'uno.not-in-game'})); + expect(i.reply).toHaveBeenCalledWith(expect.objectContaining({content: 'uno.not-ingame'})); }); }); \ No newline at end of file From 8d0e58a751afbf51cf75dca0a7fdfdec8f591bfc Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 25 Jul 2026 17:07:17 +0200 Subject: [PATCH 2/2] added back auto removed comments --- modules/color-me/commands/color-me.js | 1 + .../commands/challenge-to-connect-four.js | 6 ++++ modules/economy-system/commands/add-money.js | 4 +++ .../economy-system/commands/economy-system.js | 18 +++++++++--- .../economy-system/commands/remove-money.js | 4 +++ .../economy-system/commands/set-balance.js | 4 +++ .../migrations/economy_Shop__V1.js | 28 +++++++++++++++++-- modules/massrole/commands/add-role-to-user.js | 6 ++++ .../commands/remove-role-from-user.js | 6 ++++ .../message-quotes/commands/quote-message.js | 7 +++++ .../commands/view-moderation-history.js | 5 ++++ .../commands/view-ping-history.js | 5 ++++ modules/ping-protection/ping-protection.js | 9 ++++++ .../staff-management-system/commands/duty.js | 5 ++++ .../commands/issue-infraction.js | 8 ++++++ .../commands/promote-user.js | 7 +++++ .../commands/staff-status.js | 3 ++ .../commands/submit-review.js | 7 +++++ .../commands/view-staff-profile.js | 5 ++++ .../context-actions.js | 20 ++++++++++--- .../events/interactionCreate.js | 14 ++++++++++ .../staff-management.js | 11 ++++++++ modules/starboard/commands/star-message.js | 4 +++ .../temp-channels/commands/add-to-channel.js | 8 ++++++ .../commands/remove-from-channel.js | 7 +++++ modules/temp-channels/events/botReady.js | 2 ++ .../temp-channels/events/voiceStateUpdate.js | 11 ++++++++ .../temp-channels_TempChannel__V1.js | 18 ++++++++++-- modules/uno/commands/challenge-to-uno.js | 7 +++++ modules/uno/commands/uno.js | 1 + 30 files changed, 228 insertions(+), 13 deletions(-) diff --git a/modules/color-me/commands/color-me.js b/modules/color-me/commands/color-me.js index 0cdd8a1d..92ebc2d9 100644 --- a/modules/color-me/commands/color-me.js +++ b/modules/color-me/commands/color-me.js @@ -251,6 +251,7 @@ async function color(interaction, moduleStrings) { }; } +// Exported for unit testing of the colour-validation logic. module.exports.color = color; /** diff --git a/modules/connect-four/commands/challenge-to-connect-four.js b/modules/connect-four/commands/challenge-to-connect-four.js index 030ed84d..3291c762 100644 --- a/modules/connect-four/commands/challenge-to-connect-four.js +++ b/modules/connect-four/commands/challenge-to-connect-four.js @@ -9,6 +9,12 @@ module.exports.config = { description: localize('connect-four', 'challenge-to-connect-four-context-description') }; +/* + * Thin adapter: /connect-four run() resolves its opponent via interaction.options.getMember('user') + * and an optional field_size via getInteger('field_size') (defaulting to 7). We reuse run() + * unchanged by handing it the real interaction with those option reads overridden so the + * challenge and game flow is identical, against the right-clicked user with the default field size. + */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/economy-system/commands/add-money.js b/modules/economy-system/commands/add-money.js index 139ab25a..17998d50 100644 --- a/modules/economy-system/commands/add-money.js +++ b/modules/economy-system/commands/add-money.js @@ -15,6 +15,10 @@ module.exports.config = { description: localize('economy-system', 'add-money-context-description') }; +/* + * /economy add adapter: runs the slash admin guard, then opens the amount modal (customId encodes + * action + target) handled in events/interactionCreate.js. showModal must be first, so no defer. + */ module.exports.run = async function (interaction) { interaction.str = interaction.client.configurations['economy-system']['strings']; interaction.config = interaction.client.configurations['economy-system']['config']; diff --git a/modules/economy-system/commands/economy-system.js b/modules/economy-system/commands/economy-system.js index 8f657ae4..b1eac1d2 100644 --- a/modules/economy-system/commands/economy-system.js +++ b/modules/economy-system/commands/economy-system.js @@ -30,11 +30,13 @@ async function cooldown (command, duration, userId, client) { } }); if (cooldownModel) { + // check cooldown duration if (cooldownModel.timestamp.getTime() + duration > Date.now()) return false; cooldownModel.timestamp = new Date(); await cooldownModel.save(); return true; } else { + // create the model await model.create({ userId: userId, command: command, @@ -97,7 +99,9 @@ async function adminGuard(interaction, user) { return true; } -// Shared rob core: robs `user` on behalf of interaction.user. interaction.str/config must already be set. +/* + * Shared rob core: robs `user` on behalf of interaction.user. interaction.str/config must already be set. + */ async function robUser(interaction, user) { const robbedUser = await interaction.client.models['economy-system']['Balance'].findOne({ where: { @@ -129,7 +133,9 @@ async function robUser(interaction, user) { })); } -// Shared "add money" core: adds `amount` to `user`'s wallet. Assumes the admin guard has passed. +/* + * Shared "add money" core: adds `amount` to `user`'s wallet. Assumes the admin guard has passed. + */ async function addMoney(interaction, user, amount) { await editBalance(interaction.client, user.id, 'add', parseInt(amount)); respond(interaction, { @@ -154,7 +160,9 @@ async function addMoney(interaction, user, amount) { })); } -// Shared "remove money" core: removes `amount` from `user`'s wallet. Assumes the admin guard has passed. +/* + * Shared "remove money" core: removes `amount` from `user`'s wallet. Assumes the admin guard has passed. + */ async function removeMoney(interaction, user, amount) { await editBalance(interaction.client, user.id, 'remove', parseInt(amount)); respond(interaction, { @@ -179,7 +187,9 @@ async function removeMoney(interaction, user, amount) { })); } -// Shared "set balance" core: sets `user`'s wallet to `amount`. Assumes the admin guard has passed. +/* + * Shared "set balance" core: sets `user`'s wallet to `amount`. Assumes the admin guard has passed. + */ async function setMoney(interaction, user, amount) { await editBalance(interaction.client, user.id, 'set', parseInt(amount)); respond(interaction, { diff --git a/modules/economy-system/commands/remove-money.js b/modules/economy-system/commands/remove-money.js index aa61c886..4b219c58 100644 --- a/modules/economy-system/commands/remove-money.js +++ b/modules/economy-system/commands/remove-money.js @@ -15,6 +15,10 @@ module.exports.config = { description: localize('economy-system', 'remove-money-context-description') }; +/* + * /economy remove adapter: runs the slash admin guard, then opens the amount modal (customId encodes + * action + target) handled in events/interactionCreate.js. showModal must be first, so no defer. + */ module.exports.run = async function (interaction) { interaction.str = interaction.client.configurations['economy-system']['strings']; interaction.config = interaction.client.configurations['economy-system']['config']; diff --git a/modules/economy-system/commands/set-balance.js b/modules/economy-system/commands/set-balance.js index ebd5d6c2..3251bfed 100644 --- a/modules/economy-system/commands/set-balance.js +++ b/modules/economy-system/commands/set-balance.js @@ -15,6 +15,10 @@ module.exports.config = { description: localize('economy-system', 'set-balance-context-description') }; +/* + * /economy set adapter: runs the slash admin guard, then opens the balance modal (customId encodes + * action + target) handled in events/interactionCreate.js. showModal must be first, so no defer. + */ module.exports.run = async function (interaction) { interaction.str = interaction.client.configurations['economy-system']['strings']; interaction.config = interaction.client.configurations['economy-system']['config']; diff --git a/modules/economy-system/migrations/economy_Shop__V1.js b/modules/economy-system/migrations/economy_Shop__V1.js index f53c5d37..415efc7d 100644 --- a/modules/economy-system/migrations/economy_Shop__V1.js +++ b/modules/economy-system/migrations/economy_Shop__V1.js @@ -1,8 +1,25 @@ const TABLE = 'economy_shop'; /* - * Moves the primary key from `name` to `id`. SQLite has no `ALTER TABLE ... DROP PRIMARY KEY`, so - * this rebuilds the table; pre-V1 rows reuse `name` as their `id`. No-ops when `id` already exists. + * V1 commit (98e3b4f4, Oct 2024) changed the primary key from `name` to a new `id` + * column. The pre-V1 schema had no `id` column at all: `name` was the STRING PK. + * The current model is `id STRING PRIMARY KEY, name STRING, price INTEGER, role TEXT`. + * + * The old inline V1 did `findAll β†’ sync({force:true}) β†’ re-insert with i++` to perform + * this PK swap. Customers who ran that inline V1 have the new schema and their existing + * rows received sequential integer-as-string ids. Customers who never ran it (e.g. they + * upgraded straight from pre-V1 code to this new Umzug-based code) still have the old + * `name`-as-PK table; their shop queries by `id` would silently fail. + * + * SQLite has no `ALTER TABLE ... DROP PRIMARY KEY`, so this migration uses the canonical + * SQLite table-rebuild pattern: create a new table with the right schema, copy the rows + * across, drop the old, rename the new. For data that came from the pre-V1 `name`-as-PK + * schema, we use `name` itself as the new `id` value β€” that's the stablest mapping + * (it's already unique, and operator-facing identifiers tend to reference items by + * name in the bot's config). + * + * Idempotent: if `id` already exists in the table description (post-V1, fresh install, + * or already-migrated), the body is a no-op. */ module.exports = { tables: [TABLE], @@ -33,6 +50,11 @@ module.exports = { }); }, down: async () => { - // No-op: restore from migration-backups/ instead. + + /* + * No-op: reverting from `id`-PK back to `name`-PK is not a meaningful rollback + * once the runtime code expects `id`. Operators should restore from a backup + * (`migration-backups/__economy_Shop__V1__economy_shop.json`) instead. + */ } }; \ No newline at end of file diff --git a/modules/massrole/commands/add-role-to-user.js b/modules/massrole/commands/add-role-to-user.js index 7b1dd32b..23b18f03 100644 --- a/modules/massrole/commands/add-role-to-user.js +++ b/modules/massrole/commands/add-role-to-user.js @@ -12,6 +12,12 @@ module.exports.config = { description: localize('massrole', 'add-role-to-user-context-description') }; +/* + * Thin adapter for the massrole add logic, applied to a SINGLE target member. Discord modals + * cannot contain select menus, so we reply ephemerally with a role select whose customId encodes + * the action + target user id (massrole-ctx:add:). The select is handled in + * events/interactionCreate.js, which calls the shared applyRoleToMember core. + */ module.exports.run = async function (interaction) { if (interaction.member.roles.cache.filter(m => interaction.client.configurations['massrole']['config'].adminRoles.includes(m.id)).size === 0) { return interaction.reply({ diff --git a/modules/massrole/commands/remove-role-from-user.js b/modules/massrole/commands/remove-role-from-user.js index 1702a7b3..a335d133 100644 --- a/modules/massrole/commands/remove-role-from-user.js +++ b/modules/massrole/commands/remove-role-from-user.js @@ -12,6 +12,12 @@ module.exports.config = { description: localize('massrole', 'remove-role-from-user-context-description') }; +/* + * Thin adapter for the massrole remove logic, applied to a SINGLE target member. Discord modals + * cannot contain select menus, so we reply ephemerally with a role select whose customId encodes + * the action + target user id (massrole-ctx:remove:). The select is handled in + * events/interactionCreate.js, which calls the shared applyRoleToMember core. + */ module.exports.run = async function (interaction) { if (interaction.member.roles.cache.filter(m => interaction.client.configurations['massrole']['config'].adminRoles.includes(m.id)).size === 0) { return interaction.reply({ diff --git a/modules/message-quotes/commands/quote-message.js b/modules/message-quotes/commands/quote-message.js index 9cb7295f..63f4a589 100644 --- a/modules/message-quotes/commands/quote-message.js +++ b/modules/message-quotes/commands/quote-message.js @@ -9,6 +9,13 @@ module.exports.config = { description: localize('message-quotes', 'quote-message-description') }; +/* + * Builds the quote with the exact same renderer the auto-quote event uses (buildQuoteMessage, + * shared in renderQuote.js) and posts it into the current channel. The link is reconstructed + * from the target message's guild/channel/id. The quoter is the command user, so the + * selfQuote=false config still suppresses quoting your own message. Replies ephemerally when + * the quote is suppressed by config (noBots / selfQuote). + */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/ping-protection/commands/view-moderation-history.js b/modules/ping-protection/commands/view-moderation-history.js index 20954816..468f80c8 100644 --- a/modules/ping-protection/commands/view-moderation-history.js +++ b/modules/ping-protection/commands/view-moderation-history.js @@ -10,6 +10,11 @@ module.exports.config = { description: localize('ping-protection', 'view-moderation-history-description') }; +/* + * Thin adapter: build the same payload the /ping-protection user actions-history slash + * subcommand produces by reusing generateActionsResponse, then reply ephemerally with it so + * the output (embed + pagination buttons) is identical for the targeted user. + */ module.exports.run = async function (interaction) { const payload = await generateActionsResponse(interaction.client, interaction.targetUser.id, 1); return interaction.reply({ diff --git a/modules/ping-protection/commands/view-ping-history.js b/modules/ping-protection/commands/view-ping-history.js index db1f226b..6d6b76fa 100644 --- a/modules/ping-protection/commands/view-ping-history.js +++ b/modules/ping-protection/commands/view-ping-history.js @@ -10,6 +10,11 @@ module.exports.config = { description: localize('ping-protection', 'view-ping-history-description') }; +/* + * Thin adapter: build the same payload the /ping-protection user history slash subcommand + * produces by reusing generateHistoryResponse, then reply ephemerally with it so the output + * (embed + pagination buttons) is identical for the targeted user. + */ module.exports.run = async function (interaction) { const payload = await generateHistoryResponse(interaction.client, interaction.targetUser.id, 1); return interaction.reply({ diff --git a/modules/ping-protection/ping-protection.js b/modules/ping-protection/ping-protection.js index 1ea0a1d2..2bf3f803 100644 --- a/modules/ping-protection/ping-protection.js +++ b/modules/ping-protection/ping-protection.js @@ -658,6 +658,7 @@ async function syncNativeAutoMod(client) { keywords.splice(1000); } + // AutoMod rule data const actions = []; const blockMetadata = {}; if (config.autoModBlockMessage) { @@ -706,6 +707,7 @@ async function syncNativeAutoMod(client) { } } +// Makes the history embed async function generateHistoryResponse(client, userId, page = 1) { const storageConfig = client.configurations['ping-protection']['storage']; const limit = 5; @@ -801,6 +803,7 @@ async function generateHistoryResponse(client, userId, page = 1) { }; } +// Makes the moderation actions history embed async function generateActionsResponse(client, userId, page = 1) { const moderationConfig = client.configurations['ping-protection']['moderation']; const limit = 5; @@ -871,6 +874,7 @@ async function generateActionsResponse(client, userId, page = 1) { }; } +// Handles data deletion async function deleteAllUserData(client, userId) { await executeDataDeletion(client, userId, 'del_all'); client.logger.info(localize('ping-protection', 'log-data-deletion', { @@ -891,6 +895,7 @@ async function markUserAsRejoined(client, userId) { }); } +// Enforces data retention async function enforceRetention(client) { const storageConfig = client.configurations['ping-protection']['storage']; if (!storageConfig) return; @@ -945,9 +950,11 @@ async function enforceRetention(client) { } } +// Executes moderation action async function executeAction(client, member, rule, reason, storageConfig, originChannel = null, stats = {}) { const actionType = rule.actionType; + // Sends action log if enabled const sendActionLog = async () => { if (!rule.enableActionLogging || !originChannel) return; @@ -975,6 +982,7 @@ async function executeAction(client, member, rule, reason, storageConfig, origin } }; + // Sends error message if action fails const sendErrorLog = async (error) => { if (!originChannel) return; @@ -1074,6 +1082,7 @@ async function executeAction(client, member, rule, reason, storageConfig, origin return false; } +// Processes a ping event async function processPing(client, userId, targetId, isRole, messageUrl, originChannel, memberToPunish) { const config = client.configurations['ping-protection']['configuration']; const storageConfig = client.configurations['ping-protection']['storage']; diff --git a/modules/staff-management-system/commands/duty.js b/modules/staff-management-system/commands/duty.js index 0736f8bf..d2897d3f 100644 --- a/modules/staff-management-system/commands/duty.js +++ b/modules/staff-management-system/commands/duty.js @@ -644,6 +644,7 @@ async function buildDutyAdminPayload(client, targetMember, requestingMember) { }; } +// ----- Button handlers ----- async function handleDutyStartButton(client, interaction) { const parts = interaction.customId.split('_'); const userId = parts[2]; @@ -895,6 +896,7 @@ async function handleDutyLbPageButton(client, interaction) { return interaction.editReply(payload); } +// ----- Admin handler ----- async function handleDutyAdminForceEnd(client, interaction) { const permCheck = checkDutyAdminPermission(client, interaction); if (permCheck) return permCheck; @@ -1203,6 +1205,7 @@ async function handleDutyAdminAddTimeSubmit(client, interaction) { }); } +// ----- Dropdown handler ----- async function handleDutyDropdown(client, interaction, action, selectedType) { if (action === 'manage') { const payload = await buildDutyManagePayload(client, interaction.user.id, selectedType); @@ -1538,6 +1541,7 @@ module.exports.config = { ] }; +// Export handlers module.exports.buttonHandlers = { handleDutyStartButton, handleDutyAdminAddTimeButton, @@ -1553,6 +1557,7 @@ module.exports.buttonHandlers = { handleDutyAdminAddTimeSubmit }; +// Exported for unit testing of the pure duty helpers. module.exports._test = { getLookbackDate, canUseDutyAdmin, diff --git a/modules/staff-management-system/commands/issue-infraction.js b/modules/staff-management-system/commands/issue-infraction.js index 39991cc0..bd6c1492 100644 --- a/modules/staff-management-system/commands/issue-infraction.js +++ b/modules/staff-management-system/commands/issue-infraction.js @@ -13,6 +13,14 @@ module.exports.config = { description: localize('staff-management-system', 'issue-infraction-context-description') }; +/* + * Thin adapter for the /staff-management infraction issue subcommand. MANAGE_GUILD is only a + * coarse Discord gate; the real gate is the module's runtime SUPERVISOR check, enforced here + * before a modal is shown (and again inside issueInfraction). The modal collects the same fields + * the slash flow does (type / reason / optional expiry); its customId encodes the target user id + * so the submit handler in events/interactionCreate.js can run the shared issueInfraction core. + * showModal must be the first response, so we must NOT defer. + */ module.exports.run = async function (interaction) { if (!isSupervisor(interaction.client, interaction.member)) return interaction.reply({ content: localize('staff-management-system', 'err-gen-no-perm'), diff --git a/modules/staff-management-system/commands/promote-user.js b/modules/staff-management-system/commands/promote-user.js index b69b268a..ce8d34ec 100644 --- a/modules/staff-management-system/commands/promote-user.js +++ b/modules/staff-management-system/commands/promote-user.js @@ -13,6 +13,13 @@ module.exports.config = { description: localize('staff-management-system', 'promote-user-context-description') }; +/* + * Thin adapter for the /staff-management promote subcommand. MANAGE_GUILD is only a coarse Discord + * gate; the real gate is the module's runtime SUPERVISOR check, enforced here before the select is + * shown (and again inside promoteUser). The slash flow picks the new rank as a ROLE option, so we + * reply ephemerally with a role select whose customId encodes the target user id; the select + * submit in events/interactionCreate.js runs the shared promoteUser core with the chosen role. + */ module.exports.run = async function (interaction) { if (!isSupervisor(interaction.client, interaction.member)) return interaction.reply({ content: localize('staff-management-system', 'err-gen-no-perm'), diff --git a/modules/staff-management-system/commands/staff-status.js b/modules/staff-management-system/commands/staff-status.js index 5deeaa81..9eb2b69c 100644 --- a/modules/staff-management-system/commands/staff-status.js +++ b/modules/staff-management-system/commands/staff-status.js @@ -21,6 +21,7 @@ const { checkStaffPermissions } = require('../staff-management'); +// ---------- Status DM's and logging ---------- async function sendStatusDm(user, type, dmType, data = {}) { const label = type === 'LOA' ? 'LoA' @@ -32,6 +33,7 @@ async function sendStatusDm(user, type, dmType, data = {}) { ? `` : ''; + // These messages use the locales key to be easily used later const messages = { approved: { title: 'dm-appr-title', @@ -169,6 +171,7 @@ async function logStatusChange(client, type, action, data) { } } +// ----- Status ----- const getStatusMeta = (type) => ({ isLoa: type === 'LOA', label: type === 'LOA' diff --git a/modules/staff-management-system/commands/submit-review.js b/modules/staff-management-system/commands/submit-review.js index 380ff8d4..73a81765 100644 --- a/modules/staff-management-system/commands/submit-review.js +++ b/modules/staff-management-system/commands/submit-review.js @@ -8,6 +8,13 @@ module.exports.config = { description: localize('staff-management-system', 'submit-review-context-description') }; +/* + * Thin adapter for the /staff-management review submit subcommand. Open to everyone; the + * onlyAllowStaffReview restriction (target must be staff) is enforced at runtime inside the shared + * submitReview core, exactly as for the slash flow. The modal collects the same fields the slash + * flow does (stars 1-5 / comment); its customId encodes the target user id so the submit handler + * in events/interactionCreate.js runs submitReview. showModal must be first, so we must NOT defer. + */ module.exports.run = async function (interaction) { return interaction.showModal(buildReviewModal(interaction.targetUser.id)); }; diff --git a/modules/staff-management-system/commands/view-staff-profile.js b/modules/staff-management-system/commands/view-staff-profile.js index 16b19a0b..186e822a 100644 --- a/modules/staff-management-system/commands/view-staff-profile.js +++ b/modules/staff-management-system/commands/view-staff-profile.js @@ -9,6 +9,11 @@ module.exports.config = { description: localize('staff-management-system', 'view-staff-profile-description') }; +/* + * Thin adapter: defer ephemerally (handleProfileView responds via editReply) and hand off to + * the shared handleProfileView core, exactly like the /staff-management profile view subcommand, + * so the rendered staff profile is identical for the targeted user. + */ module.exports.run = async function (interaction) { await interaction.deferReply({ flags: MessageFlags.Ephemeral diff --git a/modules/staff-management-system/context-actions.js b/modules/staff-management-system/context-actions.js index ba468ec9..3527f39c 100644 --- a/modules/staff-management-system/context-actions.js +++ b/modules/staff-management-system/context-actions.js @@ -17,9 +17,13 @@ const { } = require('./staff-management'); /* - * Shared core for the staff-management USER context-menu commands, routing submitted modal/select - * data into the same cores the slash subcommands call. Those cores defer and editReply themselves, - * so neither the run() adapters nor the submit handlers may defer first. + * Shared core for the staff-management USER context-menu commands. Each command is a thin adapter + * that shows a modal/select; the submitted data is then routed back here and handed to the same + * issueInfraction / promoteUser / submitReview cores the slash subcommands call, so the output is + * identical. The cores call interaction.deferReply() and editReply() themselves, so the run() + * adapters must NOT defer before showing the modal/select, and the submit handlers must NOT defer + * before invoking the core. Supervisor gating for infract/promote is enforced both up-front here + * (so unauthorized users never see a modal) and again inside the cores. */ const SUPERVISOR = 'supervisor'; @@ -29,7 +33,12 @@ function isSupervisor(client, member) { return checkStaffPermissions(member, getConfig(client, 'configuration'), SUPERVISOR); } -// Modal interactions have no interaction.options, but promoteUser reads getChannel('channel'). +/* + * Discord modal interactions have no interaction.options; promoteUser reads the optional + * announcement-channel override via interaction.options.getChannel('channel'). The context flow + * has no such option, so we wrap the submit interaction with an options shim returning null, + * leaving every other property delegated to the real interaction. + */ function withOptionsShim(interaction) { return new Proxy(interaction, { get(target, prop) { @@ -40,6 +49,7 @@ function withOptionsShim(interaction) { }); } +// ----- Issue Infraction (modal) ----- function buildInfractionModal(client, userId) { const types = getConfig(client, 'infractions')?.infractionTypes || []; const selectable = types.filter(infractionType => infractionType.toLowerCase() !== 'suspension'); @@ -102,6 +112,7 @@ async function handleInfractionModal(client, interaction, userId) { return issueInfraction(client, interaction, targetMember, type, reason, expiry || null); } +// ----- Promote User (role select) ----- function buildPromoteSelect(userId) { return { flags: MessageFlags.Ephemeral, @@ -136,6 +147,7 @@ async function handlePromoteSelect(client, interaction, userId) { return promoteUser(client, withOptionsShim(interaction), targetMember, role, null); } +// ----- Submit Review (modal) ----- function buildReviewModal(userId) { const modal = new ModalBuilder() .setCustomId(`staff-mgmt_ctx-review_${userId}`) diff --git a/modules/staff-management-system/events/interactionCreate.js b/modules/staff-management-system/events/interactionCreate.js index 7f076769..4febea1c 100644 --- a/modules/staff-management-system/events/interactionCreate.js +++ b/modules/staff-management-system/events/interactionCreate.js @@ -39,6 +39,7 @@ module.exports.run = async (client, interaction) => { const parts = interaction.customId.split('_'); const action = parts[1]; + // ----- USER context-menu submits (Issue Infraction / Promote User / Submit Review) ----- if (action === 'ctx-infract' && interaction.isModalSubmit()) { return require('../context-actions').handleInfractionModal(client, interaction, parts[2]); } @@ -49,6 +50,7 @@ module.exports.run = async (client, interaction) => { return require('../context-actions').handleReviewModal(client, interaction, parts[2]); } + // ----- Duty manage handlers ----- if (interaction.customId.startsWith('duty-mgmt_')) { const dutyAction = parts[1]; @@ -75,6 +77,7 @@ module.exports.run = async (client, interaction) => { return; } + // ----- Review history pagination ----- if (action === 'rev-page') { await interaction.deferUpdate(); const targetUser = await client.users.fetch(parts[2]).catch(() => null); @@ -88,6 +91,7 @@ module.exports.run = async (client, interaction) => { return interaction.editReply(payload); } + // ----- LOA/RA handlers ----- const loaActions = ['loa-end', 'loa-end-submit', 'loa-extend', 'loa-extend-submit', 'loa-hist']; const raActions = ['ra-end', 'ra-end-submit', 'ra-extend', 'ra-extend-submit', 'ra-hist']; @@ -102,6 +106,7 @@ module.exports.run = async (client, interaction) => { if (base === 'hist') return handleStatusHistPage(client, interaction, type); } + // ----- Promotion history pagination ----- if (action === 'prom-hist') { await interaction.deferUpdate(); const targetUser = await client.users.fetch(parts[2]).catch(() => null); @@ -115,6 +120,7 @@ module.exports.run = async (client, interaction) => { return interaction.editReply(payload); } + // ----- Infraction history pagination ----- if (action === 'inf-hist') { await interaction.deferUpdate(); const targetUser = await client.users.fetch(parts[2]).catch(() => null); @@ -128,6 +134,7 @@ module.exports.run = async (client, interaction) => { return interaction.editReply(payload); } + // ----- User panel dropdown ----- if (interaction.customId.startsWith('staff-mgmt_panel-menu_')) { const targetId = interaction.customId.split('_')[2]; await interaction.deferUpdate(); @@ -151,6 +158,7 @@ module.exports.run = async (client, interaction) => { return interaction.editReply(payload); } + // ----- User panel deletion dropdown ----- if (interaction.customId.startsWith('staff-mgmt_delete-menu_')) { const targetId = interaction.customId.split('_')[2]; const selection = interaction.values[0]; @@ -192,6 +200,7 @@ module.exports.run = async (client, interaction) => { return interaction.showModal(modal); } + // ----- Data deletion modal submission ----- if (interaction.isModalSubmit() && interaction.customId.startsWith('staff-mgmt_del-confirm_')) { await interaction.deferReply({flags: MessageFlags.Ephemeral}); const configuration = getConfig(client, 'configuration'); @@ -317,6 +326,7 @@ module.exports.run = async (client, interaction) => { }); } + // ----- User panel buttons ----- if (interaction.customId.startsWith('staff-mgmt_panel-')) { const parts = interaction.customId.split('_'); const targetId = parts[2]; @@ -343,6 +353,7 @@ module.exports.run = async (client, interaction) => { } } + // ----- Status buttons ----- const LoARequest = client.models['staff-management-system']['LoaRequest']; const StaffProfile = client.models['staff-management-system']['StaffProfile']; const config = client.configurations['staff-management-system']['configuration']; @@ -433,6 +444,7 @@ module.exports.run = async (client, interaction) => { } } + // ----- Deny modal submission ----- if (interaction.isModalSubmit() && action === 'loa-deny') { const configuration = getConfig(client, 'configuration'); @@ -501,6 +513,7 @@ module.exports.run = async (client, interaction) => { }); } + // ----- Profile edit submission ----- if (interaction.isModalSubmit() && action === 'profile-edit') { const nickname = interaction.fields.getTextInputValue('nickname'); const intro = interaction.fields.getTextInputValue('intro'); @@ -517,6 +530,7 @@ module.exports.run = async (client, interaction) => { }); } + // ----- Activity checks button ----- if (action === 'ac-respond') { const ActivityCheck = client.models['staff-management-system']['ActivityCheck']; const ActivityCheckResponse = client.models['staff-management-system']['ActivityCheckResponse']; diff --git a/modules/staff-management-system/staff-management.js b/modules/staff-management-system/staff-management.js index 4e6f159b..18483c53 100644 --- a/modules/staff-management-system/staff-management.js +++ b/modules/staff-management-system/staff-management.js @@ -9,6 +9,7 @@ const schedule = require('node-schedule'); const {embedTypeV2, safeSetFooter, dateToDiscordTimestamp} = require('../../src/functions/helpers'); const { localize } = require('../../src/functions/localize'); +// --- Local helpers --- const getConfig = (client, file) => client.configurations['staff-management-system'][file]; const getSafeChannelId = (val) => Array.isArray(val) && val.length > 0 // Helper to get safe channel ID from config ? val[0] @@ -110,6 +111,7 @@ function formatDuration(seconds) { return parts.join(', ') || localize('staff-management-system', 'time-zero'); } +// ---------- Infractions ---------- async function issueInfraction(client, interaction, targetMember, type, reason, expiryInput) { await interaction.deferReply({ephemeral: true}); const config = getConfig(client, 'infractions'); @@ -221,6 +223,7 @@ async function issueInfraction(client, interaction, targetMember, type, reason, }); } +// ---------- Suspensions ---------- async function issueSuspension(client, interaction, targetMember, durationInput, reason) { await interaction.deferReply({ephemeral: true}); const config = getConfig(client, 'infractions'); @@ -382,6 +385,7 @@ async function resolveInfractionReference(client, reference) { } } +// ----- Infractions voiding ----- async function voidInfraction(client, interaction, reference) { await interaction.deferReply({ephemeral: true}); const config = getConfig(client, 'infractions'); @@ -434,6 +438,7 @@ async function voidInfraction(client, interaction, reference) { }); } +// ----- Generates infractions history embed ----- async function generateInfractionHistoryResponse(client, targetUser, page = 1) { const limit = 5; const offset = (page - 1) * limit; @@ -489,6 +494,7 @@ async function generateInfractionHistoryResponse(client, targetUser, page = 1) { return { embeds: [embed.toJSON()], components: [row.toJSON()] }; } +// ----- Gets infraction history ----- async function getInfractionHistory(client, interaction, targetUser) { await interaction.deferReply({ephemeral: true}); const response = await generateInfractionHistoryResponse(client, targetUser, 1); @@ -498,6 +504,7 @@ async function getInfractionHistory(client, interaction, targetUser) { }); } +// ---------- Promotions ---------- async function promoteUser(client, interaction, targetMember, newRole, reason) { await interaction.deferReply({ephemeral: true}); const config = getConfig(client, 'promotions'); @@ -629,6 +636,7 @@ async function promoteUser(client, interaction, targetMember, newRole, reason) { }); } +// ----- Generates promotion history & embed ----- async function generatePromotionHistoryResponse(client, targetUser, page = 1) { const Promotion = client.models['staff-management-system']['Promotion']; const limit = 5; @@ -685,6 +693,7 @@ async function getPromotionHistory(client, interaction, targetUser) { }); } +// ---------- User Panel ---------- async function generatePanelSubpage(client, targetUser, type, page) { if (type === 'infractions') return await generatePanelInfractions(client, targetUser, page); if (type === 'promotions') return await generatePanelPromotions(client, targetUser, page); @@ -1335,6 +1344,7 @@ async function executeDataDeletion(client, targetId, dataType) { } } +// ---------- Activity Checks ---------- async function startActivityCheck(client, interactionOrChannel, isAutomated = false) { const config = getConfig(client, 'activity-checks'); const ActivityCheck = client.models['staff-management-system']['ActivityCheck']; @@ -1596,6 +1606,7 @@ function initActivityCheckAutomation(client) { }); } +// ---------- Reviews ---------- async function submitReview(client, interaction, targetUser, stars, comment) { await interaction.deferReply({ephemeral: true}); const config = getConfig(client, 'reviews'); diff --git a/modules/starboard/commands/star-message.js b/modules/starboard/commands/star-message.js index 57b58109..0a3dc74d 100644 --- a/modules/starboard/commands/star-message.js +++ b/modules/starboard/commands/star-message.js @@ -8,6 +8,10 @@ module.exports.config = { description: localize('starboard', 'star-message-description') }; +/* + * Force-stars the right-clicked message by reusing handleStarboard() with options.force (bypasses the + * self-star removal and minStars threshold). Synthesizes the minimal msgReaction handleStarboard reads. + */ module.exports.run = async function (interaction) { const target = interaction.targetMessage; const starConfig = interaction.client.configurations['starboard']['config']; diff --git a/modules/temp-channels/commands/add-to-channel.js b/modules/temp-channels/commands/add-to-channel.js index 4d78451f..8ebf1213 100644 --- a/modules/temp-channels/commands/add-to-channel.js +++ b/modules/temp-channels/commands/add-to-channel.js @@ -12,6 +12,14 @@ module.exports.config = { description: localize('temp-channels', 'add-to-channel-context-description') }; +/* + * Thin adapter for the temp-channels "add user" flow. Everyone can invoke it, but it is + * creator-only: resolveOwnedTempChannel matches the channel the menu was invoked in against the + * invoker as creatorID, so a non-creator (or a non-temp channel) gets the notInChannel reply and + * we never touch the channel. On success we hand off to the shared userAdd core with the + * 'context' caller, which reads interaction.targetUser and produces output identical to the + * /temp-channel add-user subcommand and the add-user button/select flows. + */ module.exports.run = async function (interaction) { await interaction.deferReply({ephemeral: true}); const vc = await resolveOwnedTempChannel(interaction, 'context'); diff --git a/modules/temp-channels/commands/remove-from-channel.js b/modules/temp-channels/commands/remove-from-channel.js index ce1e8f78..fcdb39a2 100644 --- a/modules/temp-channels/commands/remove-from-channel.js +++ b/modules/temp-channels/commands/remove-from-channel.js @@ -12,6 +12,13 @@ module.exports.config = { description: localize('temp-channels', 'remove-from-channel-context-description') }; +/* + * Thin adapter for the temp-channels "remove user" flow. Everyone can invoke it, but it is + * creator-only via resolveOwnedTempChannel (channel the menu was invoked in must be a temp + * channel owned by the invoker, otherwise notInChannel). On success we hand off to the shared + * userRemove core with the 'context' caller, which reads interaction.targetUser and produces + * output identical to the /temp-channel remove-user subcommand and the remove-user flows. + */ module.exports.run = async function (interaction) { await interaction.deferReply({ephemeral: true}); const vc = await resolveOwnedTempChannel(interaction, 'context'); diff --git a/modules/temp-channels/events/botReady.js b/modules/temp-channels/events/botReady.js index fe8cb7c0..c0966297 100644 --- a/modules/temp-channels/events/botReady.js +++ b/modules/temp-channels/events/botReady.js @@ -8,6 +8,7 @@ module.exports.run = async function () { const moduleConfig = client.configurations['temp-channels']['config']; const settingsChannel = client.channels.cache.get(moduleConfig['settingsChannel']); + // Cleanup orphaned temp channels on startup const tempChannels = await client.models['temp-channels']['TempChannel'].findAll(); let cleanedCount = 0; for (const tempChannel of tempChannels) { @@ -37,6 +38,7 @@ module.exports.run = async function () { client.logger.info(`[temp-channels] Cleaned up ${cleanedCount} empty or orphaned temp channel(s) on startup`); } + // Schedule archive cleanup job (every hour) if (moduleConfig.enableArchiving && moduleConfig.archiveDeleteAfterHours > 0) { const archiveCleanupJob = scheduleJob('0 * * * *', async () => { const cutoff = new Date(Date.now() - moduleConfig.archiveDeleteAfterHours * 3600000); diff --git a/modules/temp-channels/events/voiceStateUpdate.js b/modules/temp-channels/events/voiceStateUpdate.js index 11ed9fd0..98ce0574 100644 --- a/modules/temp-channels/events/voiceStateUpdate.js +++ b/modules/temp-channels/events/voiceStateUpdate.js @@ -9,6 +9,7 @@ module.exports.run = async function (client, oldState, newState) { if (!client.botReadyAt) return; const moduleConfig = client.configurations['temp-channels']['config']; + // Handle channel leave β€” delete or archive if (oldState.channel) { const oldChannel = await client.models['temp-channels']['TempChannel'].findOne({ where: {id: oldState.channel.id} @@ -19,6 +20,7 @@ module.exports.run = async function (client, oldState, newState) { const dcOldChannel = await client.channels.fetch(oldChannel.id).catch(() => null); if (dcOldChannel && dcOldChannel.members.size === 0) { if (moduleConfig.enableArchiving && moduleConfig.archiveCategory) { + // Archive: move to archive category, strip permissions await dcOldChannel.setParent(moduleConfig.archiveCategory, { lockPermissions: false, reason: '[temp-channels] Archiving empty temp channel' @@ -58,6 +60,7 @@ module.exports.run = async function (client, oldState, newState) { oldChannel.archivedAt = new Date(); await oldChannel.save(); } else { + // Delete channel if (oldChannel.noMicChannel) { const noMicChannel = await client.channels.fetch(oldChannel.noMicChannel).catch(() => null); if (noMicChannel) await noMicChannel.delete(`[temp-channels] ${localize('temp-channels', 'removed-audit-log-reason')}`).catch(() => { @@ -77,6 +80,7 @@ module.exports.run = async function (client, oldState, newState) { } } + // No-mic channel visibility sync if (moduleConfig['create_no_mic_channel']) { const possibleExistingChannel = await client.models['temp-channels']['TempChannel'].findOne({ where: { @@ -97,11 +101,13 @@ module.exports.run = async function (client, oldState, newState) { if (!newState.channel) return; if (newState.channel.id === moduleConfig['channelID']) { + // Check for existing channel (active or archived) const existingChannel = await client.models['temp-channels']['TempChannel'].findOne({ where: {creatorID: newState.member.user.id} }); if (existingChannel) { + // Restore from archive if needed if (existingChannel.archivedAt) { const dcChannel = await client.channels.fetch(existingChannel.id).catch(() => null); if (dcChannel) { @@ -110,6 +116,7 @@ module.exports.run = async function (client, oldState, newState) { reason: '[temp-channels] Restoring archived channel' }).catch(() => { }); + // Re-apply permissions based on saved mode if (!existingChannel.isPublic) { await dcChannel.permissionOverwrites.create(dcChannel.guild.roles.everyone, { 'CONNECT': false, @@ -168,6 +175,7 @@ module.exports.run = async function (client, oldState, newState) { await existingChannel.destroy(); } } else { + // Active channel exists, move user there return newState.setChannel(existingChannel.id, '[temp-channels] ' + localize('temp-channels', 'move-audit-log-reason')).catch(() => { newState.setChannel(null, '[temp-channels] ' + localize('temp-channels', 'disconnect-audit-log-reason')); existingChannel.destroy(); @@ -175,6 +183,7 @@ module.exports.run = async function (client, oldState, newState) { } } + // Channel limit check if (moduleConfig.enableMaxActiveChannels && moduleConfig.maxActiveChannels > 0) { const activeCount = await client.models['temp-channels']['TempChannel'].count({where: {archivedAt: null}}); if (activeCount >= moduleConfig.maxActiveChannels) { @@ -188,6 +197,7 @@ module.exports.run = async function (client, oldState, newState) { } } + // Create new channel const n = await client.models['temp-channels']['TempChannel'].count({}) + 1; const newChannel = await newState.guild.channels.create({ name: moduleConfig['channelname_format'] @@ -227,6 +237,7 @@ module.exports.run = async function (client, oldState, newState) { if (moduleConfig['useNoMic']) await sendMessage(noMicChannel); } + // Apply private permissions if default is private if (!moduleConfig['publicChannels']) { await newChannel.permissionOverwrites.create(newState.guild.roles.everyone, { 'CONNECT': false, diff --git a/modules/temp-channels/migrations/temp-channels_TempChannel__V1.js b/modules/temp-channels/migrations/temp-channels_TempChannel__V1.js index f2d0e73c..8444920b 100644 --- a/modules/temp-channels/migrations/temp-channels_TempChannel__V1.js +++ b/modules/temp-channels/migrations/temp-channels_TempChannel__V1.js @@ -1,7 +1,18 @@ const OLD_TABLE = 'temp-channel_TempChannels'; const NEW_TABLE = 'temp-channel_TempChannelsv2'; -// Copies V1 rows into the V2 table in one transaction. No-ops when the old table is absent. +/* + * Replaces the old `migrate('temp-channels', 'TempChannelV1', 'TempChannel')` call + * (which used the now-deprecated row-by-row JavaScript helper in src/functions/helpers.js) + * with a SQL-level INSERT INTO ... SELECT inside a transaction. + * + * The legacy helper was not transactional and ran one create+destroy per row, so a + * crash mid-loop could leave the source table partially drained while the destination + * already had the copied rows. This version is atomic. + * + * Idempotent: if the old V1 table no longer exists (already migrated under the legacy + * helper, or fresh install where the V1 schema was never present), the body is a no-op. + */ module.exports = { tables: [OLD_TABLE, NEW_TABLE], up: async ({ @@ -27,6 +38,9 @@ module.exports = { }, down: async () => { - // No-op: copying rows back to a now-empty V1 schema is not a meaningful rollback. + /* + * No-op: copying rows back to a now-empty V1 schema is not a meaningful + * rollback, and the old helper had no down path either. + */ } }; \ No newline at end of file diff --git a/modules/uno/commands/challenge-to-uno.js b/modules/uno/commands/challenge-to-uno.js index dad96338..58347443 100644 --- a/modules/uno/commands/challenge-to-uno.js +++ b/modules/uno/commands/challenge-to-uno.js @@ -9,6 +9,13 @@ module.exports.config = { description: localize('uno', 'challenge-to-uno-context-description') }; +/* + * Thin adapter: /uno run() opens an open join-lobby and reads no opponent from options, so we + * reuse it unchanged to produce the identical lobby. Uno has no single opponent, so to honour the + * right-clicked user as the challenged player we additionally ping them with a follow-up inviting + * them to join the lobby. Self-targeting is harmless (the host is already in the lobby), so we skip + * the ping in that case rather than block the game. + */ module.exports.run = async function (interaction) { if (!memberCanSendInChannel(interaction.member, interaction.channel)) return interaction.reply({ ephemeral: true, diff --git a/modules/uno/commands/uno.js b/modules/uno/commands/uno.js index 12628712..49ed5f89 100644 --- a/modules/uno/commands/uno.js +++ b/modules/uno/commands/uno.js @@ -483,6 +483,7 @@ module.exports.config = { defaultPermission: true }; +// Exposed for unit testing of the pure game rules. module.exports.__test = { canUseCard, nextPlayer,