Repository: Dragory/ZeppelinBot Branch: master Commit: 6795bf7adc92 Files: 1116 Total size: 2.6 MB Directory structure: gitextract_rchn42w_/ ├── .clabot ├── .cursorignore ├── .devcontainer/ │ └── devcontainer.json ├── .dockerignore ├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ └── codequality.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── AGENTS.md ├── DEVELOPMENT.md ├── Dockerfile ├── LICENSE.md ├── MANAGEMENT.md ├── PRODUCTION.md ├── README.md ├── assets/ │ └── icons/ │ ├── LICENSE │ └── case_icons.afphoto ├── backend/ │ ├── .gitignore │ ├── .prettierignore │ ├── package.json │ ├── register-tsconfig-paths.js │ ├── src/ │ │ ├── Blocker.ts │ │ ├── DiscordJSError.ts │ │ ├── Queue.ts │ │ ├── QueuedEventEmitter.ts │ │ ├── RecoverablePluginError.ts │ │ ├── RegExpRunner.ts │ │ ├── SimpleCache.ts │ │ ├── SimpleError.ts │ │ ├── api/ │ │ │ ├── archives.ts │ │ │ ├── auth.ts │ │ │ ├── docs.ts │ │ │ ├── guilds/ │ │ │ │ ├── importExport.ts │ │ │ │ ├── index.ts │ │ │ │ └── misc.ts │ │ │ ├── guilds.ts │ │ │ ├── index.ts │ │ │ ├── permissions.ts │ │ │ ├── rateLimits.ts │ │ │ ├── responses.ts │ │ │ ├── staff.ts │ │ │ ├── start.ts │ │ │ └── tasks.ts │ │ ├── commandTypes.ts │ │ ├── configValidator.ts │ │ ├── data/ │ │ │ ├── AllowedGuilds.ts │ │ │ ├── ApiAuditLog.ts │ │ │ ├── ApiLogins.ts │ │ │ ├── ApiPermissionAssignments.ts │ │ │ ├── ApiUserInfo.ts │ │ │ ├── Archives.ts │ │ │ ├── BaseGuildRepository.ts │ │ │ ├── BaseRepository.ts │ │ │ ├── CaseTypes.ts │ │ │ ├── Configs.ts │ │ │ ├── DefaultLogMessages.json │ │ │ ├── FishFish.ts │ │ │ ├── GuildAntiraidLevels.ts │ │ │ ├── GuildArchives.ts │ │ │ ├── GuildAutoReactions.ts │ │ │ ├── GuildButtonRoles.ts │ │ │ ├── GuildCases.ts │ │ │ ├── GuildContextMenuLinks.ts │ │ │ ├── GuildCounters.ts │ │ │ ├── GuildEvents.ts │ │ │ ├── GuildLogs.ts │ │ │ ├── GuildMemberCache.ts │ │ │ ├── GuildMemberTimezones.ts │ │ │ ├── GuildMutes.ts │ │ │ ├── GuildNicknameHistory.ts │ │ │ ├── GuildPersistedData.ts │ │ │ ├── GuildPingableRoles.ts │ │ │ ├── GuildReactionRoles.ts │ │ │ ├── GuildReminders.ts │ │ │ ├── GuildRoleButtons.ts │ │ │ ├── GuildRoleQueue.ts │ │ │ ├── GuildSavedMessages.ts │ │ │ ├── GuildScheduledPosts.ts │ │ │ ├── GuildSlowmodes.ts │ │ │ ├── GuildStarboardMessages.ts │ │ │ ├── GuildStarboardReactions.ts │ │ │ ├── GuildStats.ts │ │ │ ├── GuildTags.ts │ │ │ ├── GuildTempbans.ts │ │ │ ├── GuildVCAlerts.ts │ │ │ ├── LogType.ts │ │ │ ├── MemberCache.ts │ │ │ ├── MuteTypes.ts │ │ │ ├── Mutes.ts │ │ │ ├── Reminders.ts │ │ │ ├── ScheduledPosts.ts │ │ │ ├── Supporters.ts │ │ │ ├── Tempbans.ts │ │ │ ├── UsernameHistory.ts │ │ │ ├── VCAlerts.ts │ │ │ ├── Webhooks.ts │ │ │ ├── Zalgo.ts │ │ │ ├── apiAuditLogTypes.ts │ │ │ ├── buildEntity.ts │ │ │ ├── cleanup/ │ │ │ │ ├── configs.ts │ │ │ │ ├── messages.ts │ │ │ │ ├── nicknames.ts │ │ │ │ └── usernames.ts │ │ │ ├── dataSource.ts │ │ │ ├── db.ts │ │ │ ├── entities/ │ │ │ │ ├── AllowedGuild.ts │ │ │ │ ├── AntiraidLevel.ts │ │ │ │ ├── ApiAuditLogEntry.ts │ │ │ │ ├── ApiLogin.ts │ │ │ │ ├── ApiPermissionAssignment.ts │ │ │ │ ├── ApiUserInfo.ts │ │ │ │ ├── ArchiveEntry.ts │ │ │ │ ├── AutoReaction.ts │ │ │ │ ├── ButtonRole.ts │ │ │ │ ├── Case.ts │ │ │ │ ├── CaseNote.ts │ │ │ │ ├── Config.ts │ │ │ │ ├── ContextMenuLink.ts │ │ │ │ ├── Counter.ts │ │ │ │ ├── CounterTrigger.ts │ │ │ │ ├── CounterTriggerState.ts │ │ │ │ ├── CounterValue.ts │ │ │ │ ├── MemberCacheItem.ts │ │ │ │ ├── MemberTimezone.ts │ │ │ │ ├── Mute.ts │ │ │ │ ├── NicknameHistoryEntry.ts │ │ │ │ ├── PersistedData.ts │ │ │ │ ├── PingableRole.ts │ │ │ │ ├── ReactionRole.ts │ │ │ │ ├── Reminder.ts │ │ │ │ ├── RoleButtonsItem.ts │ │ │ │ ├── RoleQueueItem.ts │ │ │ │ ├── SavedMessage.ts │ │ │ │ ├── ScheduledPost.ts │ │ │ │ ├── SlowmodeChannel.ts │ │ │ │ ├── SlowmodeUser.ts │ │ │ │ ├── StarboardMessage.ts │ │ │ │ ├── StarboardReaction.ts │ │ │ │ ├── StatValue.ts │ │ │ │ ├── Supporter.ts │ │ │ │ ├── Tag.ts │ │ │ │ ├── TagResponse.ts │ │ │ │ ├── Tempban.ts │ │ │ │ ├── UsernameHistoryEntry.ts │ │ │ │ ├── VCAlert.ts │ │ │ │ └── Webhook.ts │ │ │ ├── getChannelIdFromMessageId.ts │ │ │ ├── loops/ │ │ │ │ ├── expiredArchiveDeletionLoop.ts │ │ │ │ ├── expiredMemberCacheDeletionLoop.ts │ │ │ │ ├── expiringMutesLoop.ts │ │ │ │ ├── expiringTempbansLoop.ts │ │ │ │ ├── expiringVCAlertsLoop.ts │ │ │ │ ├── memberCacheDeletionLoop.ts │ │ │ │ ├── savedMessageCleanupLoop.ts │ │ │ │ ├── upcomingRemindersLoop.ts │ │ │ │ └── upcomingScheduledPostsLoop.ts │ │ │ ├── queryLogger.ts │ │ │ └── redis.ts │ │ ├── debugCounters.ts │ │ ├── env.ts │ │ ├── exportSchemas.ts │ │ ├── globals.ts │ │ ├── humanizeDuration.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ ├── migrateConfigsToDB.ts │ │ ├── migrations/ │ │ │ ├── 1540519249973-CreatePreTypeORMTables.ts │ │ │ ├── 1543053430712-CreateMessagesTable.ts │ │ │ ├── 1544877081073-CreateSlowmodeTables.ts │ │ │ ├── 1544887946307-CreateStarboardTable.ts │ │ │ ├── 1546770935261-CreateTagResponsesTable.ts │ │ │ ├── 1546778415930-CreateNameHistoryTable.ts │ │ │ ├── 1546788508314-MakeNameHistoryValueLengthLonger.ts │ │ │ ├── 1547290549908-CreateAutoReactionsTable.ts │ │ │ ├── 1547293464842-CreatePingableRolesTable.ts │ │ │ ├── 1547392046629-AddIndexToArchivesExpiresAt.ts │ │ │ ├── 1547393619900-AddIsHiddenToCases.ts │ │ │ ├── 1549649586803-AddPPFieldsToCases.ts │ │ │ ├── 1550409894008-FixEmojiIndexInReactionRoles.ts │ │ │ ├── 1550521627877-CreateSelfGrantableRolesTable.ts │ │ │ ├── 1550609900261-CreateRemindersTable.ts │ │ │ ├── 1556908589679-CreateUsernameHistoryTable.ts │ │ │ ├── 1556909512501-MigrateUsernamesToNewHistoryTable.ts │ │ │ ├── 1556913287547-TurnNameHistoryToNicknameHistory.ts │ │ │ ├── 1556973844545-CreateScheduledPostsTable.ts │ │ │ ├── 1558804433320-CreateDashboardLoginsTable.ts │ │ │ ├── 1558804449510-CreateDashboardUsersTable.ts │ │ │ ├── 1561111990357-CreateConfigsTable.ts │ │ │ ├── 1561117545258-CreateAllowedGuildsTable.ts │ │ │ ├── 1561282151982-RenameBackendDashboardStuffToAPI.ts │ │ │ ├── 1561282552734-RenameAllowedGuildGuildIdToId.ts │ │ │ ├── 1561282950483-CreateApiUserInfoTable.ts │ │ │ ├── 1561283165823-RenameApiUsersToApiPermissions.ts │ │ │ ├── 1561283405201-DropUserDataFromLoginsAndPermissions.ts │ │ │ ├── 1561391921385-AddVCAlertTable.ts │ │ │ ├── 1562838838927-AddMoreIndicesToVCAlerts.ts │ │ │ ├── 1573158035867-AddTypeAndPermissionsToApiPermissions.ts │ │ │ ├── 1573248462469-MoveStarboardsToConfig.ts │ │ │ ├── 1573248794313-CreateStarboardReactionsTable.ts │ │ │ ├── 1575145703039-AddIsExclusiveToReactionRoles.ts │ │ │ ├── 1575199835233-CreateStatsTable.ts │ │ │ ├── 1575230079526-AddRepeatColumnsToScheduledPosts.ts │ │ │ ├── 1578445483917-CreateReminderCreatedAtField.ts │ │ │ ├── 1580038836906-CreateAntiraidLevelsTable.ts │ │ │ ├── 1580654617890-AddActiveFollowsToLocateUser.ts │ │ │ ├── 1590616691907-CreateSupportersTable.ts │ │ │ ├── 1591036185142-OptimizeMessageIndices.ts │ │ │ ├── 1591038041635-OptimizeMessageTimestamps.ts │ │ │ ├── 1596994103885-AddCaseNotesForeignKey.ts │ │ │ ├── 1597015567215-AddLogMessageIdToCases.ts │ │ │ ├── 1597109357201-CreateMemberTimezonesTable.ts │ │ │ ├── 1600283341726-EncryptExistingMessages.ts │ │ │ ├── 1600285077890-EncryptArchives.ts │ │ │ ├── 1608608903570-CreateRestoredRolesColumn.ts │ │ │ ├── 1608692857722-FixStarboardReactionsIndices.ts │ │ │ ├── 1608753440716-CreateTempBansTable.ts │ │ │ ├── 1612010765767-CreateCounterTables.ts │ │ │ ├── 1617363975046-UpdateCounterTriggers.ts │ │ │ ├── 1622939525343-OrderReactionRoles.ts │ │ │ ├── 1623018101018-CreateButtonRolesTable.ts │ │ │ ├── 1628809879962-CreateContextMenuTable.ts │ │ │ ├── 1630837386329-AddExpiresAtToApiPermissions.ts │ │ │ ├── 1630837718830-CreateApiAuditLogTable.ts │ │ │ ├── 1630840428694-AddTimestampsToAllowedGuilds.ts │ │ │ ├── 1631474131804-AddIndexToIsBot.ts │ │ │ ├── 1632582078622-SplitScheduledPostsPostAtIndex.ts │ │ │ ├── 1632582299400-AddIndexToRemindersRemindAt.ts │ │ │ ├── 1634459708599-RemoveTagResponsesForeignKeys.ts │ │ │ ├── 1634563901575-CreatePhishermanCacheTable.ts │ │ │ ├── 1635596150234-CreatePhishermanKeyCacheTable.ts │ │ │ ├── 1635779678653-CreateWebhooksTable.ts │ │ │ ├── 1650709103864-CreateRoleQueueTable.ts │ │ │ ├── 1650712828384-CreateRoleButtonsTable.ts │ │ │ ├── 1650721020704-RemoveButtonRolesTable.ts │ │ │ ├── 1680354053183-AddTimeoutColumnsToMutes.ts │ │ │ └── 1682788165866-CreateMemberCacheTable.ts │ │ ├── paths.ts │ │ ├── pluginUtils.ts │ │ ├── plugins/ │ │ │ ├── AutoDelete/ │ │ │ │ ├── AutoDeletePlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── types.ts │ │ │ │ └── util/ │ │ │ │ ├── addMessageToDeletionQueue.ts │ │ │ │ ├── deleteNextItem.ts │ │ │ │ ├── onMessageCreate.ts │ │ │ │ ├── onMessageDelete.ts │ │ │ │ ├── onMessageDeleteBulk.ts │ │ │ │ └── scheduleNextDeletion.ts │ │ │ ├── AutoReactions/ │ │ │ │ ├── AutoReactionsPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── DisableAutoReactionsCmd.ts │ │ │ │ │ └── NewAutoReactionsCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ └── AddReactionsEvt.ts │ │ │ │ └── types.ts │ │ │ ├── Automod/ │ │ │ │ ├── AutomodPlugin.ts │ │ │ │ ├── actions/ │ │ │ │ │ ├── addRoles.ts │ │ │ │ │ ├── addToCounter.ts │ │ │ │ │ ├── alert.ts │ │ │ │ │ ├── archiveThread.ts │ │ │ │ │ ├── availableActions.ts │ │ │ │ │ ├── ban.ts │ │ │ │ │ ├── changeNickname.ts │ │ │ │ │ ├── changePerms.ts │ │ │ │ │ ├── clean.ts │ │ │ │ │ ├── exampleAction.ts │ │ │ │ │ ├── kick.ts │ │ │ │ │ ├── log.ts │ │ │ │ │ ├── mute.ts │ │ │ │ │ ├── pauseInvites.ts │ │ │ │ │ ├── removeRoles.ts │ │ │ │ │ ├── reply.ts │ │ │ │ │ ├── setAntiraidLevel.ts │ │ │ │ │ ├── setCounter.ts │ │ │ │ │ ├── setSlowmode.ts │ │ │ │ │ ├── startThread.ts │ │ │ │ │ └── warn.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── AntiraidClearCmd.ts │ │ │ │ │ ├── DebugAutomodCmd.ts │ │ │ │ │ ├── SetAntiraidCmd.ts │ │ │ │ │ └── ViewAntiraidCmd.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── RunAutomodOnJoinLeaveEvt.ts │ │ │ │ │ ├── RunAutomodOnMemberUpdate.ts │ │ │ │ │ ├── runAutomodOnAntiraidLevel.ts │ │ │ │ │ ├── runAutomodOnCounterTrigger.ts │ │ │ │ │ ├── runAutomodOnMessage.ts │ │ │ │ │ ├── runAutomodOnModAction.ts │ │ │ │ │ └── runAutomodOnThreadEvents.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── addRecentActionsFromMessage.ts │ │ │ │ │ ├── applyCooldown.ts │ │ │ │ │ ├── checkCooldown.ts │ │ │ │ │ ├── clearOldNicknameChanges.ts │ │ │ │ │ ├── clearOldRecentActions.ts │ │ │ │ │ ├── clearOldRecentSpam.ts │ │ │ │ │ ├── clearRecentActionsForMessage.ts │ │ │ │ │ ├── createMessageSpamTrigger.ts │ │ │ │ │ ├── findRecentSpam.ts │ │ │ │ │ ├── getMatchingMessageRecentActions.ts │ │ │ │ │ ├── getMatchingRecentActions.ts │ │ │ │ │ ├── getSpamIdentifier.ts │ │ │ │ │ ├── getTextMatchPartialSummary.ts │ │ │ │ │ ├── ignoredRoleChanges.ts │ │ │ │ │ ├── matchMultipleTextTypesOnMessage.ts │ │ │ │ │ ├── resolveActionContactMethods.ts │ │ │ │ │ ├── runAutomod.ts │ │ │ │ │ ├── setAntiraidLevel.ts │ │ │ │ │ └── sumRecentActionCounts.ts │ │ │ │ ├── helpers.ts │ │ │ │ ├── triggers/ │ │ │ │ │ ├── antiraidLevel.ts │ │ │ │ │ ├── anyMessage.ts │ │ │ │ │ ├── attachmentSpam.ts │ │ │ │ │ ├── availableTriggers.ts │ │ │ │ │ ├── ban.ts │ │ │ │ │ ├── characterSpam.ts │ │ │ │ │ ├── counterTrigger.ts │ │ │ │ │ ├── emojiSpam.ts │ │ │ │ │ ├── exampleTrigger.ts │ │ │ │ │ ├── hasAttachments.ts │ │ │ │ │ ├── kick.ts │ │ │ │ │ ├── lineSpam.ts │ │ │ │ │ ├── linkSpam.ts │ │ │ │ │ ├── matchAttachmentType.ts │ │ │ │ │ ├── matchInvites.ts │ │ │ │ │ ├── matchLinks.ts │ │ │ │ │ ├── matchMimeType.ts │ │ │ │ │ ├── matchRegex.ts │ │ │ │ │ ├── matchWords.ts │ │ │ │ │ ├── memberJoin.ts │ │ │ │ │ ├── memberJoinSpam.ts │ │ │ │ │ ├── memberLeave.ts │ │ │ │ │ ├── mentionSpam.ts │ │ │ │ │ ├── messageSpam.ts │ │ │ │ │ ├── mute.ts │ │ │ │ │ ├── note.ts │ │ │ │ │ ├── roleAdded.ts │ │ │ │ │ ├── roleRemoved.ts │ │ │ │ │ ├── stickerSpam.ts │ │ │ │ │ ├── threadArchive.ts │ │ │ │ │ ├── threadCreate.ts │ │ │ │ │ ├── threadCreateSpam.ts │ │ │ │ │ ├── threadDelete.ts │ │ │ │ │ ├── threadUnarchive.ts │ │ │ │ │ ├── unban.ts │ │ │ │ │ ├── unmute.ts │ │ │ │ │ └── warn.ts │ │ │ │ └── types.ts │ │ │ ├── BotControl/ │ │ │ │ ├── BotControlPlugin.ts │ │ │ │ ├── activeReload.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── AddDashboardUserCmd.ts │ │ │ │ │ ├── AddServerFromInviteCmd.ts │ │ │ │ │ ├── AllowServerCmd.ts │ │ │ │ │ ├── ChannelToServerCmd.ts │ │ │ │ │ ├── DebugCountersCmd.ts │ │ │ │ │ ├── DisallowServerCmd.ts │ │ │ │ │ ├── EligibleCmd.ts │ │ │ │ │ ├── LeaveServerCmd.ts │ │ │ │ │ ├── ListDashboardPermsCmd.ts │ │ │ │ │ ├── ListDashboardUsersCmd.ts │ │ │ │ │ ├── ProfilerDataCmd.ts │ │ │ │ │ ├── RateLimitPerformanceCmd.ts │ │ │ │ │ ├── ReloadGlobalPluginsCmd.ts │ │ │ │ │ ├── ReloadServerCmd.ts │ │ │ │ │ ├── RemoveDashboardUserCmd.ts │ │ │ │ │ ├── RestPerformanceCmd.ts │ │ │ │ │ └── ServersCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── functions/ │ │ │ │ │ └── isEligible.ts │ │ │ │ └── types.ts │ │ │ ├── Cases/ │ │ │ │ ├── CasesPlugin.ts │ │ │ │ ├── caseAbbreviations.ts │ │ │ │ ├── caseColors.ts │ │ │ │ ├── caseIcons.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── createCase.ts │ │ │ │ │ ├── createCaseNote.ts │ │ │ │ │ ├── getCaseColor.ts │ │ │ │ │ ├── getCaseEmbed.ts │ │ │ │ │ ├── getCaseIcon.ts │ │ │ │ │ ├── getCaseSummary.ts │ │ │ │ │ ├── getCaseTypeAmountForUserId.ts │ │ │ │ │ ├── getRecentCasesByMod.ts │ │ │ │ │ ├── getTotalCasesByMod.ts │ │ │ │ │ ├── postToCaseLogChannel.ts │ │ │ │ │ └── resolveCaseId.ts │ │ │ │ └── types.ts │ │ │ ├── Censor/ │ │ │ │ ├── CensorPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── types.ts │ │ │ │ └── util/ │ │ │ │ ├── applyFiltersToMsg.ts │ │ │ │ ├── censorMessage.ts │ │ │ │ ├── onMessageCreate.ts │ │ │ │ └── onMessageUpdate.ts │ │ │ ├── ChannelArchiver/ │ │ │ │ ├── ChannelArchiverPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ └── ArchiveChannelCmd.ts │ │ │ │ ├── rehostAttachment.ts │ │ │ │ └── types.ts │ │ │ ├── CommandAliases/ │ │ │ │ ├── CommandAliasesPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ └── DispatchAliasEvt.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── buildAliasMatchers.ts │ │ │ │ │ └── normalizeAliases.ts │ │ │ │ └── types.ts │ │ │ ├── Common/ │ │ │ │ ├── CommonPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── functions/ │ │ │ │ │ └── getEmoji.ts │ │ │ │ └── types.ts │ │ │ ├── CompanionChannels/ │ │ │ │ ├── CompanionChannelsPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ └── VoiceStateUpdateEvt.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── getCompanionChannelOptsForVoiceChannelId.ts │ │ │ │ │ └── handleCompanionPermissions.ts │ │ │ │ └── types.ts │ │ │ ├── ContextMenus/ │ │ │ │ ├── ContextMenuPlugin.ts │ │ │ │ ├── actions/ │ │ │ │ │ ├── ban.ts │ │ │ │ │ ├── clean.ts │ │ │ │ │ ├── mute.ts │ │ │ │ │ ├── note.ts │ │ │ │ │ ├── update.ts │ │ │ │ │ └── warn.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── BanUserCtxCmd.ts │ │ │ │ │ ├── CleanMessageCtxCmd.ts │ │ │ │ │ ├── ModMenuUserCtxCmd.ts │ │ │ │ │ ├── MuteUserCtxCmd.ts │ │ │ │ │ ├── NoteUserCtxCmd.ts │ │ │ │ │ └── WarnUserCtxCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ └── types.ts │ │ │ ├── Counters/ │ │ │ │ ├── CountersPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── AddCounterCmd.ts │ │ │ │ │ ├── CountersListCmd.ts │ │ │ │ │ ├── ResetAllCounterValuesCmd.ts │ │ │ │ │ ├── ResetCounterCmd.ts │ │ │ │ │ ├── SetCounterCmd.ts │ │ │ │ │ └── ViewCounterCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── changeCounterValue.ts │ │ │ │ │ ├── checkAllValuesForReverseTrigger.ts │ │ │ │ │ ├── checkAllValuesForTrigger.ts │ │ │ │ │ ├── checkCounterTrigger.ts │ │ │ │ │ ├── checkReverseCounterTrigger.ts │ │ │ │ │ ├── counterExists.ts │ │ │ │ │ ├── decayCounter.ts │ │ │ │ │ ├── emitCounterEvent.ts │ │ │ │ │ ├── getPrettyNameForCounter.ts │ │ │ │ │ ├── getPrettyNameForCounterTrigger.ts │ │ │ │ │ ├── offCounterEvent.ts │ │ │ │ │ ├── onCounterEvent.ts │ │ │ │ │ ├── resetAllCounterValues.ts │ │ │ │ │ └── setCounterValue.ts │ │ │ │ └── types.ts │ │ │ ├── CustomEvents/ │ │ │ │ ├── ActionError.ts │ │ │ │ ├── CustomEventsPlugin.ts │ │ │ │ ├── actions/ │ │ │ │ │ ├── addRoleAction.ts │ │ │ │ │ ├── createCaseAction.ts │ │ │ │ │ ├── makeRoleMentionableAction.ts │ │ │ │ │ ├── makeRoleUnmentionableAction.ts │ │ │ │ │ ├── messageAction.ts │ │ │ │ │ ├── moveToVoiceChannelAction.ts │ │ │ │ │ └── setChannelPermissionOverrides.ts │ │ │ │ ├── catchTemplateError.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── functions/ │ │ │ │ │ └── runEvent.ts │ │ │ │ └── types.ts │ │ │ ├── GuildAccessMonitor/ │ │ │ │ ├── GuildAccessMonitorPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ └── types.ts │ │ │ ├── GuildConfigReloader/ │ │ │ │ ├── GuildConfigReloaderPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── functions/ │ │ │ │ │ └── reloadChangedGuilds.ts │ │ │ │ └── types.ts │ │ │ ├── GuildInfoSaver/ │ │ │ │ ├── GuildInfoSaverPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ └── types.ts │ │ │ ├── GuildMemberCache/ │ │ │ │ ├── GuildMemberCachePlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── cancelDeletionOnMemberJoin.ts │ │ │ │ │ ├── removeMemberCacheOnMemberLeave.ts │ │ │ │ │ ├── updateMemberCacheOnMemberUpdate.ts │ │ │ │ │ ├── updateMemberCacheOnMessage.ts │ │ │ │ │ ├── updateMemberCacheOnRoleChange.ts │ │ │ │ │ └── updateMemberCacheOnVoiceStateUpdate.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── getCachedMemberData.ts │ │ │ │ │ └── updateMemberCacheForMember.ts │ │ │ │ └── types.ts │ │ │ ├── InternalPoster/ │ │ │ │ ├── InternalPosterPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── editMessage.ts │ │ │ │ │ ├── getOrCreateWebhookClientForChannel.ts │ │ │ │ │ ├── getOrCreateWebhookForChannel.ts │ │ │ │ │ └── sendMessage.ts │ │ │ │ └── types.ts │ │ │ ├── LocateUser/ │ │ │ │ ├── LocateUserPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── FollowCmd.ts │ │ │ │ │ ├── ListFollowCmd.ts │ │ │ │ │ └── WhereCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── BanRemoveAlertsEvt.ts │ │ │ │ │ └── SendAlertsEvts.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils/ │ │ │ │ ├── clearExpiredAlert.ts │ │ │ │ ├── createOrReuseInvite.ts │ │ │ │ ├── fillAlertsList.ts │ │ │ │ ├── moveMember.ts │ │ │ │ ├── removeUserIdFromActiveAlerts.ts │ │ │ │ ├── sendAlerts.ts │ │ │ │ └── sendWhere.ts │ │ │ ├── Logs/ │ │ │ │ ├── LogsPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── LogsChannelModifyEvts.ts │ │ │ │ │ ├── LogsEmojiAndStickerModifyEvts.ts │ │ │ │ │ ├── LogsGuildBanEvts.ts │ │ │ │ │ ├── LogsGuildMemberAddEvt.ts │ │ │ │ │ ├── LogsGuildMemberRemoveEvt.ts │ │ │ │ │ ├── LogsGuildMemberRoleChangeEvt.ts │ │ │ │ │ ├── LogsRoleModifyEvts.ts │ │ │ │ │ ├── LogsStageInstanceModifyEvts.ts │ │ │ │ │ ├── LogsThreadModifyEvts.ts │ │ │ │ │ ├── LogsUserUpdateEvts.ts │ │ │ │ │ └── LogsVoiceChannelEvts.ts │ │ │ │ ├── logFunctions/ │ │ │ │ │ ├── logAutomodAction.ts │ │ │ │ │ ├── logBotAlert.ts │ │ │ │ │ ├── logCaseCreate.ts │ │ │ │ │ ├── logCaseDelete.ts │ │ │ │ │ ├── logCaseUpdate.ts │ │ │ │ │ ├── logCensor.ts │ │ │ │ │ ├── logChannelCreate.ts │ │ │ │ │ ├── logChannelDelete.ts │ │ │ │ │ ├── logChannelUpdate.ts │ │ │ │ │ ├── logClean.ts │ │ │ │ │ ├── logDmFailed.ts │ │ │ │ │ ├── logEmojiCreate.ts │ │ │ │ │ ├── logEmojiDelete.ts │ │ │ │ │ ├── logEmojiUpdate.ts │ │ │ │ │ ├── logMassBan.ts │ │ │ │ │ ├── logMassMute.ts │ │ │ │ │ ├── logMassUnban.ts │ │ │ │ │ ├── logMemberBan.ts │ │ │ │ │ ├── logMemberForceban.ts │ │ │ │ │ ├── logMemberJoin.ts │ │ │ │ │ ├── logMemberJoinWithPriorRecords.ts │ │ │ │ │ ├── logMemberKick.ts │ │ │ │ │ ├── logMemberLeave.ts │ │ │ │ │ ├── logMemberMute.ts │ │ │ │ │ ├── logMemberMuteExpired.ts │ │ │ │ │ ├── logMemberMuteRejoin.ts │ │ │ │ │ ├── logMemberNickChange.ts │ │ │ │ │ ├── logMemberNote.ts │ │ │ │ │ ├── logMemberRestore.ts │ │ │ │ │ ├── logMemberRoleAdd.ts │ │ │ │ │ ├── logMemberRoleChanges.ts │ │ │ │ │ ├── logMemberRoleRemove.ts │ │ │ │ │ ├── logMemberTimedBan.ts │ │ │ │ │ ├── logMemberTimedMute.ts │ │ │ │ │ ├── logMemberTimedUnban.ts │ │ │ │ │ ├── logMemberTimedUnmute.ts │ │ │ │ │ ├── logMemberUnban.ts │ │ │ │ │ ├── logMemberUnmute.ts │ │ │ │ │ ├── logMemberWarn.ts │ │ │ │ │ ├── logMessageDelete.ts │ │ │ │ │ ├── logMessageDeleteAuto.ts │ │ │ │ │ ├── logMessageDeleteBare.ts │ │ │ │ │ ├── logMessageDeleteBulk.ts │ │ │ │ │ ├── logMessageEdit.ts │ │ │ │ │ ├── logMessageSpamDetected.ts │ │ │ │ │ ├── logOtherSpamDetected.ts │ │ │ │ │ ├── logPostedScheduledMessage.ts │ │ │ │ │ ├── logRepeatedMessage.ts │ │ │ │ │ ├── logRoleCreate.ts │ │ │ │ │ ├── logRoleDelete.ts │ │ │ │ │ ├── logRoleUpdate.ts │ │ │ │ │ ├── logScheduledMessage.ts │ │ │ │ │ ├── logScheduledRepeatedMessage.ts │ │ │ │ │ ├── logSetAntiraidAuto.ts │ │ │ │ │ ├── logSetAntiraidUser.ts │ │ │ │ │ ├── logStageInstanceCreate.ts │ │ │ │ │ ├── logStageInstanceDelete.ts │ │ │ │ │ ├── logStageInstanceUpdate.ts │ │ │ │ │ ├── logStickerCreate.ts │ │ │ │ │ ├── logStickerDelete.ts │ │ │ │ │ ├── logStickerUpdate.ts │ │ │ │ │ ├── logThreadCreate.ts │ │ │ │ │ ├── logThreadDelete.ts │ │ │ │ │ ├── logThreadUpdate.ts │ │ │ │ │ ├── logVoiceChannelForceDisconnect.ts │ │ │ │ │ ├── logVoiceChannelForceMove.ts │ │ │ │ │ ├── logVoiceChannelJoin.ts │ │ │ │ │ ├── logVoiceChannelLeave.ts │ │ │ │ │ └── logVoiceChannelMove.ts │ │ │ │ ├── types.ts │ │ │ │ └── util/ │ │ │ │ ├── getLogMessage.ts │ │ │ │ ├── getMessageReplyLogInfo.ts │ │ │ │ ├── isLogIgnored.ts │ │ │ │ ├── log.ts │ │ │ │ ├── onMessageDelete.ts │ │ │ │ ├── onMessageDeleteBulk.ts │ │ │ │ └── onMessageUpdate.ts │ │ │ ├── MessageSaver/ │ │ │ │ ├── MessageSaverPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── SaveMessagesToDB.ts │ │ │ │ │ └── SavePinsToDB.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ └── SaveMessagesEvts.ts │ │ │ │ ├── saveMessagesToDB.ts │ │ │ │ └── types.ts │ │ │ ├── ModActions/ │ │ │ │ ├── ModActionsPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── addcase/ │ │ │ │ │ │ ├── AddCaseMsgCmd.ts │ │ │ │ │ │ ├── AddCaseSlashCmd.ts │ │ │ │ │ │ └── actualAddCaseCmd.ts │ │ │ │ │ ├── ban/ │ │ │ │ │ │ ├── BanMsgCmd.ts │ │ │ │ │ │ ├── BanSlashCmd.ts │ │ │ │ │ │ └── actualBanCmd.ts │ │ │ │ │ ├── case/ │ │ │ │ │ │ ├── CaseMsgCmd.ts │ │ │ │ │ │ ├── CaseSlashCmd.ts │ │ │ │ │ │ └── actualCaseCmd.ts │ │ │ │ │ ├── cases/ │ │ │ │ │ │ ├── CasesModMsgCmd.ts │ │ │ │ │ │ ├── CasesSlashCmd.ts │ │ │ │ │ │ ├── CasesUserMsgCmd.ts │ │ │ │ │ │ └── actualCasesCmd.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── deletecase/ │ │ │ │ │ │ ├── DeleteCaseMsgCmd.ts │ │ │ │ │ │ ├── DeleteCaseSlashCmd.ts │ │ │ │ │ │ └── actualDeleteCaseCmd.ts │ │ │ │ │ ├── forceban/ │ │ │ │ │ │ ├── ForceBanMsgCmd.ts │ │ │ │ │ │ ├── ForceBanSlashCmd.ts │ │ │ │ │ │ └── actualForceBanCmd.ts │ │ │ │ │ ├── forcemute/ │ │ │ │ │ │ ├── ForceMuteMsgCmd.ts │ │ │ │ │ │ └── ForceMuteSlashCmd.ts │ │ │ │ │ ├── forceunmute/ │ │ │ │ │ │ ├── ForceUnmuteMsgCmd.ts │ │ │ │ │ │ └── ForceUnmuteSlashCmd.ts │ │ │ │ │ ├── hidecase/ │ │ │ │ │ │ ├── HideCaseMsgCmd.ts │ │ │ │ │ │ ├── HideCaseSlashCmd.ts │ │ │ │ │ │ └── actualHideCaseCmd.ts │ │ │ │ │ ├── kick/ │ │ │ │ │ │ ├── KickMsgCmd.ts │ │ │ │ │ │ ├── KickSlashCmd.ts │ │ │ │ │ │ └── actualKickCmd.ts │ │ │ │ │ ├── massban/ │ │ │ │ │ │ ├── MassBanMsgCmd.ts │ │ │ │ │ │ ├── MassBanSlashCmd.ts │ │ │ │ │ │ └── actualMassBanCmd.ts │ │ │ │ │ ├── massmute/ │ │ │ │ │ │ ├── MassMuteMsgCmd.ts │ │ │ │ │ │ ├── MassMuteSlashCmd.ts │ │ │ │ │ │ └── actualMassMuteCmd.ts │ │ │ │ │ ├── massunban/ │ │ │ │ │ │ ├── MassUnbanMsgCmd.ts │ │ │ │ │ │ ├── MassUnbanSlashCmd.ts │ │ │ │ │ │ └── actualMassUnbanCmd.ts │ │ │ │ │ ├── mute/ │ │ │ │ │ │ ├── MuteMsgCmd.ts │ │ │ │ │ │ ├── MuteSlashCmd.ts │ │ │ │ │ │ └── actualMuteCmd.ts │ │ │ │ │ ├── note/ │ │ │ │ │ │ ├── NoteMsgCmd.ts │ │ │ │ │ │ ├── NoteSlashCmd.ts │ │ │ │ │ │ └── actualNoteCmd.ts │ │ │ │ │ ├── unban/ │ │ │ │ │ │ ├── UnbanMsgCmd.ts │ │ │ │ │ │ ├── UnbanSlashCmd.ts │ │ │ │ │ │ └── actualUnbanCmd.ts │ │ │ │ │ ├── unhidecase/ │ │ │ │ │ │ ├── UnhideCaseMsgCmd.ts │ │ │ │ │ │ ├── UnhideCaseSlashCmd.ts │ │ │ │ │ │ └── actualUnhideCaseCmd.ts │ │ │ │ │ ├── unmute/ │ │ │ │ │ │ ├── UnmuteMsgCmd.ts │ │ │ │ │ │ ├── UnmuteSlashCmd.ts │ │ │ │ │ │ └── actualUnmuteCmd.ts │ │ │ │ │ ├── update/ │ │ │ │ │ │ ├── UpdateMsgCmd.ts │ │ │ │ │ │ └── UpdateSlashCmd.ts │ │ │ │ │ └── warn/ │ │ │ │ │ ├── WarnMsgCmd.ts │ │ │ │ │ ├── WarnSlashCmd.ts │ │ │ │ │ └── actualWarnCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── AuditLogEvents.ts │ │ │ │ │ ├── CreateBanCaseOnManualBanEvt.ts │ │ │ │ │ ├── CreateKickCaseOnManualKickEvt.ts │ │ │ │ │ ├── CreateUnbanCaseOnManualUnbanEvt.ts │ │ │ │ │ └── PostAlertOnMemberJoinEvt.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── attachmentLinkReaction.ts │ │ │ │ │ ├── banUserId.ts │ │ │ │ │ ├── clearIgnoredEvents.ts │ │ │ │ │ ├── clearTempban.ts │ │ │ │ │ ├── formatReasonForAttachments.ts │ │ │ │ │ ├── getDefaultContactMethods.ts │ │ │ │ │ ├── hasModActionPerm.ts │ │ │ │ │ ├── ignoreEvent.ts │ │ │ │ │ ├── isBanned.ts │ │ │ │ │ ├── isEventIgnored.ts │ │ │ │ │ ├── kickMember.ts │ │ │ │ │ ├── offModActionsEvent.ts │ │ │ │ │ ├── onModActionsEvent.ts │ │ │ │ │ ├── readContactMethodsFromArgs.ts │ │ │ │ │ ├── updateCase.ts │ │ │ │ │ └── warnMember.ts │ │ │ │ └── types.ts │ │ │ ├── Mutes/ │ │ │ │ ├── MutesPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── ClearBannedMutesCmd.ts │ │ │ │ │ ├── ClearMutesCmd.ts │ │ │ │ │ ├── ClearMutesWithoutRoleCmd.ts │ │ │ │ │ └── MutesCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── ClearActiveMuteOnMemberBanEvt.ts │ │ │ │ │ ├── ClearActiveMuteOnRoleRemovalEvt.ts │ │ │ │ │ ├── ReapplyActiveMuteOnJoinEvt.ts │ │ │ │ │ └── RegisterManualTimeoutsEvt.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── clearMute.ts │ │ │ │ │ ├── getDefaultMuteType.ts │ │ │ │ │ ├── getTimeoutExpiryTime.ts │ │ │ │ │ ├── memberHasMutedRole.ts │ │ │ │ │ ├── muteUser.ts │ │ │ │ │ ├── offMutesEvent.ts │ │ │ │ │ ├── onMutesEvent.ts │ │ │ │ │ ├── renewTimeoutMute.ts │ │ │ │ │ └── unmuteUser.ts │ │ │ │ └── types.ts │ │ │ ├── NameHistory/ │ │ │ │ ├── NameHistoryPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ └── NamesCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ └── UpdateNameEvts.ts │ │ │ │ ├── types.ts │ │ │ │ └── updateNickname.ts │ │ │ ├── Persist/ │ │ │ │ ├── PersistPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── LoadDataEvt.ts │ │ │ │ │ └── StoreDataEvt.ts │ │ │ │ └── types.ts │ │ │ ├── Phisherman/ │ │ │ │ ├── PhishermanPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ └── types.ts │ │ │ ├── PingableRoles/ │ │ │ │ ├── PingableRolesPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── PingableRoleDisableCmd.ts │ │ │ │ │ └── PingableRoleEnableCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ └── ChangePingableEvts.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils/ │ │ │ │ ├── disablePingableRoles.ts │ │ │ │ ├── enablePingableRoles.ts │ │ │ │ └── getPingableRolesForChannel.ts │ │ │ ├── Post/ │ │ │ │ ├── PostPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── EditCmd.ts │ │ │ │ │ ├── EditEmbedCmd.ts │ │ │ │ │ ├── PostCmd.ts │ │ │ │ │ ├── PostEmbedCmd.ts │ │ │ │ │ ├── ScheduledPostsDeleteCmd.ts │ │ │ │ │ ├── ScheduledPostsListCmd.ts │ │ │ │ │ └── ScheduledPostsShowCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── types.ts │ │ │ │ └── util/ │ │ │ │ ├── actualPostCmd.ts │ │ │ │ ├── formatContent.ts │ │ │ │ ├── parseScheduleTime.ts │ │ │ │ ├── postMessage.ts │ │ │ │ └── postScheduledPost.ts │ │ │ ├── ReactionRoles/ │ │ │ │ ├── ReactionRolesPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── ClearReactionRolesCmd.ts │ │ │ │ │ ├── InitReactionRolesCmd.ts │ │ │ │ │ └── RefreshReactionRolesCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── AddReactionRoleEvt.ts │ │ │ │ │ └── MessageDeletedEvt.ts │ │ │ │ ├── types.ts │ │ │ │ └── util/ │ │ │ │ ├── addMemberPendingRoleChange.ts │ │ │ │ ├── applyReactionRoleReactionsToMessage.ts │ │ │ │ ├── autoRefreshLoop.ts │ │ │ │ ├── refreshReactionRoles.ts │ │ │ │ └── runAutoRefresh.ts │ │ │ ├── Reminders/ │ │ │ │ ├── RemindersPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── RemindCmd.ts │ │ │ │ │ ├── RemindersCmd.ts │ │ │ │ │ └── RemindersDeleteCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── functions/ │ │ │ │ │ └── postReminder.ts │ │ │ │ └── types.ts │ │ │ ├── RoleButtons/ │ │ │ │ ├── RoleButtonsPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ └── resetButtons.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ └── buttonInteraction.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── TooManyComponentsError.ts │ │ │ │ │ ├── applyAllRoleButtons.ts │ │ │ │ │ ├── applyRoleButtons.ts │ │ │ │ │ ├── convertButtonStyleStringToEnum.ts │ │ │ │ │ ├── createButtonComponents.ts │ │ │ │ │ └── getAllRolesInButtons.ts │ │ │ │ └── types.ts │ │ │ ├── RoleManager/ │ │ │ │ ├── RoleManagerPlugin.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── addPriorityRole.ts │ │ │ │ │ ├── addRole.ts │ │ │ │ │ ├── removePriorityRole.ts │ │ │ │ │ ├── removeRole.ts │ │ │ │ │ └── runRoleAssignmentLoop.ts │ │ │ │ └── types.ts │ │ │ ├── Roles/ │ │ │ │ ├── RolesPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── AddRoleCmd.ts │ │ │ │ │ ├── MassAddRoleCmd.ts │ │ │ │ │ ├── MassRemoveRoleCmd.ts │ │ │ │ │ └── RemoveRoleCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ └── types.ts │ │ │ ├── SelfGrantableRoles/ │ │ │ │ ├── SelfGrantableRolesPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── RoleAddCmd.ts │ │ │ │ │ ├── RoleHelpCmd.ts │ │ │ │ │ └── RoleRemoveCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── types.ts │ │ │ │ └── util/ │ │ │ │ ├── findMatchingRoles.ts │ │ │ │ ├── getApplyingEntries.ts │ │ │ │ ├── normalizeRoleNames.ts │ │ │ │ └── splitRoleNames.ts │ │ │ ├── Slowmode/ │ │ │ │ ├── SlowmodePlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── SlowmodeClearCmd.ts │ │ │ │ │ ├── SlowmodeDisableCmd.ts │ │ │ │ │ ├── SlowmodeGetCmd.ts │ │ │ │ │ ├── SlowmodeListCmd.ts │ │ │ │ │ └── SlowmodeSetCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── requiredPermissions.ts │ │ │ │ ├── types.ts │ │ │ │ └── util/ │ │ │ │ ├── actualDisableSlowmodeCmd.ts │ │ │ │ ├── applyBotSlowmodeToUserId.ts │ │ │ │ ├── clearBotSlowmodeFromUserId.ts │ │ │ │ ├── clearExpiredSlowmodes.ts │ │ │ │ ├── disableBotSlowmodeForChannel.ts │ │ │ │ └── onMessageCreate.ts │ │ │ ├── Spam/ │ │ │ │ ├── SpamPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ └── SpamVoiceEvt.ts │ │ │ │ ├── types.ts │ │ │ │ └── util/ │ │ │ │ ├── addRecentAction.ts │ │ │ │ ├── clearOldRecentActions.ts │ │ │ │ ├── clearRecentUserActions.ts │ │ │ │ ├── getRecentActionCount.ts │ │ │ │ ├── getRecentActions.ts │ │ │ │ ├── logAndDetectMessageSpam.ts │ │ │ │ ├── logAndDetectOtherSpam.ts │ │ │ │ ├── logCensor.ts │ │ │ │ ├── onMessageCreate.ts │ │ │ │ └── saveSpamArchives.ts │ │ │ ├── Starboard/ │ │ │ │ ├── StarboardPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ └── MigratePinsCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── StarboardReactionAddEvt.ts │ │ │ │ │ └── StarboardReactionRemoveEvts.ts │ │ │ │ ├── types.ts │ │ │ │ └── util/ │ │ │ │ ├── createStarboardEmbedFromMessage.ts │ │ │ │ ├── createStarboardPseudoFooterForMessage.ts │ │ │ │ ├── onMessageDelete.ts │ │ │ │ ├── removeMessageFromStarboard.ts │ │ │ │ ├── removeMessageFromStarboardMessages.ts │ │ │ │ ├── saveMessageToStarboard.ts │ │ │ │ └── updateStarboardMessageStarCount.ts │ │ │ ├── Tags/ │ │ │ │ ├── TagsPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── TagCreateCmd.ts │ │ │ │ │ ├── TagDeleteCmd.ts │ │ │ │ │ ├── TagEvalCmd.ts │ │ │ │ │ ├── TagListCmd.ts │ │ │ │ │ └── TagSourceCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── templateFunctions.ts │ │ │ │ ├── types.ts │ │ │ │ └── util/ │ │ │ │ ├── findTagByName.ts │ │ │ │ ├── matchAndRenderTagFromString.ts │ │ │ │ ├── onMessageCreate.ts │ │ │ │ ├── onMessageDelete.ts │ │ │ │ ├── renderTagBody.ts │ │ │ │ └── renderTagFromString.ts │ │ │ ├── TimeAndDate/ │ │ │ │ ├── TimeAndDatePlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── ResetTimezoneCmd.ts │ │ │ │ │ ├── SetTimezoneCmd.ts │ │ │ │ │ └── ViewTimezoneCmd.ts │ │ │ │ ├── defaultDateFormats.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── getDateFormat.ts │ │ │ │ │ ├── getGuildTz.ts │ │ │ │ │ ├── getMemberTz.ts │ │ │ │ │ ├── inGuildTz.ts │ │ │ │ │ └── inMemberTz.ts │ │ │ │ └── types.ts │ │ │ ├── UsernameSaver/ │ │ │ │ ├── UsernameSaverPlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ └── UpdateUsernameEvts.ts │ │ │ │ ├── types.ts │ │ │ │ └── updateUsername.ts │ │ │ ├── Utility/ │ │ │ │ ├── UtilityPlugin.ts │ │ │ │ ├── commands/ │ │ │ │ │ ├── AboutCmd.ts │ │ │ │ │ ├── AvatarCmd.ts │ │ │ │ │ ├── BanSearchCmd.ts │ │ │ │ │ ├── ChannelInfoCmd.ts │ │ │ │ │ ├── CleanCmd.ts │ │ │ │ │ ├── ContextCmd.ts │ │ │ │ │ ├── EmojiInfoCmd.ts │ │ │ │ │ ├── HelpCmd.ts │ │ │ │ │ ├── InfoCmd.ts │ │ │ │ │ ├── InviteInfoCmd.ts │ │ │ │ │ ├── JumboCmd.ts │ │ │ │ │ ├── LevelCmd.ts │ │ │ │ │ ├── MessageInfoCmd.ts │ │ │ │ │ ├── NicknameCmd.ts │ │ │ │ │ ├── NicknameResetCmd.ts │ │ │ │ │ ├── PingCmd.ts │ │ │ │ │ ├── ReloadGuildCmd.ts │ │ │ │ │ ├── RoleInfoCmd.ts │ │ │ │ │ ├── RolesCmd.ts │ │ │ │ │ ├── SearchCmd.ts │ │ │ │ │ ├── ServerInfoCmd.ts │ │ │ │ │ ├── SnowflakeInfoCmd.ts │ │ │ │ │ ├── SourceCmd.ts │ │ │ │ │ ├── UserInfoCmd.ts │ │ │ │ │ ├── VcdisconnectCmd.ts │ │ │ │ │ └── VcmoveCmd.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ └── AutoJoinThreadEvt.ts │ │ │ │ ├── functions/ │ │ │ │ │ ├── cleanMessages.ts │ │ │ │ │ ├── fetchChannelMessagesToClean.ts │ │ │ │ │ ├── getChannelInfoEmbed.ts │ │ │ │ │ ├── getCustomEmojiId.ts │ │ │ │ │ ├── getEmojiInfoEmbed.ts │ │ │ │ │ ├── getGuildPreview.ts │ │ │ │ │ ├── getInviteInfoEmbed.ts │ │ │ │ │ ├── getMessageInfoEmbed.ts │ │ │ │ │ ├── getRoleInfoEmbed.ts │ │ │ │ │ ├── getServerInfoEmbed.ts │ │ │ │ │ ├── getSnowflakeInfoEmbed.ts │ │ │ │ │ ├── getUserInfoEmbed.ts │ │ │ │ │ └── hasPermission.ts │ │ │ │ ├── guildReloads.ts │ │ │ │ ├── refreshMembers.ts │ │ │ │ ├── search.ts │ │ │ │ └── types.ts │ │ │ ├── WelcomeMessage/ │ │ │ │ ├── WelcomeMessagePlugin.ts │ │ │ │ ├── docs.ts │ │ │ │ ├── events/ │ │ │ │ │ └── SendWelcomeMessageEvt.ts │ │ │ │ └── types.ts │ │ │ └── availablePlugins.ts │ │ ├── profiler.ts │ │ ├── rateLimitStats.ts │ │ ├── regExpRunners.ts │ │ ├── restCallStats.ts │ │ ├── staff.ts │ │ ├── templateFormatter.test.ts │ │ ├── templateFormatter.ts │ │ ├── threadsSignalFix.ts │ │ ├── types.ts │ │ ├── uptime.ts │ │ ├── utils/ │ │ │ ├── DecayingCounter.ts │ │ │ ├── MessageBuffer.ts │ │ │ ├── async.ts │ │ │ ├── buildCustomId.ts │ │ │ ├── calculateEmbedSize.ts │ │ │ ├── canAssignRole.ts │ │ │ ├── canReadChannel.ts │ │ │ ├── categorize.ts │ │ │ ├── createPaginatedMessage.ts │ │ │ ├── crypt.test.ts │ │ │ ├── crypt.ts │ │ │ ├── cryptHelpers.ts │ │ │ ├── cryptWorker.ts │ │ │ ├── easyProfiler.ts │ │ │ ├── erisAllowedMentionsToDjsMentionOptions.ts │ │ │ ├── filterObject.ts │ │ │ ├── findMatchingAuditLogEntry.ts │ │ │ ├── formatZodIssue.ts │ │ │ ├── getChunkedEmbedFields.ts │ │ │ ├── getGuildPrefix.ts │ │ │ ├── getMissingChannelPermissions.ts │ │ │ ├── getMissingPermissions.ts │ │ │ ├── getOrFetchGuildMember.ts │ │ │ ├── getOrFetchUser.ts │ │ │ ├── getPermissionNames.ts │ │ │ ├── hasDiscordPermissions.ts │ │ │ ├── idToTimestamp.ts │ │ │ ├── intToRgb.ts │ │ │ ├── isDefaultSticker.ts │ │ │ ├── isDmChannel.ts │ │ │ ├── isGuildChannel.ts │ │ │ ├── isScalar.ts │ │ │ ├── isThreadChannel.ts │ │ │ ├── isValidTimezone.ts │ │ │ ├── loadYamlSafely.ts │ │ │ ├── lockNameHelpers.ts │ │ │ ├── mergeRegexes.ts │ │ │ ├── mergeWordsIntoRegex.ts │ │ │ ├── messageHasContent.ts │ │ │ ├── messageIsEmpty.ts │ │ │ ├── missingPermissionError.ts │ │ │ ├── multipleSlashOptions.ts │ │ │ ├── normalizeText.test.ts │ │ │ ├── normalizeText.ts │ │ │ ├── parseColor.ts │ │ │ ├── parseCustomId.ts │ │ │ ├── parseFuzzyTimezone.ts │ │ │ ├── permissionNames.ts │ │ │ ├── readChannelPermissions.ts │ │ │ ├── registerEventListenersFromMap.ts │ │ │ ├── resolveChannelIds.ts │ │ │ ├── resolveMessageTarget.ts │ │ │ ├── rgbToInt.ts │ │ │ ├── sendDM.ts │ │ │ ├── snowflakeToTimestamp.ts │ │ │ ├── stripMarkdown.ts │ │ │ ├── templateSafeObjects.ts │ │ │ ├── typeUtils.ts │ │ │ ├── unregisterEventListenersFromMap.ts │ │ │ ├── validateNoObjectAliases.test.ts │ │ │ ├── validateNoObjectAliases.ts │ │ │ ├── waitForInteraction.ts │ │ │ ├── zColor.ts │ │ │ ├── zValidTimezone.ts │ │ │ └── zodDeepPartial.ts │ │ ├── utils.test.ts │ │ ├── utils.ts │ │ └── validateActiveConfigs.ts │ ├── start-dev.js │ └── tsconfig.json ├── build-image.sh ├── config-checker/ │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── public/ │ │ └── config-schema.json │ ├── src/ │ │ ├── main.ts │ │ ├── style.css │ │ ├── vite-env.d.ts │ │ └── yaml.worker.js │ └── tsconfig.json ├── dashboard/ │ ├── .editorconfig │ ├── .eslintrc.json │ ├── .gitignore │ ├── .prettierignore │ ├── index.html │ ├── package.json │ ├── postcss.config.js │ ├── public/ │ │ └── env.js │ ├── serve.js │ ├── src/ │ │ ├── api.ts │ │ ├── auth.ts │ │ ├── components/ │ │ │ ├── App.vue │ │ │ ├── Expandable.vue │ │ │ ├── PrivacyPolicy.vue │ │ │ ├── Splash.vue │ │ │ ├── Tab.vue │ │ │ ├── Tabs.vue │ │ │ ├── Title.vue │ │ │ ├── dashboard/ │ │ │ │ ├── GuildAccess.vue │ │ │ │ ├── GuildConfigEditor.vue │ │ │ │ ├── GuildImportExport.vue │ │ │ │ ├── GuildInfo.vue │ │ │ │ ├── GuildList.vue │ │ │ │ ├── Layout.vue │ │ │ │ ├── PermissionTree.vue │ │ │ │ └── permissionTreeUtils.ts │ │ │ └── docs/ │ │ │ ├── ArgumentTypes.vue │ │ │ ├── CodeBlock.vue │ │ │ ├── ConfigurationFormat.vue │ │ │ ├── Counters.vue │ │ │ ├── DocsLayout.vue │ │ │ ├── Introduction.vue │ │ │ ├── MarkdownBlock.vue │ │ │ ├── Moderation.vue │ │ │ ├── Permissions.vue │ │ │ ├── Plugin.vue │ │ │ ├── PluginConfiguration.vue │ │ │ └── WorkInProgress.vue │ │ ├── directives/ │ │ │ └── trim-indents.ts │ │ ├── index.ts │ │ ├── routes.ts │ │ ├── store/ │ │ │ ├── auth.ts │ │ │ ├── docs.ts │ │ │ ├── guilds.ts │ │ │ ├── index.ts │ │ │ ├── staff.ts │ │ │ └── types.ts │ │ ├── style/ │ │ │ ├── app.css │ │ │ ├── base.css │ │ │ ├── components.css │ │ │ ├── content.css │ │ │ ├── docs.css │ │ │ ├── privacy-policy.css │ │ │ ├── reset.css │ │ │ └── splash.css │ │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.ts ├── docker/ │ ├── development/ │ │ ├── devenv/ │ │ │ └── Dockerfile │ │ └── nginx/ │ │ ├── Dockerfile │ │ └── default.conf │ └── production/ │ └── nginx/ │ ├── Dockerfile │ └── default.conf ├── docker-compose.development.yml ├── docker-compose.lightweight.yml ├── docker-compose.standalone.yml ├── docs/ │ ├── DEVELOPMENT.md │ ├── MANAGEMENT.md │ ├── MIGRATE_DEV.md │ ├── MIGRATE_PROD.md │ └── PRODUCTION.md ├── package.json ├── pnpm-workspace.yaml ├── presetup-configurator/ │ ├── .gitignore │ ├── .prettierignore │ ├── package.json │ ├── snowpack.config.js │ ├── src/ │ │ ├── App.css │ │ ├── App.tsx │ │ ├── Configurator.css │ │ ├── Configurator.tsx │ │ ├── Levels.tsx │ │ ├── LogChannels.css │ │ ├── LogChannels.tsx │ │ ├── index.css │ │ ├── index.html │ │ └── index.tsx │ └── tsconfig.json ├── shared/ │ ├── .gitignore │ ├── package.json │ ├── src/ │ │ ├── apiPermissions.test.ts │ │ └── apiPermissions.ts │ └── tsconfig.json ├── tsconfig.base.json └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .clabot ================================================ { "contributors": [ "almeidx", "axisiscool", "BanTheNons", "Benricheson101", "brawaru", "CleverSource", "Dalkskkskk", "DarkView", "DenverCoder1", "dexbiobot", "greenbigfrog", "hawkeye7662", "iamshoXy", "Jernik", "k200-1", "LilyBergonzat", "martinbndr", "metal0", "Obliie", "paolojpa", "roflmaoqwerty", "Rstar284", "rubyowo", "rukogit", "Scraayp", "seeyebe", "TheKodeToad", "thewilloftheshadow", "usoka", "vcokltfre", "WeebHiroyuki", "zayKenyon", "Dragory", "app/dependabot", "dependabot[bot]" ], "message": "Thank you for contributing to Zeppelin! We require contributors to sign our Contributor License Agreement (CLA). To let us review and merge your code, please visit https://github.com/ZeppelinBot/CLA to sign the CLA!" } ================================================ FILE: .cursorignore ================================================ # Created by .ignore support plugin (hsz.mobi) ### Node template # Logs /logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* .clinic .clinic-bot .clinic-api # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Typescript v1 declaration files typings/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file *.env .env # windows folder options desktop.ini # PHPStorm .idea/ # Misc /convert.js /startscript.js .cache npm-ls.txt npm-audit.txt .vscode/launch.json # Debug files *.debug.ts *.debug.js .vscode/ config-errors.txt /config-schema.json *.tsbuildinfo # Legacy data folders /docker/development/data /docker/production/data ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "name": "Zeppelin Development", "dockerComposeFile": "../docker-compose.development.yml", "service": "devenv", "remoteUser": "ubuntu", "workspaceFolder": "/workspace/zeppelin", "customizations": { "vscode": { "extensions": [ "Vue.volar" ] } } } ================================================ FILE: .dockerignore ================================================ **/.git **/.github **/.idea **/.devcontainer /docker/development/data /docker/production/data **/node_modules **/dist **/.pnpm-store **/.docker **/*.log **/npm-debug.log* **/yarn-debug.log* **/yarn-error.log* **/.clinic **/.clinic-bot **/.clinic-api # dotenv environment variables file **/*.env **/.env # windows folder options **/desktop.ini # PHPStorm **/.idea # Misc **/npm-ls.txt **/npm-audit.txt **/.cache # Debug files **/*.debug.ts **/*.debug.js /debug **/.vscode config-errors.txt /config-schema.json **/*.tsbuildinfo ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 ================================================ FILE: .eslintrc.js ================================================ module.exports = { root: true, env: { node: true, browser: true, es6: true, }, extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], parser: "@typescript-eslint/parser", plugins: ["@typescript-eslint"], rules: { "@typescript-eslint/no-explicit-any": 0, "@typescript-eslint/ban-ts-comment": 0, "@typescript-eslint/no-non-null-assertion": 0, "no-async-promise-executor": 0, "@typescript-eslint/no-empty-interface": 0, "no-constant-condition": ["error", { checkLoops: false, }], "prefer-const": ["error", { destructuring: "all", ignoreReadBeforeAssign: true, }], "@typescript-eslint/no-namespace": ["error", { allowDeclarations: true, }], }, }; ================================================ FILE: .gitattributes ================================================ package-lock.json binary pnpm-lock.yaml binary ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: npm directory: / schedule: interval: daily groups: non-major: update-types: - minor - patch - package-ecosystem: github-actions directory: / schedule: interval: weekly groups: github-actions: patterns: - "*" - package-ecosystem: docker directory: / schedule: interval: weekly ================================================ FILE: .github/workflows/codequality.yml ================================================ name: Code quality checks on: [push, pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 with: node-version: 24 - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 with: version: 10.19.0 run_install: true - run: | pnpm run lint pnpm run codestyle-check ================================================ FILE: .gitignore ================================================ # Created by .ignore support plugin (hsz.mobi) ### Node template # Logs /logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* .clinic .clinic-bot .clinic-api # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ .pnpm-store # Typescript v1 declaration files typings/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file *.env .env # windows folder options desktop.ini # PHPStorm .idea/ # Misc /convert.js /startscript.js .cache npm-ls.txt npm-audit.txt .vscode/launch.json # Debug files *.debug.ts *.debug.js .vscode/ config-errors.txt /config-schema.json *.tsbuildinfo # Legacy data folders /docker/development/data /docker/production/data ================================================ FILE: .nvmrc ================================================ 24 ================================================ FILE: .prettierignore ================================================ .github .idea node_modules /assets /debug ================================================ FILE: .prettierrc ================================================ { "printWidth": 120, "trailingComma": "all" } ================================================ FILE: AGENTS.md ================================================ The project is called Zeppelin. It's a Discord bot that uses Discord.js. The bot is built on the Vety framework (formerly called Knub). This repository is a monorepository that contains these projects: 1. **Backend**: The shared codebase of the bot and API. Located in `backend`. 2. **Dashboard**: The web dashboard that contains the bot's management interface and documentation. Located in `dashboard`. 3. **Config checker**: A tool to check the configuration of the bot. Located in `config-checker`. There is also a `shared` folder that contains shared code used by all projects, such as types and utilities. # Backend The backend codebase is located in the `backend` directory. It contains the main bot code, API code, and shared code used by both the bot and API. Zeppelin's functionality is split into plugins, which are located in the `src/plugins` directory. Each plugin has its own directory, with a `types.ts` for config types, `docs.ts` for a `ZeppelinPluginDocs` structure, and the plugin's main file. Each plugin has an internal name, such as "common". In this example, the folder would be `src/plugins/Common` (note the capitalization). The plugin's main file would be `src/plugins/CommonPlugin.ts`. There are two types of plugins: "guild plugins" and "global plugins". Guild plugins are loaded on a per-guild basis, while global plugins are loaded once for the entire bot. Plugins can specify dependencies on other plugins and call their public methods. Likewise, plugins can specify public methods in the main file. Available plugins are specified in `src/plugins/availablePlugins.ts`. Zeppelin's data layer uses TypeORM. Entities are located in `src/data/entities`, while repositories are in `src/data`. If the repository name is prefixed with "Guild", it's a guild-specific repository. If it's prefixed with "User", it's a user-specific repository. If it has no prefix, it's a global repository. Environment variables are parsed in `src/env.ts`. ================================================ FILE: DEVELOPMENT.md ================================================ Moved to [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) ================================================ FILE: Dockerfile ================================================ FROM node:24 AS build ARG COMMIT_HASH ARG BUILD_TIME RUN mkdir /zeppelin RUN chown node:node /zeppelin # Install pnpm RUN npm install -g pnpm@10.19.0 USER node # Install dependencies before copying over any other files COPY --chown=node:node package.json pnpm-workspace.yaml pnpm-lock.yaml /zeppelin RUN mkdir /zeppelin/backend COPY --chown=node:node backend/package.json /zeppelin/backend RUN mkdir /zeppelin/shared COPY --chown=node:node shared/package.json /zeppelin/shared RUN mkdir /zeppelin/dashboard COPY --chown=node:node dashboard/package.json /zeppelin/dashboard WORKDIR /zeppelin RUN CI=true pnpm install COPY --chown=node:node . /zeppelin # Build backend WORKDIR /zeppelin/backend RUN pnpm run build # Build dashboard WORKDIR /zeppelin/dashboard RUN pnpm run build # Only keep prod dependencies WORKDIR /zeppelin RUN CI=true pnpm install --prod # Add version info RUN echo "${COMMIT_HASH}" > /zeppelin/.commit-hash RUN echo "${BUILD_TIME}" > /zeppelin/.build-time # --- Main image --- FROM node:24-alpine AS main RUN npm install -g pnpm@10.19.0 USER node COPY --from=build --chown=node:node /zeppelin /zeppelin WORKDIR /zeppelin ================================================ FILE: LICENSE.md ================================================ # Elastic License 2.0 (ELv2) ## Elastic License ### Acceptance By using the software, you agree to all of the terms and conditions below. ### Copyright License The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations and conditions below. ### Limitations You may not provide the software to third parties as a hosted or managed service, where the service provides users with access to any substantial set of the features or functionality of the software. You may not move, change, disable, or circumvent the license key functionality in the software, and you may not remove or obscure any functionality in the software that is protected by the license key. You may not alter, remove, or obscure any licensing, copyright, or other notices of the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law. ### Patents The licensor grants you a license, under any patent claims the licensor can license, or becomes able to license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case subject to the limitations and conditions in this license. This license does not cover any patent claims that you cause to be infringed by modifications or additions to the software. If you or your company make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. ### Notices You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. If you modify the software, you must include in any modified copies of the software prominent notices stating that you have modified the software. ### No Other Rights These terms do not imply any licenses other than those expressly granted in these terms. ### Termination If you use the software in violation of these terms, such use is not licensed, and your licenses will automatically terminate. If the licensor provides you with a notice of your violation, and you cease all violation of this license no later than 30 days after you receive that notice, your licenses will be reinstated retroactively. However, if you violate these terms after such reinstatement, any additional violation of these terms will cause your licenses to terminate automatically and permanently. ### No Liability ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.*** ### Definitions The **licensor** is the entity offering these terms, and the **software** is the software the licensor makes available under these terms, including any portion of it. **you** refers to the individual or entity agreeing to these terms. **your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. **your licenses** are all the licenses granted to you for the software under these terms. **use** means anything you do with the software requiring one of your licenses. **trademark** means trademarks, service marks, and similar rights. ================================================ FILE: MANAGEMENT.md ================================================ Moved to [docs/MANAGEMENT.md](docs/MANAGEMENT.md) ================================================ FILE: PRODUCTION.md ================================================ Moved to [docs/PRODUCTION.md](docs/PRODUCTION.md) ================================================ FILE: README.md ================================================ ![Zeppelin Banner](assets/zepbanner.png) # Zeppelin Zeppelin is a moderation bot for Discord, designed with large servers and reliability in mind. **Main features include:** - Extensive automoderator features (automod) - Word filters, spam detection, etc. - Detailed moderator action tracking and notes (cases) - Customizable server logs - Tags/custom commands - Reaction roles - Tons of utility commands, including a granular member search - Full configuration via a web dashboard - Override specific settings and permissions on e.g. a per-user, per-channel, or per-permission-level basis - Bot-managed slowmodes - Automatically switches between native slowmodes (for 6h or less) and bot-enforced (for longer slowmodes) - Starboard - And more! See https://zeppelin.gg/ for more details. ## Usage documentation For information on how to use the bot, see https://zeppelin.gg/docs ## Development See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for instructions on running the development environment. Once you have the environment up and running, see [docs/MANAGEMENT.md](docs/MANAGEMENT.md) for how to manage your bot. ## Production See [docs/PRODUCTION.md](docs/PRODUCTION.md) for instructions on how to run the bot in production. Once you have the environment up and running, see [docs/MANAGEMENT.md](docs/MANAGEMENT.md) for how to manage your bot. ================================================ FILE: assets/icons/LICENSE ================================================ # TWEMOJI Copyright 2020 Twitter, Inc and other contributors Code licensed under the MIT License: http://opensource.org/licenses/MIT Graphics licensed under CC-BY 4.0: https://creativecommons.org/licenses/by/4.0/ ================================================ FILE: backend/.gitignore ================================================ /.cache /dist /node_modules ================================================ FILE: backend/.prettierignore ================================================ /dist ================================================ FILE: backend/package.json ================================================ { "name": "@zeppelinbot/backend", "version": "0.0.1", "description": "", "private": true, "type": "module", "exports": { "./*": "./dist/*" }, "scripts": { "watch": "tsc-watch --build --onSuccess \"node start-dev.js\"", "watch-yaml-parse-test": "tsc-watch --build --onSuccess \"node dist/yamlParseTest.js\"", "build": "tsc --build", "typecheck": "tsc --noEmit", "start-bot-dev": "node --enable-source-maps --stack-trace-limit=30 --trace-warnings --inspect=0.0.0.0:9229 dist/index.js", "start-bot-prod": "node --enable-source-maps --stack-trace-limit=30 --trace-warnings dist/index.js", "watch-bot": "tsc-watch --build --onSuccess \"pnpm run start-bot-dev\"", "start-api-dev": "node --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9239 dist/api/index.js", "start-api-prod": "node --enable-source-maps --stack-trace-limit=30 dist/api/index.js", "watch-api": "tsc-watch --build --onSuccess \"pnpm run start-api-dev\"", "migrate": "pnpm exec typeorm migration:run -d dist/data/dataSource.js", "migrate-prod": "pnpm run migrate", "migrate-dev": "pnpm run build && pnpm run migrate", "migrate-rollback": "pnpm exec typeorm migration:revert -d dist/data/dataSource.js", "migrate-rollback-prod": "pnpm run migrate-rollback", "migrate-rollback-dev": "pnpm run build && pnpm run migrate-rollback", "validate-active-configs": "node --enable-source-maps dist/validateActiveConfigs.js > ../config-errors.txt", "export-config-json-schema": "node --enable-source-maps dist/exportSchemas.js ../config-checker/public/config-schema.json", "test": "pnpm run build && pnpm run run-tests", "run-tests": "ava", "test-watch": "tsc-watch --build --onSuccess \"pnpm exec ava\"" }, "dependencies": { "@silvia-odwyer/photon-node": "^0.3.1", "@zeppelinbot/shared": "workspace:*", "bufferutil": "^4.0.3", "cors": "^2.8.5", "cross-env": "^7.0.3", "deep-diff": "^1.0.2", "discord.js": "*", "emoji-regex": "^8.0.0", "escape-string-regexp": "^1.0.5", "express": "^4.20.0", "fp-ts": "^2.0.1", "humanize-duration": "^3.15.0", "js-yaml": "^4.1.0", "knub-command-manager": "^9.1.0", "lodash-es": "^4.17.21", "moment-timezone": "^0.5.21", "multer": "^2.0.2", "mysql2": "^3.9.8", "parse-color": "^1.0.0", "passport": "^0.6.0", "passport-custom": "^1.0.5", "passport-oauth2": "^1.6.1", "pkg-up": "^3.1.0", "redis": "^5.9.0", "reflect-metadata": "^0.1.12", "regexp-worker": "^1.1.0", "safe-regex": "^2.0.2", "seedrandom": "^3.0.1", "strip-combining-marks": "^1.0.0", "threads": "^1.7.0", "tlds": "^1.221.1", "tmp": "0.2.5", "tsconfig-paths": "^3.9.0", "twemoji": "^12.1.4", "typeorm": "^0.3.27", "utf-8-validate": "^5.0.5", "uuid": "^9.0.0", "vety": "1.0.0-rc2", "zod": "^4.1.12" }, "devDependencies": { "@types/cors": "^2.8.5", "@types/express": "^4.16.1", "@types/js-yaml": "^3.12.1", "@types/lodash-es": "^4.17.12", "@types/multer": "^1.4.7", "@types/passport": "^1.0.0", "@types/passport-oauth2": "^1.4.8", "@types/passport-strategy": "^0.2.35", "@types/safe-regex": "^1.1.2", "@types/tmp": "0.0.33", "@types/twemoji": "^12.1.0", "@types/uuid": "^9.0.2", "ava": "^5.3.1", "source-map-support": "^0.5.16" }, "ava": { "files": [ "dist/**/*.test.js" ], "require": [ "./register-tsconfig-paths.js" ] } } ================================================ FILE: backend/register-tsconfig-paths.js ================================================ /** * See: * https://github.com/dividab/tsconfig-paths * https://github.com/TypeStrong/ts-node/issues/138 * https://github.com/TypeStrong/ts-node/issues/138#issuecomment-519602402 * https://github.com/TypeStrong/ts-node/pull/254 */ const path = require("path"); const tsconfig = require("./tsconfig.json"); const tsconfigPaths = require("tsconfig-paths"); // E.g. ./dist/backend const baseUrl = path.resolve(tsconfig.compilerOptions.outDir, path.basename(__dirname)); tsconfigPaths.register({ baseUrl, paths: tsconfig.compilerOptions.paths || [], }); ================================================ FILE: backend/src/Blocker.ts ================================================ export type Block = { count: number; unblock: () => void; getPromise: () => Promise; }; export class Blocker { #blocks: Map = new Map(); block(key: string): void { if (!this.#blocks.has(key)) { const promise = new Promise((resolve) => { this.#blocks.set(key, { count: 0, // Incremented to 1 further below unblock() { this.count--; if (this.count === 0) { resolve(); } }, getPromise: () => promise, // :d }); }); } this.#blocks.get(key)!.count++; } unblock(key: string): void { if (this.#blocks.has(key)) { this.#blocks.get(key)!.unblock(); } } async waitToBeUnblocked(key: string): Promise { if (!this.#blocks.has(key)) { return; } await this.#blocks.get(key)!.getPromise(); } } ================================================ FILE: backend/src/DiscordJSError.ts ================================================ import util from "util"; export class DiscordJSError extends Error { code: number | string | undefined; shardId: number; constructor(message: string, code: number | string | undefined, shardId: number) { super(message); this.code = code; this.shardId = shardId; } [util.inspect.custom]() { return `[DISCORDJS] [ERROR CODE ${this.code ?? "?"}] [SHARD ${this.shardId}] ${this.message}`; } } ================================================ FILE: backend/src/Queue.ts ================================================ import { SECONDS } from "./utils.js"; type InternalQueueFn = () => Promise; type AnyFn = (...args: any[]) => any; const DEFAULT_TIMEOUT = 10 * SECONDS; export class Queue { protected running = false; protected queue: InternalQueueFn[] = []; protected _timeout: number; constructor(timeout = DEFAULT_TIMEOUT) { this._timeout = timeout; } get timeout(): number { return this._timeout; } /** * The number of operations that are currently queued up or running. * I.e. backlog (queue) + current running process, if any. * * If this is 0, queueing a function will run it as soon as possible. */ get length(): number { return this.queue.length + (this.running ? 1 : 0); } public add(fn: TQueueFunction): Promise { const promise = new Promise((resolve, reject) => { this.queue.push(async () => { try { const result = await fn(); resolve(result); } catch (err) { reject(err); } }); if (!this.running) this.next(); }); return promise; } public next(): void { this.running = true; if (this.queue.length === 0) { this.running = false; return; } const fn = this.queue.shift()!; new Promise((resolve) => { // Either fn() completes or the timeout is reached void fn().then(resolve); setTimeout(resolve, this._timeout); }).then(() => this.next()); } public clear() { this.queue.splice(0, this.queue.length); } } ================================================ FILE: backend/src/QueuedEventEmitter.ts ================================================ import { Queue } from "./Queue.js"; type Listener = (...args: any[]) => void; export class QueuedEventEmitter { protected listeners: Map; protected queue: Queue; constructor() { this.listeners = new Map(); this.queue = new Queue(); } on(eventName: string, listener: Listener): Listener { if (!this.listeners.has(eventName)) { this.listeners.set(eventName, []); } this.listeners.get(eventName)!.push(listener); return listener; } off(eventName: string, listener: Listener) { if (!this.listeners.has(eventName)) { return; } const listeners = this.listeners.get(eventName)!; listeners.splice(listeners.indexOf(listener), 1); } once(eventName: string, listener: Listener): Listener { const handler = this.on(eventName, (...args) => { const result = listener(...args); this.off(eventName, handler); return result; }); return handler; } emit(eventName: string, args: any[] = []): Promise { const listeners = [...(this.listeners.get(eventName) || []), ...(this.listeners.get("*") || [])]; let promise: Promise = Promise.resolve(); listeners.forEach((listener) => { promise = this.queue.add(listener.bind(null, ...args)); }); return promise; } } ================================================ FILE: backend/src/RecoverablePluginError.ts ================================================ import { Guild } from "discord.js"; export enum ERRORS { NO_MUTE_ROLE_IN_CONFIG = 1, UNKNOWN_NOTE_CASE, INVALID_EMOJI, NO_USER_NOTIFICATION_CHANNEL, INVALID_USER_NOTIFICATION_CHANNEL, INVALID_USER, INVALID_MUTE_ROLE_ID, MUTE_ROLE_ABOVE_ZEP, USER_ABOVE_ZEP, USER_NOT_MODERATABLE, TEMPLATE_PARSE_ERROR, } export const RECOVERABLE_PLUGIN_ERROR_MESSAGES = { [ERRORS.NO_MUTE_ROLE_IN_CONFIG]: "No mute role specified in config", [ERRORS.UNKNOWN_NOTE_CASE]: "Tried to add a note to an unknown case", [ERRORS.INVALID_EMOJI]: "Invalid emoji", [ERRORS.NO_USER_NOTIFICATION_CHANNEL]: "No user notify channel specified", [ERRORS.INVALID_USER_NOTIFICATION_CHANNEL]: "Invalid user notify channel specified", [ERRORS.INVALID_USER]: "Invalid user", [ERRORS.INVALID_MUTE_ROLE_ID]: "Specified mute role is not valid", [ERRORS.MUTE_ROLE_ABOVE_ZEP]: "Specified mute role is above Zeppelin in the role hierarchy", [ERRORS.USER_ABOVE_ZEP]: "Cannot mute user, specified user is above Zeppelin in the role hierarchy", [ERRORS.USER_NOT_MODERATABLE]: "Cannot mute user, specified user is not moderatable", [ERRORS.TEMPLATE_PARSE_ERROR]: "Template parse error", }; export class RecoverablePluginError extends Error { public readonly code: ERRORS; public readonly guild?: Guild; constructor(code: ERRORS, guild?: Guild) { super(RECOVERABLE_PLUGIN_ERROR_MESSAGES[code]); this.guild = guild; this.code = code; } } ================================================ FILE: backend/src/RegExpRunner.ts ================================================ import { CooldownManager } from "vety"; import { EventEmitter } from "node:events"; import { RegExpWorker, TimeoutError } from "regexp-worker"; import { MINUTES, SECONDS } from "./utils.js"; import Timeout = NodeJS.Timeout; const isTimeoutError = (a): a is TimeoutError => { return a.message != null && a.elapsedTimeMs != null; }; export class RegExpTimeoutError extends Error { constructor( message: string, public elapsedTimeMs: number, ) { super(message); } } export function allowTimeout(err: RegExpTimeoutError | Error) { if (err instanceof RegExpTimeoutError) { return null; } throw err; } // Regex timeout starts at a higher value while the bot loads initially, and gets lowered afterwards const INITIAL_REGEX_TIMEOUT = 5 * SECONDS; const INITIAL_REGEX_TIMEOUT_DURATION = 30 * SECONDS; const FINAL_REGEX_TIMEOUT = 5 * SECONDS; const regexTimeoutUpgradePromise = new Promise((resolve) => setTimeout(resolve, INITIAL_REGEX_TIMEOUT_DURATION)); let newWorkerTimeout = INITIAL_REGEX_TIMEOUT; regexTimeoutUpgradePromise.then(() => (newWorkerTimeout = FINAL_REGEX_TIMEOUT)); const REGEX_FAIL_TO_COOLDOWN_COUNT = 5; // If a regex times out this many times... const REGEX_FAIL_DECAY_TIME = 2 * MINUTES; // ...in this interval... const REGEX_FAIL_COOLDOWN = 2 * MINUTES + 30 * SECONDS; // ...it goes on cooldown for this long export interface RegExpRunner { on(event: "timeout", listener: (regexSource: string, timeoutMs: number) => void); on(event: "repeatedTimeout", listener: (regexSource: string, timeoutMs: number, failTimes: number) => void); } /** * Leverages RegExpWorker to run regular expressions in worker threads with a timeout. * Repeatedly failing regexes are put on a cooldown where requests to execute them are ignored. */ export class RegExpRunner extends EventEmitter { private _worker: RegExpWorker | null; private readonly _failedTimesInterval: Timeout; private cooldown: CooldownManager; private failedTimes: Map; constructor() { super(); this.cooldown = new CooldownManager(); this.failedTimes = new Map(); this._failedTimesInterval = setInterval(() => { for (const [pattern, times] of this.failedTimes.entries()) { this.failedTimes.set(pattern, times - 1); } }, REGEX_FAIL_DECAY_TIME); } private get worker(): RegExpWorker { if (!this._worker) { this._worker = new RegExpWorker(newWorkerTimeout); if (newWorkerTimeout !== FINAL_REGEX_TIMEOUT) { regexTimeoutUpgradePromise.then(() => { if (!this._worker) return; this._worker.timeout = FINAL_REGEX_TIMEOUT; }); } } return this._worker; } public async exec(regex: RegExp, str: string): Promise { if (this.cooldown.isOnCooldown(regex.source)) { return null; } try { const result = await this.worker.execRegExp(regex, str); return result.matches.length || regex.global ? result.matches : null; } catch (e) { if (isTimeoutError(e)) { if (this.failedTimes.has(regex.source)) { // Regex has failed before, increment fail counter this.failedTimes.set(regex.source, this.failedTimes.get(regex.source)! + 1); } else { // This is the first time this regex failed, init fail counter this.failedTimes.set(regex.source, 1); } if (this.failedTimes.has(regex.source) && this.failedTimes.get(regex.source)! >= REGEX_FAIL_TO_COOLDOWN_COUNT) { // Regex has failed too many times, set it on cooldown this.cooldown.setCooldown(regex.source, REGEX_FAIL_COOLDOWN); this.failedTimes.delete(regex.source); this.emit("repeatedTimeout", regex.source, this.worker.timeout, REGEX_FAIL_TO_COOLDOWN_COUNT); } this.emit("timeout", regex.source, this.worker.timeout); throw new RegExpTimeoutError(e.message, e.elapsedTimeMs); } throw e; } } public async dispose() { await this.worker.dispose(); this._worker = null; clearInterval(this._failedTimesInterval); } } ================================================ FILE: backend/src/SimpleCache.ts ================================================ import Timeout = NodeJS.Timeout; const CLEAN_INTERVAL = 1000; export class SimpleCache { protected readonly retentionTime: number; protected readonly maxItems: number; protected cleanTimeout: Timeout; protected unloaded: boolean; protected store: Map; constructor(retentionTime: number, maxItems?: number) { this.retentionTime = retentionTime; if (maxItems) { this.maxItems = maxItems; } this.store = new Map(); } unload() { this.unloaded = true; clearTimeout(this.cleanTimeout); } cleanLoop() { const now = Date.now(); for (const [key, info] of this.store.entries()) { if (now >= info.remove_at) { this.store.delete(key); } } if (!this.unloaded) { this.cleanTimeout = setTimeout(() => this.cleanLoop(), CLEAN_INTERVAL); } } set(key: string, value: T) { this.store.set(key, { remove_at: Date.now() + this.retentionTime, value, }); if (this.maxItems && this.store.size > this.maxItems) { const keyToDelete = this.store.keys().next().value!; this.store.delete(keyToDelete); } } get(key: string): T | null { const info = this.store.get(key); if (!info) return null; return info.value; } has(key: string) { return this.store.has(key); } delete(key: string) { this.store.delete(key); } clear() { this.store.clear(); } } ================================================ FILE: backend/src/SimpleError.ts ================================================ import util from "util"; export class SimpleError extends Error { public message: string; constructor(message: string) { super(message); } [util.inspect.custom]() { return `Error: ${this.message}`; } } ================================================ FILE: backend/src/api/archives.ts ================================================ import express, { Request, Response } from "express"; import moment from "moment-timezone"; import { GuildArchives } from "../data/GuildArchives.js"; import { notFound } from "./responses.js"; export function initArchives(router: express.Router) { const archives = new GuildArchives(null); // Legacy redirect router.get("/spam-logs/:id", (req: Request, res: Response) => { res.redirect("/archives/" + req.params.id); }); router.get("/archives/:id", async (req: Request, res: Response) => { const archive = await archives.find(req.params.id); if (!archive) return notFound(res); let body = archive.body; // Add some metadata at the end of the log file (but only if it doesn't already have it directly in the body) // TODO: Use server timezone / date formats if (archive.body.indexOf("Log file generated on") === -1) { const createdAt = moment.utc(archive.created_at).format("YYYY-MM-DD [at] HH:mm:ss [(+00:00)]"); body += `\n\nLog file generated on ${createdAt}`; if (archive.expires_at !== null) { const expiresAt = moment.utc(archive.expires_at).format("YYYY-MM-DD [at] HH:mm:ss [(+00:00)]"); body += `\nExpires at ${expiresAt}`; } } res.setHeader("Content-Type", "text/plain; charset=UTF-8"); res.setHeader("X-Content-Type-Options", "nosniff"); res.end(body); }); } ================================================ FILE: backend/src/api/auth.ts ================================================ import express, { Request, Response } from "express"; import https from "https"; import { pick } from "lodash-es"; import passport from "passport"; import { Strategy as CustomStrategy } from "passport-custom"; import OAuth2Strategy from "passport-oauth2"; import { ApiLogins } from "../data/ApiLogins.js"; import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments.js"; import { ApiUserInfo } from "../data/ApiUserInfo.js"; import { ApiUserInfoData } from "../data/entities/ApiUserInfo.js"; import { env } from "../env.js"; import { ok } from "./responses.js"; interface IPassportApiUser { apiKey: string; userId: string; } declare global { namespace Express { interface User extends IPassportApiUser {} } } const DISCORD_API_URL = "https://discord.com/api"; function simpleDiscordAPIRequest(bearerToken, path): Promise { return new Promise((resolve, reject) => { const request = https.get( `${DISCORD_API_URL}/${path}`, { headers: { Authorization: `Bearer ${bearerToken}`, }, }, (res) => { if (res.statusCode !== 200) { reject(new Error(`Discord API error ${res.statusCode}`)); return; } let rawData = ""; res.on("data", (data) => (rawData += data)); res.on("end", () => { resolve(JSON.parse(rawData)); }); }, ); request.on("error", (err) => reject(err)); }); } export function initAuth(router: express.Router) { router.use(passport.initialize()); passport.serializeUser((user, done) => done(null, user)); passport.deserializeUser((user, done) => done(null, user as IPassportApiUser)); const apiLogins = new ApiLogins(); const apiUserInfo = new ApiUserInfo(); const apiPermissionAssignments = new ApiPermissionAssignments(); // Initialize API tokens passport.use( "api-token", new CustomStrategy(async (req, cb) => { const apiKey = req.header("X-Api-Key") || req.body?.["X-Api-Key"]; if (!apiKey) return cb("API key missing"); const userId = await apiLogins.getUserIdByApiKey(apiKey); if (userId) { void apiLogins.refreshApiKeyExpiryTime(apiKey); // Refresh expiry time in the background return cb(null, { apiKey, userId }); } cb("API key not found"); }), ); // Initialize OAuth2 for Discord login // When the user logs in through OAuth2, we create them a "login" (= api token) and update their user info in the DB passport.use( new OAuth2Strategy( { authorizationURL: "https://discord.com/api/oauth2/authorize", tokenURL: "https://discord.com/api/oauth2/token", clientID: env.CLIENT_ID, clientSecret: env.CLIENT_SECRET, callbackURL: `${env.API_URL}/auth/oauth-callback`, scope: ["identify"], }, async (accessToken, refreshToken, profile, cb) => { const user = await simpleDiscordAPIRequest(accessToken, "users/@me"); // Make sure the user is able to access at least 1 guild const permissions = await apiPermissionAssignments.getByUserId(user.id); if (permissions.length === 0) { cb(null, {}); return; } // Generate API key const apiKey = await apiLogins.addLogin(user.id); const userData = pick(user, ["username", "discriminator", "avatar"]) as ApiUserInfoData; await apiUserInfo.update(user.id, userData); // TODO: Revoke access token, we don't need it anymore cb(null, { apiKey }); }, ), ); router.get("/auth/login", passport.authenticate("oauth2")); router.get( "/auth/oauth-callback", passport.authenticate("oauth2", { failureRedirect: "/", session: false }), (req: Request, res: Response) => { if (req.user && req.user.apiKey) { res.redirect(`${env.DASHBOARD_URL}/login-callback/?apiKey=${req.user.apiKey}`); } else { res.redirect(`${env.DASHBOARD_URL}/login-callback/?error=noAccess`); } }, ); router.post("/auth/validate-key", async (req: Request, res: Response) => { const key = req.body.key; if (!key) { return res.status(400).json({ error: "No key supplied" }); } const userId = await apiLogins.getUserIdByApiKey(key); if (!userId) { return res.json({ valid: false }); } res.json({ valid: true, userId }); }); router.post("/auth/logout", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => { await apiLogins.expireApiKey(req.user!.apiKey); return ok(res); }); // API route to refresh the given API token's expiry time // The actual refreshing happens in the api-token passport strategy above, so we just return 200 OK here router.post("/auth/refresh", ...apiTokenAuthHandlers(), (req, res) => { return ok(res); }); } export function apiTokenAuthHandlers() { return [ passport.authenticate("api-token", { failWithError: true, session: false }), // eslint-disable-next-line @typescript-eslint/no-unused-vars (err, req: Request, res: Response, next) => { return res.status(401).json({ error: err.message }); }, ]; } ================================================ FILE: backend/src/api/docs.ts ================================================ import express from "express"; import { z } from "zod"; import { $ZodPipeDef } from "zod/v4/core"; import { availableGuildPlugins } from "../plugins/availablePlugins.js"; import { ZeppelinGuildPluginInfo } from "../types.js"; import { indentLines } from "../utils.js"; import { notFound } from "./responses.js"; function isZodObject(schema: z.ZodType): schema is z.ZodObject { return schema.def.type === "object"; } function isZodRecord(schema: z.ZodType): schema is z.ZodRecord { return schema.def.type === "record"; } function isZodOptional(schema: z.ZodType): schema is z.ZodOptional { return schema.def.type === "optional"; } function isZodArray(schema: z.ZodType): schema is z.ZodArray { return schema.def.type === "array"; } function isZodUnion(schema: z.ZodType): schema is z.ZodUnion { return schema.def.type === "union"; } function isZodNullable(schema: z.ZodType): schema is z.ZodNullable { return schema.def.type === "nullable"; } function isZodDefault(schema: z.ZodType): schema is z.ZodDefault { return schema.def.type === "default"; } function isZodLiteral(schema: z.ZodType): schema is z.ZodLiteral { return schema.def.type === "literal"; } function isZodIntersection(schema: z.ZodType): schema is z.ZodIntersection { return schema.def.type === "intersection"; } function formatZodConfigSchema(schema: z.ZodType) { if (isZodObject(schema)) { return ( `{\n` + Object.entries(schema.def.shape) .map(([k, value]) => indentLines(`${k}: ${formatZodConfigSchema(value as z.ZodType)}`, 2)) .join("\n") + "\n}" ); } if (isZodRecord(schema)) { return "{\n" + indentLines(`[string]: ${formatZodConfigSchema(schema.valueType as z.ZodType)}`, 2) + "\n}"; } if (isZodOptional(schema)) { return `Optional<${formatZodConfigSchema(schema.def.innerType)}>`; } if (isZodArray(schema)) { return `Array<${formatZodConfigSchema(schema.def.element)}>`; } if (isZodUnion(schema)) { return schema.def.options.map((t) => formatZodConfigSchema(t)).join(" | "); } if (isZodNullable(schema)) { return `Nullable<${formatZodConfigSchema(schema.def.innerType)}>`; } if (isZodDefault(schema)) { return formatZodConfigSchema(schema.def.innerType); } if (isZodLiteral(schema)) { return schema.def.values; } if (isZodIntersection(schema)) { return [ formatZodConfigSchema(schema.def.left as z.ZodType), formatZodConfigSchema(schema.def.right as z.ZodType), ].join(" & "); } if (schema.def.type === "string") { return "string"; } if (schema.def.type === "number") { return "number"; } if (schema.def.type === "boolean") { return "boolean"; } if (schema.def.type === "never") { return "never"; } if (schema.def.type === "pipe") { return formatZodConfigSchema((schema.def as $ZodPipeDef).in as z.ZodType); } return "unknown"; } const availableGuildPluginsByName = availableGuildPlugins.reduce>( (map, obj) => { map[obj.plugin.name] = obj; return map; }, {}, ); export function initDocs(router: express.Router) { const docsPlugins = availableGuildPlugins.filter((obj) => obj.docs.type !== "internal"); router.get("/docs/plugins", (req: express.Request, res: express.Response) => { res.json( docsPlugins.map((obj) => ({ name: obj.plugin.name, info: { prettyName: obj.docs.prettyName, type: obj.docs.type, }, })), ); }); router.get("/docs/plugins/:pluginName", (req: express.Request, res: express.Response) => { const pluginInfo = availableGuildPluginsByName[req.params.pluginName]; if (!pluginInfo) { return notFound(res); } const { configSchema, ...info } = pluginInfo.docs; const formattedConfigSchema = formatZodConfigSchema(configSchema); const messageCommands = (pluginInfo.plugin.messageCommands || []).map((cmd) => ({ trigger: cmd.trigger, permission: cmd.permission, signature: cmd.signature, description: cmd.description, usage: cmd.usage, config: cmd.config, })); const defaultOptions = pluginInfo.docs.configSchema.safeParse({}).data ?? {}; res.json({ name: pluginInfo.plugin.name, info, configSchema: formattedConfigSchema, defaultOptions, messageCommands, }); }); } ================================================ FILE: backend/src/api/guilds/importExport.ts ================================================ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import express, { Request, Response } from "express"; import moment from "moment-timezone"; import { z } from "zod"; import { GuildCases } from "../../data/GuildCases.js"; import { Case } from "../../data/entities/Case.js"; import { MINUTES } from "../../utils.js"; import { requireGuildPermission } from "../permissions.js"; import { rateLimit } from "../rateLimits.js"; import { clientError, ok } from "../responses.js"; const caseHandlingModeSchema = z.union([ z.literal("replace"), z.literal("bumpExistingCases"), z.literal("bumpImportedCases"), ]); type CaseHandlingMode = z.infer; const caseNoteData = z.object({ mod_id: z.string(), mod_name: z.string(), body: z.string(), created_at: z.string(), }); const caseData = z.object({ case_number: z.number(), user_id: z.string(), user_name: z.string(), mod_id: z.nullable(z.string()), mod_name: z.nullable(z.string()), type: z.number(), created_at: z.string(), is_hidden: z.boolean(), pp_id: z.nullable(z.string()), pp_name: z.nullable(z.string()), log_message_id: z.string().optional(), notes: z.array(caseNoteData), }); const importExportData = z.object({ cases: z.array(caseData), }); type TImportExportData = z.infer; export function initGuildsImportExportAPI(guildRouter: express.Router) { const importExportRouter = express.Router(); importExportRouter.get( "/:guildId/pre-import", requireGuildPermission(ApiPermissions.ManageAccess), async (req: Request) => { const guildCases = GuildCases.getGuildInstance(req.params.guildId); const minNum = await guildCases.getMinCaseNumber(); const maxNum = await guildCases.getMaxCaseNumber(); return { minCaseNumber: minNum, maxCaseNumber: maxNum, }; }, ); importExportRouter.post( "/:guildId/import", requireGuildPermission(ApiPermissions.ManageAccess), rateLimit( (req) => `import-${req.params.guildId}`, 5 * MINUTES, "A single server can only import data once every 5 minutes", ), async (req: Request, res: Response) => { let data: TImportExportData; try { data = importExportData.parse(req.body.data); } catch (err) { const prettyMessage = `${err.issues[0].code}: expected ${err.issues[0].expected}, received ${ err.issues[0].received } at /${err.issues[0].path.join("/")}`; return clientError(res, `Invalid import data format: ${prettyMessage}`); return; } let caseHandlingMode: CaseHandlingMode; try { caseHandlingMode = caseHandlingModeSchema.parse(req.body.caseHandlingMode); } catch (err) { return clientError(res, "Invalid case handling mode"); return; } const seenCaseNumbers = new Set(); for (const theCase of data.cases) { if (seenCaseNumbers.has(theCase.case_number)) { return clientError(res, `Duplicate case number: ${theCase.case_number}`); } seenCaseNumbers.add(theCase.case_number); } const guildCases = GuildCases.getGuildInstance(req.params.guildId); // Prepare cases if (caseHandlingMode === "replace") { // Replace existing cases await guildCases.deleteAllCases(); } else if (caseHandlingMode === "bumpExistingCases") { // Bump existing numbers const maxNumberInData = data.cases.reduce((max, theCase) => Math.max(max, theCase.case_number), 0); await guildCases.bumpCaseNumbers(maxNumberInData); } else if (caseHandlingMode === "bumpImportedCases") { const maxExistingNumber = await guildCases.getMaxCaseNumber(); for (const theCase of data.cases) { theCase.case_number += maxExistingNumber; } } // Import cases for (const theCase of data.cases) { const insertData: any = { ...theCase, is_hidden: theCase.is_hidden ? 1 : 0, guild_id: req.params.guildId, notes: undefined, }; const caseInsertData = await guildCases.createInternal(insertData); for (const note of theCase.notes) { await guildCases.createNote(caseInsertData.identifiers[0].id, note); } } ok(res); }, ); const exportBatchSize = 500; importExportRouter.post( "/:guildId/export", requireGuildPermission(ApiPermissions.ManageAccess), rateLimit( (req) => `export-${req.params.guildId}`, 5 * MINUTES, "A single server can only export data once every 5 minutes", ), async (req: Request, res: Response) => { const guildCases = GuildCases.getGuildInstance(req.params.guildId); const data: TImportExportData = { cases: [], }; let n = 0; let cases: Case[]; do { cases = await guildCases.getExportCases(n, exportBatchSize); n += cases.length; for (const theCase of cases) { data.cases.push({ case_number: theCase.case_number, user_id: theCase.user_id, user_name: theCase.user_name, mod_id: theCase.mod_id, mod_name: theCase.mod_name, type: theCase.type, created_at: theCase.created_at, is_hidden: theCase.is_hidden, pp_id: theCase.pp_id, pp_name: theCase.pp_name, log_message_id: theCase.log_message_id ?? undefined, notes: theCase.notes.map((note) => ({ mod_id: note.mod_id, mod_name: note.mod_name, body: note.body, created_at: note.created_at, })), }); } } while (cases.length === exportBatchSize); const filename = `export_${req.params.guildId}_${moment().format("YYYY-MM-DD_HH-mm-ss")}.json`; const serialized = JSON.stringify(data, null, 2); res.setHeader("Content-Disposition", `attachment; filename=${filename}`); res.setHeader("Content-Type", "application/octet-stream"); res.setHeader("Content-Length", serialized.length); res.send(serialized); }, ); guildRouter.use("/", importExportRouter); } ================================================ FILE: backend/src/api/guilds/index.ts ================================================ import express from "express"; import { apiTokenAuthHandlers } from "../auth.js"; import { initGuildsImportExportAPI } from "./importExport.js"; import { initGuildsMiscAPI } from "./misc.js"; export function initGuildsAPI(router: express.Router) { const guildRouter = express.Router(); guildRouter.use(...apiTokenAuthHandlers()); initGuildsMiscAPI(guildRouter); initGuildsImportExportAPI(guildRouter); router.use("/guilds", guildRouter); } ================================================ FILE: backend/src/api/guilds/misc.ts ================================================ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import express, { Request, Response } from "express"; import { YAMLException } from "js-yaml"; import moment from "moment-timezone"; import { Queue } from "../../Queue.js"; import { validateGuildConfig } from "../../configValidator.js"; import { AllowedGuilds } from "../../data/AllowedGuilds.js"; import { ApiAuditLog } from "../../data/ApiAuditLog.js"; import { ApiPermissionAssignments, ApiPermissionTypes } from "../../data/ApiPermissionAssignments.js"; import { Configs } from "../../data/Configs.js"; import { AuditLogEventTypes } from "../../data/apiAuditLogTypes.js"; import { isSnowflake } from "../../utils.js"; import { loadYamlSafely } from "../../utils/loadYamlSafely.js"; import { ObjectAliasError } from "../../utils/validateNoObjectAliases.js"; import { hasGuildPermission, requireGuildPermission } from "../permissions.js"; import { clientError, ok, serverError, unauthorized } from "../responses.js"; const apiPermissionAssignments = new ApiPermissionAssignments(); const auditLog = new ApiAuditLog(); export function initGuildsMiscAPI(router: express.Router) { const allowedGuilds = new AllowedGuilds(); const configs = new Configs(); const miscRouter = express.Router(); miscRouter.get("/available", async (req: Request, res: Response) => { const guilds = await allowedGuilds.getForApiUser(req.user!.userId); res.json(guilds); }); miscRouter.get( "/my-permissions", // a async (req: Request, res: Response) => { const permissions = await apiPermissionAssignments.getByUserId(req.user!.userId); res.json(permissions); }, ); miscRouter.get("/:guildId", async (req: Request, res: Response) => { if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) { return unauthorized(res); } const guild = await allowedGuilds.find(req.params.guildId); res.json(guild); }); miscRouter.post("/:guildId/check-permission", async (req: Request, res: Response) => { const permission = req.body.permission; const hasPermission = await hasGuildPermission(req.user!.userId, req.params.guildId, permission); res.json({ result: hasPermission }); }); miscRouter.get( "/:guildId/config", requireGuildPermission(ApiPermissions.ReadConfig), async (req: Request, res: Response) => { const config = await configs.getActiveByKey(`guild-${req.params.guildId}`); res.json({ config: config ? config.config : "" }); }, ); miscRouter.post("/:guildId/config", requireGuildPermission(ApiPermissions.EditConfig), async (req, res) => { let config = req.body.config; if (config == null) return clientError(res, "No config supplied"); config = config.trim() + "\n"; // Normalize start/end whitespace in the config const currentConfig = await configs.getActiveByKey(`guild-${req.params.guildId}`); if (currentConfig && config === currentConfig.config) { return ok(res); } // Validate config let parsedConfig; try { parsedConfig = loadYamlSafely(config); } catch (e) { if (e instanceof YAMLException) { return res.status(400).json({ errors: [e.message] }); } if (e instanceof ObjectAliasError) { return res.status(400).json({ errors: [e.message] }); } // tslint:disable-next-line:no-console console.error("Error when loading YAML: " + e.message); return serverError(res, "Server error"); } if (parsedConfig == null) { parsedConfig = {}; } const error = await validateGuildConfig(parsedConfig); if (error) { return res.status(422).json({ errors: [error] }); } await configs.saveNewRevision(`guild-${req.params.guildId}`, config, req.user!.userId); ok(res); }); miscRouter.get( "/:guildId/permissions", requireGuildPermission(ApiPermissions.ManageAccess), async (req: Request, res: Response) => { const permissions = await apiPermissionAssignments.getByGuildId(req.params.guildId); res.json(permissions); }, ); const permissionManagementQueue = new Queue(); miscRouter.post( "/:guildId/set-target-permissions", requireGuildPermission(ApiPermissions.ManageAccess), async (req: Request, res: Response) => { await permissionManagementQueue.add(async () => { const { type, targetId, permissions, expiresAt } = req.body; if (type !== ApiPermissionTypes.User) { return clientError(res, "Invalid type"); } if (!isSnowflake(targetId)) { return clientError(res, "Invalid targetId"); } const validPermissions = new Set(Object.values(ApiPermissions)); validPermissions.delete(ApiPermissions.Owner); if (!Array.isArray(permissions) || permissions.some((p) => !validPermissions.has(p))) { return clientError(res, "Invalid permissions"); } if (expiresAt != null && !moment.utc(expiresAt).isValid()) { return clientError(res, "Invalid expiresAt"); } const existingAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId); if (existingAssignment && existingAssignment.permissions.includes(ApiPermissions.Owner)) { return clientError(res, "Can't change owner permissions"); } if (permissions.length === 0) { await apiPermissionAssignments.removeUser(req.params.guildId, targetId); await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.REMOVE_API_PERMISSION, { type: ApiPermissionTypes.User, target_id: targetId, }); } else { const existing = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId); if (existing) { await apiPermissionAssignments.updateUserPermissions(req.params.guildId, targetId, permissions); await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.EDIT_API_PERMISSION, { type: ApiPermissionTypes.User, target_id: targetId, permissions, expires_at: existing.expires_at, }); } else { await apiPermissionAssignments.addUser(req.params.guildId, targetId, permissions, expiresAt); await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.ADD_API_PERMISSION, { type: ApiPermissionTypes.User, target_id: targetId, permissions, expires_at: expiresAt, }); } } ok(res); }); }, ); router.use("/", miscRouter); } ================================================ FILE: backend/src/api/guilds.ts ================================================ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import express, { Request, Response } from "express"; import jsYaml from "js-yaml"; import moment from "moment-timezone"; import { Queue } from "../Queue.js"; import { validateGuildConfig } from "../configValidator.js"; import { AllowedGuilds } from "../data/AllowedGuilds.js"; import { ApiAuditLog } from "../data/ApiAuditLog.js"; import { ApiPermissionAssignments, ApiPermissionTypes } from "../data/ApiPermissionAssignments.js"; import { Configs } from "../data/Configs.js"; import { AuditLogEventTypes } from "../data/apiAuditLogTypes.js"; import { isSnowflake } from "../utils.js"; import { loadYamlSafely } from "../utils/loadYamlSafely.js"; import { ObjectAliasError } from "../utils/validateNoObjectAliases.js"; import { apiTokenAuthHandlers } from "./auth.js"; import { hasGuildPermission, requireGuildPermission } from "./permissions.js"; import { clientError, ok, serverError, unauthorized } from "./responses.js"; const YAMLException = jsYaml.YAMLException; const apiPermissionAssignments = new ApiPermissionAssignments(); const auditLog = new ApiAuditLog(); export function initGuildsAPI(app: express.Express) { const allowedGuilds = new AllowedGuilds(); const configs = new Configs(); const guildRouter = express.Router(); guildRouter.use(...apiTokenAuthHandlers()); guildRouter.get("/available", async (req: Request, res: Response) => { const guilds = await allowedGuilds.getForApiUser(req.user!.userId); res.json(guilds); }); guildRouter.get( "/my-permissions", // a async (req: Request, res: Response) => { const permissions = await apiPermissionAssignments.getByUserId(req.user!.userId); res.json(permissions); }, ); guildRouter.get("/:guildId", async (req: Request, res: Response) => { if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) { return unauthorized(res); } const guild = await allowedGuilds.find(req.params.guildId); res.json(guild); }); guildRouter.post("/:guildId/check-permission", async (req: Request, res: Response) => { const permission = req.body.permission; const hasPermission = await hasGuildPermission(req.user!.userId, req.params.guildId, permission); res.json({ result: hasPermission }); }); guildRouter.get( "/:guildId/config", requireGuildPermission(ApiPermissions.ReadConfig), async (req: Request, res: Response) => { const config = await configs.getActiveByKey(`guild-${req.params.guildId}`); res.json({ config: config ? config.config : "" }); }, ); guildRouter.post("/:guildId/config", requireGuildPermission(ApiPermissions.EditConfig), async (req, res) => { let config = req.body.config; if (config == null) return clientError(res, "No config supplied"); config = config.trim() + "\n"; // Normalize start/end whitespace in the config const currentConfig = await configs.getActiveByKey(`guild-${req.params.guildId}`); if (currentConfig && config === currentConfig.config) { return ok(res); } // Validate config let parsedConfig; try { parsedConfig = loadYamlSafely(config); } catch (e) { if (e instanceof YAMLException) { return res.status(400).json({ errors: [e.message] }); } if (e instanceof ObjectAliasError) { return res.status(400).json({ errors: [e.message] }); } // tslint:disable-next-line:no-console console.error("Error when loading YAML: " + e.message); return serverError(res, "Server error"); } if (parsedConfig == null) { parsedConfig = {}; } const error = await validateGuildConfig(parsedConfig); if (error) { return res.status(422).json({ errors: [error] }); } await configs.saveNewRevision(`guild-${req.params.guildId}`, config, req.user!.userId); ok(res); }); guildRouter.get( "/:guildId/permissions", requireGuildPermission(ApiPermissions.ManageAccess), async (req: Request, res: Response) => { const permissions = await apiPermissionAssignments.getByGuildId(req.params.guildId); res.json(permissions); }, ); const permissionManagementQueue = new Queue(); guildRouter.post( "/:guildId/set-target-permissions", requireGuildPermission(ApiPermissions.ManageAccess), async (req: Request, res: Response) => { await permissionManagementQueue.add(async () => { const { type, targetId, permissions, expiresAt } = req.body; if (type !== ApiPermissionTypes.User) { return clientError(res, "Invalid type"); } if (!isSnowflake(targetId) || targetId === req.user!.userId) { return clientError(res, "Invalid targetId"); } const validPermissions = new Set(Object.values(ApiPermissions)); validPermissions.delete(ApiPermissions.Owner); if (!Array.isArray(permissions) || permissions.some((p) => !validPermissions.has(p))) { return clientError(res, "Invalid permissions"); } if (expiresAt != null && !moment.utc(expiresAt).isValid()) { return clientError(res, "Invalid expiresAt"); } const existingAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId); if (existingAssignment && existingAssignment.permissions.includes(ApiPermissions.Owner)) { return clientError(res, "Can't change owner permissions"); } if (permissions.length === 0) { await apiPermissionAssignments.removeUser(req.params.guildId, targetId); await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.REMOVE_API_PERMISSION, { type: ApiPermissionTypes.User, target_id: targetId, }); } else { const existing = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId); if (existing) { await apiPermissionAssignments.updateUserPermissions(req.params.guildId, targetId, permissions); await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.EDIT_API_PERMISSION, { type: ApiPermissionTypes.User, target_id: targetId, permissions, expires_at: existing.expires_at, }); } else { await apiPermissionAssignments.addUser(req.params.guildId, targetId, permissions, expiresAt); await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.ADD_API_PERMISSION, { type: ApiPermissionTypes.User, target_id: targetId, permissions, expires_at: expiresAt, }); } } ok(res); }); }, ); app.use("/guilds", guildRouter); } ================================================ FILE: backend/src/api/index.ts ================================================ // KEEP THIS AS FIRST IMPORT // See comment in module for details import "../threadsSignalFix.js"; import { connect } from "../data/db.js"; import { env } from "../env.js"; import { setIsAPI } from "../globals.js"; if (!env.KEY) { // tslint:disable-next-line:no-console console.error("Project root .env with KEY is required!"); process.exit(1); } function errorHandler(err) { console.error(err.stack || err); // tslint:disable-line:no-console process.exit(1); } process.on("unhandledRejection", errorHandler); setIsAPI(true); // Connect to the database before loading the rest of the code (that depend on the database connection) console.log("Connecting to database..."); // tslint:disable-line connect().then(() => { import("./start.js"); }); ================================================ FILE: backend/src/api/permissions.ts ================================================ import { ApiPermissions, hasPermission, permissionArrToSet } from "@zeppelinbot/shared/apiPermissions.js"; import { Request, Response } from "express"; import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments.js"; import { isStaff } from "../staff.js"; import { unauthorized } from "./responses.js"; const apiPermissionAssignments = new ApiPermissionAssignments(); export const hasGuildPermission = async (userId: string, guildId: string, permission: ApiPermissions) => { if (isStaff(userId)) { return true; } const permAssignment = await apiPermissionAssignments.getByGuildAndUserId(guildId, userId); if (!permAssignment) { return false; } return hasPermission(permissionArrToSet(permAssignment.permissions), permission); }; /** * Requires `guildId` in req.params */ export function requireGuildPermission(permission: ApiPermissions) { return async (req: Request, res: Response, next) => { if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, permission))) { return unauthorized(res); } next(); }; } ================================================ FILE: backend/src/api/rateLimits.ts ================================================ import { Request, Response } from "express"; import { error } from "./responses.js"; const lastRequestsByKey: Map = new Map(); export function rateLimit(getKey: (req: Request) => string, limitMs: number, message = "Rate limited") { return async (req: Request, res: Response, next) => { const key = getKey(req); if (lastRequestsByKey.has(key)) { if (lastRequestsByKey.get(key)! > Date.now() - limitMs) { return error(res, message, 429); } } lastRequestsByKey.set(key, Date.now()); next(); }; } ================================================ FILE: backend/src/api/responses.ts ================================================ import { Response } from "express"; export function unauthorized(res: Response) { res.status(403).json({ error: "Unauthorized" }); } export function error(res: Response, message: string, statusCode = 500) { res.status(statusCode).json({ error: message }); } export function serverError(res: Response, message = "Server error") { error(res, message, 500); } export function clientError(res: Response, message: string) { error(res, message, 400); } export function notFound(res: Response) { res.status(404).json({ error: "Not found" }); } export function ok(res: Response) { res.json({ result: "ok" }); } ================================================ FILE: backend/src/api/staff.ts ================================================ import express, { Request, Response } from "express"; import { isStaff } from "../staff.js"; import { apiTokenAuthHandlers } from "./auth.js"; export function initStaff(app: express.Express) { const staffRouter = express.Router(); staffRouter.use(...apiTokenAuthHandlers()); staffRouter.get("/status", (req: Request, res: Response) => { const userIsStaff = isStaff(req.user!.userId); res.json({ isStaff: userIsStaff }); }); app.use("/staff", staffRouter); } ================================================ FILE: backend/src/api/start.ts ================================================ import cors from "cors"; import express from "express"; import multer from "multer"; import { TokenError } from "passport-oauth2"; import { env } from "../env.js"; import { initArchives } from "./archives.js"; import { initAuth } from "./auth.js"; import { initDocs } from "./docs.js"; import { initGuildsAPI } from "./guilds/index.js"; import { clientError, error, notFound } from "./responses.js"; import { startBackgroundTasks } from "./tasks.js"; const apiPathPrefix = env.API_PATH_PREFIX || (env.NODE_ENV === "development" ? "/api" : ""); const app = express(); app.use( cors({ origin: env.DASHBOARD_URL, }), ); app.use( express.json({ limit: "50mb", }), ); app.use(multer().none()); const rootRouter = express.Router(); initAuth(rootRouter); initGuildsAPI(rootRouter); initArchives(rootRouter); initDocs(rootRouter); // Default route rootRouter.get("/", (req, res) => { res.json({ status: "cookies", with: "milk" }); }); app.use(apiPathPrefix, rootRouter); // Error response // eslint-disable-next-line @typescript-eslint/no-unused-vars app.use((err, req, res, next) => { if (err instanceof TokenError) { clientError(res, "Invalid code"); } else { console.error(err); // tslint:disable-line error(res, "Server error", err.status || 500); } }); // 404 response // eslint-disable-next-line @typescript-eslint/no-unused-vars app.use((req, res, next) => { return notFound(res); }); const port = 3001; app.listen(port, "0.0.0.0", () => console.log(`API server listening on port ${port}`)); // tslint:disable-line startBackgroundTasks(); ================================================ FILE: backend/src/api/tasks.ts ================================================ import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments.js"; import { MINUTES } from "../utils.js"; export function startBackgroundTasks() { // Clear expired API permissions every minute const apiPermissions = new ApiPermissionAssignments(); setInterval(() => { apiPermissions.clearExpiredPermissions(); }, 1 * MINUTES); } ================================================ FILE: backend/src/commandTypes.ts ================================================ import { escapeCodeBlock, escapeInlineCode, GuildChannel, GuildMember, GuildTextBasedChannel, Snowflake, User, } from "discord.js"; import { baseCommandParameterTypeHelpers, CommandContext, messageCommandBaseTypeConverters, TypeConversionError, } from "vety"; import { createTypeHelper } from "knub-command-manager"; import { channelMentionRegex, convertDelayStringToMS, inputPatternToRegExp, isValidSnowflake, resolveMember, resolveUser, resolveUserId, roleMentionRegex, UnknownUser, } from "./utils.js"; import { isValidTimezone } from "./utils/isValidTimezone.js"; import { MessageTarget, resolveMessageTarget } from "./utils/resolveMessageTarget.js"; export const commandTypes = { ...messageCommandBaseTypeConverters, delay(value) { const result = convertDelayStringToMS(value); if (result == null) { throw new TypeConversionError(`Could not convert ${value} to a delay`); } return result; }, async resolvedUser(value, context: CommandContext) { const result = await resolveUser(context.pluginData.client, value, "commandTypes:resolvedUser"); if (result == null || result instanceof UnknownUser) { throw new TypeConversionError(`User \`${escapeCodeBlock(value)}\` was not found`); } return result; }, async resolvedUserLoose(value, context: CommandContext) { const result = await resolveUser(context.pluginData.client, value, "commandTypes:resolvedUserLoose"); if (result == null) { throw new TypeConversionError(`Invalid user: \`${escapeCodeBlock(value)}\``); } return result; }, async resolvedMember(value, context: CommandContext) { if (!(context.message.channel instanceof GuildChannel)) { throw new TypeConversionError(`Cannot resolve member for non-guild channels`); } const result = await resolveMember(context.pluginData.client, context.message.channel.guild, value); if (result == null) { throw new TypeConversionError(`Member \`${escapeCodeBlock(value)}\` was not found or they have left the server`); } return result; }, async messageTarget(value: string, context: CommandContext) { value = String(value).trim(); const result = await resolveMessageTarget(context.pluginData, value); if (!result) { throw new TypeConversionError(`Unknown message \`${escapeInlineCode(value)}\``); } return result; }, async anyId(value: string, context: CommandContext) { const userId = resolveUserId(context.pluginData.client, value); if (userId) return userId as Snowflake; const channelIdMatch = value.match(channelMentionRegex); if (channelIdMatch) return channelIdMatch[1] as Snowflake; const roleIdMatch = value.match(roleMentionRegex); if (roleIdMatch) return roleIdMatch[1] as Snowflake; if (isValidSnowflake(value)) { return value as Snowflake; } throw new TypeConversionError(`Could not parse ID: \`${escapeInlineCode(value)}\``); }, regex(value: string): RegExp { try { return inputPatternToRegExp(value); } catch (e) { throw new TypeConversionError(`Could not parse RegExp: \`${escapeInlineCode(e.message)}\``); } }, timezone(value: string) { if (!isValidTimezone(value)) { throw new TypeConversionError(`Invalid timezone: ${escapeInlineCode(value)}`); } return value; }, guildTextBasedChannel(value: string, context: CommandContext) { return messageCommandBaseTypeConverters.textChannel(value, context); }, }; export const commandTypeHelpers = { ...baseCommandParameterTypeHelpers, delay: createTypeHelper(commandTypes.delay), resolvedUser: createTypeHelper>(commandTypes.resolvedUser), resolvedUserLoose: createTypeHelper>(commandTypes.resolvedUserLoose), resolvedMember: createTypeHelper>(commandTypes.resolvedMember), messageTarget: createTypeHelper>(commandTypes.messageTarget), anyId: createTypeHelper>(commandTypes.anyId), regex: createTypeHelper(commandTypes.regex), timezone: createTypeHelper(commandTypes.timezone), guildTextBasedChannel: createTypeHelper(commandTypes.guildTextBasedChannel), }; ================================================ FILE: backend/src/configValidator.ts ================================================ import { BaseConfig, ConfigValidationError, GuildPluginBlueprint, PluginConfigManager } from "vety"; import { z, ZodError } from "zod"; import { availableGuildPlugins } from "./plugins/availablePlugins.js"; import { zZeppelinGuildConfig } from "./types.js"; import { formatZodIssue } from "./utils/formatZodIssue.js"; const pluginNameToPlugin = new Map>(); for (const pluginInfo of availableGuildPlugins) { pluginNameToPlugin.set(pluginInfo.plugin.name, pluginInfo.plugin); } export async function validateGuildConfig(config: any): Promise { const validationResult = zZeppelinGuildConfig.safeParse(config); if (!validationResult.success) { return validationResult.error.issues.map(formatZodIssue).join("\n"); } const guildConfig = config as BaseConfig; if (guildConfig.plugins) { for (const [pluginName, pluginOptions] of Object.entries(guildConfig.plugins)) { if (!pluginNameToPlugin.has(pluginName)) { return `Unknown plugin: ${pluginName}`; } if (typeof pluginOptions !== "object" || pluginOptions == null) { return `Invalid options specified for plugin ${pluginName}`; } const plugin = pluginNameToPlugin.get(pluginName)!; const configManager = new PluginConfigManager(pluginOptions, { configSchema: plugin.configSchema, defaultOverrides: plugin.defaultOverrides ?? [], levels: {}, customOverrideCriteriaFunctions: plugin.customOverrideCriteriaFunctions, }); try { await configManager.init(); } catch (err) { if (err instanceof ZodError) { return `${pluginName}:\n${z.prettifyError(err)}`; } if (err instanceof ConfigValidationError) { return `${pluginName}: ${err.message}`; } throw err; } } } return null; } ================================================ FILE: backend/src/data/AllowedGuilds.ts ================================================ import moment from "moment-timezone"; import { Repository } from "typeorm"; import { DBDateFormat } from "../utils.js"; import { ApiPermissionTypes } from "./ApiPermissionAssignments.js"; import { BaseRepository } from "./BaseRepository.js"; import { dataSource } from "./dataSource.js"; import { AllowedGuild } from "./entities/AllowedGuild.js"; export class AllowedGuilds extends BaseRepository { private allowedGuilds: Repository; constructor() { super(); this.allowedGuilds = dataSource.getRepository(AllowedGuild); } async isAllowed(guildId: string) { const count = await this.allowedGuilds.count({ where: { id: guildId, }, }); return count !== 0; } find(guildId: string) { return this.allowedGuilds.findOne({ where: { id: guildId, }, }); } getForApiUser(userId: string) { return this.allowedGuilds .createQueryBuilder("allowed_guilds") .innerJoin( "api_permissions", "api_permissions", "api_permissions.guild_id = allowed_guilds.id AND api_permissions.type = :type AND api_permissions.target_id = :userId", { type: ApiPermissionTypes.User, userId }, ) .getMany(); } updateInfo(id, name, icon, ownerId) { return this.allowedGuilds.update( { id }, { name, icon, owner_id: ownerId, updated_at: moment.utc().format(DBDateFormat) }, ); } add(id, data: Partial> = {}) { return this.allowedGuilds.insert({ name: "Server", icon: null, owner_id: "0", ...data, id, }); } remove(id) { return this.allowedGuilds.delete({ id }); } } ================================================ FILE: backend/src/data/ApiAuditLog.ts ================================================ import { Repository } from "typeorm"; import { BaseRepository } from "./BaseRepository.js"; import { AuditLogEventData, AuditLogEventType } from "./apiAuditLogTypes.js"; import { dataSource } from "./dataSource.js"; import { ApiAuditLogEntry } from "./entities/ApiAuditLogEntry.js"; export class ApiAuditLog extends BaseRepository { private auditLog: Repository>; constructor() { super(); this.auditLog = dataSource.getRepository(ApiAuditLogEntry); } addEntry( guildId: string, authorId: string, eventType: TEventType, eventData: AuditLogEventData[TEventType], ) { this.auditLog.insert({ guild_id: guildId, author_id: authorId, event_type: eventType as any, event_data: eventData as any, }); } } ================================================ FILE: backend/src/data/ApiLogins.ts ================================================ import crypto from "crypto"; import moment from "moment-timezone"; import { Repository } from "typeorm"; // tslint:disable-next-line:no-submodule-imports import { v4 as uuidv4 } from "uuid"; import { DAYS, DBDateFormat } from "../utils.js"; import { BaseRepository } from "./BaseRepository.js"; import { dataSource } from "./dataSource.js"; import { ApiLogin } from "./entities/ApiLogin.js"; const LOGIN_EXPIRY_TIME = 1 * DAYS; export class ApiLogins extends BaseRepository { private apiLogins: Repository; constructor() { super(); this.apiLogins = dataSource.getRepository(ApiLogin); } async getUserIdByApiKey(apiKey: string): Promise { const [loginId, token] = apiKey.split("."); if (!loginId || !token) { return null; } const login = await this.apiLogins .createQueryBuilder() .where("id = :id", { id: loginId }) .andWhere("expires_at > NOW()") .getOne(); if (!login) { return null; } const hash = crypto.createHash("sha256"); hash.update(loginId + token); // Remember to use loginId as the salt const hashedToken = hash.digest("hex"); if (hashedToken !== login.token) { return null; } return login.user_id; } async addLogin(userId: string): Promise { // Generate random login id let loginId; while (true) { loginId = uuidv4(); const existing = await this.apiLogins.findOne({ where: { id: loginId, }, }); if (!existing) break; } // Generate token const token = uuidv4(); const hash = crypto.createHash("sha256"); hash.update(loginId + token); // Use loginId as a salt const hashedToken = hash.digest("hex"); // Save this to the DB await this.apiLogins.insert({ id: loginId, token: hashedToken, user_id: userId, logged_in_at: moment.utc().format(DBDateFormat), expires_at: moment.utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat), }); return `${loginId}.${token}`; } expireApiKey(apiKey) { const [loginId, token] = apiKey.split("."); if (!loginId || !token) return; return this.apiLogins.update( { id: loginId }, { expires_at: moment.utc().format(DBDateFormat), }, ); } async refreshApiKeyExpiryTime(apiKey) { const [loginId, token] = apiKey.split("."); if (!loginId || !token) return; const updatedTime = moment().utc().add(LOGIN_EXPIRY_TIME, "ms"); const login = await this.apiLogins.createQueryBuilder().where("id = :id", { id: loginId }).getOne(); if (!login || moment.utc(login.expires_at).isSameOrAfter(updatedTime)) return; await this.apiLogins.update( { id: loginId }, { expires_at: updatedTime.format(DBDateFormat), }, ); } } ================================================ FILE: backend/src/data/ApiPermissionAssignments.ts ================================================ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import { Repository } from "typeorm"; import { ApiAuditLog } from "./ApiAuditLog.js"; import { BaseRepository } from "./BaseRepository.js"; import { AuditLogEventTypes } from "./apiAuditLogTypes.js"; import { dataSource } from "./dataSource.js"; import { ApiPermissionAssignment } from "./entities/ApiPermissionAssignment.js"; export enum ApiPermissionTypes { User = "USER", Role = "ROLE", } export class ApiPermissionAssignments extends BaseRepository { private apiPermissions: Repository; private auditLogs: ApiAuditLog; constructor() { super(); this.apiPermissions = dataSource.getRepository(ApiPermissionAssignment); this.auditLogs = new ApiAuditLog(); } getByGuildId(guildId) { return this.apiPermissions.find({ where: { guild_id: guildId, }, }); } getByUserId(userId) { return this.apiPermissions.find({ where: { type: ApiPermissionTypes.User, target_id: userId, }, }); } getByGuildAndUserId(guildId, userId) { return this.apiPermissions.findOne({ where: { guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId, }, }); } addUser(guildId, userId, permissions: ApiPermissions[], expiresAt: string | null = null) { return this.apiPermissions.insert({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId, permissions, expires_at: expiresAt, }); } removeUser(guildId, userId) { return this.apiPermissions.delete({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId }); } async updateUserPermissions(guildId: string, userId: string, permissions: ApiPermissions[]): Promise { await this.apiPermissions.update( { guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId, }, { permissions, }, ); } async clearExpiredPermissions() { await this.apiPermissions .createQueryBuilder() .where("expires_at IS NOT NULL") .andWhere("expires_at <= NOW()") .delete() .execute(); } async applyOwnerChange(guildId: string, newOwnerId: string) { const existingPermissions = await this.getByGuildId(guildId); let updatedOwner = false; for (const perm of existingPermissions) { let hasChanges = false; // Remove owner permission from anyone who currently has it if (perm.permissions.includes(ApiPermissions.Owner)) { perm.permissions.splice(perm.permissions.indexOf(ApiPermissions.Owner), 1); hasChanges = true; } // Add owner permission if we encounter the new owner if (perm.type === ApiPermissionTypes.User && perm.target_id === newOwnerId) { perm.permissions.push(ApiPermissions.Owner); updatedOwner = true; hasChanges = true; } if (hasChanges) { const criteria = { guild_id: perm.guild_id, type: perm.type, target_id: perm.target_id, }; if (perm.permissions.length === 0) { // No remaining permissions -> remove entry this.auditLogs.addEntry(guildId, "0", AuditLogEventTypes.REMOVE_API_PERMISSION, { type: perm.type, target_id: perm.target_id, }); await this.apiPermissions.delete(criteria); } else { this.auditLogs.addEntry(guildId, "0", AuditLogEventTypes.EDIT_API_PERMISSION, { type: perm.type, target_id: perm.target_id, permissions: perm.permissions, expires_at: perm.expires_at, }); await this.apiPermissions.update(criteria, { permissions: perm.permissions, }); } } } if (!updatedOwner) { this.auditLogs.addEntry(guildId, "0", AuditLogEventTypes.ADD_API_PERMISSION, { type: ApiPermissionTypes.User, target_id: newOwnerId, permissions: [ApiPermissions.Owner], expires_at: null, }); await this.apiPermissions.insert({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: newOwnerId, permissions: [ApiPermissions.Owner], }); } } } ================================================ FILE: backend/src/data/ApiUserInfo.ts ================================================ import moment from "moment-timezone"; import { Repository } from "typeorm"; import { DBDateFormat } from "../utils.js"; import { BaseRepository } from "./BaseRepository.js"; import { dataSource } from "./dataSource.js"; import { ApiUserInfoData, ApiUserInfo as ApiUserInfoEntity } from "./entities/ApiUserInfo.js"; export class ApiUserInfo extends BaseRepository { private apiUserInfo: Repository; constructor() { super(); this.apiUserInfo = dataSource.getRepository(ApiUserInfoEntity); } get(id) { return this.apiUserInfo.findOne({ where: { id, }, }); } update(id, data: ApiUserInfoData) { return dataSource.transaction(async (entityManager) => { const repo = entityManager.getRepository(ApiUserInfoEntity); const existingInfo = await repo.findOne({ where: { id } }); const updatedAt = moment.utc().format(DBDateFormat); if (existingInfo) { await repo.update({ id }, { data, updated_at: updatedAt }); } else { await repo.insert({ id, data, updated_at: updatedAt }); } }); } } ================================================ FILE: backend/src/data/Archives.ts ================================================ import { Repository } from "typeorm"; import { BaseRepository } from "./BaseRepository.js"; import { dataSource } from "./dataSource.js"; import { ArchiveEntry } from "./entities/ArchiveEntry.js"; export class Archives extends BaseRepository { protected archives: Repository; constructor() { super(); this.archives = dataSource.getRepository(ArchiveEntry); } public deleteExpiredArchives() { this.archives .createQueryBuilder() .andWhere("expires_at IS NOT NULL") .andWhere("expires_at <= NOW()") .delete() .execute(); } } ================================================ FILE: backend/src/data/BaseGuildRepository.ts ================================================ import { BaseRepository } from "./BaseRepository.js"; export class BaseGuildRepository extends BaseRepository { private static guildInstances: Map; protected guildId: string; constructor(guildId: string) { super(); this.guildId = guildId; } /** * Returns a cached instance of the inheriting class for the specified guildId, * or creates a new instance if one doesn't exist yet */ public static getGuildInstance(this: T, guildId: string): InstanceType { if (!this.guildInstances) { this.guildInstances = new Map(); } if (!this.guildInstances.has(guildId)) { this.guildInstances.set(guildId, new this(guildId)); } return this.guildInstances.get(guildId) as InstanceType; } } ================================================ FILE: backend/src/data/BaseRepository.ts ================================================ import { asyncMap } from "../utils/async.js"; export class BaseRepository { private nextRelations: string[]; constructor() { this.nextRelations = []; } /** * Primes the specified relation(s) to be used in the next database operation. * Can be chained. */ public with(relations: string | string[]): this { if (Array.isArray(relations)) { this.nextRelations.push(...relations); } else { this.nextRelations.push(relations); } return this; } /** * Gets and resets the relations primed using with() */ protected getRelations(): string[] { const relations = this.nextRelations || []; this.nextRelations = []; return relations; } protected async _processEntityFromDB(entity) { // No-op, override in repository return entity; } protected async _processEntityToDB(entity) { // No-op, override in repository return entity; } protected async processEntityFromDB(entity: T): Promise { return this._processEntityFromDB(entity); } protected async processMultipleEntitiesFromDB(entities: TArr): Promise { return asyncMap(entities, (entity) => this.processEntityFromDB(entity)) as Promise; } protected async processEntityToDB>(entity: T): Promise { return this._processEntityToDB(entity); } } ================================================ FILE: backend/src/data/CaseTypes.ts ================================================ export enum CaseTypes { Ban = 1, Unban, Note, Warn, Kick, Mute, Unmute, Deleted, Softban, } export const CaseNameToType = { ban: CaseTypes.Ban, unban: CaseTypes.Unban, note: CaseTypes.Note, warn: CaseTypes.Warn, kick: CaseTypes.Kick, mute: CaseTypes.Mute, unmute: CaseTypes.Unmute, deleted: CaseTypes.Deleted, softban: CaseTypes.Softban, }; export const CaseTypeToName = Object.entries(CaseNameToType).reduce((map, [name, type]) => { map[type] = name; return map; }, {}) as Record; ================================================ FILE: backend/src/data/Configs.ts ================================================ import { Repository } from "typeorm"; import { isAPI } from "../globals.js"; import { HOURS, SECONDS } from "../utils.js"; import { BaseRepository } from "./BaseRepository.js"; import { cleanupConfigs } from "./cleanup/configs.js"; import { dataSource } from "./dataSource.js"; import { Config } from "./entities/Config.js"; const CLEANUP_INTERVAL = 1 * HOURS; async function cleanup() { await cleanupConfigs(); setTimeout(cleanup, CLEANUP_INTERVAL); } if (isAPI()) { // Start first cleanup 30 seconds after startup // TODO: Move to bot startup code setTimeout(cleanup, 30 * SECONDS); } export class Configs extends BaseRepository { private configs: Repository; constructor() { super(); this.configs = dataSource.getRepository(Config); } getActive() { return this.configs.find({ where: { is_active: true }, }); } getActiveByKey(key) { return this.configs.findOne({ where: { key, is_active: true, }, }); } async getHighestId(): Promise { const rows = await dataSource.query("SELECT MAX(id) AS highest_id FROM configs"); return (rows.length && rows[0].highest_id) || 0; } getActiveLargerThanId(id) { return this.configs.createQueryBuilder().where("id > :id", { id }).andWhere("is_active = 1").getMany(); } async hasConfig(key) { return (await this.getActiveByKey(key)) != null; } getRevisions(key, num = 10) { return this.configs.find({ relations: this.getRelations(), where: { key }, select: ["id", "key", "is_active", "edited_by", "edited_at"], order: { edited_at: "DESC", }, take: num, }); } async saveNewRevision(key, config, editedBy) { return dataSource.transaction(async (entityManager) => { const repo = entityManager.getRepository(Config); // Mark all old revisions inactive await repo.update({ key }, { is_active: false }); // Add new, active revision await repo.insert({ key, config, is_active: true, edited_by: editedBy, }); }); } } ================================================ FILE: backend/src/data/DefaultLogMessages.json ================================================ { "MEMBER_NOTE": "{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}", "MEMBER_WARN": "{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}", "MEMBER_MUTE": "{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}", "MEMBER_TIMED_MUTE": "{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}", "MEMBER_UNMUTE": "{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}", "MEMBER_TIMED_UNMUTE": "{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}", "MEMBER_MUTE_EXPIRED": "{timestamp} 🔊 {userMention(member)}'s mute expired", "MEMBER_KICK": "{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}", "MEMBER_BAN": "{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}", "MEMBER_UNBAN": "{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}", "MEMBER_FORCEBAN": "{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}", "MEMBER_SOFTBAN": "{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}", "MEMBER_JOIN": "{timestamp} 📥 {new} {userMention(member)} joined (created )", "MEMBER_LEAVE": "{timestamp} 📤 {userMention(member)} left the server", "MEMBER_ROLE_ADD": "{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**", "MEMBER_ROLE_REMOVE": "{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**", "MEMBER_ROLE_CHANGES": "{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**", "MEMBER_NICK_CHANGE": "{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", "MEMBER_USERNAME_CHANGE": "{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", "MEMBER_RESTORE": "{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin", "MEMBER_TIMED_BAN": "{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}", "MEMBER_TIMED_UNBAN": "{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}", "CHANNEL_CREATE": "{timestamp} 🖊 Channel {channelMention(channel)} was created", "CHANNEL_DELETE": "{timestamp} 🗑 Channel {channelMention(channel)} was deleted", "CHANNEL_UPDATE": "{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\n{differenceString}", "THREAD_CREATE": "{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>", "THREAD_DELETE": "{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>", "THREAD_UPDATE": "{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\n{differenceString}", "ROLE_CREATE": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created", "ROLE_DELETE": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted", "ROLE_UPDATE": "{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\n{differenceString}", "MESSAGE_EDIT": "{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}", "MESSAGE_DELETE": "{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}", "MESSAGE_DELETE_BULK": "{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})", "MESSAGE_DELETE_BARE": "{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)", "MESSAGE_DELETE_AUTO": "{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}", "VOICE_CHANNEL_JOIN": "{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}", "VOICE_CHANNEL_MOVE": "{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}", "VOICE_CHANNEL_LEAVE": "{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}", "VOICE_CHANNEL_FORCE_MOVE": "{timestamp} \uD83C\uDF99 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}", "VOICE_CHANNEL_FORCE_DISCONNECT": "{timestamp} \uD83C\uDF99 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}", "STAGE_INSTANCE_CREATE": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>", "STAGE_INSTANCE_DELETE": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>", "STAGE_INSTANCE_UPDATE": "{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}", "EMOJI_CREATE": "{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created", "EMOJI_DELETE": "{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted", "EMOJI_UPDATE": "{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}", "STICKER_CREATE": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}", "STICKER_DELETE": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.", "STICKER_UPDATE": "{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\n{differenceString}", "COMMAND": "{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`", "MESSAGE_SPAM_DETECTED": "{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\n{archiveUrl}", "OTHER_SPAM_DETECTED": "{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)", "CENSOR": "{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\n```{messageText}```", "CLEAN": "{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\n{archiveUrl}", "CASE_CREATE": "{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})", "CASE_DELETE": "{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}", "MASSUNBAN": "{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users", "MASSBAN": "{timestamp} ⚒ {userMention(mod)} massbanned {count} users", "MASSMUTE": "{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users", "MEMBER_JOIN_WITH_PRIOR_RECORDS": "{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}", "CASE_UPDATE": "{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```", "MEMBER_MUTE_REJOIN": "{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin", "SCHEDULED_MESSAGE": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}", "SCHEDULED_REPEATED_MESSAGE": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}", "REPEATED_MESSAGE": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}", "POSTED_SCHEDULED_MESSAGE": "{timestamp} \uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", "BOT_ALERT": "{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}", "DM_FAILED": "{timestamp} \uD83D\uDEA7 Failed to send DM ({source}) to {userMention(user)}", "AUTOMOD_ACTION": "{timestamp} \uD83E\uDD16 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\n{matchSummary}\nActions taken: **{actionsTaken}**", "SET_ANTIRAID_USER": "{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**", "SET_ANTIRAID_AUTO": "{timestamp} ⚔ Anti-raid automatically set to **{level}**" } ================================================ FILE: backend/src/data/FishFish.ts ================================================ import { z } from "zod"; import { env } from "../env.js"; import { HOURS, MINUTES, SECONDS } from "../utils.js"; const API_ROOT = "https://api.fishfish.gg/v1"; const zDomainCategory = z.literal(["safe", "malware", "phishing"]); const zDomain = z.object({ name: z.string(), category: zDomainCategory, description: z.string(), added: z.number(), checked: z.number(), }); export type FishFishDomain = z.output; const FULL_REFRESH_INTERVAL = 6 * HOURS; const domains = new Map(); let sessionTokenPromise: Promise | null = null; const WS_RECONNECT_DELAY = 30 * SECONDS; let updatesWs: WebSocket | null = null; export class FishFishError extends Error {} const zTokenResponse = z.object({ expires: z.number(), token: z.string(), }); async function getSessionToken(): Promise { if (sessionTokenPromise) { return sessionTokenPromise; } const apiKey = env.FISHFISH_API_KEY; if (!apiKey) { throw new FishFishError("FISHFISH_API_KEY is missing"); } sessionTokenPromise = (async () => { const response = await fetch(`${API_ROOT}/users/@me/tokens`, { method: "POST", headers: { Authorization: apiKey, "Content-Type": "application/json", }, }); if (!response.ok) { throw new FishFishError(`Failed to get session token: ${response.status} ${response.statusText}`); } const parseResult = zTokenResponse.safeParse(await response.json()); if (!parseResult.success) { throw new FishFishError(`Parse error when fetching session token: ${parseResult.error.message}`); } const timeUntilExpiry = parseResult.data.expires * 1000 - Date.now(); setTimeout( () => { sessionTokenPromise = null; }, timeUntilExpiry - 1 * MINUTES, ); // Subtract a minute to ensure we refresh before expiry return parseResult.data.token; })(); sessionTokenPromise.catch((err) => { sessionTokenPromise = null; throw err; }); return sessionTokenPromise; } async function fishFishApiCall(method: string, path: string, query: Record = {}): Promise { const sessionToken = await getSessionToken(); const queryParams = new URLSearchParams(query); const response = await fetch(`https://api.fishfish.gg/v1/${path}?${queryParams}`, { method, headers: { Authorization: sessionToken, "Content-Type": "application/json", }, }); if (!response.ok) { throw new FishFishError(`FishFish API call failed: ${response.status} ${response.statusText}`); } return response.json(); } async function refreshFishFishDomains() { const rawData = await fishFishApiCall("GET", "domains", { full: "true" }); const parseResult = z.array(zDomain).safeParse(rawData); if (!parseResult.success) { throw new FishFishError(`Parse error when refreshing domains: ${parseResult.error.message}`); } domains.clear(); for (const domain of parseResult.data) { domains.set(domain.name, domain); } domains.set("malware-link.test.zeppelin.gg", { name: "malware-link.test.zeppelin.gg", category: "malware", description: "", added: Date.now(), checked: Date.now(), }); domains.set("phishing-link.test.zeppelin.gg", { name: "phishing-link.test.zeppelin.gg", category: "phishing", description: "", added: Date.now(), checked: Date.now(), }); domains.set("safe-link.test.zeppelin.gg", { name: "safe-link.test.zeppelin.gg", category: "safe", description: "", added: Date.now(), checked: Date.now(), }); console.log("[FISHFISH] Refreshed FishFish domains, total count:", domains.size); } export async function initFishFish() { if (!env.FISHFISH_API_KEY) { console.warn("[FISHFISH] FISHFISH_API_KEY is not set, FishFish functionality will be disabled."); return; } await refreshFishFishDomains(); // Real-time updates disabled until we switch to a WebSocket lib that supports authorization headers // void subscribeToFishFishUpdates(); setInterval(() => refreshFishFishDomains(), FULL_REFRESH_INTERVAL); } export function getFishFishDomain(domain: string): FishFishDomain | undefined { return domains.get(domain.toLowerCase()); } ================================================ FILE: backend/src/data/GuildAntiraidLevels.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { AntiraidLevel } from "./entities/AntiraidLevel.js"; export class GuildAntiraidLevels extends BaseGuildRepository { protected antiraidLevels: Repository; constructor(guildId: string) { super(guildId); this.antiraidLevels = dataSource.getRepository(AntiraidLevel); } async get() { const row = await this.antiraidLevels.findOne({ where: { guild_id: this.guildId, }, }); return row?.level ?? null; } async set(level: string | null) { if (level === null) { await this.antiraidLevels.delete({ guild_id: this.guildId, }); } else { // Upsert: https://stackoverflow.com/a/47064558/316944 // But the MySQL version: https://github.com/typeorm/typeorm/issues/1090#issuecomment-634391487 await this.antiraidLevels .createQueryBuilder() .insert() .values({ guild_id: this.guildId, level, }) .orUpdate({ conflict_target: ["guild_id"], overwrite: ["level"], }) .execute(); } } } ================================================ FILE: backend/src/data/GuildArchives.ts ================================================ import { Guild, Snowflake } from "discord.js"; import moment from "moment-timezone"; import { Repository } from "typeorm"; import { TemplateSafeValueContainer, renderTemplate } from "../templateFormatter.js"; import { renderUsername, trimLines } from "../utils.js"; import { decrypt, encrypt } from "../utils/crypt.js"; import { isDefaultSticker } from "../utils/isDefaultSticker.js"; import { channelToTemplateSafeChannel, guildToTemplateSafeGuild } from "../utils/templateSafeObjects.js"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { ArchiveEntry } from "./entities/ArchiveEntry.js"; import { SavedMessage } from "./entities/SavedMessage.js"; const DEFAULT_EXPIRY_DAYS = 30; const MESSAGE_ARCHIVE_HEADER_FORMAT = trimLines(` Server: {guild.name} ({guild.id}) `); const MESSAGE_ARCHIVE_MESSAGE_FORMAT = "[#{channel.name}] [{user.id}] [{timestamp}] {username}: {content}{attachments}{stickers}"; export class GuildArchives extends BaseGuildRepository { protected archives: Repository; constructor(guildId) { super(guildId); this.archives = dataSource.getRepository(ArchiveEntry); } protected async _processEntityFromDB(entity: ArchiveEntry | undefined) { if (entity == null) { return entity; } entity.body = await decrypt(entity.body); return entity; } protected async _processEntityToDB(entity: Partial) { if (entity.body) { entity.body = await encrypt(entity.body); } return entity; } async find(id: string): Promise { const result = await this.archives.findOne({ where: { id }, relations: this.getRelations(), }); return this.processEntityFromDB(result); } async makePermanent(id: string): Promise { await this.archives.update( { id }, { expires_at: null, }, ); } /** * @return - ID of the created archive */ async create(body: string, expiresAt?: moment.Moment): Promise { if (!expiresAt) { expiresAt = moment.utc().add(DEFAULT_EXPIRY_DAYS, "days"); } const data = await this.processEntityToDB({ guild_id: this.guildId, body, expires_at: expiresAt.format("YYYY-MM-DD HH:mm:ss"), }); const result = await this.archives.insert(data); return result.identifiers[0].id; } protected async renderLinesFromSavedMessages(savedMessages: SavedMessage[], guild: Guild): Promise { const msgLines: string[] = []; for (const msg of savedMessages) { const channel = guild.channels.cache.get(msg.channel_id as Snowflake); const partialUser = new TemplateSafeValueContainer({ ...msg.data.author, id: msg.user_id }); const line = await renderTemplate( MESSAGE_ARCHIVE_MESSAGE_FORMAT, new TemplateSafeValueContainer({ id: msg.id, timestamp: moment.utc(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"), content: msg.data.content, attachments: msg.data.attachments?.map((att) => { return JSON.stringify({ name: att.name, url: att.url, type: att.contentType }); }), stickers: msg.data.stickers?.map((sti) => { return JSON.stringify({ name: sti.name, id: sti.id, isDefault: isDefaultSticker(sti.id) }); }), user: partialUser, channel: channel ? channelToTemplateSafeChannel(channel) : null, username: renderUsername(msg.data.author.username, msg.data.author.discriminator), }), ); msgLines.push(line); } return msgLines; } /** * @return - ID of the created archive */ async createFromSavedMessages( savedMessages: SavedMessage[], guild: Guild, expiresAt?: moment.Moment, ): Promise { if (expiresAt == null) { expiresAt = moment.utc().add(DEFAULT_EXPIRY_DAYS, "days"); } const headerStr = await renderTemplate( MESSAGE_ARCHIVE_HEADER_FORMAT, new TemplateSafeValueContainer({ guild: guildToTemplateSafeGuild(guild), }), ); const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild); const messagesStr = msgLines.join("\n"); return this.create([headerStr, messagesStr].join("\n\n"), expiresAt); } async addSavedMessagesToArchive(archiveId: string, savedMessages: SavedMessage[], guild: Guild) { const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild); const messagesStr = msgLines.join("\n"); let archive = await this.find(archiveId); if (archive == null) { throw new Error("Archive not found"); } archive.body += "\n" + messagesStr; archive = await this.processEntityToDB(archive); await this.archives.update({ id: archiveId }, { body: archive.body }); } getUrl(baseUrl, archiveId) { return baseUrl ? `${baseUrl}/archives/${archiveId}` : `Archive ID: ${archiveId}`; } } ================================================ FILE: backend/src/data/GuildAutoReactions.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { AutoReaction } from "./entities/AutoReaction.js"; export class GuildAutoReactions extends BaseGuildRepository { private autoReactions: Repository; constructor(guildId) { super(guildId); this.autoReactions = dataSource.getRepository(AutoReaction); } async all(): Promise { return this.autoReactions.find({ where: { guild_id: this.guildId, }, }); } async getForChannel(channelId: string): Promise { return this.autoReactions.findOne({ where: { guild_id: this.guildId, channel_id: channelId, }, }); } async removeFromChannel(channelId: string) { await this.autoReactions.delete({ guild_id: this.guildId, channel_id: channelId, }); } async set(channelId: string, reactions: string[]) { const existingRecord = await this.getForChannel(channelId); if (existingRecord) { this.autoReactions.update( { guild_id: this.guildId, channel_id: channelId, }, { reactions, }, ); } else { await this.autoReactions.insert({ guild_id: this.guildId, channel_id: channelId, reactions, }); } } } ================================================ FILE: backend/src/data/GuildButtonRoles.ts ================================================ import { getRepository, Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { ButtonRole } from "./entities/ButtonRole.js"; export class GuildButtonRoles extends BaseGuildRepository { private buttonRoles: Repository; constructor(guildId) { super(guildId); this.buttonRoles = getRepository(ButtonRole); } async getForButtonId(buttonId: string) { return this.buttonRoles.findOne({ where: { guild_id: this.guildId, button_id: buttonId, }, }); } async getAllForMessageId(messageId: string) { return this.buttonRoles.find({ where: { guild_id: this.guildId, message_id: messageId, }, }); } async removeForButtonId(buttonId: string) { return this.buttonRoles.delete({ guild_id: this.guildId, button_id: buttonId, }); } async removeAllForMessageId(messageId: string) { return this.buttonRoles.delete({ guild_id: this.guildId, message_id: messageId, }); } async getForButtonGroup(buttonGroup: string) { return this.buttonRoles.find({ where: { guild_id: this.guildId, button_group: buttonGroup, }, }); } async add(channelId: string, messageId: string, buttonId: string, buttonGroup: string, buttonName: string) { await this.buttonRoles.insert({ guild_id: this.guildId, channel_id: channelId, message_id: messageId, button_id: buttonId, button_group: buttonGroup, button_name: buttonName, }); } } ================================================ FILE: backend/src/data/GuildCases.ts ================================================ import { FindOptionsWhere, In, InsertResult, Repository } from "typeorm"; import { Queue } from "../Queue.js"; import { chunkArray } from "../utils.js"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { CaseTypes } from "./CaseTypes.js"; import { dataSource } from "./dataSource.js"; import { Case } from "./entities/Case.js"; import { CaseNote } from "./entities/CaseNote.js"; export class GuildCases extends BaseGuildRepository { private cases: Repository; private caseNotes: Repository; protected createQueue: Queue; constructor(guildId) { super(guildId); this.cases = dataSource.getRepository(Case); this.caseNotes = dataSource.getRepository(CaseNote); this.createQueue = new Queue(); } async get(ids: number[]): Promise { return this.cases.find({ relations: this.getRelations(), where: { guild_id: this.guildId, id: In(ids), }, }); } async find(id: number): Promise { return this.cases.findOne({ relations: this.getRelations(), where: { guild_id: this.guildId, id, }, }); } async findByCaseNumber(caseNumber: number): Promise { return this.cases.findOne({ relations: this.getRelations(), where: { guild_id: this.guildId, case_number: caseNumber, }, }); } async findLatestByModId(modId: string): Promise { return this.cases.findOne({ relations: this.getRelations(), where: { guild_id: this.guildId, mod_id: modId, }, order: { case_number: "DESC", }, }); } async findByAuditLogId(auditLogId: string): Promise { return this.cases.findOne({ relations: this.getRelations(), where: { guild_id: this.guildId, audit_log_id: auditLogId, }, }); } async getByUserId( userId: string, filters: Omit, "guild_id" | "user_id"> = {}, ): Promise { return this.cases.find({ relations: this.getRelations(), where: { guild_id: this.guildId, user_id: userId, ...filters, }, }); } async getRecentByUserId(userId: string, count: number, skip = 0): Promise { return this.cases.find({ relations: this.getRelations(), where: { guild_id: this.guildId, user_id: userId, }, skip, take: count, order: { case_number: "DESC", }, }); } async getTotalCasesByModId( modId: string, filters: Omit, "guild_id" | "mod_id" | "is_hidden"> = {}, ): Promise { return this.cases.count({ where: { guild_id: this.guildId, mod_id: modId, is_hidden: false, ...filters, }, }); } async getRecentByModId( modId: string, count: number, skip = 0, filters: Omit, "guild_id" | "mod_id"> = {}, ): Promise { const where: FindOptionsWhere = { guild_id: this.guildId, mod_id: modId, is_hidden: false, ...filters, }; if (where.is_hidden === true) { delete where.is_hidden; } return this.cases.find({ relations: this.getRelations(), where, skip, take: count, order: { case_number: "DESC", }, }); } async getMinCaseNumber(): Promise { const result = await this.cases .createQueryBuilder() .where("guild_id = :guildId", { guildId: this.guildId }) .select(["MIN(case_number) AS min_case_number"]) .getRawOne<{ min_case_number: number }>(); return result?.min_case_number || 0; } async getMaxCaseNumber(): Promise { const result = await this.cases .createQueryBuilder() .where("guild_id = :guildId", { guildId: this.guildId }) .select(["MAX(case_number) AS max_case_number"]) .getRawOne<{ max_case_number: number }>(); return result?.max_case_number || 0; } async setHidden(id: number, hidden: boolean): Promise { await this.cases.update( { id }, { is_hidden: hidden, }, ); } async createInternal(data): Promise { return this.createQueue.add(async () => { const lastCaseNumberRow = await this.cases .createQueryBuilder() .select(["MAX(case_number) AS last_case_number"]) .where("guild_id = :guildId", { guildId: this.guildId }) .getRawOne(); const lastCaseNumber = lastCaseNumberRow?.last_case_number || 0; return this.cases .insert({ case_number: lastCaseNumber + 1, ...data, guild_id: this.guildId, }) .catch((err) => { if (err?.code === "ER_DUP_ENTRY") { if (data.audit_log_id) { // FIXME: Debug // tslint:disable-next-line:no-console console.trace(`Tried to insert case with duplicate audit_log_id`); return this.createInternal({ ...data, audit_log_id: undefined, }); } } throw err; }); }); } async create(data): Promise { const result = await this.createInternal(data); return (await this.find(result.identifiers[0].id))!; } update(id, data) { return this.cases.update(id, data); } async softDelete(id: number, deletedById: string, deletedByName: string, deletedByText: string) { return dataSource.transaction(async (entityManager) => { const cases = entityManager.getRepository(Case); const caseNotes = entityManager.getRepository(CaseNote); await Promise.all([ caseNotes.delete({ case_id: id, }), cases.update(id, { user_id: "0", user_name: "Unknown#0000", mod_id: null, mod_name: "Unknown#0000", type: CaseTypes.Deleted, audit_log_id: null, is_hidden: false, pp_id: null, pp_name: null, }), ]); await caseNotes.insert({ case_id: id, mod_id: deletedById, mod_name: deletedByName, body: deletedByText, }); }); } async createNote(caseId: number, data: any): Promise { await this.caseNotes.insert({ ...data, case_id: caseId, }); } async deleteAllCases(): Promise { const idRows = await this.cases .createQueryBuilder() .where("guild_id = :guildId", { guildId: this.guildId }) .select(["id"]) .getRawMany<{ id: number }>(); const ids = idRows.map((r) => r.id); const batches = chunkArray(ids, 500); for (const batch of batches) { await this.cases.createQueryBuilder().where("id IN (:ids)", { ids: batch }).delete().execute(); } } async bumpCaseNumbers(amount: number): Promise { await this.cases .createQueryBuilder() .where("guild_id = :guildId", { guildId: this.guildId }) .update() .set({ case_number: () => `case_number + ${parseInt(amount as unknown as string, 10)}`, }) .execute(); } getExportCases(skip: number, take: number): Promise { return this.cases.find({ where: { guild_id: this.guildId, }, relations: ["notes"], order: { case_number: "ASC", }, skip, take, }); } } ================================================ FILE: backend/src/data/GuildContextMenuLinks.ts ================================================ import { DeleteResult, InsertResult, Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { ContextMenuLink } from "./entities/ContextMenuLink.js"; export class GuildContextMenuLinks extends BaseGuildRepository { private contextLinks: Repository; constructor(guildId) { super(guildId); this.contextLinks = dataSource.getRepository(ContextMenuLink); } async get(id: string): Promise { return this.contextLinks.findOne({ where: { guild_id: this.guildId, context_id: id, }, }); } async create(contextId: string, contextAction: string): Promise { return this.contextLinks.insert({ guild_id: this.guildId, context_id: contextId, action_name: contextAction, }); } async deleteAll(): Promise { return this.contextLinks.delete({ guild_id: this.guildId, }); } } ================================================ FILE: backend/src/data/GuildCounters.ts ================================================ import moment from "moment-timezone"; import { FindOptionsWhere, In, IsNull, Not, Repository } from "typeorm"; import { Queue } from "../Queue.js"; import { DAYS, DBDateFormat, HOURS, MINUTES } from "../utils.js"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { Counter } from "./entities/Counter.js"; import { CounterTrigger, TriggerComparisonOp, isValidCounterComparisonOp } from "./entities/CounterTrigger.js"; import { CounterTriggerState } from "./entities/CounterTriggerState.js"; import { CounterValue } from "./entities/CounterValue.js"; const DELETE_UNUSED_COUNTERS_AFTER = 1 * DAYS; const DELETE_UNUSED_COUNTER_TRIGGERS_AFTER = 1 * DAYS; export const MIN_COUNTER_VALUE = 0; export const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT const decayQueue = new Queue(); async function deleteCountersMarkedToBeDeleted(): Promise { await dataSource.getRepository(Counter).createQueryBuilder().where("delete_at <= NOW()").delete().execute(); } async function deleteTriggersMarkedToBeDeleted(): Promise { await dataSource.getRepository(CounterTrigger).createQueryBuilder().where("delete_at <= NOW()").delete().execute(); } setInterval(deleteCountersMarkedToBeDeleted, 1 * HOURS); setInterval(deleteTriggersMarkedToBeDeleted, 1 * HOURS); setTimeout(deleteCountersMarkedToBeDeleted, 1 * MINUTES); setTimeout(deleteTriggersMarkedToBeDeleted, 1 * MINUTES); export class GuildCounters extends BaseGuildRepository { private counters: Repository; private counterValues: Repository; private counterTriggers: Repository; private counterTriggerStates: Repository; constructor(guildId) { super(guildId); this.counters = dataSource.getRepository(Counter); this.counterValues = dataSource.getRepository(CounterValue); this.counterTriggers = dataSource.getRepository(CounterTrigger); this.counterTriggerStates = dataSource.getRepository(CounterTriggerState); } async findOrCreateCounter(name: string, perChannel: boolean, perUser: boolean): Promise { const existing = await this.counters.findOne({ where: { guild_id: this.guildId, name, }, }); if (existing) { // If the existing counter's properties match the ones we're looking for, return it. // Otherwise, delete the existing counter and re-create it with the proper properties. if (existing.per_channel === perChannel && existing.per_user === perUser) { await this.counters.update({ id: existing.id }, { delete_at: null }); return existing; } await this.counters.delete({ id: existing.id }); } const insertResult = await this.counters.insert({ guild_id: this.guildId, name, per_channel: perChannel, per_user: perUser, last_decay_at: moment.utc().format(DBDateFormat), }); return (await this.counters.findOne({ where: { id: insertResult.identifiers[0].id, }, }))!; } async markUnusedCountersToBeDeleted(idsToKeep: number[]): Promise { const criteria: FindOptionsWhere = { guild_id: this.guildId, delete_at: IsNull(), }; if (idsToKeep.length) { criteria.id = Not(In(idsToKeep)); } const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTERS_AFTER, "ms").format(DBDateFormat); await this.counters.update(criteria, { delete_at: deleteAt, }); } async deleteCountersMarkedToBeDeleted(): Promise { await this.counters.createQueryBuilder().where("delete_at <= NOW()").delete().execute(); } async changeCounterValue( id: number, channelId: string | null, userId: string | null, change: number, initialValue: number, ): Promise { if (typeof change !== "number" || Number.isNaN(change) || !Number.isFinite(change)) { throw new Error(`changeCounterValue() change argument must be a number`); } channelId = channelId || "0"; userId = userId || "0"; const rawUpdate = change >= 0 ? `value = LEAST(value + ${change}, ${MAX_COUNTER_VALUE})` : `value = GREATEST(value ${change}, ${MIN_COUNTER_VALUE})`; await this.counterValues.query( ` INSERT INTO counter_values (counter_id, channel_id, user_id, value) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE ${rawUpdate} `, [id, channelId, userId, Math.max(initialValue + change, 0)], ); } async setCounterValue(id: number, channelId: string | null, userId: string | null, value: number): Promise { if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) { throw new Error(`setCounterValue() value argument must be a number`); } channelId = channelId || "0"; userId = userId || "0"; value = Math.max(value, 0); await this.counterValues.query( ` INSERT INTO counter_values (counter_id, channel_id, user_id, value) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE value = ? `, [id, channelId, userId, value, value], ); } decay(id: number, decayPeriodMs: number, decayAmount: number) { return decayQueue.add(async () => { const counter = (await this.counters.findOne({ where: { id, }, }))!; const diffFromLastDecayMs = moment.utc().diff(moment.utc(counter.last_decay_at!), "ms"); if (diffFromLastDecayMs < decayPeriodMs) { return; } const decayAmountToApply = Math.round((diffFromLastDecayMs / decayPeriodMs) * decayAmount); if (decayAmountToApply === 0 || Number.isNaN(decayAmountToApply)) { return; } // Calculate new last_decay_at based on the rounded decay amount we applied. This makes it so that over time, the decayed amount will stay accurate, even if we round some here. const newLastDecayDate = moment .utc(counter.last_decay_at) .add((decayAmountToApply / decayAmount) * decayPeriodMs, "ms") .format(DBDateFormat); const rawUpdate = decayAmountToApply >= 0 ? `GREATEST(value - ${decayAmountToApply}, ${MIN_COUNTER_VALUE})` : `LEAST(value + ${Math.abs(decayAmountToApply)}, ${MAX_COUNTER_VALUE})`; // Using an UPDATE with ORDER BY in an attempt to avoid deadlocks from simultaneous decays // Also see https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks-handling.html await this.counterValues .createQueryBuilder("CounterValue") .where("counter_id = :id", { id }) .orderBy("id") .update({ value: () => rawUpdate, }) .execute(); await this.counters.update( { id, }, { last_decay_at: newLastDecayDate, }, ); }); } async markUnusedTriggersToBeDeleted(triggerIdsToKeep: number[]) { let triggersToMarkQuery = this.counterTriggers .createQueryBuilder("counterTriggers") .innerJoin(Counter, "counters", "counters.id = counterTriggers.counter_id") .where("counters.guild_id = :guildId", { guildId: this.guildId }); // If there are no active triggers, we just mark all triggers from the guild to be deleted. // Otherwise, we mark all but the active triggers in the guild. if (triggerIdsToKeep.length) { triggersToMarkQuery = triggersToMarkQuery.andWhere("counterTriggers.id NOT IN (:...triggerIds)", { triggerIds: triggerIdsToKeep, }); } const triggersToMark = await triggersToMarkQuery.getMany(); if (triggersToMark.length) { const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms").format(DBDateFormat); await this.counterTriggers.update( { id: In(triggersToMark.map((t) => t.id)), }, { delete_at: deleteAt, }, ); } } async deleteTriggersMarkedToBeDeleted(): Promise { await this.counterTriggers.createQueryBuilder().where("delete_at <= NOW()").delete().execute(); } async initCounterTrigger( counterId: number, triggerName: string, comparisonOp: TriggerComparisonOp, comparisonValue: number, reverseComparisonOp: TriggerComparisonOp, reverseComparisonValue: number, ): Promise { if (!isValidCounterComparisonOp(comparisonOp)) { throw new Error(`Invalid comparison op: ${comparisonOp}`); } if (!isValidCounterComparisonOp(reverseComparisonOp)) { throw new Error(`Invalid comparison op: ${reverseComparisonOp}`); } if (typeof comparisonValue !== "number") { throw new Error(`Invalid comparison value: ${comparisonValue}`); } if (typeof reverseComparisonValue !== "number") { throw new Error(`Invalid comparison value: ${reverseComparisonValue}`); } return dataSource.transaction(async (entityManager) => { const existing = await entityManager.findOne(CounterTrigger, { where: { counter_id: counterId, name: triggerName, }, }); if (existing) { // Since all existing triggers are marked as to-be-deleted before they are re-initialized, this needs to be reset await entityManager.update(CounterTrigger, existing.id, { comparison_op: comparisonOp, comparison_value: comparisonValue, reverse_comparison_op: reverseComparisonOp, reverse_comparison_value: reverseComparisonValue, delete_at: null, }); return existing; } const insertResult = await entityManager.insert(CounterTrigger, { counter_id: counterId, name: triggerName, comparison_op: comparisonOp, comparison_value: comparisonValue, reverse_comparison_op: reverseComparisonOp, reverse_comparison_value: reverseComparisonValue, }); return (await entityManager.findOne(CounterTrigger, { where: { id: insertResult.identifiers[0].id, }, }))!; }); } /** * Checks if a counter value with the given parameters triggers the specified comparison for the specified counter. * If it does, mark this comparison for these parameters as triggered. * Note that if this comparison for these parameters was already triggered previously, this function will return false. * This means that a specific comparison for the specific parameters specified will only trigger *once* until the reverse trigger is triggered. * * @param counterId * @param comparisonOp * @param comparisonValue * @param userId * @param channelId * @return Whether the given parameters newly triggered the given comparison */ async checkForTrigger( counterTrigger: CounterTrigger, channelId: string | null, userId: string | null, ): Promise { channelId = channelId || "0"; userId = userId || "0"; return dataSource.transaction(async (entityManager) => { const previouslyTriggered = await entityManager.findOne(CounterTriggerState, { where: { trigger_id: counterTrigger.id, user_id: userId!, channel_id: channelId!, }, }); if (previouslyTriggered) { return false; } const matchingValue = await entityManager .createQueryBuilder(CounterValue, "cv") .leftJoin( CounterTriggerState, "triggerStates", "triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id", { triggerId: counterTrigger.id }, ) .where(`cv.value ${counterTrigger.comparison_op} :value`, { value: counterTrigger.comparison_value }) .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) .andWhere("cv.channel_id = :channelId AND cv.user_id = :userId", { channelId, userId }) .andWhere("triggerStates.id IS NULL") .getOne(); if (matchingValue) { await entityManager.insert(CounterTriggerState, { trigger_id: counterTrigger.id, user_id: userId!, channel_id: channelId!, }); return true; } return false; }); } /** * Checks if any counter values of the specified counter match the specified comparison. * Like checkForTrigger(), this can only happen *once* per unique counter value parameters until the reverse trigger is triggered for those values. * * @return Counter value parameters that triggered the condition */ async checkAllValuesForTrigger( counterTrigger: CounterTrigger, ): Promise> { return dataSource.transaction(async (entityManager) => { const matchingValues = await entityManager .createQueryBuilder(CounterValue, "cv") .leftJoin( CounterTriggerState, "triggerStates", "triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id", { triggerId: counterTrigger.id }, ) .where(`cv.value ${counterTrigger.comparison_op} :value`, { value: counterTrigger.comparison_value }) .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) .andWhere("triggerStates.id IS NULL") .getMany(); if (matchingValues.length) { await entityManager.insert( CounterTriggerState, matchingValues.map((row) => ({ trigger_id: counterTrigger.id, channel_id: row.channel_id, user_id: row.user_id, })), ); } return matchingValues.map((row) => ({ channelId: row.channel_id, userId: row.user_id, })); }); } /** * Checks if a counter value with the given parameters *no longer* matches the specified comparison, and thus triggers a "reverse trigger". * Like checkForTrigger(), this can only happen *once* until the comparison is triggered normally again. * * @param counterId * @param comparisonOp * @param comparisonValue * @param userId * @param channelId * @return Whether the given parameters triggered a reverse trigger for the given comparison */ async checkForReverseTrigger( counterTrigger: CounterTrigger, channelId: string | null, userId: string | null, ): Promise { channelId = channelId || "0"; userId = userId || "0"; return dataSource.transaction(async (entityManager) => { const matchingValue = await entityManager .createQueryBuilder(CounterValue, "cv") .innerJoin( CounterTriggerState, "triggerStates", "triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id", { triggerId: counterTrigger.id }, ) .where(`cv.value ${counterTrigger.reverse_comparison_op} :value`, { value: counterTrigger.reverse_comparison_value, }) .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) .andWhere(`cv.channel_id = :channelId AND cv.user_id = :userId`, { channelId, userId }) .getOne(); if (matchingValue) { await entityManager.delete(CounterTriggerState, { trigger_id: counterTrigger.id, user_id: userId!, channel_id: channelId!, }); return true; } return false; }); } /** * Checks if any counter values of the specified counter *no longer* match the specified comparison, and thus triggers a "reverse trigger" for those values. * Like checkForTrigger(), this can only happen *once* per unique counter value parameters until the comparison is triggered normally again. * * @return Counter value parameters that triggered a reverse trigger */ async checkAllValuesForReverseTrigger( counterTrigger: CounterTrigger, ): Promise> { return dataSource.transaction(async (entityManager) => { const matchingValues: Array<{ id: string; triggerStateId: string; user_id: string; channel_id: string; }> = await entityManager .createQueryBuilder(CounterValue, "cv") .innerJoin( CounterTriggerState, "triggerStates", "triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id", { triggerId: counterTrigger.id }, ) .where(`cv.value ${counterTrigger.reverse_comparison_op} :value`, { value: counterTrigger.reverse_comparison_value, }) .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) .select([ "cv.id AS id", "cv.user_id AS user_id", "cv.channel_id AS channel_id", "triggerStates.id AS triggerStateId", ]) .getRawMany(); if (matchingValues.length) { await entityManager.delete(CounterTriggerState, { id: In(matchingValues.map((v) => v.triggerStateId)), }); } return matchingValues.map((row) => ({ channelId: row.channel_id, userId: row.user_id, })); }); } async getCurrentValue( counterId: number, channelId: string | null, userId: string | null, ): Promise { const value = await this.counterValues.findOne({ where: { counter_id: counterId, channel_id: channelId || "0", user_id: userId || "0", }, }); return value?.value; } async resetAllCounterValues(counterId: number): Promise { // Foreign keys will remove any related triggers and counter values await this.counters.delete({ id: counterId, }); } } ================================================ FILE: backend/src/data/GuildEvents.ts ================================================ import { Mute } from "./entities/Mute.js"; import { Reminder } from "./entities/Reminder.js"; import { ScheduledPost } from "./entities/ScheduledPost.js"; import { Tempban } from "./entities/Tempban.js"; import { VCAlert } from "./entities/VCAlert.js"; interface GuildEventArgs extends Record { expiredMute: [Mute]; timeoutMuteToRenew: [Mute]; scheduledPost: [ScheduledPost]; reminder: [Reminder]; expiredTempban: [Tempban]; expiredVCAlert: [VCAlert]; } type GuildEvent = keyof GuildEventArgs; type GuildEventListener = (...args: GuildEventArgs[K]) => void; type ListenerMap = { [K in GuildEvent]?: Array>; }; const guildListeners: Map = new Map(); /** * @return - Function to unregister the listener */ export function onGuildEvent( guildId: string, eventName: K, listener: GuildEventListener, ): () => void { if (!guildListeners.has(guildId)) { guildListeners.set(guildId, {}); } const listenerMap = guildListeners.get(guildId)!; if (listenerMap[eventName] == null) { listenerMap[eventName] = []; } listenerMap[eventName]!.push(listener); return () => { listenerMap[eventName]!.splice(listenerMap[eventName]!.indexOf(listener), 1); }; } export function emitGuildEvent(guildId: string, eventName: K, args: GuildEventArgs[K]): void { if (!guildListeners.has(guildId)) { return; } const listenerMap = guildListeners.get(guildId)!; if (listenerMap[eventName] == null) { return; } for (const listener of listenerMap[eventName]!) { listener(...args); } } export function hasGuildEventListener(guildId: string, eventName: K): boolean { if (!guildListeners.has(guildId)) { return false; } const listenerMap = guildListeners.get(guildId)!; if (listenerMap[eventName] == null || listenerMap[eventName]!.length === 0) { return false; } return true; } ================================================ FILE: backend/src/data/GuildLogs.ts ================================================ import * as events from "events"; import { LogType } from "./LogType.js"; // Use the same instance for the same guild, even if a new instance is created const guildInstances: Map = new Map(); interface IIgnoredLog { type: keyof typeof LogType; ignoreId: any; } export class GuildLogs extends events.EventEmitter { protected guildId: string; protected ignoredLogs: IIgnoredLog[]; constructor(guildId) { if (guildInstances.has(guildId)) { // Return existing instance for this guild if one exists return guildInstances.get(guildId)!; } super(); this.guildId = guildId; this.ignoredLogs = []; // Store the instance for this guild so it can be returned later if a new instance for this guild is requested guildInstances.set(guildId, this); } log(type: keyof typeof LogType, data: any, ignoreId?: string) { if (ignoreId && this.isLogIgnored(type, ignoreId)) { this.clearIgnoredLog(type, ignoreId); return; } this.emit("log", { type, data }); } ignoreLog(type: keyof typeof LogType, ignoreId: any, timeout?: number) { this.ignoredLogs.push({ type, ignoreId }); // Clear after expiry (15sec by default) setTimeout( () => { this.clearIgnoredLog(type, ignoreId); }, timeout || 1000 * 15, ); } isLogIgnored(type: keyof typeof LogType, ignoreId: any) { return this.ignoredLogs.some((info) => type === info.type && ignoreId === info.ignoreId); } clearIgnoredLog(type: keyof typeof LogType, ignoreId: any) { this.ignoredLogs.splice( this.ignoredLogs.findIndex((info) => type === info.type && ignoreId === info.ignoreId), 1, ); } } ================================================ FILE: backend/src/data/GuildMemberCache.ts ================================================ import moment from "moment-timezone"; import { Repository } from "typeorm"; import { Blocker } from "../Blocker.js"; import { DBDateFormat, MINUTES } from "../utils.js"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { MemberCacheItem } from "./entities/MemberCacheItem.js"; const SAVE_PENDING_BLOCKER_KEY = "save-pending" as const; const DELETION_DELAY = 5 * MINUTES; type UpdateData = Pick; export class GuildMemberCache extends BaseGuildRepository { #memberCache: Repository; #pendingUpdates: Map>; #blocker: Blocker; constructor(guildId: string) { super(guildId); this.#memberCache = dataSource.getRepository(MemberCacheItem); this.#pendingUpdates = new Map(); this.#blocker = new Blocker(); } async savePendingUpdates(): Promise { await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY); if (this.#pendingUpdates.size === 0) { return; } this.#blocker.block(SAVE_PENDING_BLOCKER_KEY); const entitiesToSave = Array.from(this.#pendingUpdates.values()); this.#pendingUpdates.clear(); await this.#memberCache.upsert(entitiesToSave, ["guild_id", "user_id"]).finally(() => { this.#blocker.unblock(SAVE_PENDING_BLOCKER_KEY); }); } async getCachedMemberData(userId: string): Promise { await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY); const dbItem = await this.#memberCache.findOne({ where: { guild_id: this.guildId, user_id: userId, }, }); const pendingItem = this.#pendingUpdates.get(userId); if (!dbItem && !pendingItem) { return null; } const item = new MemberCacheItem(); Object.assign(item, dbItem ?? {}); Object.assign(item, pendingItem ?? {}); return item; } async setCachedMemberData(userId: string, data: UpdateData): Promise { await this.#blocker.waitToBeUnblocked(SAVE_PENDING_BLOCKER_KEY); if (!this.#pendingUpdates.has(userId)) { const newItem = new MemberCacheItem(); newItem.guild_id = this.guildId; newItem.user_id = userId; this.#pendingUpdates.set(userId, newItem); } Object.assign(this.#pendingUpdates.get(userId)!, data); this.#pendingUpdates.get(userId)!.last_seen = moment().format("YYYY-MM-DD"); } async markMemberForDeletion(userId: string): Promise { await this.#memberCache.update( { guild_id: this.guildId, user_id: userId, }, { delete_at: moment().add(DELETION_DELAY, "ms").format(DBDateFormat), }, ); } async unmarkMemberForDeletion(userId: string): Promise { await this.#memberCache.update( { guild_id: this.guildId, user_id: userId, }, { delete_at: null, }, ); } } ================================================ FILE: backend/src/data/GuildMemberTimezones.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { MemberTimezone } from "./entities/MemberTimezone.js"; export class GuildMemberTimezones extends BaseGuildRepository { protected memberTimezones: Repository; constructor(guildId: string) { super(guildId); this.memberTimezones = dataSource.getRepository(MemberTimezone); } get(memberId: string) { return this.memberTimezones.findOne({ where: { guild_id: this.guildId, member_id: memberId, }, }); } async set(memberId, timezone: string) { await dataSource.transaction(async (entityManager) => { const repo = entityManager.getRepository(MemberTimezone); const existingRow = await repo.findOne({ where: { guild_id: this.guildId, member_id: memberId, }, }); if (existingRow) { await repo.update( { guild_id: this.guildId, member_id: memberId, }, { timezone, }, ); } else { await repo.insert({ guild_id: this.guildId, member_id: memberId, timezone, }); } }); } reset(memberId: string) { return this.memberTimezones.delete({ guild_id: this.guildId, member_id: memberId, }); } } ================================================ FILE: backend/src/data/GuildMutes.ts ================================================ import moment from "moment-timezone"; import { Brackets, Repository } from "typeorm"; import { DBDateFormat } from "../utils.js"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { MuteTypes } from "./MuteTypes.js"; import { dataSource } from "./dataSource.js"; import { Mute } from "./entities/Mute.js"; export type AddMuteParams = { userId: Mute["user_id"]; type: MuteTypes; expiresAt: number | null; rolesToRestore?: Mute["roles_to_restore"]; muteRole?: string | null; timeoutExpiresAt?: number; }; export class GuildMutes extends BaseGuildRepository { private mutes: Repository; constructor(guildId) { super(guildId); this.mutes = dataSource.getRepository(Mute); } async getExpiredMutes(): Promise { return this.mutes .createQueryBuilder("mutes") .where("guild_id = :guild_id", { guild_id: this.guildId }) .andWhere("expires_at IS NOT NULL") .andWhere("expires_at <= NOW()") .getMany(); } async findExistingMuteForUserId(userId: string): Promise { return this.mutes.findOne({ where: { guild_id: this.guildId, user_id: userId, }, }); } async isMuted(userId: string): Promise { const mute = await this.findExistingMuteForUserId(userId); return mute != null; } async addMute(params: AddMuteParams): Promise { const expiresAt = params.expiresAt ? moment.utc(params.expiresAt).format(DBDateFormat) : null; const timeoutExpiresAt = params.timeoutExpiresAt ? moment.utc(params.timeoutExpiresAt).format(DBDateFormat) : null; const result = await this.mutes.insert({ guild_id: this.guildId, user_id: params.userId, type: params.type, expires_at: expiresAt, roles_to_restore: params.rolesToRestore ?? [], mute_role: params.muteRole, timeout_expires_at: timeoutExpiresAt, }); return (await this.mutes.findOne({ where: result.identifiers[0] }))!; } async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]): Promise { const expiresAt = newExpiryTime ? moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null; if (rolesToRestore && rolesToRestore.length) { await this.mutes.update( { guild_id: this.guildId, user_id: userId, }, { expires_at: expiresAt, roles_to_restore: rolesToRestore, }, ); } else { await this.mutes.update( { guild_id: this.guildId, user_id: userId, }, { expires_at: expiresAt, }, ); } } async updateExpiresAt(userId: string, timestamp: number | null): Promise { const expiresAt = timestamp ? moment.utc(timestamp).format("YYYY-MM-DD HH:mm:ss") : null; await this.mutes.update( { guild_id: this.guildId, user_id: userId, }, { expires_at: expiresAt, }, ); } async updateTimeoutExpiresAt(userId: string, timestamp: number): Promise { const timeoutExpiresAt = moment.utc(timestamp).format(DBDateFormat); await this.mutes.update( { guild_id: this.guildId, user_id: userId, }, { timeout_expires_at: timeoutExpiresAt, }, ); } async getActiveMutes(): Promise { return this.mutes .createQueryBuilder("mutes") .where("guild_id = :guild_id", { guild_id: this.guildId }) .andWhere( new Brackets((qb) => { qb.where("expires_at > NOW()").orWhere("expires_at IS NULL"); }), ) .getMany(); } async setCaseId(userId: string, caseId: number) { await this.mutes.update( { guild_id: this.guildId, user_id: userId, }, { case_id: caseId, }, ); } async clear(userId) { await this.mutes.delete({ guild_id: this.guildId, user_id: userId, }); } async fillMissingMuteRole(muteRole: string): Promise { await this.mutes .createQueryBuilder() .where("guild_id = :guild_id", { guild_id: this.guildId }) .andWhere("type = :type", { type: MuteTypes.Role }) .andWhere("mute_role IS NULL") .update({ mute_role: muteRole, }) .execute(); } } ================================================ FILE: backend/src/data/GuildNicknameHistory.ts ================================================ import { In, Repository } from "typeorm"; import { isAPI } from "../globals.js"; import { MINUTES, SECONDS } from "../utils.js"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { cleanupNicknames } from "./cleanup/nicknames.js"; import { dataSource } from "./dataSource.js"; import { NicknameHistoryEntry } from "./entities/NicknameHistoryEntry.js"; const CLEANUP_INTERVAL = 5 * MINUTES; async function cleanup() { await cleanupNicknames(); setTimeout(cleanup, CLEANUP_INTERVAL); } if (!isAPI()) { // Start first cleanup 30 seconds after startup // TODO: Move to bot startup code setTimeout(cleanup, 30 * SECONDS); } export const MAX_NICKNAME_ENTRIES_PER_USER = 10; export class GuildNicknameHistory extends BaseGuildRepository { private nicknameHistory: Repository; constructor(guildId) { super(guildId); this.nicknameHistory = dataSource.getRepository(NicknameHistoryEntry); } async getByUserId(userId): Promise { return this.nicknameHistory.find({ where: { guild_id: this.guildId, user_id: userId, }, order: { id: "DESC", }, }); } getLastEntry(userId): Promise { return this.nicknameHistory.findOne({ where: { guild_id: this.guildId, user_id: userId, }, order: { id: "DESC", }, }); } async addEntry(userId, nickname) { await this.nicknameHistory.insert({ guild_id: this.guildId, user_id: userId, nickname, }); // Cleanup (leave only the last MAX_USERNAME_ENTRIES_PER_USER entries) const toDelete = await this.nicknameHistory .createQueryBuilder() .where("guild_id = :guildId", { guildId: this.guildId }) .andWhere("user_id = :userId", { userId }) .orderBy("id", "DESC") .skip(MAX_NICKNAME_ENTRIES_PER_USER) .take(99_999) .getMany(); if (toDelete.length > 0) { await this.nicknameHistory.delete({ id: In(toDelete.map((v) => v.id)), }); } } } ================================================ FILE: backend/src/data/GuildPersistedData.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { PersistedData } from "./entities/PersistedData.js"; export class GuildPersistedData extends BaseGuildRepository { private persistedData: Repository; constructor(guildId) { super(guildId); this.persistedData = dataSource.getRepository(PersistedData); } async find(userId: string) { return this.persistedData.findOne({ where: { guild_id: this.guildId, user_id: userId, }, }); } async set(userId: string, data: Partial = {}) { const existing = await this.find(userId); if (existing) { await this.persistedData.update( { guild_id: this.guildId, user_id: userId, }, data, ); } else { await this.persistedData.insert({ ...data, guild_id: this.guildId, user_id: userId, }); } } async clear(userId: string) { await this.persistedData.delete({ guild_id: this.guildId, user_id: userId, }); } } ================================================ FILE: backend/src/data/GuildPingableRoles.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { PingableRole } from "./entities/PingableRole.js"; export class GuildPingableRoles extends BaseGuildRepository { private pingableRoles: Repository; constructor(guildId) { super(guildId); this.pingableRoles = dataSource.getRepository(PingableRole); } async all(): Promise { return this.pingableRoles.find({ where: { guild_id: this.guildId, }, }); } async getForChannel(channelId: string): Promise { return this.pingableRoles.find({ where: { guild_id: this.guildId, channel_id: channelId, }, }); } async getByChannelAndRoleId(channelId: string, roleId: string): Promise { return this.pingableRoles.findOne({ where: { guild_id: this.guildId, channel_id: channelId, role_id: roleId, }, }); } async delete(channelId: string, roleId: string) { await this.pingableRoles.delete({ guild_id: this.guildId, channel_id: channelId, role_id: roleId, }); } async add(channelId: string, roleId: string) { await this.pingableRoles.insert({ guild_id: this.guildId, channel_id: channelId, role_id: roleId, }); } } ================================================ FILE: backend/src/data/GuildReactionRoles.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { ReactionRole } from "./entities/ReactionRole.js"; export class GuildReactionRoles extends BaseGuildRepository { private reactionRoles: Repository; constructor(guildId) { super(guildId); this.reactionRoles = dataSource.getRepository(ReactionRole); } async all(): Promise { return this.reactionRoles.find({ where: { guild_id: this.guildId, }, }); } async getForMessage(messageId: string): Promise { return this.reactionRoles.find({ where: { guild_id: this.guildId, message_id: messageId, }, order: { order: "ASC", }, }); } async getByMessageAndEmoji(messageId: string, emoji: string): Promise { return this.reactionRoles.findOne({ where: { guild_id: this.guildId, message_id: messageId, emoji, }, }); } async removeFromMessage(messageId: string, emoji?: string) { const criteria: any = { guild_id: this.guildId, message_id: messageId, }; if (emoji) { criteria.emoji = emoji; } await this.reactionRoles.delete(criteria); } async add( channelId: string, messageId: string, emoji: string, roleId: string, exclusive?: boolean, position?: number, ) { await this.reactionRoles.insert({ guild_id: this.guildId, channel_id: channelId, message_id: messageId, emoji, role_id: roleId, is_exclusive: Boolean(exclusive), order: position, }); } } ================================================ FILE: backend/src/data/GuildReminders.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { Reminder } from "./entities/Reminder.js"; export class GuildReminders extends BaseGuildRepository { private reminders: Repository; constructor(guildId) { super(guildId); this.reminders = dataSource.getRepository(Reminder); } async getDueReminders(): Promise { return this.reminders .createQueryBuilder() .where("guild_id = :guildId", { guildId: this.guildId }) .andWhere("remind_at <= NOW()") .getMany(); } async getRemindersByUserId(userId: string): Promise { return this.reminders.find({ where: { guild_id: this.guildId, user_id: userId, }, }); } find(id: number) { return this.reminders.findOne({ where: { id }, }); } async delete(id) { await this.reminders.delete({ guild_id: this.guildId, id, }); } async add(userId: string, channelId: string, remindAt: string, body: string, created_at: string) { const result = await this.reminders.insert({ guild_id: this.guildId, user_id: userId, channel_id: channelId, remind_at: remindAt, body, created_at, }); return (await this.find(result.identifiers[0].id))!; } } ================================================ FILE: backend/src/data/GuildRoleButtons.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { RoleButtonsItem } from "./entities/RoleButtonsItem.js"; export class GuildRoleButtons extends BaseGuildRepository { private roleButtons: Repository; constructor(guildId) { super(guildId); this.roleButtons = dataSource.getRepository(RoleButtonsItem); } getSavedRoleButtons(): Promise { return this.roleButtons.find({ where: { guild_id: this.guildId, }, }); } async deleteRoleButtonItem(name: string): Promise { await this.roleButtons.delete({ guild_id: this.guildId, name, }); } async saveRoleButtonItem(name: string, channelId: string, messageId: string, hash: string): Promise { await this.roleButtons.insert({ guild_id: this.guildId, name, channel_id: channelId, message_id: messageId, hash, }); } } ================================================ FILE: backend/src/data/GuildRoleQueue.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { RoleQueueItem } from "./entities/RoleQueueItem.js"; export class GuildRoleQueue extends BaseGuildRepository { private roleQueue: Repository; constructor(guildId) { super(guildId); this.roleQueue = dataSource.getRepository(RoleQueueItem); } consumeNextRoleAssignments(count: number): Promise { return dataSource.transaction(async (entityManager) => { const repository = entityManager.getRepository(RoleQueueItem); const nextAssignments = await repository .createQueryBuilder() .where("guild_id = :guildId", { guildId: this.guildId }) .addOrderBy("priority", "DESC") .addOrderBy("id", "ASC") .take(count) .getMany(); if (nextAssignments.length > 0) { const ids = nextAssignments.map((assignment) => assignment.id); await repository.createQueryBuilder().where("id IN (:ids)", { ids }).delete().execute(); } return nextAssignments; }); } async addQueueItem(userId: string, roleId: string, shouldAdd: boolean, priority = 0) { await this.roleQueue.insert({ guild_id: this.guildId, user_id: userId, role_id: roleId, should_add: shouldAdd, priority, }); } } ================================================ FILE: backend/src/data/GuildSavedMessages.ts ================================================ import { GuildChannel, Message } from "discord.js"; import moment from "moment-timezone"; import { Repository } from "typeorm"; import { QueuedEventEmitter } from "../QueuedEventEmitter.js"; import { noop } from "../utils.js"; import { asyncMap } from "../utils/async.js"; import { decryptJson, encryptJson } from "../utils/cryptHelpers.js"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { buildEntity } from "./buildEntity.js"; import { dataSource } from "./dataSource.js"; import { ISavedMessageData, SavedMessage } from "./entities/SavedMessage.js"; export class GuildSavedMessages extends BaseGuildRepository { private messages: Repository; protected toBePermanent: Set; public events: QueuedEventEmitter; constructor(guildId) { super(guildId); this.messages = dataSource.getRepository(SavedMessage); this.events = new QueuedEventEmitter(); this.toBePermanent = new Set(); } protected msgToSavedMessageData(msg: Message): ISavedMessageData { const data: ISavedMessageData = { author: { username: msg.author.username, discriminator: msg.author.discriminator, }, content: msg.content, timestamp: msg.createdTimestamp, }; if (msg.attachments.size) { data.attachments = Array.from(msg.attachments.values()).map((att) => ({ id: att.id, contentType: att.contentType, name: att.name, proxyURL: att.proxyURL, size: att.size, spoiler: att.spoiler, url: att.url, width: att.width, })); } if (msg.embeds.length) { data.embeds = msg.embeds.map((embed) => ({ title: embed.title, description: embed.description, url: embed.url, timestamp: embed.timestamp ? Date.parse(embed.timestamp) : null, color: embed.color, fields: embed.fields.map((field) => ({ name: field.name, value: field.value, inline: field.inline ?? false, })), author: embed.author ? { name: embed.author.name, url: embed.author.url, iconURL: embed.author.iconURL, proxyIconURL: embed.author.proxyIconURL, } : undefined, thumbnail: embed.thumbnail ? { url: embed.thumbnail.url, proxyURL: embed.thumbnail.proxyURL, height: embed.thumbnail.height, width: embed.thumbnail.width, } : undefined, image: embed.image ? { url: embed.image.url, proxyURL: embed.image.proxyURL, height: embed.image.height, width: embed.image.width, } : undefined, video: embed.video ? { url: embed.video.url, proxyURL: embed.video.proxyURL, height: embed.video.height, width: embed.video.width, } : undefined, footer: embed.footer ? { text: embed.footer.text, iconURL: embed.footer.iconURL, proxyIconURL: embed.footer.proxyIconURL, } : undefined, })); } if (msg.stickers?.size) { data.stickers = Array.from(msg.stickers.values()).map((sticker) => ({ format: sticker.format, guildId: sticker.guildId, id: sticker.id, name: sticker.name, description: sticker.description, available: sticker.available, type: sticker.type, })); } if (msg.reference && (msg.reference.messageId || msg.reference.channelId || msg.reference.guildId)) { data.reference = { messageId: msg.reference.messageId ?? null, channelId: msg.reference.channelId ?? null, guildId: msg.reference.guildId ?? null, }; } return data; } protected async _processEntityFromDB(entity: SavedMessage | undefined) { if (entity == null) { return entity; } entity.data = (await decryptJson(entity.data as unknown as string)) as ISavedMessageData; return entity; } protected async _processEntityToDB(entity: Partial) { if (entity.data) { entity.data = (await encryptJson(entity.data)) as any; } return entity; } async find(id: string, includeDeleted = false): Promise { let query = this.messages .createQueryBuilder() .where("guild_id = :guild_id", { guild_id: this.guildId }) .andWhere("id = :id", { id }); if (!includeDeleted) { query = query.andWhere("deleted_at IS NULL"); } const result = await query.getOne(); return this.processEntityFromDB(result); } async getUserMessagesByChannelAfterId(userId, channelId, afterId, limit?: number): Promise { let query = this.messages .createQueryBuilder() .where("guild_id = :guild_id", { guild_id: this.guildId }) .andWhere("channel_id = :channel_id", { channel_id: channelId }) .andWhere("user_id = :user_id", { user_id: userId }) .andWhere("id > :afterId", { afterId }) .andWhere("deleted_at IS NULL"); if (limit != null) { query = query.limit(limit); } const results = await query.getMany(); return this.processMultipleEntitiesFromDB(results); } async getMultiple(messageIds: string[]): Promise { if (messageIds.length === 0) { return []; } const results = await this.messages .createQueryBuilder() .where("guild_id = :guild_id", { guild_id: this.guildId }) .andWhere("id IN (:messageIds)", { messageIds }) .getMany(); return this.processMultipleEntitiesFromDB(results); } async createFromMsg(msg: Message, overrides = {}): Promise { // FIXME: Hotfix if (!msg.channel) { return; } // Don't actually save bot messages. Just pass them through as if they were saved. if (msg.author.bot) { const fakeSavedMessage = buildEntity(SavedMessage, await this.msgToInsertReadyEntity(msg)); this.fireCreateEvents(fakeSavedMessage); return; } await this.createFromMessages([msg], overrides); } async createFromMessages(messages: Message[], overrides = {}): Promise { const items = await asyncMap(messages, async (msg) => ({ ...(await this.msgToInsertReadyEntity(msg)), ...overrides, })); await this.insertBulk(items); } protected async msgToInsertReadyEntity(msg: Message): Promise> { const savedMessageData = this.msgToSavedMessageData(msg); const postedAt = moment.utc(msg.createdTimestamp, "x").format("YYYY-MM-DD HH:mm:ss"); return { id: msg.id, guild_id: (msg.channel as GuildChannel).guild.id, channel_id: msg.channel.id, user_id: msg.author.id, is_bot: msg.author.bot, data: savedMessageData, posted_at: postedAt, }; } protected async insertBulk(items: Array>): Promise { for (const item of items) { if (this.toBePermanent.has(item.id!)) { item.is_permanent = true; this.toBePermanent.delete(item.id!); } } const itemsToInsert = await asyncMap(items, (item) => this.processEntityToDB({ ...item })); await this.messages.createQueryBuilder().insert().values(itemsToInsert).execute().catch(noop); for (const item of items) { // perf: save a db lookup and message content decryption by building the entity manually const inserted = buildEntity(SavedMessage, item); this.fireCreateEvents(inserted); } } protected async fireCreateEvents(message: SavedMessage) { this.events.emit("create", [message]); this.events.emit(`create:${message.id}`, [message]); } async markAsDeleted(id): Promise { await this.messages .createQueryBuilder("messages") .update() .set({ deleted_at: () => "NOW(3)", }) .where("guild_id = :guild_id", { guild_id: this.guildId }) .andWhere("id = :id", { id }) .execute(); const deleted = await this.find(id, true); if (deleted) { this.events.emit("delete", [deleted]); this.events.emit(`delete:${id}`, [deleted]); } } /** * Marks the specified messages as deleted in the database (if they weren't already marked before). * If any messages were marked as deleted, also emits the deleteBulk event. */ async markBulkAsDeleted(ids) { const deletedAt = moment.utc().format("YYYY-MM-DD HH:mm:ss"); await this.messages .createQueryBuilder() .update() .set({ deleted_at: deletedAt }) .where("guild_id = :guild_id", { guild_id: this.guildId }) .andWhere("id IN (:ids)", { ids }) .andWhere("deleted_at IS NULL") .execute(); let deleted = await this.messages .createQueryBuilder() .where("id IN (:ids)", { ids }) .andWhere("deleted_at = :deletedAt", { deletedAt }) .getMany(); deleted = await this.processMultipleEntitiesFromDB(deleted); if (deleted.length) { this.events.emit("deleteBulk", [deleted]); } } async saveEdit(id, newData: ISavedMessageData): Promise { const oldMessage = await this.find(id); if (!oldMessage) return; const newMessage = { ...oldMessage, data: newData }; // @ts-ignore const updateData = await this.processEntityToDB({ data: newData, }); await this.messages.update({ id }, updateData); this.events.emit("update", [newMessage, oldMessage]); this.events.emit(`update:${id}`, [newMessage, oldMessage]); } async saveEditFromMsg(msg: Message): Promise { const newData = this.msgToSavedMessageData(msg); await this.saveEdit(msg.id, newData); } async setPermanent(id: string): Promise { const savedMsg = await this.find(id); if (savedMsg) { await this.messages.update( { id }, { is_permanent: true, }, ); } else { this.toBePermanent.add(id); } } async onceMessageAvailable( id: string, handler: (msg?: SavedMessage) => any, timeout: number = 60 * 1000, ): Promise { let called = false; let onceEventListener; let timeoutFn; const callHandler = async (msg?: SavedMessage) => { this.events.off(`create:${id}`, onceEventListener); clearTimeout(timeoutFn); if (called) return; called = true; await handler(msg); }; onceEventListener = this.events.once(`create:${id}`, callHandler); timeoutFn = setTimeout(() => { called = true; callHandler(undefined); }, timeout); const messageInDB = await this.find(id); if (messageInDB) { callHandler(messageInDB); } } } ================================================ FILE: backend/src/data/GuildScheduledPosts.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { ScheduledPost } from "./entities/ScheduledPost.js"; export class GuildScheduledPosts extends BaseGuildRepository { private scheduledPosts: Repository; constructor(guildId) { super(guildId); this.scheduledPosts = dataSource.getRepository(ScheduledPost); } all(): Promise { return this.scheduledPosts.createQueryBuilder().where("guild_id = :guildId", { guildId: this.guildId }).getMany(); } getDueScheduledPosts(): Promise { return this.scheduledPosts .createQueryBuilder() .where("guild_id = :guildId", { guildId: this.guildId }) .andWhere("post_at <= NOW()") .getMany(); } find(id: number) { return this.scheduledPosts.findOne({ where: { id, }, }); } async delete(id) { await this.scheduledPosts.delete({ guild_id: this.guildId, id, }); } async create(data: Partial) { const result = await this.scheduledPosts.insert({ ...data, guild_id: this.guildId, }); return (await this.find(result.identifiers[0].id))!; } async update(id: number, data: Partial) { await this.scheduledPosts.update(id, data); } } ================================================ FILE: backend/src/data/GuildSlowmodes.ts ================================================ import moment from "moment-timezone"; import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { SlowmodeChannel } from "./entities/SlowmodeChannel.js"; import { SlowmodeUser } from "./entities/SlowmodeUser.js"; export class GuildSlowmodes extends BaseGuildRepository { private slowmodeChannels: Repository; private slowmodeUsers: Repository; constructor(guildId) { super(guildId); this.slowmodeChannels = dataSource.getRepository(SlowmodeChannel); this.slowmodeUsers = dataSource.getRepository(SlowmodeUser); } async getChannelSlowmode(channelId): Promise { return this.slowmodeChannels.findOne({ where: { guild_id: this.guildId, channel_id: channelId, }, }); } async setChannelSlowmode(channelId, seconds): Promise { const existingSlowmode = await this.getChannelSlowmode(channelId); if (existingSlowmode) { await this.slowmodeChannels.update( { guild_id: this.guildId, channel_id: channelId, }, { slowmode_seconds: seconds, }, ); } else { await this.slowmodeChannels.insert({ guild_id: this.guildId, channel_id: channelId, slowmode_seconds: seconds, }); } } async deleteChannelSlowmode(channelId): Promise { await this.slowmodeChannels.delete({ guild_id: this.guildId, channel_id: channelId, }); } async getChannelSlowmodeUser(channelId, userId): Promise { return this.slowmodeUsers.findOne({ where: { guild_id: this.guildId, channel_id: channelId, user_id: userId, }, }); } async userHasSlowmode(channelId, userId): Promise { return (await this.getChannelSlowmodeUser(channelId, userId)) != null; } async addSlowmodeUser(channelId, userId): Promise { const slowmode = await this.getChannelSlowmode(channelId); if (!slowmode) return; const expiresAt = moment.utc().add(slowmode.slowmode_seconds, "seconds").format("YYYY-MM-DD HH:mm:ss"); if (await this.userHasSlowmode(channelId, userId)) { // Update existing await this.slowmodeUsers.update( { guild_id: this.guildId, channel_id: channelId, user_id: userId, }, { expires_at: expiresAt, }, ); } else { // Add new await this.slowmodeUsers.insert({ guild_id: this.guildId, channel_id: channelId, user_id: userId, expires_at: expiresAt, }); } } async clearSlowmodeUser(channelId, userId): Promise { await this.slowmodeUsers.delete({ guild_id: this.guildId, channel_id: channelId, user_id: userId, }); } async getChannelSlowmodeUsers(channelId): Promise { return this.slowmodeUsers.find({ where: { guild_id: this.guildId, channel_id: channelId, }, }); } async getExpiredSlowmodeUsers(): Promise { return this.slowmodeUsers .createQueryBuilder() .where("guild_id = :guildId", { guildId: this.guildId }) .andWhere("expires_at <= NOW()") .getMany(); } } ================================================ FILE: backend/src/data/GuildStarboardMessages.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { StarboardMessage } from "./entities/StarboardMessage.js"; export class GuildStarboardMessages extends BaseGuildRepository { private allStarboardMessages: Repository; constructor(guildId) { super(guildId); this.allStarboardMessages = dataSource.getRepository(StarboardMessage); } async getStarboardMessagesForMessageId(messageId: string) { return this.allStarboardMessages .createQueryBuilder() .where("guild_id = :gid", { gid: this.guildId }) .andWhere("message_id = :msgid", { msgid: messageId }) .getMany(); } async getStarboardMessagesForStarboardMessageId(starboardMessageId: string) { return this.allStarboardMessages .createQueryBuilder() .where("guild_id = :gid", { gid: this.guildId }) .andWhere("starboard_message_id = :messageId", { messageId: starboardMessageId }) .getMany(); } async getMatchingStarboardMessages(starboardChannelId: string, sourceMessageId: string) { return this.allStarboardMessages .createQueryBuilder() .where("guild_id = :guildId", { guildId: this.guildId }) .andWhere("message_id = :msgId", { msgId: sourceMessageId }) .andWhere("starboard_channel_id = :channelId", { channelId: starboardChannelId }) .getMany(); } async createStarboardMessage(starboardId: string, messageId: string, starboardMessageId: string) { await this.allStarboardMessages.insert({ message_id: messageId, starboard_message_id: starboardMessageId, starboard_channel_id: starboardId, guild_id: this.guildId, }); } async deleteStarboardMessage(starboardMessageId: string, starboardChannelId: string) { await this.allStarboardMessages.delete({ guild_id: this.guildId, starboard_message_id: starboardMessageId, starboard_channel_id: starboardChannelId, }); } } ================================================ FILE: backend/src/data/GuildStarboardReactions.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { StarboardReaction } from "./entities/StarboardReaction.js"; export class GuildStarboardReactions extends BaseGuildRepository { private allStarboardReactions: Repository; constructor(guildId) { super(guildId); this.allStarboardReactions = dataSource.getRepository(StarboardReaction); } async getAllReactionsForMessageId(messageId: string) { return this.allStarboardReactions .createQueryBuilder() .where("guild_id = :gid", { gid: this.guildId }) .andWhere("message_id = :msgid", { msgid: messageId }) .getMany(); } async createStarboardReaction(messageId: string, reactorId: string) { const existingReaction = await this.allStarboardReactions.findOne({ where: { guild_id: this.guildId, message_id: messageId, reactor_id: reactorId, }, }); if (existingReaction) { return; } await this.allStarboardReactions.insert({ guild_id: this.guildId, message_id: messageId, reactor_id: reactorId, }); } async deleteAllStarboardReactionsForMessageId(messageId: string) { await this.allStarboardReactions.delete({ guild_id: this.guildId, message_id: messageId, }); } async deleteStarboardReaction(messageId: string, reactorId: string) { await this.allStarboardReactions.delete({ guild_id: this.guildId, reactor_id: reactorId, message_id: messageId, }); } } ================================================ FILE: backend/src/data/GuildStats.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { StatValue } from "./entities/StatValue.js"; export class GuildStats extends BaseGuildRepository { private stats: Repository; constructor(guildId) { super(guildId); this.stats = dataSource.getRepository(StatValue); } async saveValue(source: string, key: string, value: number): Promise { await this.stats.insert({ guild_id: this.guildId, source, key, value, }); } async deleteOldValues(source: string, cutoff: string): Promise { await this.stats .createQueryBuilder() .where("source = :source", { source }) .andWhere("created_at < :cutoff", { cutoff }) .delete(); } } ================================================ FILE: backend/src/data/GuildTags.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { Tag } from "./entities/Tag.js"; import { TagResponse } from "./entities/TagResponse.js"; export class GuildTags extends BaseGuildRepository { private tags: Repository; private tagResponses: Repository; constructor(guildId) { super(guildId); this.tags = dataSource.getRepository(Tag); this.tagResponses = dataSource.getRepository(TagResponse); } async all(): Promise { return this.tags.find({ where: { guild_id: this.guildId, }, }); } async find(tag): Promise { return this.tags.findOne({ where: { guild_id: this.guildId, tag, }, }); } async createOrUpdate(tag, body, userId) { const existingTag = await this.find(tag); if (existingTag) { await this.tags .createQueryBuilder() .update() .set({ body, user_id: userId, created_at: () => "NOW()", }) .where("guild_id = :guildId", { guildId: this.guildId }) .andWhere("tag = :tag", { tag }) .execute(); } else { await this.tags.insert({ guild_id: this.guildId, user_id: userId, tag, body, }); } } async delete(tag) { await this.tags.delete({ guild_id: this.guildId, tag, }); } async findResponseByCommandMessageId(messageId: string): Promise { return this.tagResponses.findOne({ where: { guild_id: this.guildId, command_message_id: messageId, }, }); } async findResponseByResponseMessageId(messageId: string): Promise { return this.tagResponses.findOne({ where: { guild_id: this.guildId, response_message_id: messageId, }, }); } async addResponse(cmdMessageId, responseMessageId) { await this.tagResponses.insert({ guild_id: this.guildId, command_message_id: cmdMessageId, response_message_id: responseMessageId, }); } async deleteResponseByCommandMessageId(messageId: string): Promise { await this.tagResponses.delete({ guild_id: this.guildId, command_message_id: messageId, }); } async deleteResponseByResponseMessageId(messageId: string): Promise { await this.tagResponses.delete({ guild_id: this.guildId, response_message_id: messageId, }); } } ================================================ FILE: backend/src/data/GuildTempbans.ts ================================================ import moment from "moment-timezone"; import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { Tempban } from "./entities/Tempban.js"; export class GuildTempbans extends BaseGuildRepository { private tempbans: Repository; constructor(guildId) { super(guildId); this.tempbans = dataSource.getRepository(Tempban); } async getExpiredTempbans(): Promise { return this.tempbans .createQueryBuilder("mutes") .where("guild_id = :guild_id", { guild_id: this.guildId }) .andWhere("expires_at IS NOT NULL") .andWhere("expires_at <= NOW()") .getMany(); } async findExistingTempbanForUserId(userId: string): Promise { return this.tempbans.findOne({ where: { guild_id: this.guildId, user_id: userId, }, }); } async addTempban(userId, expiryTime, modId): Promise { const expiresAt = moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss"); const result = await this.tempbans.insert({ guild_id: this.guildId, user_id: userId, mod_id: modId, expires_at: expiresAt, created_at: moment.utc().format("YYYY-MM-DD HH:mm:ss"), }); return (await this.tempbans.findOne({ where: result.identifiers[0] }))!; } async updateExpiryTime(userId, newExpiryTime, modId) { const expiresAt = moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss"); return this.tempbans.update( { guild_id: this.guildId, user_id: userId, }, { created_at: moment.utc().format("YYYY-MM-DD HH:mm:ss"), expires_at: expiresAt, mod_id: modId, }, ); } async clear(userId) { await this.tempbans.delete({ guild_id: this.guildId, user_id: userId, }); } } ================================================ FILE: backend/src/data/GuildVCAlerts.ts ================================================ import { Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { VCAlert } from "./entities/VCAlert.js"; export class GuildVCAlerts extends BaseGuildRepository { private allAlerts: Repository; constructor(guildId) { super(guildId); this.allAlerts = dataSource.getRepository(VCAlert); } async getOutdatedAlerts(): Promise { return this.allAlerts .createQueryBuilder() .where("guild_id = :guildId", { guildId: this.guildId }) .andWhere("expires_at <= NOW()") .getMany(); } async getAllGuildAlerts(): Promise { return this.allAlerts.createQueryBuilder().where("guild_id = :guildId", { guildId: this.guildId }).getMany(); } async getAlertsByUserId(userId: string): Promise { return this.allAlerts.find({ where: { guild_id: this.guildId, user_id: userId, }, }); } async getAlertsByRequestorId(requestorId: string): Promise { return this.allAlerts.find({ where: { guild_id: this.guildId, requestor_id: requestorId, }, }); } find(id: number) { return this.allAlerts.findOne({ where: { id }, }); } async delete(id) { await this.allAlerts.delete({ guild_id: this.guildId, id, }); } async add(requestorId: string, userId: string, channelId: string, expiresAt: string, body: string, active: boolean) { const result = await this.allAlerts.insert({ guild_id: this.guildId, requestor_id: requestorId, user_id: userId, channel_id: channelId, expires_at: expiresAt, body, active, }); return (await this.find(result.identifiers[0].id))!; } } ================================================ FILE: backend/src/data/LogType.ts ================================================ export const LogType = { MEMBER_WARN: "MEMBER_WARN", MEMBER_MUTE: "MEMBER_MUTE", MEMBER_UNMUTE: "MEMBER_UNMUTE", MEMBER_MUTE_EXPIRED: "MEMBER_MUTE_EXPIRED", MEMBER_KICK: "MEMBER_KICK", MEMBER_BAN: "MEMBER_BAN", MEMBER_UNBAN: "MEMBER_UNBAN", MEMBER_FORCEBAN: "MEMBER_FORCEBAN", MEMBER_SOFTBAN: "MEMBER_SOFTBAN", MEMBER_JOIN: "MEMBER_JOIN", MEMBER_LEAVE: "MEMBER_LEAVE", MEMBER_ROLE_ADD: "MEMBER_ROLE_ADD", MEMBER_ROLE_REMOVE: "MEMBER_ROLE_REMOVE", MEMBER_NICK_CHANGE: "MEMBER_NICK_CHANGE", MEMBER_USERNAME_CHANGE: "MEMBER_USERNAME_CHANGE", MEMBER_RESTORE: "MEMBER_RESTORE", CHANNEL_CREATE: "CHANNEL_CREATE", CHANNEL_DELETE: "CHANNEL_DELETE", CHANNEL_UPDATE: "CHANNEL_UPDATE", THREAD_CREATE: "THREAD_CREATE", THREAD_DELETE: "THREAD_DELETE", THREAD_UPDATE: "THREAD_UPDATE", ROLE_CREATE: "ROLE_CREATE", ROLE_DELETE: "ROLE_DELETE", ROLE_UPDATE: "ROLE_UPDATE", MESSAGE_EDIT: "MESSAGE_EDIT", MESSAGE_DELETE: "MESSAGE_DELETE", MESSAGE_DELETE_BULK: "MESSAGE_DELETE_BULK", MESSAGE_DELETE_BARE: "MESSAGE_DELETE_BARE", VOICE_CHANNEL_JOIN: "VOICE_CHANNEL_JOIN", VOICE_CHANNEL_LEAVE: "VOICE_CHANNEL_LEAVE", VOICE_CHANNEL_MOVE: "VOICE_CHANNEL_MOVE", STAGE_INSTANCE_CREATE: "STAGE_INSTANCE_CREATE", STAGE_INSTANCE_DELETE: "STAGE_INSTANCE_DELETE", STAGE_INSTANCE_UPDATE: "STAGE_INSTANCE_UPDATE", EMOJI_CREATE: "EMOJI_CREATE", EMOJI_DELETE: "EMOJI_DELETE", EMOJI_UPDATE: "EMOJI_UPDATE", STICKER_CREATE: "STICKER_CREATE", STICKER_DELETE: "STICKER_DELETE", STICKER_UPDATE: "STICKER_UPDATE", COMMAND: "COMMAND", MESSAGE_SPAM_DETECTED: "MESSAGE_SPAM_DETECTED", CENSOR: "CENSOR", CLEAN: "CLEAN", CASE_CREATE: "CASE_CREATE", MASSUNBAN: "MASSUNBAN", MASSBAN: "MASSBAN", MASSMUTE: "MASSMUTE", MEMBER_TIMED_MUTE: "MEMBER_TIMED_MUTE", MEMBER_TIMED_UNMUTE: "MEMBER_TIMED_UNMUTE", MEMBER_TIMED_BAN: "MEMBER_TIMED_BAN", MEMBER_TIMED_UNBAN: "MEMBER_TIMED_UNBAN", MEMBER_JOIN_WITH_PRIOR_RECORDS: "MEMBER_JOIN_WITH_PRIOR_RECORDS", OTHER_SPAM_DETECTED: "OTHER_SPAM_DETECTED", MEMBER_ROLE_CHANGES: "MEMBER_ROLE_CHANGES", VOICE_CHANNEL_FORCE_MOVE: "VOICE_CHANNEL_FORCE_MOVE", VOICE_CHANNEL_FORCE_DISCONNECT: "VOICE_CHANNEL_FORCE_DISCONNECT", CASE_UPDATE: "CASE_UPDATE", MEMBER_MUTE_REJOIN: "MEMBER_MUTE_REJOIN", SCHEDULED_MESSAGE: "SCHEDULED_MESSAGE", POSTED_SCHEDULED_MESSAGE: "POSTED_SCHEDULED_MESSAGE", BOT_ALERT: "BOT_ALERT", AUTOMOD_ACTION: "AUTOMOD_ACTION", SCHEDULED_REPEATED_MESSAGE: "SCHEDULED_REPEATED_MESSAGE", REPEATED_MESSAGE: "REPEATED_MESSAGE", MESSAGE_DELETE_AUTO: "MESSAGE_DELETE_AUTO", SET_ANTIRAID_USER: "SET_ANTIRAID_USER", SET_ANTIRAID_AUTO: "SET_ANTIRAID_AUTO", MEMBER_NOTE: "MEMBER_NOTE", CASE_DELETE: "CASE_DELETE", DM_FAILED: "DM_FAILED", } as const; ================================================ FILE: backend/src/data/MemberCache.ts ================================================ import moment from "moment-timezone"; import { Repository } from "typeorm"; import { DAYS } from "../utils.js"; import { BaseRepository } from "./BaseRepository.js"; import { dataSource } from "./dataSource.js"; import { MemberCacheItem } from "./entities/MemberCacheItem.js"; const STALE_PERIOD = 90 * DAYS; export class MemberCache extends BaseRepository { #memberCache: Repository; constructor() { super(); this.#memberCache = dataSource.getRepository(MemberCacheItem); } async deleteStaleData(): Promise { const cutoff = moment().subtract(STALE_PERIOD, "ms").format("YYYY-MM-DD"); await this.#memberCache.createQueryBuilder().where("last_seen < :cutoff", { cutoff }).delete().execute(); } async deleteMarkedToBeDeletedEntries(): Promise { await this.#memberCache .createQueryBuilder() .where("delete_at IS NOT NULL AND delete_at <= NOW()") .delete() .execute(); } } ================================================ FILE: backend/src/data/MuteTypes.ts ================================================ export enum MuteTypes { Role = 1, Timeout = 2, } ================================================ FILE: backend/src/data/Mutes.ts ================================================ import moment from "moment-timezone"; import { Repository } from "typeorm"; import { DAYS, DBDateFormat } from "../utils.js"; import { BaseRepository } from "./BaseRepository.js"; import { MuteTypes } from "./MuteTypes.js"; import { dataSource } from "./dataSource.js"; import { Mute } from "./entities/Mute.js"; const OLD_EXPIRED_MUTE_THRESHOLD = 7 * DAYS; export const MAX_TIMEOUT_DURATION = 27 * DAYS; // When a timeout is under this duration but the mute expires later, the timeout will be reset to max duration export const TIMEOUT_RENEWAL_THRESHOLD = 21 * DAYS; export class Mutes extends BaseRepository { private mutes: Repository; constructor() { super(); this.mutes = dataSource.getRepository(Mute); } findMute(guildId: string, userId: string): Promise { return this.mutes.findOne({ where: { guild_id: guildId, user_id: userId, }, }); } getSoonExpiringMutes(threshold: number): Promise { const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat); return this.mutes .createQueryBuilder("mutes") .andWhere("expires_at IS NOT NULL") .andWhere("expires_at <= :date", { date: thresholdDateStr }) .getMany(); } getTimeoutMutesToRenew(threshold: number): Promise { const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat); return this.mutes .createQueryBuilder("mutes") .andWhere("type = :type", { type: MuteTypes.Timeout }) .andWhere("(expires_at IS NULL OR timeout_expires_at < expires_at)") .andWhere("timeout_expires_at <= :date", { date: thresholdDateStr }) .getMany(); } async clearOldExpiredMutes(): Promise { const thresholdDateStr = moment.utc().subtract(OLD_EXPIRED_MUTE_THRESHOLD, "ms").format(DBDateFormat); await this.mutes .createQueryBuilder("mutes") .andWhere("expires_at IS NOT NULL") .andWhere("expires_at <= :date", { date: thresholdDateStr }) .delete() .execute(); } } ================================================ FILE: backend/src/data/Reminders.ts ================================================ import moment from "moment-timezone"; import { Repository } from "typeorm"; import { DBDateFormat } from "../utils.js"; import { BaseRepository } from "./BaseRepository.js"; import { dataSource } from "./dataSource.js"; import { Reminder } from "./entities/Reminder.js"; export class Reminders extends BaseRepository { private reminders: Repository; constructor() { super(); this.reminders = dataSource.getRepository(Reminder); } async getRemindersDueSoon(threshold: number): Promise { const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat); return this.reminders.createQueryBuilder().andWhere("remind_at <= :date", { date: thresholdDateStr }).getMany(); } } ================================================ FILE: backend/src/data/ScheduledPosts.ts ================================================ import moment from "moment-timezone"; import { Repository } from "typeorm"; import { DBDateFormat } from "../utils.js"; import { BaseRepository } from "./BaseRepository.js"; import { dataSource } from "./dataSource.js"; import { ScheduledPost } from "./entities/ScheduledPost.js"; export class ScheduledPosts extends BaseRepository { private scheduledPosts: Repository; constructor() { super(); this.scheduledPosts = dataSource.getRepository(ScheduledPost); } getScheduledPostsDueSoon(threshold: number): Promise { const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat); return this.scheduledPosts.createQueryBuilder().andWhere("post_at <= :date", { date: thresholdDateStr }).getMany(); } } ================================================ FILE: backend/src/data/Supporters.ts ================================================ import { Repository } from "typeorm"; import { BaseRepository } from "./BaseRepository.js"; import { dataSource } from "./dataSource.js"; import { Supporter } from "./entities/Supporter.js"; export class Supporters extends BaseRepository { private supporters: Repository; constructor() { super(); this.supporters = dataSource.getRepository(Supporter); } getAll() { return this.supporters.find(); } } ================================================ FILE: backend/src/data/Tempbans.ts ================================================ import moment from "moment-timezone"; import { Repository } from "typeorm"; import { DBDateFormat } from "../utils.js"; import { BaseRepository } from "./BaseRepository.js"; import { dataSource } from "./dataSource.js"; import { Tempban } from "./entities/Tempban.js"; export class Tempbans extends BaseRepository { private tempbans: Repository; constructor() { super(); this.tempbans = dataSource.getRepository(Tempban); } getSoonExpiringTempbans(threshold: number): Promise { const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat); return this.tempbans.createQueryBuilder().where("expires_at <= :date", { date: thresholdDateStr }).getMany(); } } ================================================ FILE: backend/src/data/UsernameHistory.ts ================================================ import { In, Repository } from "typeorm"; import { isAPI } from "../globals.js"; import { MINUTES, SECONDS } from "../utils.js"; import { BaseRepository } from "./BaseRepository.js"; import { cleanupUsernames } from "./cleanup/usernames.js"; import { dataSource } from "./dataSource.js"; import { UsernameHistoryEntry } from "./entities/UsernameHistoryEntry.js"; const CLEANUP_INTERVAL = 5 * MINUTES; async function cleanup() { await cleanupUsernames(); setTimeout(cleanup, CLEANUP_INTERVAL); } if (!isAPI()) { // Start first cleanup 30 seconds after startup // TODO: Move to bot startup code setTimeout(cleanup, 30 * SECONDS); } export const MAX_USERNAME_ENTRIES_PER_USER = 5; export class UsernameHistory extends BaseRepository { private usernameHistory: Repository; constructor() { super(); this.usernameHistory = dataSource.getRepository(UsernameHistoryEntry); } async getByUserId(userId): Promise { return this.usernameHistory.find({ where: { user_id: userId, }, order: { id: "DESC", }, take: MAX_USERNAME_ENTRIES_PER_USER, }); } getLastEntry(userId): Promise { return this.usernameHistory.findOne({ where: { user_id: userId, }, order: { id: "DESC", }, }); } async addEntry(userId, username) { await this.usernameHistory.insert({ user_id: userId, username, }); // Cleanup (leave only the last MAX_USERNAME_ENTRIES_PER_USER entries) const toDelete = await this.usernameHistory .createQueryBuilder() .where("user_id = :userId", { userId }) .orderBy("id", "DESC") .skip(MAX_USERNAME_ENTRIES_PER_USER) .take(99_999) .getMany(); if (toDelete.length > 0) { await this.usernameHistory.delete({ id: In(toDelete.map((v) => v.id)), }); } } } ================================================ FILE: backend/src/data/VCAlerts.ts ================================================ import moment from "moment-timezone"; import { Repository } from "typeorm"; import { DBDateFormat } from "../utils.js"; import { BaseRepository } from "./BaseRepository.js"; import { dataSource } from "./dataSource.js"; import { VCAlert } from "./entities/VCAlert.js"; export class VCAlerts extends BaseRepository { private allAlerts: Repository; constructor() { super(); this.allAlerts = dataSource.getRepository(VCAlert); } async getSoonExpiringAlerts(threshold: number): Promise { const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat); return this.allAlerts.createQueryBuilder().andWhere("expires_at <= :date", { date: thresholdDateStr }).getMany(); } } ================================================ FILE: backend/src/data/Webhooks.ts ================================================ import { Repository } from "typeorm"; import { decrypt, encrypt } from "../utils/crypt.js"; import { BaseRepository } from "./BaseRepository.js"; import { dataSource } from "./dataSource.js"; import { Webhook } from "./entities/Webhook.js"; export class Webhooks extends BaseRepository { repository: Repository = dataSource.getRepository(Webhook); protected async _processEntityFromDB(entity) { entity.token = await decrypt(entity.token); return entity; } protected async _processEntityToDB(entity) { entity.token = await encrypt(entity.token); return entity; } async find(id: string): Promise { const result = await this.repository.findOne({ where: { id, }, }); return result ? this._processEntityFromDB(result) : null; } async findByChannelId(channelId: string): Promise { const result = await this.repository.findOne({ where: { channel_id: channelId, }, }); return result ? this.processEntityFromDB(result) : null; } async create(data: Partial): Promise { data = await this.processEntityToDB(data); await this.repository.insert(data); } async delete(id: string): Promise { await this.repository.delete({ id }); } } ================================================ FILE: backend/src/data/Zalgo.ts ================================================ // From https://github.com/b1naryth1ef/rowboat/blob/master/rowboat/util/zalgo.py const zalgoChars = [ "\u030d", "\u030e", "\u0304", "\u0305", "\u033f", "\u0311", "\u0306", "\u0310", "\u0352", "\u0357", "\u0351", "\u0307", "\u0308", "\u030a", "\u0342", "\u0343", "\u0344", "\u034a", "\u034b", "\u034c", "\u0303", "\u0302", "\u030c", "\u0350", "\u0300", "\u030b", "\u030f", "\u0312", "\u0313", "\u0314", "\u033d", "\u0309", "\u0363", "\u0364", "\u0365", "\u0366", "\u0367", "\u0368", "\u0369", "\u036a", "\u036b", "\u036c", "\u036d", "\u036e", "\u036f", "\u033e", "\u035b", "\u0346", "\u031a", "\u0315", "\u031b", "\u0340", "\u0341", "\u0358", "\u0321", "\u0322", "\u0327", "\u0328", "\u0334", "\u0335", "\u0336", "\u034f", "\u035c", "\u035d", "\u035e", "\u035f", "\u0360", "\u0362", "\u0338", "\u0337", "\u0361", "\u0489", "\u0316", "\u0317", "\u0318", "\u0319", "\u031c", "\u031d", "\u031e", "\u031f", "\u0320", "\u0324", "\u0325", "\u0326", "\u0329", "\u032a", "\u032b", "\u032c", "\u032d", "\u032e", "\u032f", "\u0330", "\u0331", "\u0332", "\u0333", "\u0339", "\u033a", "\u033b", "\u033c", "\u0345", "\u0347", "\u0348", "\u0349", "\u034d", "\u034e", "\u0353", "\u0354", "\u0355", "\u0356", "\u0359", "\u035a", "\u0323", ]; export const ZalgoRegex = new RegExp(zalgoChars.join("|")); ================================================ FILE: backend/src/data/apiAuditLogTypes.ts ================================================ import { ApiPermissionTypes } from "./ApiPermissionAssignments.js"; export const AuditLogEventTypes = { ADD_API_PERMISSION: "ADD_API_PERMISSION" as const, EDIT_API_PERMISSION: "EDIT_API_PERMISSION" as const, REMOVE_API_PERMISSION: "REMOVE_API_PERMISSION" as const, EDIT_CONFIG: "EDIT_CONFIG" as const, }; export type AuditLogEventType = keyof typeof AuditLogEventTypes; export type AddApiPermissionEventData = { target_id: string; permissions: string[]; expires_at: string | null; }; export type RemoveApiPermissionEventData = { target_id: string; }; export interface AuditLogEventData extends Record { ADD_API_PERMISSION: { type: ApiPermissionTypes; target_id: string; permissions: string[]; expires_at: string | null; }; EDIT_API_PERMISSION: { type: ApiPermissionTypes; target_id: string; permissions: string[]; expires_at: string | null; }; REMOVE_API_PERMISSION: { type: ApiPermissionTypes; target_id: string; }; EDIT_CONFIG: Record; } export type AnyAuditLogEventData = AuditLogEventData[AuditLogEventType]; ================================================ FILE: backend/src/data/buildEntity.ts ================================================ export function buildEntity(Entity: new () => T, data: Partial): T { const instance = new Entity(); for (const [key, value] of Object.entries(data)) { instance[key] = value; } return instance; } ================================================ FILE: backend/src/data/cleanup/configs.ts ================================================ import moment from "moment-timezone"; import { In } from "typeorm"; import { DBDateFormat } from "../../utils.js"; import { dataSource } from "../dataSource.js"; import { Config } from "../entities/Config.js"; const CLEAN_PER_LOOP = 50; export async function cleanupConfigs() { const configRepository = dataSource.getRepository(Config); // FIXME: The query below doesn't work on MySQL 8.0. Pending an update. return; let cleaned = 0; let rows; // >1 month old: 1 config retained per month const oneMonthCutoff = moment.utc().subtract(30, "days").format(DBDateFormat); do { rows = await dataSource.query( ` WITH _configs AS ( SELECT id, \`key\`, YEAR(edited_at) AS \`year\`, MONTH(edited_at) AS \`month\`, ROW_NUMBER() OVER ( PARTITION BY \`key\`, \`year\`, \`month\` ORDER BY edited_at ) AS row_num FROM configs WHERE is_active = 0 AND edited_at < ? ) SELECT * FROM _configs WHERE row_num > 1 `, [oneMonthCutoff], ); if (rows.length > 0) { await configRepository.delete({ id: In(rows.map((r) => r.id)), }); } cleaned += rows.length; } while (rows.length === CLEAN_PER_LOOP); // >2 weeks old: 1 config retained per day const twoWeekCutoff = moment.utc().subtract(2, "weeks").format(DBDateFormat); do { rows = await dataSource.query( ` WITH _configs AS ( SELECT id, \`key\`, DATE(edited_at) AS \`date\`, ROW_NUMBER() OVER ( PARTITION BY \`key\`, \`date\` ORDER BY edited_at ) AS row_num FROM configs WHERE is_active = 0 AND edited_at < ? AND edited_at >= ? ) SELECT * FROM _configs WHERE row_num > 1 `, [twoWeekCutoff, oneMonthCutoff], ); if (rows.length > 0) { await configRepository.delete({ id: In(rows.map((r) => r.id)), }); } cleaned += rows.length; } while (rows.length === CLEAN_PER_LOOP); return cleaned; } ================================================ FILE: backend/src/data/cleanup/messages.ts ================================================ import moment from "moment-timezone"; import { In } from "typeorm"; import { DAYS, DBDateFormat, MINUTES, SECONDS, sleep } from "../../utils.js"; import { dataSource } from "../dataSource.js"; import { SavedMessage } from "../entities/SavedMessage.js"; /** * How long message edits, deletions, etc. will include the original message content. * This is very heavy storage-wise, so keeping it as low as possible is ideal. */ const RETENTION_PERIOD = 1 * DAYS; const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES; const DELETED_MESSAGE_RETENTION_PERIOD = 5 * MINUTES; const CLEAN_PER_LOOP = 100; export async function cleanupMessages(): Promise { let cleaned = 0; const messagesRepository = dataSource.getRepository(SavedMessage); const deletedAtThreshold = moment.utc().subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms").format(DBDateFormat); const postedAtThreshold = moment.utc().subtract(RETENTION_PERIOD, "ms").format(DBDateFormat); const botPostedAtThreshold = moment.utc().subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms").format(DBDateFormat); // SELECT + DELETE messages in batches // This is to avoid deadlocks that happened frequently when deleting with the same criteria as the select below // when a message was being inserted at the same time let ids: string[]; do { const deletedMessageRows = await dataSource.query( ` SELECT id FROM messages WHERE ( deleted_at IS NOT NULL AND deleted_at <= ? ) LIMIT ${CLEAN_PER_LOOP} `, [deletedAtThreshold], ); const oldPostedRows = await dataSource.query( ` SELECT id FROM messages WHERE ( posted_at <= ? AND is_permanent = 0 ) LIMIT ${CLEAN_PER_LOOP} `, [postedAtThreshold], ); const oldBotPostedRows = await dataSource.query( ` SELECT id FROM messages WHERE ( is_bot = 1 AND posted_at <= ? AND is_permanent = 0 ) LIMIT ${CLEAN_PER_LOOP} `, [botPostedAtThreshold], ); ids = Array.from( new Set([ ...deletedMessageRows.map((r) => r.id), ...oldPostedRows.map((r) => r.id), ...oldBotPostedRows.map((r) => r.id), ]), ); if (ids.length > 0) { await messagesRepository.delete({ id: In(ids), }); await sleep(1 * SECONDS); } cleaned += ids.length; } while (ids.length > 0); return cleaned; } ================================================ FILE: backend/src/data/cleanup/nicknames.ts ================================================ import moment from "moment-timezone"; import { In } from "typeorm"; import { DAYS, DBDateFormat } from "../../utils.js"; import { dataSource } from "../dataSource.js"; import { NicknameHistoryEntry } from "../entities/NicknameHistoryEntry.js"; export const NICKNAME_RETENTION_PERIOD = 30 * DAYS; const CLEAN_PER_LOOP = 500; export async function cleanupNicknames(): Promise { let cleaned = 0; const nicknameHistoryRepository = dataSource.getRepository(NicknameHistoryEntry); const dateThreshold = moment.utc().subtract(NICKNAME_RETENTION_PERIOD, "ms").format(DBDateFormat); // Clean old nicknames (NICKNAME_RETENTION_PERIOD) let rows; do { rows = await dataSource.query( ` SELECT id FROM nickname_history WHERE timestamp < ? LIMIT ${CLEAN_PER_LOOP} `, [dateThreshold], ); if (rows.length > 0) { await nicknameHistoryRepository.delete({ id: In(rows.map((r) => r.id)), }); } cleaned += rows.length; } while (rows.length === CLEAN_PER_LOOP); return cleaned; } ================================================ FILE: backend/src/data/cleanup/usernames.ts ================================================ import moment from "moment-timezone"; import { In } from "typeorm"; import { DAYS, DBDateFormat } from "../../utils.js"; import { dataSource } from "../dataSource.js"; import { UsernameHistoryEntry } from "../entities/UsernameHistoryEntry.js"; export const USERNAME_RETENTION_PERIOD = 30 * DAYS; const CLEAN_PER_LOOP = 500; export async function cleanupUsernames(): Promise { let cleaned = 0; const usernameHistoryRepository = dataSource.getRepository(UsernameHistoryEntry); const dateThreshold = moment.utc().subtract(USERNAME_RETENTION_PERIOD, "ms").format(DBDateFormat); // Clean old usernames (USERNAME_RETENTION_PERIOD) let rows; do { rows = await dataSource.query( ` SELECT id FROM username_history WHERE timestamp < ? LIMIT ${CLEAN_PER_LOOP} `, [dateThreshold], ); if (rows.length > 0) { await usernameHistoryRepository.delete({ id: In(rows.map((r) => r.id)), }); } cleaned += rows.length; } while (rows.length === CLEAN_PER_LOOP); return cleaned; } ================================================ FILE: backend/src/data/dataSource.ts ================================================ import moment from "moment-timezone"; import path from "path"; import { DataSource } from "typeorm"; import { env } from "../env.js"; import { backendDir } from "../paths.js"; moment.tz.setDefault("UTC"); const entities = path.relative(process.cwd(), path.resolve(backendDir, "dist/data/entities/*.js")); const migrations = path.relative(process.cwd(), path.resolve(backendDir, "dist/migrations/*.js")); export const dataSource = new DataSource({ type: "mysql", host: env.DB_HOST || "mysql", port: env.DB_PORT || 3306, username: env.DB_USER || "zeppelin", password: env.DB_PASSWORD || env.DEVELOPMENT_MYSQL_PASSWORD, database: env.DB_DATABASE || "zeppelin", charset: "utf8mb4", supportBigNumbers: true, bigNumberStrings: true, dateStrings: true, synchronize: false, connectTimeout: 2000, logging: ["error", "warn"], // Entities entities: [entities], // Pool options extra: { typeCast(field, next) { if (field.type === "DATETIME") { const val = field.string(); return val != null ? moment.utc(val).format("YYYY-MM-DD HH:mm:ss") : null; } return next(); }, }, // Migrations migrations: [migrations], }); ================================================ FILE: backend/src/data/db.ts ================================================ import { SimpleError } from "../SimpleError.js"; import { dataSource } from "./dataSource.js"; let connectionPromise: Promise; export function connect() { if (!connectionPromise) { connectionPromise = dataSource.initialize().then(async (initializedDataSource) => { const tzResult = await initializedDataSource.query("SELECT TIMEDIFF(NOW(), UTC_TIMESTAMP) AS tz"); if (tzResult[0].tz !== "00:00:00") { throw new SimpleError(`Database timezone must be UTC (detected ${tzResult[0].tz})`); } }); } return connectionPromise; } export function disconnect() { if (connectionPromise) { connectionPromise.then(() => dataSource.destroy()); } } ================================================ FILE: backend/src/data/entities/AllowedGuild.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("allowed_guilds") export class AllowedGuild { @Column() @PrimaryColumn() id: string; @Column() name: string; @Column({ type: String, nullable: true }) icon: string | null; @Column() owner_id: string; @Column() created_at: string; @Column() updated_at: string; } ================================================ FILE: backend/src/data/entities/AntiraidLevel.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("antiraid_levels") export class AntiraidLevel { @Column() @PrimaryColumn() guild_id: string; @Column() level: string; } ================================================ FILE: backend/src/data/entities/ApiAuditLogEntry.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; import { AuditLogEventData, AuditLogEventType } from "../apiAuditLogTypes.js"; @Entity("api_audit_log") export class ApiAuditLogEntry { @Column() @PrimaryColumn() id: number; @Column() guild_id: string; @Column() author_id: string; @Column({ type: String }) event_type: TEventType; @Column("simple-json") event_data: AuditLogEventData[TEventType]; @Column() created_at: string; } ================================================ FILE: backend/src/data/entities/ApiLogin.ts ================================================ import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, Relation } from "typeorm"; import { ApiUserInfo } from "./ApiUserInfo.js"; @Entity("api_logins") export class ApiLogin { @Column() @PrimaryColumn() id: string; @Column() token: string; @Column() user_id: string; @Column() logged_in_at: string; @Column() expires_at: string; @ManyToOne(() => ApiUserInfo, (userInfo) => userInfo.logins) @JoinColumn({ name: "user_id" }) userInfo: Relation; } ================================================ FILE: backend/src/data/entities/ApiPermissionAssignment.ts ================================================ import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, Relation } from "typeorm"; import { ApiPermissionTypes } from "../ApiPermissionAssignments.js"; import { ApiUserInfo } from "./ApiUserInfo.js"; @Entity("api_permissions") export class ApiPermissionAssignment { @Column() @PrimaryColumn() guild_id: string; @Column({ type: String }) @PrimaryColumn() type: ApiPermissionTypes; @Column() @PrimaryColumn() target_id: string; @Column("simple-array") permissions: string[]; @Column({ type: String, nullable: true }) expires_at: string | null; @ManyToOne(() => ApiUserInfo, (userInfo) => userInfo.permissionAssignments) @JoinColumn({ name: "target_id" }) userInfo: Relation; } ================================================ FILE: backend/src/data/entities/ApiUserInfo.ts ================================================ import { Column, Entity, OneToMany, PrimaryColumn, Relation } from "typeorm"; import { ApiLogin } from "./ApiLogin.js"; import { ApiPermissionAssignment } from "./ApiPermissionAssignment.js"; export interface ApiUserInfoData { username: string; discriminator: string; avatar: string; } @Entity("api_user_info") export class ApiUserInfo { @Column() @PrimaryColumn() id: string; @Column("simple-json") data: ApiUserInfoData; @Column() updated_at: string; @OneToMany(() => ApiLogin, (login) => login.userInfo) logins: Relation; @OneToMany(() => ApiPermissionAssignment, (p) => p.userInfo) permissionAssignments: Relation; } ================================================ FILE: backend/src/data/entities/ArchiveEntry.ts ================================================ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; @Entity("archives") export class ArchiveEntry { @Column() @PrimaryGeneratedColumn("uuid") id: string; @Column() guild_id: string; @Column({ type: "mediumtext", }) body: string; @Column() created_at: string; @Column({ type: String, nullable: true }) expires_at: string | null; } ================================================ FILE: backend/src/data/entities/AutoReaction.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("auto_reactions") export class AutoReaction { @Column() @PrimaryColumn() guild_id: string; @Column() @PrimaryColumn() channel_id: string; @Column("simple-array") reactions: string[]; } ================================================ FILE: backend/src/data/entities/ButtonRole.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("button_roles") export class ButtonRole { @Column() @PrimaryColumn() guild_id: string; @Column() @PrimaryColumn() channel_id: string; @Column() @PrimaryColumn() message_id: string; @Column() @PrimaryColumn() button_id: string; @Column() button_group: string; @Column() button_name: string; } ================================================ FILE: backend/src/data/entities/Case.ts ================================================ import { Column, Entity, OneToMany, PrimaryGeneratedColumn, Relation } from "typeorm"; import { CaseNote } from "./CaseNote.js"; @Entity("cases") export class Case { @PrimaryGeneratedColumn() id: number; @Column() guild_id: string; @Column() case_number: number; @Column() user_id: string; @Column() user_name: string; @Column({ type: String, nullable: true }) mod_id: string | null; @Column({ type: String, nullable: true }) mod_name: string | null; @Column() type: number; @Column({ type: String, nullable: true }) audit_log_id: string | null; @Column() created_at: string; @Column() is_hidden: boolean; @Column({ type: String, nullable: true }) pp_id: string | null; @Column({ type: String, nullable: true }) pp_name: string | null; /** * ID of the channel and message where this case was logged. * Format: "channelid-messageid" */ @Column({ type: String, nullable: true }) log_message_id: string | null; @OneToMany(() => CaseNote, (note) => note.case) notes: Relation; } ================================================ FILE: backend/src/data/entities/CaseNote.ts ================================================ import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Relation } from "typeorm"; import { Case } from "./Case.js"; @Entity("case_notes") export class CaseNote { @PrimaryGeneratedColumn() id: number; @Column() case_id: number; @Column() mod_id: string; @Column() mod_name: string; @Column() body: string; @Column() created_at: string; @ManyToOne(() => Case, (theCase) => theCase.notes) @JoinColumn({ name: "case_id" }) case: Relation; } ================================================ FILE: backend/src/data/entities/Config.ts ================================================ import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm"; import { ApiUserInfo } from "./ApiUserInfo.js"; @Entity("configs") export class Config { @Column() @PrimaryColumn() id: number; @Column() key: string; @Column() config: string; @Column() is_active: boolean; @Column() edited_by: string; @Column() edited_at: string; @ManyToOne(() => ApiUserInfo) @JoinColumn({ name: "edited_by" }) userInfo: ApiUserInfo; } ================================================ FILE: backend/src/data/entities/ContextMenuLink.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("context_menus") export class ContextMenuLink { @Column() guild_id: string; @Column() @PrimaryColumn() context_id: string; @Column() action_name: string; } ================================================ FILE: backend/src/data/entities/Counter.ts ================================================ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; @Entity("counters") export class Counter { @PrimaryGeneratedColumn() id: number; @Column() guild_id: string; @Column() name: string; @Column() per_channel: boolean; @Column() per_user: boolean; @Column() last_decay_at: string; @Column({ type: "datetime", nullable: true }) delete_at: string | null; } ================================================ FILE: backend/src/data/entities/CounterTrigger.ts ================================================ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; export const TRIGGER_COMPARISON_OPS = ["=", "!=", ">", "<", ">=", "<="] as const; export type TriggerComparisonOp = (typeof TRIGGER_COMPARISON_OPS)[number]; const REVERSE_OPS: Record = { "=": "!=", "!=": "=", ">": "<=", "<": ">=", ">=": "<", "<=": ">", }; export function getReverseCounterComparisonOp(op: TriggerComparisonOp): TriggerComparisonOp { return REVERSE_OPS[op]; } const comparisonStringRegex = new RegExp(`^(${TRIGGER_COMPARISON_OPS.join("|")})(\\d*)$`); /** * @return Parsed comparison op and value, or null if the comparison string was invalid */ export function parseCounterConditionString(str: string): [TriggerComparisonOp, number] | null { const matches = str.match(comparisonStringRegex); return matches ? [matches[1] as TriggerComparisonOp, parseInt(matches[2], 10)] : null; } export function buildCounterConditionString(comparisonOp: TriggerComparisonOp, comparisonValue: number): string { return `${comparisonOp}${comparisonValue}`; } export function isValidCounterComparisonOp(op: string): boolean { return TRIGGER_COMPARISON_OPS.includes(op as any); } @Entity("counter_triggers") export class CounterTrigger { @PrimaryGeneratedColumn() id: number; @Column() counter_id: number; @Column() name: string; @Column({ type: "varchar" }) comparison_op: TriggerComparisonOp; @Column() comparison_value: number; @Column({ type: "varchar" }) reverse_comparison_op: TriggerComparisonOp; @Column() reverse_comparison_value: number; @Column({ type: "datetime", nullable: true }) delete_at: string | null; } ================================================ FILE: backend/src/data/entities/CounterTriggerState.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("counter_trigger_states") export class CounterTriggerState { @Column({ type: "bigint", generated: "increment" }) @PrimaryColumn() id: string; @Column() trigger_id: number; @Column({ type: "bigint" }) channel_id: string; @Column({ type: "bigint" }) user_id: string; } ================================================ FILE: backend/src/data/entities/CounterValue.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("counter_values") export class CounterValue { @Column() @PrimaryColumn() id: string; @Column() counter_id: number; @Column({ type: "bigint" }) channel_id: string; @Column({ type: "bigint" }) user_id: string; @Column() value: number; } ================================================ FILE: backend/src/data/entities/MemberCacheItem.ts ================================================ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; @Entity("member_cache") export class MemberCacheItem { @PrimaryGeneratedColumn() id: number; @Column() guild_id: string; @Column() user_id: string; @Column() username: string; @Column({ type: String, nullable: true }) nickname: string | null; @Column("simple-json") roles: string[]; @Column() last_seen: string; @Column({ type: String, nullable: true }) delete_at: string | null; } ================================================ FILE: backend/src/data/entities/MemberTimezone.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("member_timezones") export class MemberTimezone { @Column() @PrimaryColumn() guild_id: string; @Column() @PrimaryColumn() member_id: string; @Column() timezone: string; } ================================================ FILE: backend/src/data/entities/Mute.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("mutes") export class Mute { @Column() @PrimaryColumn() guild_id: string; @Column() @PrimaryColumn() user_id: string; @Column() type: number; @Column() created_at: string; @Column({ type: String, nullable: true }) expires_at: string | null; @Column() case_id: number; @Column("simple-array") roles_to_restore: string[]; @Column({ type: String, nullable: true }) mute_role: string | null; @Column({ type: String, nullable: true }) timeout_expires_at: string | null; } ================================================ FILE: backend/src/data/entities/NicknameHistoryEntry.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("nickname_history") export class NicknameHistoryEntry { @Column() @PrimaryColumn() id: string; @Column() guild_id: string; @Column() user_id: string; @Column() nickname: string; @Column() timestamp: string; } ================================================ FILE: backend/src/data/entities/PersistedData.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("persisted_data") export class PersistedData { @Column() @PrimaryColumn() guild_id: string; @Column() @PrimaryColumn() user_id: string; @Column("simple-array") roles: string[]; @Column() nickname: string; @Column({ type: "boolean" }) is_voice_muted: boolean; } ================================================ FILE: backend/src/data/entities/PingableRole.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("pingable_roles") export class PingableRole { @Column() @PrimaryColumn() id: number; @Column() guild_id: string; @Column() channel_id: string; @Column() role_id: string; } ================================================ FILE: backend/src/data/entities/ReactionRole.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("reaction_roles") export class ReactionRole { @Column() @PrimaryColumn() guild_id: string; @Column() @PrimaryColumn() channel_id: string; @Column() @PrimaryColumn() message_id: string; @Column() @PrimaryColumn() emoji: string; @Column() role_id: string; @Column() is_exclusive: boolean; @Column() order: number; } ================================================ FILE: backend/src/data/entities/Reminder.ts ================================================ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; @Entity("reminders") export class Reminder { @PrimaryGeneratedColumn() id: number; @Column() guild_id: string; @Column() user_id: string; @Column() channel_id: string; @Column() remind_at: string; @Column() body: string; @Column() created_at: string; } ================================================ FILE: backend/src/data/entities/RoleButtonsItem.ts ================================================ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; @Entity("role_buttons") export class RoleButtonsItem { @PrimaryGeneratedColumn() id: number; @Column() guild_id: string; @Column() name: string; @Column() channel_id: string; @Column() message_id: string; @Column() hash: string; } ================================================ FILE: backend/src/data/entities/RoleQueueItem.ts ================================================ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; @Entity("role_queue") export class RoleQueueItem { @PrimaryGeneratedColumn() id: number; @Column() guild_id: string; @Column() user_id: string; @Column() role_id: string; @Column() should_add: boolean; @Column() priority: number; } ================================================ FILE: backend/src/data/entities/SavedMessage.ts ================================================ import { EmbedType, Snowflake, StickerFormatType, StickerType } from "discord.js"; import { Column, Entity, PrimaryColumn } from "typeorm"; export interface ISavedMessageAttachmentData { id: Snowflake; contentType: string | null; name: string | null; proxyURL: string; size: number; spoiler: boolean; url: string; width: number | null; } export interface ISavedMessageEmbedData { title: string | null; type?: EmbedType; description: string | null; url: string | null; timestamp: number | null; color: number | null; fields: Array<{ name: string; value: string; inline: boolean; }>; author?: { name?: string; url?: string; iconURL?: string; proxyIconURL?: string; }; thumbnail?: { url: string; proxyURL?: string; height?: number; width?: number; }; image?: { url: string; proxyURL?: string; height?: number; width?: number; }; video?: { url?: string; proxyURL?: string; height?: number; width?: number; }; footer?: { text?: string; iconURL?: string; proxyIconURL?: string; }; } export interface ISavedMessageStickerData { format: StickerFormatType; guildId: Snowflake | null; id: Snowflake; name: string; description: string | null; available: boolean | null; type: StickerType | null; } export interface ISavedMessageData { attachments?: ISavedMessageAttachmentData[]; author: { username: string; discriminator: string; }; content: string; embeds?: ISavedMessageEmbedData[]; stickers?: ISavedMessageStickerData[]; timestamp: number; reference?: { messageId?: Snowflake | null; channelId?: Snowflake | null; guildId?: Snowflake | null; }; } @Entity("messages") export class SavedMessage { @Column() @PrimaryColumn() id: string; @Column() guild_id: string; @Column() channel_id: string; @Column() user_id: string; @Column() is_bot: boolean; @Column({ type: "mediumtext", }) data: ISavedMessageData; @Column() posted_at: string; @Column() deleted_at: string; @Column() is_permanent: boolean; } ================================================ FILE: backend/src/data/entities/ScheduledPost.ts ================================================ import { Attachment } from "discord.js"; import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { StrictMessageContent } from "../../utils.js"; @Entity("scheduled_posts") export class ScheduledPost { @PrimaryGeneratedColumn() id: number; @Column() guild_id: string; @Column() author_id: string; @Column() author_name: string; @Column() channel_id: string; @Column("simple-json") content: StrictMessageContent; @Column("simple-json") attachments: Attachment[]; @Column({ type: String, nullable: true }) post_at: string | null; /** * How often to post the message, in milliseconds */ @Column({ type: String, nullable: true }) repeat_interval: number | null; @Column({ type: String, nullable: true }) repeat_until: string | null; @Column({ type: String, nullable: true }) repeat_times: number | null; @Column() enable_mentions: boolean; } ================================================ FILE: backend/src/data/entities/SlowmodeChannel.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("slowmode_channels") export class SlowmodeChannel { @Column() @PrimaryColumn() guild_id: string; @Column() @PrimaryColumn() channel_id: string; @Column() slowmode_seconds: number; } ================================================ FILE: backend/src/data/entities/SlowmodeUser.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("slowmode_users") export class SlowmodeUser { @Column() @PrimaryColumn() guild_id: string; @Column() @PrimaryColumn() channel_id: string; @Column() @PrimaryColumn() user_id: string; @Column() expires_at: string; } ================================================ FILE: backend/src/data/entities/StarboardMessage.ts ================================================ import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from "typeorm"; import { SavedMessage } from "./SavedMessage.js"; @Entity("starboard_messages") export class StarboardMessage { @Column() message_id: string; @Column() @PrimaryColumn() starboard_message_id: string; @Column() starboard_channel_id: string; @Column() guild_id: string; @OneToOne(() => SavedMessage) @JoinColumn({ name: "message_id" }) message: SavedMessage; } ================================================ FILE: backend/src/data/entities/StarboardReaction.ts ================================================ import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from "typeorm"; import { SavedMessage } from "./SavedMessage.js"; @Entity("starboard_reactions") export class StarboardReaction { @Column() @PrimaryColumn() id: string; @Column() guild_id: string; @Column() message_id: string; @Column() reactor_id: string; @OneToOne(() => SavedMessage) @JoinColumn({ name: "message_id" }) message: SavedMessage; } ================================================ FILE: backend/src/data/entities/StatValue.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("stats") export class StatValue { @Column() @PrimaryColumn() id: string; @Column() guild_id: string; @Column() source: string; @Column() key: string; @Column() value: number; @Column() created_at: string; } ================================================ FILE: backend/src/data/entities/Supporter.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("supporters") export class Supporter { @Column() @PrimaryColumn() user_id: string; @Column() name: string; @Column({ type: String, nullable: true }) amount: string | null; } ================================================ FILE: backend/src/data/entities/Tag.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("tags") export class Tag { @Column() @PrimaryColumn() guild_id: string; @Column() @PrimaryColumn() tag: string; @Column() user_id: string; @Column() body: string; @Column() created_at: string; } ================================================ FILE: backend/src/data/entities/TagResponse.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("tag_responses") export class TagResponse { @Column() @PrimaryColumn() id: string; @Column() guild_id: string; @Column() command_message_id: string; @Column() response_message_id: string; } ================================================ FILE: backend/src/data/entities/Tempban.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("tempbans") export class Tempban { @Column() @PrimaryColumn() guild_id: string; @Column() @PrimaryColumn() user_id: string; @Column() mod_id: string; @Column() created_at: string; @Column() expires_at: string; } ================================================ FILE: backend/src/data/entities/UsernameHistoryEntry.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("username_history") export class UsernameHistoryEntry { @Column() @PrimaryColumn() id: string; @Column() user_id: string; @Column() username: string; @Column() timestamp: string; } ================================================ FILE: backend/src/data/entities/VCAlert.ts ================================================ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; @Entity("vc_alerts") export class VCAlert { @PrimaryGeneratedColumn() id: number; @Column() guild_id: string; @Column() requestor_id: string; @Column() user_id: string; @Column() channel_id: string; @Column() expires_at: string; @Column() body: string; @Column() active: boolean; } ================================================ FILE: backend/src/data/entities/Webhook.ts ================================================ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity("webhooks") export class Webhook { @Column() @PrimaryColumn() id: string; @Column() guild_id: string; @Column() channel_id: string; @Column() token: string; } ================================================ FILE: backend/src/data/getChannelIdFromMessageId.ts ================================================ import { Repository } from "typeorm"; import { dataSource } from "./dataSource.js"; import { SavedMessage } from "./entities/SavedMessage.js"; let repository: Repository; export async function getChannelIdFromMessageId(messageId: string): Promise { if (!repository) { repository = dataSource.getRepository(SavedMessage); } const savedMessage = await repository.findOne({ where: { id: messageId } }); if (savedMessage) { return savedMessage.channel_id; } return null; } ================================================ FILE: backend/src/data/loops/expiredArchiveDeletionLoop.ts ================================================ // tslint:disable:no-console import { lazyMemoize, MINUTES } from "../../utils.js"; import { Archives } from "../Archives.js"; const LOOP_INTERVAL = 15 * MINUTES; const getArchivesRepository = lazyMemoize(() => new Archives()); export async function runExpiredArchiveDeletionLoop() { console.log("[EXPIRED ARCHIVE DELETION LOOP] Deleting expired archives"); await getArchivesRepository().deleteExpiredArchives(); setTimeout(() => runExpiredArchiveDeletionLoop(), LOOP_INTERVAL); } ================================================ FILE: backend/src/data/loops/expiredMemberCacheDeletionLoop.ts ================================================ // tslint:disable:no-console import { HOURS, lazyMemoize } from "../../utils.js"; import { MemberCache } from "../MemberCache.js"; const LOOP_INTERVAL = 6 * HOURS; const getMemberCacheRepository = lazyMemoize(() => new MemberCache()); export async function runExpiredMemberCacheDeletionLoop() { console.log("[EXPIRED MEMBER CACHE DELETION LOOP] Deleting stale member cache entries"); await getMemberCacheRepository().deleteStaleData(); setTimeout(() => runExpiredMemberCacheDeletionLoop(), LOOP_INTERVAL); } ================================================ FILE: backend/src/data/loops/expiringMutesLoop.ts ================================================ // tslint:disable:no-console import moment from "moment-timezone"; import { lazyMemoize, MINUTES, SECONDS } from "../../utils.js"; import { Mute } from "../entities/Mute.js"; import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents.js"; import { Mutes, TIMEOUT_RENEWAL_THRESHOLD } from "../Mutes.js"; import Timeout = NodeJS.Timeout; const LOOP_INTERVAL = 15 * MINUTES; const MAX_TRIES_PER_SERVER = 3; const getMutesRepository = lazyMemoize(() => new Mutes()); const timeouts = new Map(); function muteToKey(mute: Mute) { return `${mute.guild_id}/${mute.user_id}`; } async function broadcastExpiredMute(guildId: string, userId: string, tries = 0): Promise { const mute = await getMutesRepository().findMute(guildId, userId); if (!mute) { // Mute was already cleared return; } if (!mute.expires_at || moment(mute.expires_at).diff(moment()) > 10 * SECONDS) { // Mute duration was changed and it's no longer expiring now return; } console.log(`[EXPIRING MUTES LOOP] Broadcasting expired mute: ${mute.guild_id}/${mute.user_id}`); if (!hasGuildEventListener(mute.guild_id, "expiredMute")) { // If there are no listeners registered for the server yet, try again in a bit if (tries < MAX_TRIES_PER_SERVER) { timeouts.set( muteToKey(mute), setTimeout(() => broadcastExpiredMute(guildId, userId, tries + 1), 1 * MINUTES), ); } return; } emitGuildEvent(mute.guild_id, "expiredMute", [mute]); } function broadcastTimeoutMuteToRenew(mute: Mute, tries = 0) { if (!hasGuildEventListener(mute.guild_id, "timeoutMuteToRenew")) { // If there are no listeners registered for the server yet, try again in a bit if (tries < MAX_TRIES_PER_SERVER) { timeouts.set( muteToKey(mute), setTimeout(() => broadcastTimeoutMuteToRenew(mute, tries + 1), 1 * MINUTES), ); } return; } console.log(`[EXPIRING MUTES LOOP] Broadcasting timeout mute to renew: ${mute.guild_id}/${mute.user_id}`); emitGuildEvent(mute.guild_id, "timeoutMuteToRenew", [mute]); } export async function runExpiringMutesLoop() { console.log("[EXPIRING MUTES LOOP] Clearing old timeouts"); for (const timeout of timeouts.values()) { clearTimeout(timeout); } console.log("[EXPIRING MUTES LOOP] Clearing old expired mutes"); await getMutesRepository().clearOldExpiredMutes(); console.log("[EXPIRING MUTES LOOP] Setting timeouts for expiring mutes"); const expiringMutes = await getMutesRepository().getSoonExpiringMutes(LOOP_INTERVAL); for (const mute of expiringMutes) { const remaining = Math.max(0, moment.utc(mute.expires_at!).diff(moment.utc())); timeouts.set( muteToKey(mute), setTimeout(() => broadcastExpiredMute(mute.guild_id, mute.user_id), remaining), ); } console.log("[EXPIRING MUTES LOOP] Broadcasting timeout mutes to renew"); const timeoutMutesToRenew = await getMutesRepository().getTimeoutMutesToRenew(TIMEOUT_RENEWAL_THRESHOLD); for (const mute of timeoutMutesToRenew) { broadcastTimeoutMuteToRenew(mute); } console.log("[EXPIRING MUTES LOOP] Scheduling next loop"); setTimeout(() => runExpiringMutesLoop(), LOOP_INTERVAL); } export function registerExpiringMute(mute: Mute) { clearExpiringMute(mute); if (mute.expires_at === null) { return; } console.log("[EXPIRING MUTES LOOP] Registering new expiring mute"); const remaining = Math.max(0, moment.utc(mute.expires_at).diff(moment.utc())); if (remaining > LOOP_INTERVAL) { return; } timeouts.set( muteToKey(mute), setTimeout(() => broadcastExpiredMute(mute.guild_id, mute.user_id), remaining), ); } export function clearExpiringMute(mute: Mute) { console.log("[EXPIRING MUTES LOOP] Clearing expiring mute"); if (timeouts.has(muteToKey(mute))) { clearTimeout(timeouts.get(muteToKey(mute))!); } } ================================================ FILE: backend/src/data/loops/expiringTempbansLoop.ts ================================================ // tslint:disable:no-console import moment from "moment-timezone"; import { lazyMemoize, MINUTES } from "../../utils.js"; import { Tempban } from "../entities/Tempban.js"; import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents.js"; import { Tempbans } from "../Tempbans.js"; import Timeout = NodeJS.Timeout; const LOOP_INTERVAL = 15 * MINUTES; const MAX_TRIES_PER_SERVER = 3; const getBansRepository = lazyMemoize(() => new Tempbans()); const timeouts = new Map(); function tempbanToKey(tempban: Tempban) { return `${tempban.guild_id}/${tempban.user_id}`; } function broadcastExpiredTempban(tempban: Tempban, tries = 0) { if (!hasGuildEventListener(tempban.guild_id, "expiredTempban")) { // If there are no listeners registered for the server yet, try again in a bit if (tries < MAX_TRIES_PER_SERVER) { timeouts.set( tempbanToKey(tempban), setTimeout(() => broadcastExpiredTempban(tempban, tries + 1), 1 * MINUTES), ); } return; } console.log(`[EXPIRING TEMPBANS LOOP] Broadcasting expired tempban: ${tempban.guild_id}/${tempban.user_id}`); emitGuildEvent(tempban.guild_id, "expiredTempban", [tempban]); } export async function runExpiringTempbansLoop() { console.log("[EXPIRING TEMPBANS LOOP] Clearing old timeouts"); for (const timeout of timeouts.values()) { clearTimeout(timeout); } console.log("[EXPIRING TEMPBANS LOOP] Setting timeouts for expiring tempbans"); const expiringTempbans = await getBansRepository().getSoonExpiringTempbans(LOOP_INTERVAL); for (const tempban of expiringTempbans) { const remaining = Math.max(0, moment.utc(tempban.expires_at!).diff(moment.utc())); timeouts.set( tempbanToKey(tempban), setTimeout(() => broadcastExpiredTempban(tempban), remaining), ); } console.log("[EXPIRING TEMPBANS LOOP] Scheduling next loop"); setTimeout(() => runExpiringTempbansLoop(), LOOP_INTERVAL); } export function registerExpiringTempban(tempban: Tempban) { clearExpiringTempban(tempban); console.log("[EXPIRING TEMPBANS LOOP] Registering new expiring tempban"); const remaining = Math.max(0, moment.utc(tempban.expires_at).diff(moment.utc())); if (remaining > LOOP_INTERVAL) { return; } timeouts.set( tempbanToKey(tempban), setTimeout(() => broadcastExpiredTempban(tempban), remaining), ); } export function clearExpiringTempban(tempban: Tempban) { console.log("[EXPIRING TEMPBANS LOOP] Clearing expiring tempban"); if (timeouts.has(tempbanToKey(tempban))) { clearTimeout(timeouts.get(tempbanToKey(tempban))!); } } ================================================ FILE: backend/src/data/loops/expiringVCAlertsLoop.ts ================================================ // tslint:disable:no-console import moment from "moment-timezone"; import { lazyMemoize, MINUTES } from "../../utils.js"; import { VCAlert } from "../entities/VCAlert.js"; import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents.js"; import { VCAlerts } from "../VCAlerts.js"; import Timeout = NodeJS.Timeout; const LOOP_INTERVAL = 15 * MINUTES; const MAX_TRIES_PER_SERVER = 3; const getVCAlertsRepository = lazyMemoize(() => new VCAlerts()); const timeouts = new Map(); function broadcastExpiredVCAlert(alert: VCAlert, tries = 0) { console.log(`[EXPIRING VCALERTS LOOP] Broadcasting expired vcalert: ${alert.guild_id}/${alert.user_id}`); if (!hasGuildEventListener(alert.guild_id, "expiredVCAlert")) { // If there are no listeners registered for the server yet, try again in a bit if (tries < MAX_TRIES_PER_SERVER) { timeouts.set( alert.id, setTimeout(() => broadcastExpiredVCAlert(alert, tries + 1), 1 * MINUTES), ); } return; } emitGuildEvent(alert.guild_id, "expiredVCAlert", [alert]); } export async function runExpiringVCAlertsLoop() { console.log("[EXPIRING VCALERTS LOOP] Clearing old timeouts"); for (const timeout of timeouts.values()) { clearTimeout(timeout); } console.log("[EXPIRING VCALERTS LOOP] Setting timeouts for expiring vcalerts"); const expiringVCAlerts = await getVCAlertsRepository().getSoonExpiringAlerts(LOOP_INTERVAL); for (const alert of expiringVCAlerts) { const remaining = Math.max(0, moment.utc(alert.expires_at!).diff(moment.utc())); timeouts.set( alert.id, setTimeout(() => broadcastExpiredVCAlert(alert), remaining), ); } console.log("[EXPIRING VCALERTS LOOP] Scheduling next loop"); setTimeout(() => runExpiringVCAlertsLoop(), LOOP_INTERVAL); } export function registerExpiringVCAlert(alert: VCAlert) { clearExpiringVCAlert(alert); console.log("[EXPIRING VCALERTS LOOP] Registering new expiring vcalert"); const remaining = Math.max(0, moment.utc(alert.expires_at).diff(moment.utc())); if (remaining > LOOP_INTERVAL) { return; } timeouts.set( alert.id, setTimeout(() => broadcastExpiredVCAlert(alert), remaining), ); } export function clearExpiringVCAlert(alert: VCAlert) { console.log("[EXPIRING VCALERTS LOOP] Clearing expiring vcalert"); if (timeouts.has(alert.id)) { clearTimeout(timeouts.get(alert.id)!); } } ================================================ FILE: backend/src/data/loops/memberCacheDeletionLoop.ts ================================================ // tslint:disable:no-console import { lazyMemoize, MINUTES } from "../../utils.js"; import { MemberCache } from "../MemberCache.js"; const LOOP_INTERVAL = 5 * MINUTES; const getMemberCacheRepository = lazyMemoize(() => new MemberCache()); export async function runMemberCacheDeletionLoop() { console.log("[MEMBER CACHE DELETION LOOP] Deleting entries marked to be deleted"); await getMemberCacheRepository().deleteMarkedToBeDeletedEntries(); setTimeout(() => runMemberCacheDeletionLoop(), LOOP_INTERVAL); } ================================================ FILE: backend/src/data/loops/savedMessageCleanupLoop.ts ================================================ // tslint:disable:no-console import { MINUTES } from "../../utils.js"; import { cleanupMessages } from "../cleanup/messages.js"; const LOOP_INTERVAL = 5 * MINUTES; export async function runSavedMessageCleanupLoop() { try { console.log("[SAVED MESSAGE CLEANUP LOOP] Deleting old/deleted messages from the database"); const deleted = await cleanupMessages(); console.log(`[SAVED MESSAGE CLEANUP LOOP] Deleted ${deleted} old/deleted messages from the database`); } finally { setTimeout(() => runSavedMessageCleanupLoop(), LOOP_INTERVAL); } } ================================================ FILE: backend/src/data/loops/upcomingRemindersLoop.ts ================================================ // tslint:disable:no-console import moment from "moment-timezone"; import { lazyMemoize, MINUTES } from "../../utils.js"; import { Reminder } from "../entities/Reminder.js"; import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents.js"; import { Reminders } from "../Reminders.js"; import Timeout = NodeJS.Timeout; const LOOP_INTERVAL = 15 * MINUTES; const MAX_TRIES_PER_SERVER = 3; const getRemindersRepository = lazyMemoize(() => new Reminders()); const timeouts = new Map(); function broadcastReminder(reminder: Reminder, tries = 0) { if (!hasGuildEventListener(reminder.guild_id, "reminder")) { // If there are no listeners registered for the server yet, try again in a bit if (tries < MAX_TRIES_PER_SERVER) { timeouts.set( reminder.id, setTimeout(() => broadcastReminder(reminder, tries + 1), 1 * MINUTES), ); } return; } emitGuildEvent(reminder.guild_id, "reminder", [reminder]); } export async function runUpcomingRemindersLoop() { console.log("[REMINDERS LOOP] Clearing old timeouts"); for (const timeout of timeouts.values()) { clearTimeout(timeout); } console.log("[REMINDERS LOOP] Setting timeouts for upcoming reminders"); const remindersDueSoon = await getRemindersRepository().getRemindersDueSoon(LOOP_INTERVAL); for (const reminder of remindersDueSoon) { const remaining = Math.max(0, moment.utc(reminder.remind_at!).diff(moment.utc())); timeouts.set( reminder.id, setTimeout(() => broadcastReminder(reminder), remaining), ); } console.log("[REMINDERS LOOP] Scheduling next loop"); setTimeout(() => runUpcomingRemindersLoop(), LOOP_INTERVAL); } export function registerUpcomingReminder(reminder: Reminder) { clearUpcomingReminder(reminder); console.log("[REMINDERS LOOP] Registering new upcoming reminder"); const remaining = Math.max(0, moment.utc(reminder.remind_at).diff(moment.utc())); if (remaining > LOOP_INTERVAL) { return; } timeouts.set( reminder.id, setTimeout(() => broadcastReminder(reminder), remaining), ); } export function clearUpcomingReminder(reminder: Reminder) { console.log("[REMINDERS LOOP] Clearing upcoming reminder"); if (timeouts.has(reminder.id)) { clearTimeout(timeouts.get(reminder.id)!); } } ================================================ FILE: backend/src/data/loops/upcomingScheduledPostsLoop.ts ================================================ // tslint:disable:no-console import moment from "moment-timezone"; import { lazyMemoize, MINUTES } from "../../utils.js"; import { ScheduledPost } from "../entities/ScheduledPost.js"; import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents.js"; import { ScheduledPosts } from "../ScheduledPosts.js"; import Timeout = NodeJS.Timeout; const LOOP_INTERVAL = 15 * MINUTES; const MAX_TRIES_PER_SERVER = 3; const getScheduledPostsRepository = lazyMemoize(() => new ScheduledPosts()); const timeouts = new Map(); function broadcastScheduledPost(post: ScheduledPost, tries = 0) { if (!hasGuildEventListener(post.guild_id, "scheduledPost")) { // If there are no listeners registered for the server yet, try again in a bit if (tries < MAX_TRIES_PER_SERVER) { timeouts.set( post.id, setTimeout(() => broadcastScheduledPost(post, tries + 1), 1 * MINUTES), ); } return; } emitGuildEvent(post.guild_id, "scheduledPost", [post]); } export async function runUpcomingScheduledPostsLoop() { console.log("[SCHEDULED POSTS LOOP] Clearing old timeouts"); for (const timeout of timeouts.values()) { clearTimeout(timeout); } console.log("[SCHEDULED POSTS LOOP] Setting timeouts for upcoming scheduled posts"); const postsDueSoon = await getScheduledPostsRepository().getScheduledPostsDueSoon(LOOP_INTERVAL); for (const post of postsDueSoon) { const remaining = Math.max(0, moment.utc(post.post_at!).diff(moment.utc())); timeouts.set( post.id, setTimeout(() => broadcastScheduledPost(post), remaining), ); } console.log("[SCHEDULED POSTS LOOP] Scheduling next loop"); setTimeout(() => runUpcomingScheduledPostsLoop(), LOOP_INTERVAL); } export function registerUpcomingScheduledPost(post: ScheduledPost) { clearUpcomingScheduledPost(post); if (post.post_at === null) { return; } const remaining = Math.max(0, moment.utc(post.post_at).diff(moment.utc())); if (remaining > LOOP_INTERVAL) { return; } console.log("[SCHEDULED POSTS LOOP] Registering new upcoming scheduled post"); timeouts.set( post.id, setTimeout(() => broadcastScheduledPost(post), remaining), ); } export function clearUpcomingScheduledPost(post: ScheduledPost) { if (timeouts.has(post.id)) { console.log("[SCHEDULED POSTS LOOP] Clearing upcoming scheduled post"); clearTimeout(timeouts.get(post.id)!); } } ================================================ FILE: backend/src/data/queryLogger.ts ================================================ import { AdvancedConsoleLogger } from "typeorm"; let groupedQueryStats: Map = new Map(); const selectTableRegex = /FROM `?([^\s`]+)/i; const updateTableRegex = /UPDATE `?([^\s`]+)/i; const deleteTableRegex = /FROM `?([^\s`]+)/; const insertTableRegex = /INTO `?([^\s`]+)/; export class QueryLogger extends AdvancedConsoleLogger { logQuery(query: string): any { let type: string | undefined; let table: string | undefined; if (query.startsWith("SELECT")) { type = "SELECT"; table = query.match(selectTableRegex)?.[1]; } else if (query.startsWith("UPDATE")) { type = "UPDATE"; table = query.match(updateTableRegex)?.[1]; } else if (query.startsWith("DELETE")) { type = "DELETE"; table = query.match(deleteTableRegex)?.[1]; } else if (query.startsWith("INSERT")) { type = "INSERT"; table = query.match(insertTableRegex)?.[1]; } else { return; } const key = `${type} ${table}`; const newCount = (groupedQueryStats.get(key) ?? 0) + 1; groupedQueryStats.set(key, newCount); } } export function consumeQueryStats() { const map = groupedQueryStats; groupedQueryStats = new Map(); return map; } ================================================ FILE: backend/src/data/redis.ts ================================================ import { createClient } from "redis"; import { env } from "../env.js"; // Silly type inference issue... https://github.com/redis/node-redis/issues/1732#issuecomment-979977316 type RedisClient = ReturnType; export const redis: RedisClient = await createClient({ url: env.REDIS_URL }).connect(); ================================================ FILE: backend/src/debugCounters.ts ================================================ type DebugCounterValue = { count: number; }; const debugCounterValueMap = new Map(); export function incrementDebugCounter(name: string) { if (!debugCounterValueMap.has(name)) { debugCounterValueMap.set(name, { count: 0 }); } debugCounterValueMap.get(name)!.count++; } export function getDebugCounterValues() { return debugCounterValueMap; } ================================================ FILE: backend/src/env.ts ================================================ import dotenv from "dotenv"; import fs from "fs"; import path from "path"; import { z } from "zod"; import { rootDir } from "./paths.js"; const envType = z.object({ KEY: z.string().length(32), CLIENT_ID: z.string().min(16), CLIENT_SECRET: z.string().length(32), BOT_TOKEN: z.string().min(50), DASHBOARD_URL: z.string().url(), API_URL: z.string().url(), STAFF: z .preprocess( (v) => String(v) .split(",") .map((s) => s.trim()) .filter((s) => s !== ""), z.array(z.string()), ) .optional(), DEFAULT_ALLOWED_SERVERS: z .preprocess( (v) => String(v) .split(",") .map((s) => s.trim()) .filter((s) => s !== ""), z.array(z.string()), ) .optional(), PHISHERMAN_API_KEY: z.string().optional(), FISHFISH_API_KEY: z.string().optional(), DEFAULT_SUCCESS_EMOJI: z.string().optional().default("✅"), DEFAULT_ERROR_EMOJI: z.string().optional().default("❌"), DB_HOST: z.string().optional(), DB_PORT: z.preprocess((v) => Number(v), z.number()).optional(), DB_USER: z.string().optional(), DB_PASSWORD: z.string().optional(), DB_DATABASE: z.string().optional(), REDIS_URL: z.string().default("redis://redis:6379"), DEVELOPMENT_MYSQL_PASSWORD: z.string().optional(), API_PATH_PREFIX: z.string().optional(), DEBUG: z .string() .optional() .transform((str) => str === "true"), NODE_ENV: z.string().default("development"), }); let toValidate = { ...process.env }; const envPath = path.join(rootDir, ".env"); if (fs.existsSync(envPath)) { const buf = fs.readFileSync(envPath); toValidate = { ...toValidate, ...dotenv.parse(buf) }; } export const env = envType.parse(toValidate); ================================================ FILE: backend/src/exportSchemas.ts ================================================ import fs from "node:fs"; import { z } from "zod"; import { availableGuildPlugins } from "./plugins/availablePlugins.js"; import { zZeppelinGuildConfig } from "./types.js"; import { deepPartial } from "./utils/zodDeepPartial.js"; const basePluginOverrideCriteriaSchema = z.strictObject({ channel: z .union([z.string(), z.array(z.string())]) .nullable() .optional(), category: z .union([z.string(), z.array(z.string())]) .nullable() .optional(), level: z .union([z.string(), z.array(z.string())]) .nullable() .optional(), user: z .union([z.string(), z.array(z.string())]) .nullable() .optional(), role: z .union([z.string(), z.array(z.string())]) .nullable() .optional(), thread: z .union([z.string(), z.array(z.string())]) .nullable() .optional(), is_thread: z.boolean().nullable().optional(), thread_type: z.literal(["public", "private"]).nullable().optional(), extra: z.any().optional(), }); const pluginOverrideCriteriaSchema = basePluginOverrideCriteriaSchema .extend({ get zzz_dummy_property_do_not_use() { return pluginOverrideCriteriaSchema.optional(); }, get all() { return z.array(pluginOverrideCriteriaSchema).optional(); }, get any() { return z.array(pluginOverrideCriteriaSchema).optional(); }, get not() { return pluginOverrideCriteriaSchema.optional(); }, }) .meta({ id: "overrideCriteria", }); const outputPath = process.argv[2]; if (!outputPath) { console.error("Output path required"); process.exit(1); } const partialConfigs = new Map(); function getPartialConfig(configSchema: z.ZodType) { if (!partialConfigs.has(configSchema)) { partialConfigs.set(configSchema, deepPartial(configSchema)); } return partialConfigs.get(configSchema)!; } function overrides(configSchema: z.ZodType): z.ZodType { const partialConfig = getPartialConfig(configSchema); return pluginOverrideCriteriaSchema.extend({ config: partialConfig, }); } const pluginSchemaMap = availableGuildPlugins.reduce((map, pluginInfo) => { map[pluginInfo.plugin.name] = z.object({ config: pluginInfo.docs.configSchema.optional(), overrides: z.array(overrides(pluginInfo.docs.configSchema)).optional(), }); return map; }, {}); const fullSchema = zZeppelinGuildConfig.omit({ plugins: true }).extend({ plugins: z.strictObject(pluginSchemaMap).partial().optional(), }); const jsonSchema = z.toJSONSchema(fullSchema, { io: "input", cycles: "ref" }); fs.writeFileSync(outputPath, JSON.stringify(jsonSchema, null, 2), { encoding: "utf8" }); process.exit(0); ================================================ FILE: backend/src/globals.ts ================================================ let isAPIValue = false; export function isAPI() { return isAPIValue; } export function setIsAPI(value: boolean) { isAPIValue = value; } ================================================ FILE: backend/src/humanizeDuration.ts ================================================ import humanizeduration from "humanize-duration"; export const delayStringMultipliers = { y: 1000 * 60 * 60 * 24 * (365 + 1 / 4 - 1 / 100 + 1 / 400), mo: (1000 * 60 * 60 * 24 * (365 + 1 / 4 - 1 / 100 + 1 / 400)) / 12, w: 1000 * 60 * 60 * 24 * 7, d: 1000 * 60 * 60 * 24, h: 1000 * 60 * 60, m: 1000 * 60, s: 1000, x: 1, }; export const humanizeDurationShort = humanizeduration.humanizer({ language: "shortEn", languages: { shortEn: { y: () => "y", mo: () => "mo", w: () => "w", d: () => "d", h: () => "h", m: () => "m", s: () => "s", ms: () => "ms", }, }, spacer: "", unitMeasures: delayStringMultipliers, }); export const humanizeDuration = humanizeduration.humanizer({ unitMeasures: delayStringMultipliers, }); ================================================ FILE: backend/src/index.ts ================================================ // KEEP THIS AS FIRST IMPORT // See comment in module for details import "./threadsSignalFix.js"; import { Client, Events, GatewayIntentBits, Options, Partials, RESTEvents, TextChannel, ThreadChannel, } from "discord.js"; import { Vety, PluginError, PluginLoadError, PluginNotLoadedError } from "vety"; import moment from "moment-timezone"; import { performance } from "perf_hooks"; import process from "process"; import { DiscordJSError } from "./DiscordJSError.js"; import { RecoverablePluginError } from "./RecoverablePluginError.js"; import { SimpleError } from "./SimpleError.js"; import { AllowedGuilds } from "./data/AllowedGuilds.js"; import { Configs } from "./data/Configs.js"; import { FishFishError, initFishFish } from "./data/FishFish.js"; import { GuildLogs } from "./data/GuildLogs.js"; import { LogType } from "./data/LogType.js"; import { dataSource } from "./data/dataSource.js"; import { connect } from "./data/db.js"; import { runExpiredArchiveDeletionLoop } from "./data/loops/expiredArchiveDeletionLoop.js"; import { runExpiredMemberCacheDeletionLoop } from "./data/loops/expiredMemberCacheDeletionLoop.js"; import { runExpiringMutesLoop } from "./data/loops/expiringMutesLoop.js"; import { runExpiringTempbansLoop } from "./data/loops/expiringTempbansLoop.js"; import { runExpiringVCAlertsLoop } from "./data/loops/expiringVCAlertsLoop.js"; import { runMemberCacheDeletionLoop } from "./data/loops/memberCacheDeletionLoop.js"; import { runSavedMessageCleanupLoop } from "./data/loops/savedMessageCleanupLoop.js"; import { runUpcomingRemindersLoop } from "./data/loops/upcomingRemindersLoop.js"; import { runUpcomingScheduledPostsLoop } from "./data/loops/upcomingScheduledPostsLoop.js"; import { consumeQueryStats } from "./data/queryLogger.js"; import { env } from "./env.js"; import { logger } from "./logger.js"; import { availableGlobalPlugins, availableGuildPlugins } from "./plugins/availablePlugins.js"; import { setProfiler } from "./profiler.js"; import { logRateLimit } from "./rateLimitStats.js"; import { startUptimeCounter } from "./uptime.js"; import { MINUTES, SECONDS, errorMessage, isDiscordAPIError, isDiscordHTTPError, sleep, successMessage, } from "./utils.js"; import { DecayingCounter } from "./utils/DecayingCounter.js"; import { enableProfiling } from "./utils/easyProfiler.js"; import { loadYamlSafely } from "./utils/loadYamlSafely.js"; // Error handling let recentPluginErrors = 0; const RECENT_PLUGIN_ERROR_EXIT_THRESHOLD = 5; let recentDiscordErrors = 0; const RECENT_DISCORD_ERROR_EXIT_THRESHOLD = 5; setInterval(() => (recentPluginErrors = Math.max(0, recentPluginErrors - 1)), 2000); setInterval(() => (recentDiscordErrors = Math.max(0, recentDiscordErrors - 1)), 2000); // Eris handles these internally, so we don't need to panic if we get one of them const SAFE_TO_IGNORE_ERIS_ERROR_CODES = [ 1001, // "CloudFlare WebSocket proxy restarting" 1006, // "Connection reset by peer" "ECONNRESET", // Pretty much the same as above ]; const SAFE_TO_IGNORE_ERIS_ERROR_MESSAGES = ["Server didn't acknowledge previous heartbeat, possible lost connection"]; // Ignore plugin load errors during initial startup to avoid noise in the logs let ignorePluginLoadErrors = true; function errorHandler(err) { const guildId = err.guild?.id || err.guildId || "0"; const guildName = err.guild?.name || (guildId && guildId !== "0" ? "Unknown" : "Global"); if (err instanceof RecoverablePluginError) { // Recoverable plugin errors can be, well, recovered from. // Log it in the console as a warning and post a warning to the guild's log. // tslint:disable:no-console console.warn(`${guildId} ${guildName}: [${err.code}] ${err.message}`); if (err.guild) { const logs = new GuildLogs(err.guild.id); logs.log(LogType.BOT_ALERT, { body: `\`[${err.code}]\` ${err.message}` }); } return; } if (err instanceof PluginLoadError) { if (!ignorePluginLoadErrors) { // tslint:disable:no-console console.warn(`${guildName} (${guildId}): Failed to load plugin '${err.pluginName}': ${err.message}`); } return; } if (err instanceof DiscordJSError) { if (err.code && SAFE_TO_IGNORE_ERIS_ERROR_CODES.includes(err.code)) { return; } if (err.message && SAFE_TO_IGNORE_ERIS_ERROR_MESSAGES.includes(err.message)) { return; } } if (isDiscordHTTPError(err) && err.code >= 500) { // Don't need stack traces on HTTP 500 errors // These also shouldn't count towards RECENT_DISCORD_ERROR_EXIT_THRESHOLD because they don't indicate an error in our code console.error(err.message); return; } if (err.message && err.message.startsWith("Request timed out")) { // These are very noisy, so just print the message without stack. The stack trace doesn't really help here anyway. console.error(err.message); return; } // FIXME: Hotfix if (err.message && err.message.startsWith("Unknown custom override criteria")) { // console.warn(err.message); return; } // FIXME: Hotfix if (err.message && err.message.startsWith("Unknown override criteria")) { // console.warn(err.message); return; } if (err instanceof PluginNotLoadedError) { // We don't want to crash the bot here, although this *should not happen* // TODO: Proper system for preventing plugin load/unload race conditions console.error(err); return; } if (err instanceof FishFishError) { // FishFish errors are not critical, so we just log them console.error(`[FISHFISH] ${err.message}`); return; } // tslint:disable:no-console console.error(err); if (err instanceof PluginError) { // Tolerate a few recent plugin errors before crashing if (++recentPluginErrors >= RECENT_PLUGIN_ERROR_EXIT_THRESHOLD) { console.error(`Exiting after ${RECENT_PLUGIN_ERROR_EXIT_THRESHOLD} plugin errors`); process.exit(1); } } else if (isDiscordAPIError(err) || isDiscordHTTPError(err)) { // Discord API errors, usually safe to just log instead of crash // We still bail if we get a ton of them in a short amount of time if (++recentDiscordErrors >= RECENT_DISCORD_ERROR_EXIT_THRESHOLD) { console.error(`Exiting after ${RECENT_DISCORD_ERROR_EXIT_THRESHOLD} API errors`); process.exit(1); } } else { // On other errors, crash immediately process.exit(1); } // tslint:enable:no-console } process.on("uncaughtException", errorHandler); process.on("unhandledRejection", errorHandler); // Verify required Node.js version const REQUIRED_NODE_VERSION = "16.9.0"; const requiredParts = REQUIRED_NODE_VERSION.split(".").map((v) => parseInt(v, 10)); const actualVersionParts = process.versions.node.split(".").map((v) => parseInt(v, 10)); for (const [i, part] of actualVersionParts.entries()) { if (part > requiredParts[i]) break; if (part === requiredParts[i]) continue; throw new SimpleError(`Unsupported Node.js version! Must be at least ${REQUIRED_NODE_VERSION}`); } // Always use UTC internally // This is also enforced for the database in data/db.ts moment.tz.setDefault("UTC"); // Blocking check let avgTotal = 0; let avgCount = 0; let lastCheck = performance.now(); setInterval(() => { const now = performance.now(); let diff = Math.max(0, now - lastCheck); if (diff < 5) diff = 0; avgTotal += diff; avgCount++; lastCheck = now; }, 500); setInterval( () => { const avgBlocking = avgTotal / (avgCount || 1); // FIXME: Debug // tslint:disable-next-line:no-console console.log(`Average blocking in the last 5min: ${avgBlocking / avgTotal}ms`); avgTotal = 0; avgCount = 0; }, 5 * 60 * 1000, ); if (env.DEBUG) { logger.info("NOTE: Bot started in DEBUG mode"); } logger.info("Connecting to database"); connect().then(async () => { const client = new Client({ partials: [Partials.User, Partials.Channel, Partials.GuildMember, Partials.Message, Partials.Reaction], makeCache: Options.cacheWithLimits({ ...Options.DefaultMakeCacheSettings, MessageManager: 1, // GuildMemberManager: 15000, GuildInviteManager: 0, }), rest: { // globalRequestsPerSecond: 50, // offset: 1000, }, // Disable mentions by default allowedMentions: { parse: [], users: [], roles: [], repliedUser: false, }, intents: [ // Privileged GatewayIntentBits.GuildMembers, GatewayIntentBits.MessageContent, // GatewayIntentBits.GuildPresences, // Regular GatewayIntentBits.GuildMessageTyping, GatewayIntentBits.DirectMessages, GatewayIntentBits.GuildModeration, GatewayIntentBits.GuildEmojisAndStickers, GatewayIntentBits.GuildInvites, GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.GuildMessages, GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates, ], }); client.setMaxListeners(200); const safe429DecayInterval = 5 * SECONDS; const safe429MaxCount = 5; const safe429Counter = new DecayingCounter(safe429DecayInterval); client.on(Events.Debug, (errorText) => { if (!errorText.includes("429")) { return; } // tslint:disable-next-line:no-console console.warn(`[DEBUG] [WARN] [429] ${errorText}`); const value = safe429Counter.add(1); if (value > safe429MaxCount) { // tslint:disable-next-line:no-console console.error(`Too many 429s (over ${safe429MaxCount} in ${safe429MaxCount * safe429DecayInterval}ms), exiting`); process.exit(1); } }); client.on("error", (err) => { if (err instanceof PluginLoadError) { errorHandler(err); return; } errorHandler(new DiscordJSError(err.message, (err as any).code, 0)); }); const allowedGuilds = new AllowedGuilds(); const guildConfigs = new Configs(); const bot = new Vety(client, { guildPlugins: availableGuildPlugins.map((obj) => obj.plugin), globalPlugins: availableGlobalPlugins.map((obj) => obj.plugin), options: { canLoadGuild(guildId): Promise { return allowedGuilds.isAllowed(guildId); }, /** * Plugins are enabled if they... * - are marked to be autoloaded, or * - are explicitly enabled in the guild config * Dependencies are also automatically loaded by Vety. */ async getEnabledGuildPlugins(ctx, plugins): Promise { if (!ctx.config || !ctx.config.plugins) { return []; } const configuredPlugins = ctx.config.plugins; const autoloadPluginNames = availableGuildPlugins.filter((obj) => obj.autoload).map((obj) => obj.plugin.name); return Array.from(plugins.keys()).filter((pluginName) => { if (autoloadPluginNames.includes(pluginName)) return true; return configuredPlugins[pluginName] && (configuredPlugins[pluginName] as any).enabled !== false; }); }, async getConfig(id) { const key = id === "global" ? "global" : `guild-${id}`; if (id !== "global") { const allowedGuild = await allowedGuilds.find(id); if (!allowedGuild) { return {}; } } const row = await guildConfigs.getActiveByKey(key); if (row) { try { const loaded = loadYamlSafely(row.config); if (loaded.success_emoji || loaded.error_emoji) { const deprecatedKeys = [] as string[]; // const exampleConfig = `plugins:\n common:\n config:\n success_emoji: "👍"\n error_emoji: "👎"`; if (loaded.success_emoji) { deprecatedKeys.push("success_emoji"); } if (loaded.error_emoji) { deprecatedKeys.push("error_emoji"); } // logger.warn(`Deprecated config properties found in "${key}": ${deprecatedKeys.join(", ")}`); // logger.warn(`You can now configure those emojis in the "common" plugin config\n${exampleConfig}`); } // Remove deprecated properties some may still have in their config delete loaded.success_emoji; delete loaded.error_emoji; return loaded; } catch (err) { logger.error(`Error while loading config "${key}"`); return {}; } } logger.warn(`No config with key "${key}"`); return {}; }, logFn: (level, msg) => { if (level === "debug") return; if (logger[level]) { logger[level](msg); } else { logger.log(`[${level.toUpperCase()}] ${msg}`); } }, performanceDebug: { enabled: false, size: 30, threshold: 200, }, sendSuccessMessageFn(channel, body) { const guildId = channel instanceof TextChannel || channel instanceof ThreadChannel ? channel.guild.id : undefined; // @ts-expect-error const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.success_emoji : undefined; channel.send(successMessage(body, emoji)); }, sendErrorMessageFn(channel, body) { const guildId = channel instanceof TextChannel || channel instanceof ThreadChannel ? channel.guild.id : undefined; // @ts-expect-error const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.error_emoji : undefined; channel.send(errorMessage(body, emoji)); }, }, }); client.once("clientReady", () => { startUptimeCounter(); }); client.rest.on(RESTEvents.RateLimited, (data) => { logRateLimit(data); }); bot.on("error", errorHandler); bot.on("loadingFinished", async () => { setProfiler(bot.profiler); if (process.env.PROFILING === "true") { enableProfiling(); } ignorePluginLoadErrors = false; initFishFish(); runExpiringMutesLoop(); await sleep(10 * SECONDS); runExpiringTempbansLoop(); await sleep(10 * SECONDS); runUpcomingScheduledPostsLoop(); await sleep(10 * SECONDS); runUpcomingRemindersLoop(); await sleep(10 * SECONDS); runExpiringVCAlertsLoop(); await sleep(10 * SECONDS); runExpiredArchiveDeletionLoop(); await sleep(10 * SECONDS); runSavedMessageCleanupLoop(); await sleep(10 * SECONDS); runExpiredMemberCacheDeletionLoop(); await sleep(10 * SECONDS); runMemberCacheDeletionLoop(); }); let lowestGlobalRemaining = Infinity; setInterval(() => { lowestGlobalRemaining = Math.min(lowestGlobalRemaining, (client as any).rest.globalRemaining); }, 100); setInterval(() => { // FIXME: Debug if (lowestGlobalRemaining < 30) { // tslint:disable-next-line:no-console console.log("[DEBUG] Lowest global remaining in the past 15 seconds:", lowestGlobalRemaining); } lowestGlobalRemaining = Infinity; }, 15000); setInterval(() => { const queryStatsMap = consumeQueryStats(); const entries = Array.from(queryStatsMap.entries()); entries.sort((a, b) => b[1] - a[1]); const topEntriesStr = entries .slice(0, 5) .map(([key, count]) => `${count}x ${key}`) .join("\n"); // FIXME: Debug // tslint:disable-next-line:no-console console.log(`Top query entries in the past 5 minutes:\n${topEntriesStr}`); }, 5 * MINUTES); bot.initialize(); logger.info("Bot Initialized"); logger.info("Logging in..."); await client.login(env.BOT_TOKEN); // Don't intercept any signals in DEBUG mode: https://github.com/clinicjs/node-clinic/issues/444#issuecomment-1474997090 if (!env.DEBUG) { let stopping = false; const cleanupAndStop = async (code) => { if (stopping) { return; } stopping = true; logger.info("Cleaning up before exit..."); // Force exit after 10sec setTimeout(() => process.exit(code), 10 * SECONDS); await bot.destroy(); await dataSource.destroy(); logger.info("Done! Exiting now."); process.exit(code); }; process.on("beforeExit", () => cleanupAndStop(0)); process.on("SIGINT", () => { logger.info("Received SIGINT, exiting..."); cleanupAndStop(0); }); process.on("SIGTERM", () => { logger.info("Received SIGTERM, exiting..."); cleanupAndStop(0); }); } }); ================================================ FILE: backend/src/logger.ts ================================================ // tslint:disable:no-console export const logger = { info(...args: Parameters) { console.log("[INFO]", ...args); }, warn(...args: Parameters) { console.warn("[WARN]", ...args); }, error(...args: Parameters) { console.error("[ERROR]", ...args); }, debug(...args: Parameters) { console.log("[DEBUG]", ...args); }, log(...args: Parameters) { console.log(...args); }, }; ================================================ FILE: backend/src/migrateConfigsToDB.ts ================================================ // tslint:disable:no-console import * as _fs from "fs"; import path from "path"; import { Configs } from "./data/Configs.js"; import { connect } from "./data/db.js"; const fs = _fs.promises; const authorId = process.argv[2]; if (!authorId) { console.error("No author id specified"); process.exit(1); } console.log("Connecting to database"); connect().then(async () => { const configs = new Configs(); console.log("Loading config files"); const configDir = path.join(import.meta.dirname, "..", "config"); const configFiles = await fs.readdir(configDir); console.log("Looping through config files"); for (const configFile of configFiles) { const parts = configFile.split("."); const ext = parts[parts.length - 1]; if (ext !== "yml") continue; const id = parts.slice(0, -1).join("."); const key = id === "global" ? "global" : `guild-${id}`; if (await configs.hasConfig(key)) continue; const content = await fs.readFile(path.join(configDir, configFile), { encoding: "utf8" }); console.log(`Migrating config for ${key}`); await configs.saveNewRevision(key, content, authorId); } console.log("Done!"); process.exit(0); }); ================================================ FILE: backend/src/migrations/1540519249973-CreatePreTypeORMTables.ts ================================================ import { MigrationInterface, QueryRunner } from "typeorm"; export class CreatePreTypeORMTables1540519249973 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TABLE IF NOT EXISTS \`archives\` ( \`id\` VARCHAR(36) NOT NULL, \`guild_id\` VARCHAR(20) NOT NULL, \`body\` MEDIUMTEXT NOT NULL, \`created_at\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, \`expires_at\` DATETIME NULL DEFAULT NULL, PRIMARY KEY (\`id\`) ) COLLATE='utf8mb4_general_ci' `); await queryRunner.query(` CREATE TABLE IF NOT EXISTS \`cases\` ( \`id\` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, \`guild_id\` BIGINT(20) UNSIGNED NOT NULL, \`case_number\` INT(10) UNSIGNED NOT NULL, \`user_id\` BIGINT(20) UNSIGNED NOT NULL, \`user_name\` VARCHAR(128) NOT NULL, \`mod_id\` BIGINT(20) UNSIGNED NULL DEFAULT NULL, \`mod_name\` VARCHAR(128) NULL DEFAULT NULL, \`type\` INT(10) UNSIGNED NOT NULL, \`audit_log_id\` BIGINT(20) NULL DEFAULT NULL, \`created_at\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (\`id\`), UNIQUE INDEX \`mod_actions_guild_id_case_number_unique\` (\`guild_id\`, \`case_number\`), UNIQUE INDEX \`mod_actions_audit_log_id_unique\` (\`audit_log_id\`), INDEX \`mod_actions_user_id_index\` (\`user_id\`), INDEX \`mod_actions_mod_id_index\` (\`mod_id\`), INDEX \`mod_actions_created_at_index\` (\`created_at\`) ) COLLATE = 'utf8mb4_general_ci' `); await queryRunner.query(` CREATE TABLE IF NOT EXISTS \`case_notes\` ( \`id\` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, \`case_id\` INT(10) UNSIGNED NOT NULL, \`mod_id\` BIGINT(20) UNSIGNED NULL DEFAULT NULL, \`mod_name\` VARCHAR(128) NULL DEFAULT NULL, \`body\` TEXT NOT NULL, \`created_at\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (\`id\`), INDEX \`mod_action_notes_mod_action_id_index\` (\`case_id\`), INDEX \`mod_action_notes_mod_id_index\` (\`mod_id\`), INDEX \`mod_action_notes_created_at_index\` (\`created_at\`) ) COLLATE = 'utf8mb4_general_ci' `); await queryRunner.query(` CREATE TABLE IF NOT EXISTS \`mutes\` ( \`guild_id\` BIGINT(20) UNSIGNED NOT NULL, \`user_id\` BIGINT(20) UNSIGNED NOT NULL, \`created_at\` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, \`expires_at\` DATETIME NULL DEFAULT NULL, \`case_id\` INT(10) UNSIGNED NULL DEFAULT NULL, PRIMARY KEY (\`guild_id\`, \`user_id\`), INDEX \`mutes_expires_at_index\` (\`expires_at\`), INDEX \`mutes_case_id_foreign\` (\`case_id\`), CONSTRAINT \`mutes_case_id_foreign\` FOREIGN KEY (\`case_id\`) REFERENCES \`cases\` (\`id\`) ON DELETE SET NULL ) COLLATE = 'utf8mb4_general_ci' `); await queryRunner.query(` CREATE TABLE IF NOT EXISTS \`persisted_data\` ( \`guild_id\` VARCHAR(20) NOT NULL, \`user_id\` VARCHAR(20) NOT NULL, \`roles\` VARCHAR(1024) NULL DEFAULT NULL, \`nickname\` VARCHAR(255) NULL DEFAULT NULL, \`is_voice_muted\` INT(11) NOT NULL DEFAULT '0', PRIMARY KEY (\`guild_id\`, \`user_id\`) ) COLLATE = 'utf8mb4_general_ci' `); await queryRunner.query(` CREATE TABLE IF NOT EXISTS \`reaction_roles\` ( \`guild_id\` VARCHAR(20) NOT NULL, \`channel_id\` VARCHAR(20) NOT NULL, \`message_id\` VARCHAR(20) NOT NULL, \`emoji\` VARCHAR(20) NOT NULL, \`role_id\` VARCHAR(20) NOT NULL, PRIMARY KEY (\`guild_id\`, \`channel_id\`, \`message_id\`, \`emoji\`), INDEX \`reaction_roles_message_id_emoji_index\` (\`message_id\`, \`emoji\`) ) COLLATE = 'utf8mb4_general_ci' `); await queryRunner.query(` CREATE TABLE IF NOT EXISTS \`tags\` ( \`guild_id\` BIGINT(20) UNSIGNED NOT NULL, \`tag\` VARCHAR(64) NOT NULL, \`user_id\` BIGINT(20) UNSIGNED NOT NULL, \`body\` TEXT NOT NULL, \`created_at\` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (\`guild_id\`, \`tag\`) ) COLLATE = 'utf8mb4_general_ci' `); } public async down(): Promise { // No down function since we're migrating (hehe) from another migration system (knex) } } ================================================ FILE: backend/src/migrations/1543053430712-CreateMessagesTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateMessagesTable1543053430712 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "messages", columns: [ { name: "id", type: "bigint", unsigned: true, isPrimary: true, }, { name: "guild_id", type: "bigint", unsigned: true, }, { name: "channel_id", type: "bigint", unsigned: true, }, { name: "user_id", type: "bigint", unsigned: true, }, { name: "is_bot", type: "tinyint", unsigned: true, }, { name: "data", type: "mediumtext", }, { name: "posted_at", type: "datetime(3)", }, { name: "deleted_at", type: "datetime(3)", isNullable: true, default: null, }, { name: "is_permanent", type: "tinyint", unsigned: true, default: 0, }, ], indices: [ { columnNames: ["guild_id"] }, { columnNames: ["channel_id"] }, { columnNames: ["user_id"] }, { columnNames: ["is_bot"] }, { columnNames: ["posted_at"] }, { columnNames: ["deleted_at"] }, { columnNames: ["is_permanent"] }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("messages"); } } ================================================ FILE: backend/src/migrations/1544877081073-CreateSlowmodeTables.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateSlowmodeTables1544877081073 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "slowmode_channels", columns: [ { name: "guild_id", type: "bigint", unsigned: true, isPrimary: true, }, { name: "channel_id", type: "bigint", unsigned: true, isPrimary: true, }, { name: "slowmode_seconds", type: "int", unsigned: true, }, ], indices: [], }), ); await queryRunner.createTable( new Table({ name: "slowmode_users", columns: [ { name: "guild_id", type: "bigint", unsigned: true, isPrimary: true, }, { name: "channel_id", type: "bigint", unsigned: true, isPrimary: true, }, { name: "user_id", type: "bigint", unsigned: true, isPrimary: true, }, { name: "expires_at", type: "datetime", }, ], indices: [ { columnNames: ["expires_at"], }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await Promise.all([ queryRunner.dropTable("slowmode_channels", true), queryRunner.dropTable("slowmode_users", true), ]); } } ================================================ FILE: backend/src/migrations/1544887946307-CreateStarboardTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateStarboardTable1544887946307 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "starboards", columns: [ { name: "id", type: "int", unsigned: true, isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "guild_id", type: "bigint", unsigned: true, }, { name: "channel_id", type: "bigint", unsigned: true, }, { name: "channel_whitelist", type: "text", isNullable: true, default: null, }, { name: "emoji", type: "varchar", length: "64", }, { name: "reactions_required", type: "smallint", unsigned: true, }, ], indices: [ { columnNames: ["guild_id", "emoji"], }, { columnNames: ["guild_id", "channel_id"], isUnique: true, }, ], }), ); await queryRunner.createTable( new Table({ name: "starboard_messages", columns: [ { name: "starboard_id", type: "int", unsigned: true, isPrimary: true, }, { name: "message_id", type: "bigint", unsigned: true, isPrimary: true, }, { name: "starboard_message_id", type: "bigint", unsigned: true, }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("starboards", true); await queryRunner.dropTable("starboard_messages", true); } } ================================================ FILE: backend/src/migrations/1546770935261-CreateTagResponsesTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateTagResponsesTable1546770935261 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "tag_responses", columns: [ { name: "id", type: "int", unsigned: true, isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "guild_id", type: "bigint", unsigned: true, }, { name: "command_message_id", type: "bigint", unsigned: true, }, { name: "response_message_id", type: "bigint", unsigned: true, }, ], indices: [ { columnNames: ["guild_id"], }, ], foreignKeys: [ { columnNames: ["command_message_id"], referencedTableName: "messages", referencedColumnNames: ["id"], onDelete: "CASCADE", }, { columnNames: ["response_message_id"], referencedTableName: "messages", referencedColumnNames: ["id"], onDelete: "CASCADE", }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("tag_responses"); } } ================================================ FILE: backend/src/migrations/1546778415930-CreateNameHistoryTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateNameHistoryTable1546778415930 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "name_history", columns: [ { name: "id", type: "int", unsigned: true, isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "guild_id", type: "bigint", unsigned: true, }, { name: "user_id", type: "bigint", unsigned: true, }, { name: "type", type: "tinyint", unsigned: true, }, { name: "value", type: "varchar", length: "128", isNullable: true, }, { name: "timestamp", type: "datetime", default: "CURRENT_TIMESTAMP", }, ], indices: [ { columnNames: ["guild_id", "user_id"], }, { columnNames: ["type"], }, { columnNames: ["timestamp"], }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("name_history"); } } ================================================ FILE: backend/src/migrations/1546788508314-MakeNameHistoryValueLengthLonger.ts ================================================ import { MigrationInterface, QueryRunner } from "typeorm"; export class MakeNameHistoryValueLengthLonger1546788508314 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE \`name_history\` CHANGE COLUMN \`value\` \`value\` VARCHAR(160) NULL DEFAULT NULL COLLATE 'utf8mb4_swedish_ci' AFTER \`type\`; `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE \`name_history\` CHANGE COLUMN \`value\` \`value\` VARCHAR(128) NULL DEFAULT NULL COLLATE 'utf8mb4_swedish_ci' AFTER \`type\`; `); } } ================================================ FILE: backend/src/migrations/1547290549908-CreateAutoReactionsTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateAutoReactionsTable1547290549908 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "auto_reactions", columns: [ { name: "guild_id", type: "bigint", unsigned: true, isPrimary: true, }, { name: "channel_id", type: "bigint", unsigned: true, isPrimary: true, }, { name: "reactions", type: "text", }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("auto_reactions", true); } } ================================================ FILE: backend/src/migrations/1547293464842-CreatePingableRolesTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreatePingableRolesTable1547293464842 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "pingable_roles", columns: [ { name: "id", type: "int", unsigned: true, isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "guild_id", type: "bigint", unsigned: true, }, { name: "channel_id", type: "bigint", unsigned: true, }, { name: "role_id", type: "bigint", unsigned: true, }, ], indices: [ { columnNames: ["guild_id", "channel_id"], }, { columnNames: ["guild_id", "channel_id", "role_id"], isUnique: true, }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("pingable_roles", true); } } ================================================ FILE: backend/src/migrations/1547392046629-AddIndexToArchivesExpiresAt.ts ================================================ import { MigrationInterface, QueryRunner, TableIndex } from "typeorm"; export class AddIndexToArchivesExpiresAt1547392046629 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createIndex( "archives", new TableIndex({ columnNames: ["expires_at"], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropIndex( "archives", new TableIndex({ columnNames: ["expires_at"], }), ); } } ================================================ FILE: backend/src/migrations/1547393619900-AddIsHiddenToCases.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeorm"; export class AddIsHiddenToCases1547393619900 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.addColumn( "cases", new TableColumn({ name: "is_hidden", type: "tinyint", unsigned: true, default: 0, }), ); await queryRunner.createIndex( "cases", new TableIndex({ columnNames: ["is_hidden"], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("cases", "is_hidden"); } } ================================================ FILE: backend/src/migrations/1549649586803-AddPPFieldsToCases.ts ================================================ import { MigrationInterface, QueryRunner } from "typeorm"; export class AddPPFieldsToCases1549649586803 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE \`cases\` ADD COLUMN \`pp_id\` BIGINT NULL, ADD COLUMN \`pp_name\` VARCHAR(128) NULL `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE \`cases\` DROP COLUMN \`pp_id\`, DROP COLUMN \`pp_name\` `); } } ================================================ FILE: backend/src/migrations/1550409894008-FixEmojiIndexInReactionRoles.ts ================================================ import { MigrationInterface, QueryRunner } from "typeorm"; export class FixEmojiIndexInReactionRoles1550409894008 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // In utf8mb4_swedish_ci, different native emojis are counted as the same char for indexes, which means we can't // have multiple native emojis on a single message since the emoji field is part of the primary key await queryRunner.query(` ALTER TABLE \`reaction_roles\` CHANGE COLUMN \`emoji\` \`emoji\` VARCHAR(64) NOT NULL COLLATE 'utf8mb4_bin' AFTER \`message_id\` `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE \`reaction_roles\` CHANGE COLUMN \`emoji\` \`emoji\` VARCHAR(64) NOT NULL AFTER \`message_id\` `); } } ================================================ FILE: backend/src/migrations/1550521627877-CreateSelfGrantableRolesTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateSelfGrantableRolesTable1550521627877 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "self_grantable_roles", columns: [ { name: "id", type: "int", unsigned: true, isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "guild_id", type: "bigint", unsigned: true, }, { name: "channel_id", type: "bigint", unsigned: true, }, { name: "role_id", type: "bigint", unsigned: true, }, { name: "aliases", type: "varchar", length: "255", }, ], indices: [ { columnNames: ["guild_id", "channel_id", "role_id"], isUnique: true, }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("self_grantable_roles", true); } } ================================================ FILE: backend/src/migrations/1550609900261-CreateRemindersTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateRemindersTable1550609900261 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "reminders", columns: [ { name: "id", type: "int", unsigned: true, isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "guild_id", type: "bigint", unsigned: true, }, { name: "user_id", type: "bigint", unsigned: true, }, { name: "channel_id", type: "bigint", unsigned: true, }, { name: "remind_at", type: "datetime", }, { name: "body", type: "text", }, ], indices: [ { columnNames: ["guild_id", "user_id"], }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("reminders", true); } } ================================================ FILE: backend/src/migrations/1556908589679-CreateUsernameHistoryTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateUsernameHistoryTable1556908589679 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "username_history", columns: [ { name: "id", type: "int", unsigned: true, isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "user_id", type: "bigint", unsigned: true, }, { name: "username", type: "varchar", length: "160", isNullable: true, }, { name: "timestamp", type: "datetime", default: "CURRENT_TIMESTAMP", }, ], indices: [ { columnNames: ["user_id"], }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("username_history", true); } } ================================================ FILE: backend/src/migrations/1556909512501-MigrateUsernamesToNewHistoryTable.ts ================================================ import { MigrationInterface, QueryRunner } from "typeorm"; const BATCH_SIZE = 200; export class MigrateUsernamesToNewHistoryTable1556909512501 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // Start by ending the migration transaction because this is gonna be a looooooooot of data await queryRunner.query("COMMIT"); const migratedUsernames = new Set(); await new Promise(async (resolve) => { const stream = await queryRunner.stream("SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history"); stream.on("data", (row: any) => { migratedUsernames.add(row.key); }); stream.on("end", resolve); }); const migrateNextBatch = (): Promise<{ finished: boolean; migrated?: number }> => { return new Promise(async (resolve) => { const toInsert: any[][] = []; const toDelete: number[] = []; const stream = await queryRunner.stream( `SELECT * FROM name_history WHERE type=1 ORDER BY timestamp ASC LIMIT ${BATCH_SIZE}`, ); stream.on("data", (row: any) => { const key = `${row.user_id}-${row.value}`; if (!migratedUsernames.has(key)) { migratedUsernames.add(key); toInsert.push([row.user_id, row.value, row.timestamp]); } toDelete.push(row.id); }); stream.on("end", async () => { if (toInsert.length || toDelete.length) { await queryRunner.query("START TRANSACTION"); if (toInsert.length) { await queryRunner.query( "INSERT INTO username_history (user_id, username, timestamp) VALUES " + Array.from({ length: toInsert.length }, () => "(?, ?, ?)").join(","), toInsert.flat(), ); } if (toDelete.length) { await queryRunner.query( "DELETE FROM name_history WHERE id IN (" + Array.from("?".repeat(toDelete.length)).join(", ") + ")", toDelete, ); } await queryRunner.query("COMMIT"); resolve({ finished: false, migrated: toInsert.length }); } else { resolve({ finished: true }); } }); }); }; while (true) { const result = await migrateNextBatch(); if (result.finished) { break; } else { // tslint:disable-next-line:no-console console.log(`Migrated ${result.migrated} usernames`); } } await queryRunner.query("START TRANSACTION"); } // eslint-disable-next-line @typescript-eslint/no-empty-function public async down(): Promise {} } ================================================ FILE: backend/src/migrations/1556913287547-TurnNameHistoryToNicknameHistory.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; export class TurnNameHistoryToNicknameHistory1556913287547 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("name_history", "type"); // As a raw query because of some bug with renameColumn that generated an invalid query await queryRunner.query(` ALTER TABLE \`name_history\` CHANGE COLUMN \`value\` \`nickname\` VARCHAR(160) NULL DEFAULT 'NULL' COLLATE 'utf8mb4_swedish_ci' AFTER \`user_id\`; `); // Drop unneeded timestamp column index await queryRunner.dropIndex("name_history", "IDX_6bd0600f9d55d4e4a08b508999"); await queryRunner.renameTable("name_history", "nickname_history"); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.addColumn( "nickname_history", new TableColumn({ name: "type", type: "tinyint", unsigned: true, }), ); // As a raw query because of some bug with renameColumn that generated an invalid query await queryRunner.query(` ALTER TABLE \`nickname_history\` CHANGE COLUMN \`nickname\` \`value\` VARCHAR(160) NULL DEFAULT 'NULL' COLLATE 'utf8mb4_swedish_ci' AFTER \`user_id\` `); await queryRunner.renameTable("nickname_history", "name_history"); } } ================================================ FILE: backend/src/migrations/1556973844545-CreateScheduledPostsTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateScheduledPostsTable1556973844545 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "scheduled_posts", columns: [ { name: "id", type: "int", unsigned: true, isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "guild_id", type: "bigint", unsigned: true, }, { name: "author_id", type: "bigint", unsigned: true, }, { name: "author_name", type: "varchar", length: "160", }, { name: "channel_id", type: "bigint", unsigned: true, }, { name: "content", type: "text", }, { name: "attachments", type: "text", }, { name: "post_at", type: "datetime", }, { name: "enable_mentions", type: "tinyint", unsigned: true, default: 0, }, ], indices: [ { columnNames: ["guild_id", "post_at"], }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("scheduled_posts", true); } } ================================================ FILE: backend/src/migrations/1558804433320-CreateDashboardLoginsTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateDashboardLoginsTable1558804433320 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "dashboard_logins", columns: [ { name: "id", type: "varchar", length: "36", isPrimary: true, collation: "ascii_bin", }, { name: "token", type: "varchar", length: "64", collation: "ascii_bin", }, { name: "user_id", type: "bigint", }, { name: "user_data", type: "text", }, { name: "logged_in_at", type: "DATETIME", }, { name: "expires_at", type: "DATETIME", }, ], indices: [ { columnNames: ["user_id"], }, { columnNames: ["expires_at"], }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("dashboard_logins", true); } } ================================================ FILE: backend/src/migrations/1558804449510-CreateDashboardUsersTable.ts ================================================ import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm"; export class CreateDashboardUsersTable1558804449510 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "dashboard_users", columns: [ { name: "guild_id", type: "bigint", isPrimary: true, }, { name: "user_id", type: "bigint", isPrimary: true, }, { name: "username", type: "varchar", length: "255", }, { name: "role", type: "varchar", length: "32", }, ], }), ); await queryRunner.createIndex( "dashboard_users", new TableIndex({ columnNames: ["user_id"], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("dashboard_users", true); } } ================================================ FILE: backend/src/migrations/1561111990357-CreateConfigsTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateConfigsTable1561111990357 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "configs", columns: [ { name: "id", type: "int", isPrimary: true, isGenerated: true, generationStrategy: "increment", }, { name: "key", type: "varchar", length: "48", }, { name: "config", type: "mediumtext", }, { name: "is_active", type: "tinyint", }, { name: "edited_by", type: "bigint", }, { name: "edited_at", type: "datetime", default: "now()", }, ], indices: [ { columnNames: ["key", "is_active"], }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("configs", true); } } ================================================ FILE: backend/src/migrations/1561117545258-CreateAllowedGuildsTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateAllowedGuildsTable1561117545258 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "allowed_guilds", columns: [ { name: "guild_id", type: "bigint", isPrimary: true, }, { name: "name", type: "varchar", length: "255", }, { name: "icon", type: "varchar", length: "255", collation: "ascii_general_ci", isNullable: true, }, { name: "owner_id", type: "bigint", }, ], indices: [{ columnNames: ["owner_id"] }], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("allowed_guilds", true); } } ================================================ FILE: backend/src/migrations/1561282151982-RenameBackendDashboardStuffToAPI.ts ================================================ import { MigrationInterface, QueryRunner } from "typeorm"; export class RenameBackendDashboardStuffToAPI1561282151982 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE dashboard_users RENAME api_users`); await queryRunner.query(`ALTER TABLE dashboard_logins RENAME api_logins`); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE api_users RENAME dashboard_users`); await queryRunner.query(`ALTER TABLE api_logins RENAME dashboard_logins`); } } ================================================ FILE: backend/src/migrations/1561282552734-RenameAllowedGuildGuildIdToId.ts ================================================ import { MigrationInterface, QueryRunner } from "typeorm"; export class RenameAllowedGuildGuildIdToId1561282552734 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query("ALTER TABLE `allowed_guilds` CHANGE COLUMN `guild_id` `id` BIGINT(20) NOT NULL FIRST;"); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query("ALTER TABLE `allowed_guilds` CHANGE COLUMN `id` `guild_id` BIGINT(20) NOT NULL FIRST;"); } } ================================================ FILE: backend/src/migrations/1561282950483-CreateApiUserInfoTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateApiUserInfoTable1561282950483 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "api_user_info", columns: [ { name: "id", type: "bigint", isPrimary: true, }, { name: "data", type: "text", }, { name: "updated_at", type: "datetime", default: "now()", }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("api_user_info", true); } } ================================================ FILE: backend/src/migrations/1561283165823-RenameApiUsersToApiPermissions.ts ================================================ import { MigrationInterface, QueryRunner } from "typeorm"; export class RenameApiUsersToApiPermissions1561283165823 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE api_users RENAME api_permissions`); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE api_permissions RENAME api_users`); } } ================================================ FILE: backend/src/migrations/1561283405201-DropUserDataFromLoginsAndPermissions.ts ================================================ import { MigrationInterface, QueryRunner } from "typeorm"; export class DropUserDataFromLoginsAndPermissions1561283405201 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query("ALTER TABLE `api_logins` DROP COLUMN `user_data`"); await queryRunner.query("ALTER TABLE `api_permissions` DROP COLUMN `username`"); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( "ALTER TABLE `api_logins` ADD COLUMN `user_data` TEXT NOT NULL COLLATE 'utf8mb4_swedish_ci' AFTER `user_id`", ); await queryRunner.query( "ALTER TABLE `api_permissions` ADD COLUMN `username` VARCHAR(255) NOT NULL COLLATE 'utf8mb4_swedish_ci' AFTER `user_id`", ); } } ================================================ FILE: backend/src/migrations/1561391921385-AddVCAlertTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class AddVCAlertTable1561391921385 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "vc_alerts", columns: [ { name: "id", type: "int", unsigned: true, isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "guild_id", type: "bigint", unsigned: true, }, { name: "requestor_id", type: "bigint", unsigned: true, }, { name: "user_id", type: "bigint", unsigned: true, }, { name: "channel_id", type: "bigint", unsigned: true, }, { name: "expires_at", type: "datetime", }, { name: "body", type: "text", }, ], indices: [ { columnNames: ["guild_id", "user_id"], }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("vc_alerts", true, false, true); } } ================================================ FILE: backend/src/migrations/1562838838927-AddMoreIndicesToVCAlerts.ts ================================================ import { MigrationInterface, QueryRunner, TableIndex } from "typeorm"; export class AddMoreIndicesToVCAlerts1562838838927 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { const table = (await queryRunner.getTable("vc_alerts"))!; await table.addIndex( new TableIndex({ columnNames: ["requestor_id"], }), ); await table.addIndex( new TableIndex({ columnNames: ["expires_at"], }), ); } public async down(queryRunner: QueryRunner): Promise { const table = (await queryRunner.getTable("vc_alerts"))!; await table.removeIndex( new TableIndex({ columnNames: ["requestor_id"], }), ); await table.removeIndex( new TableIndex({ columnNames: ["expires_at"], }), ); } } ================================================ FILE: backend/src/migrations/1573158035867-AddTypeAndPermissionsToApiPermissions.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeorm"; export class AddTypeAndPermissionsToApiPermissions1573158035867 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // We can't use a TableIndex object in dropIndex directly as the table name is included in the generated index name // and the table name has changed since the original index was created const originalIndexName = queryRunner.connection.namingStrategy.indexName("dashboard_users", ["user_id"]); await queryRunner.dropIndex("api_permissions", originalIndexName); await queryRunner.addColumn( "api_permissions", new TableColumn({ name: "type", type: "varchar", length: "16", }), ); await queryRunner.renameColumn("api_permissions", "user_id", "target_id"); await queryRunner.query(` ALTER TABLE api_permissions DROP PRIMARY KEY, ADD PRIMARY KEY(\`guild_id\`, \`type\`, \`target_id\`); `); await queryRunner.dropColumn("api_permissions", "role"); await queryRunner.addColumn( "api_permissions", new TableColumn({ name: "permissions", type: "text", }), ); await queryRunner.query(` UPDATE api_permissions SET type='USER', permissions='EDIT_CONFIG' `); await queryRunner.createIndex( "api_permissions", new TableIndex({ columnNames: ["type", "target_id"], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropIndex( "api_permissions", new TableIndex({ columnNames: ["type", "target_id"], }), ); await queryRunner.dropColumn("api_permissions", "permissions"); await queryRunner.addColumn( "api_permissions", new TableColumn({ name: "role", type: "varchar", length: "32", }), ); await queryRunner.query(` ALTER TABLE api_permissions DROP PRIMARY KEY, ADD PRIMARY KEY(\`guild_id\`, \`type\`); `); await queryRunner.renameColumn("api_permissions", "target_id", "user_id"); await queryRunner.dropColumn("api_permissions", "type"); await queryRunner.createIndex( "api_permissions", new TableIndex({ columnNames: ["user_id"], }), ); } } ================================================ FILE: backend/src/migrations/1573248462469-MoveStarboardsToConfig.ts ================================================ import { MigrationInterface, QueryRunner, Table, TableColumn } from "typeorm"; export class MoveStarboardsToConfig1573248462469 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // Create a new column for the channel's id await queryRunner.addColumn( "starboard_messages", new TableColumn({ name: "starboard_channel_id", type: "bigint", unsigned: true, }), ); // Since we are removing the guild_id with the starboards table, we might want it here await queryRunner.addColumn( "starboard_messages", new TableColumn({ name: "guild_id", type: "bigint", unsigned: true, }), ); // Migrate the old starboard_id to the new starboard_channel_id await queryRunner.query(` UPDATE starboard_messages AS sm JOIN starboards AS sb ON sm.starboard_id = sb.id SET sm.starboard_channel_id = sb.channel_id, sm.guild_id = sb.guild_id; `); // Set new Primary Key await queryRunner.query(` ALTER TABLE starboard_messages DROP PRIMARY KEY, ADD PRIMARY KEY(\`starboard_message_id\`); `); // Drop the starboard_id column as it is now obsolete // We can't use queyrRunner.dropColumn() here because TypeORM helpfully thinks that // starboard_id is still part of the primary key and tries to drop the PK first await queryRunner.query("ALTER TABLE starboard_messages DROP COLUMN starboard_id"); // Finally, drop the starboards table as it is now obsolete await queryRunner.dropTable("starboards", true); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("starboard_messages", "starboard_channel_id"); await queryRunner.dropColumn("starboard_messages", "guild_id"); await queryRunner.addColumn( "starboard_messages", new TableColumn({ name: "starboard_id", type: "int", unsigned: true, }), ); await queryRunner.query(` ALTER TABLE starboard_messages DROP PRIMARY KEY, ADD PRIMARY KEY(\`starboard_id\`, \`message_id\`); `); await queryRunner.createTable( new Table({ name: "starboards", columns: [ { name: "id", type: "int", unsigned: true, isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "guild_id", type: "bigint", unsigned: true, }, { name: "channel_id", type: "bigint", unsigned: true, }, { name: "channel_whitelist", type: "text", isNullable: true, default: null, }, { name: "emoji", type: "varchar", length: "64", }, { name: "reactions_required", type: "smallint", unsigned: true, }, ], indices: [ { columnNames: ["guild_id", "emoji"], }, { columnNames: ["guild_id", "channel_id"], isUnique: true, }, ], }), ); } } ================================================ FILE: backend/src/migrations/1573248794313-CreateStarboardReactionsTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateStarboardReactionsTable1573248794313 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "starboard_reactions", columns: [ { name: "id", type: "int", isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "guild_id", type: "bigint", unsigned: true, }, { name: "message_id", type: "bigint", unsigned: true, }, { name: "reactor_id", type: "bigint", unsigned: true, }, ], indices: [ { columnNames: ["reactor_id", "message_id"], }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("starboard_reactions", true, false, true); } } ================================================ FILE: backend/src/migrations/1575145703039-AddIsExclusiveToReactionRoles.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; export class AddIsExclusiveToReactionRoles1575145703039 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.addColumn( "reaction_roles", new TableColumn({ name: "is_exclusive", type: "tinyint", unsigned: true, default: 0, }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("reaction_roles", "is_exclusive"); } } ================================================ FILE: backend/src/migrations/1575199835233-CreateStatsTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateStatsTable1575199835233 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "stats", columns: [ { name: "id", type: "bigint", unsigned: true, isPrimary: true, generationStrategy: "increment", }, { name: "guild_id", type: "bigint", unsigned: true, }, { name: "source", type: "varchar", length: "64", collation: "ascii_bin", }, { name: "key", type: "varchar", length: "64", collation: "ascii_bin", }, { name: "value", type: "integer", unsigned: true, }, { name: "created_at", type: "datetime", default: "NOW()", }, ], indices: [ { columnNames: ["guild_id", "source", "key"], }, { columnNames: ["created_at"], }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("stats"); } } ================================================ FILE: backend/src/migrations/1575230079526-AddRepeatColumnsToScheduledPosts.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; export class AddRepeatColumnsToScheduledPosts1575230079526 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.addColumns("scheduled_posts", [ new TableColumn({ name: "repeat_interval", type: "integer", unsigned: true, isNullable: true, }), new TableColumn({ name: "repeat_until", type: "datetime", isNullable: true, }), new TableColumn({ name: "repeat_times", type: "integer", unsigned: true, isNullable: true, }), ]); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("scheduled_posts", "repeat_interval"); await queryRunner.dropColumn("scheduled_posts", "repeat_until"); await queryRunner.dropColumn("scheduled_posts", "repeat_times"); } } ================================================ FILE: backend/src/migrations/1578445483917-CreateReminderCreatedAtField.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; export class CreateReminderCreatedAtField1578445483917 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.addColumn( "reminders", new TableColumn({ name: "created_at", type: "datetime", isNullable: false, }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("reminders", "created_at"); } } ================================================ FILE: backend/src/migrations/1580038836906-CreateAntiraidLevelsTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateAntiraidLevelsTable1580038836906 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "antiraid_levels", columns: [ { name: "guild_id", type: "bigint", unsigned: true, isPrimary: true, }, { name: "level", type: "varchar", length: "64", }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("antiraid_levels"); } } ================================================ FILE: backend/src/migrations/1580654617890-AddActiveFollowsToLocateUser.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; export class AddActiveFollowsToLocateUser1580654617890 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.addColumn( "vc_alerts", new TableColumn({ name: "active", type: "boolean", isNullable: false, }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("vc_alerts", "active"); } } ================================================ FILE: backend/src/migrations/1590616691907-CreateSupportersTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateSupportersTable1590616691907 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "supporters", columns: [ { name: "user_id", type: "bigint", unsigned: true, isPrimary: true, }, { name: "name", type: "varchar", length: "255", }, { name: "amount", type: "decimal", precision: 6, scale: 2, isNullable: true, default: null, }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("supporters"); } } ================================================ FILE: backend/src/migrations/1591036185142-OptimizeMessageIndices.ts ================================================ import { MigrationInterface, QueryRunner, TableIndex } from "typeorm"; export class OptimizeMessageIndices1591036185142 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // guild_id, channel_id, user_id indices -> composite(guild_id, channel_id, user_id) await queryRunner.dropIndex("messages", "IDX_b193588441b085352a4c010942"); // guild_id await queryRunner.dropIndex("messages", "IDX_86b9109b155eb70c0a2ca3b4b6"); // channel_id await queryRunner.dropIndex("messages", "IDX_830a3c1d92614d1495418c4673"); // user_id await queryRunner.createIndex( "messages", new TableIndex({ columnNames: ["guild_id", "channel_id", "user_id"], }), ); // posted_at, is_permanent indices -> composite(posted_at, is_permanent) await queryRunner.dropIndex("messages", "IDX_08e1f5a0fef0175ea402c6b2ac"); // posted_at await queryRunner.dropIndex("messages", "IDX_f520029c07824f8d96c6cd98e8"); // is_permanent await queryRunner.createIndex( "messages", new TableIndex({ columnNames: ["posted_at", "is_permanent"], }), ); // is_bot -> no index (the database doesn't appear to use this index anyway) await queryRunner.dropIndex("messages", "IDX_eec2c581ff6f13595902c31840"); } public async down(queryRunner: QueryRunner): Promise { // no index -> is_bot index await queryRunner.createIndex("messages", new TableIndex({ columnNames: ["is_bot"] })); // composite(posted_at, is_permanent) -> posted_at, is_permanent indices await queryRunner.dropIndex("messages", "IDX_afe125bfd65341cd90eee0b310"); // composite(posted_at, is_permanent) await queryRunner.createIndex("messages", new TableIndex({ columnNames: ["posted_at"] })); await queryRunner.createIndex("messages", new TableIndex({ columnNames: ["is_permanent"] })); // composite(guild_id, channel_id, user_id) -> guild_id, channel_id, user_id indices await queryRunner.dropIndex("messages", "IDX_dedc3ea6396e1de8ac75533589"); // composite(guild_id, channel_id, user_id) await queryRunner.createIndex("messages", new TableIndex({ columnNames: ["guild_id"] })); await queryRunner.createIndex("messages", new TableIndex({ columnNames: ["channel_id"] })); await queryRunner.createIndex("messages", new TableIndex({ columnNames: ["user_id"] })); } } ================================================ FILE: backend/src/migrations/1591038041635-OptimizeMessageTimestamps.ts ================================================ import { MigrationInterface, QueryRunner } from "typeorm"; export class OptimizeMessageTimestamps1591038041635 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // DATETIME(3) -> DATETIME(0) await queryRunner.query(` ALTER TABLE \`messages\` CHANGE COLUMN \`posted_at\` \`posted_at\` DATETIME(0) NOT NULL AFTER \`data\`, CHANGE COLUMN \`deleted_at\` \`deleted_at\` DATETIME(0) NULL DEFAULT NULL AFTER \`posted_at\` `); } public async down(queryRunner: QueryRunner): Promise { // DATETIME(0) -> DATETIME(3) await queryRunner.query(` ALTER TABLE \`messages\` CHANGE COLUMN \`posted_at\` \`posted_at\` DATETIME(3) NOT NULL AFTER \`data\`, CHANGE COLUMN \`deleted_at\` \`deleted_at\` DATETIME(3) NULL DEFAULT NULL AFTER \`posted_at\` `); } } ================================================ FILE: backend/src/migrations/1596994103885-AddCaseNotesForeignKey.ts ================================================ import { MigrationInterface, QueryRunner, TableForeignKey } from "typeorm"; export class AddCaseNotesForeignKey1596994103885 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createForeignKey( "case_notes", new TableForeignKey({ name: "case_notes_case_id_fk", columnNames: ["case_id"], referencedTableName: "cases", referencedColumnNames: ["id"], onDelete: "CASCADE", onUpdate: "CASCADE", }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropForeignKey("case_notes", "case_notes_case_id_fk"); } } ================================================ FILE: backend/src/migrations/1597015567215-AddLogMessageIdToCases.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; export class AddLogMessageIdToCases1597015567215 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.addColumn( "cases", new TableColumn({ name: "log_message_id", type: "varchar", length: "64", isNullable: true, default: null, }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("cases", "log_message_id"); } } ================================================ FILE: backend/src/migrations/1597109357201-CreateMemberTimezonesTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateMemberTimezonesTable1597109357201 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "member_timezones", columns: [ { name: "guild_id", type: "bigint", isPrimary: true, }, { name: "member_id", type: "bigint", isPrimary: true, }, { name: "timezone", type: "varchar", length: "255", }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("member_timezones"); } } ================================================ FILE: backend/src/migrations/1600283341726-EncryptExistingMessages.ts ================================================ import { MigrationInterface, QueryRunner } from "typeorm"; import { decrypt, encrypt } from "../utils/crypt.js"; export class EncryptExistingMessages1600283341726 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // 1. Delete non-permanent messages await queryRunner.query("DELETE FROM messages WHERE is_permanent = 0"); // 2. Encrypt all permanent messages const messages = await queryRunner.query("SELECT id, data FROM messages"); for (const message of messages) { const encryptedData = await encrypt(message.data); await queryRunner.query("UPDATE messages SET data = ? WHERE id = ?", [encryptedData, message.id]); } } public async down(queryRunner: QueryRunner): Promise { // Decrypt all messages const messages = await queryRunner.query("SELECT id, data FROM messages"); for (const message of messages) { const decryptedData = await decrypt(message.data); await queryRunner.query("UPDATE messages SET data = ? WHERE id = ?", [decryptedData, message.id]); } } } ================================================ FILE: backend/src/migrations/1600285077890-EncryptArchives.ts ================================================ import { MigrationInterface, QueryRunner } from "typeorm"; import { decrypt, encrypt } from "../utils/crypt.js"; export class EncryptArchives1600285077890 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { const archives = await queryRunner.query("SELECT id, body FROM archives"); for (const archive of archives) { const encryptedBody = await encrypt(archive.body); await queryRunner.query("UPDATE archives SET body = ? WHERE id = ?", [encryptedBody, archive.id]); } } public async down(queryRunner: QueryRunner): Promise { const archives = await queryRunner.query("SELECT id, body FROM archives"); for (const archive of archives) { const decryptedBody = await decrypt(archive.body); await queryRunner.query("UPDATE archives SET body = ? WHERE id = ?", [decryptedBody, archive.id]); } } } ================================================ FILE: backend/src/migrations/1608608903570-CreateRestoredRolesColumn.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; export class CreateRestoredRolesColumn1608608903570 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.addColumn( "mutes", new TableColumn({ name: "roles_to_restore", type: "text", isNullable: true, }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("mutes", "roles_to_restore"); } } ================================================ FILE: backend/src/migrations/1608692857722-FixStarboardReactionsIndices.ts ================================================ import { MigrationInterface, QueryRunner, TableIndex } from "typeorm"; export class FixStarboardReactionsIndices1608692857722 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // Remove previously-added duplicate stars await queryRunner.query(` DELETE r1.* FROM starboard_reactions AS r1 INNER JOIN starboard_reactions AS r2 ON r2.guild_id = r1.guild_id AND r2.message_id = r1.message_id AND r2.reactor_id = r1.reactor_id AND r2.id < r1.id `); await queryRunner.dropIndex("starboard_reactions", "IDX_dd871a4ef459dd294aa368e736"); await queryRunner.createIndex( "starboard_reactions", new TableIndex({ isUnique: true, columnNames: ["guild_id", "message_id", "reactor_id"], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropIndex("starboard_reactions", "IDX_d08ee47552c92ec8afd1a5bd1b"); await queryRunner.createIndex("starboard_reactions", new TableIndex({ columnNames: ["reactor_id", "message_id"] })); } } ================================================ FILE: backend/src/migrations/1608753440716-CreateTempBansTable.ts ================================================ import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm"; export class CreateTempBansTable1608753440716 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "tempbans", columns: [ { name: "guild_id", type: "bigint", isPrimary: true, }, { name: "user_id", type: "bigint", isPrimary: true, }, { name: "mod_id", type: "bigint", }, { name: "created_at", type: "datetime", }, { name: "expires_at", type: "datetime", }, ], }), ); queryRunner.createIndex( "tempbans", new TableIndex({ columnNames: ["expires_at"], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("tempbans"); } } ================================================ FILE: backend/src/migrations/1612010765767-CreateCounterTables.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateCounterTables1612010765767 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "counters", columns: [ { name: "id", type: "int", isPrimary: true, isGenerated: true, generationStrategy: "increment", }, { name: "guild_id", type: "bigint", }, { name: "name", type: "varchar", length: "255", }, { name: "per_channel", type: "boolean", }, { name: "per_user", type: "boolean", }, { name: "last_decay_at", type: "datetime", }, { name: "delete_at", type: "datetime", isNullable: true, default: null, }, ], indices: [ { columnNames: ["guild_id", "name"], isUnique: true, }, { columnNames: ["delete_at"], }, ], }), ); await queryRunner.createTable( new Table({ name: "counter_values", columns: [ { name: "id", type: "bigint", isPrimary: true, isGenerated: true, generationStrategy: "increment", }, { name: "counter_id", type: "int", }, { name: "channel_id", type: "bigint", }, { name: "user_id", type: "bigint", }, { name: "value", type: "int", }, ], indices: [ { columnNames: ["counter_id", "channel_id", "user_id"], isUnique: true, }, ], foreignKeys: [ { columnNames: ["counter_id"], referencedTableName: "counters", referencedColumnNames: ["id"], onDelete: "CASCADE", onUpdate: "CASCADE", }, ], }), ); await queryRunner.createTable( new Table({ name: "counter_triggers", columns: [ { name: "id", type: "int", isPrimary: true, isGenerated: true, generationStrategy: "increment", }, { name: "counter_id", type: "int", }, { name: "comparison_op", type: "varchar", length: "16", }, { name: "comparison_value", type: "int", }, { name: "delete_at", type: "datetime", isNullable: true, default: null, }, ], indices: [ { columnNames: ["counter_id", "comparison_op", "comparison_value"], isUnique: true, }, { columnNames: ["delete_at"], }, ], foreignKeys: [ { columnNames: ["counter_id"], referencedTableName: "counters", referencedColumnNames: ["id"], onDelete: "CASCADE", onUpdate: "CASCADE", }, ], }), ); await queryRunner.createTable( new Table({ name: "counter_trigger_states", columns: [ { name: "id", type: "bigint", isPrimary: true, isGenerated: true, generationStrategy: "increment", }, { name: "trigger_id", type: "int", }, { name: "channel_id", type: "bigint", }, { name: "user_id", type: "bigint", }, ], indices: [ { columnNames: ["trigger_id", "channel_id", "user_id"], isUnique: true, }, ], foreignKeys: [ { columnNames: ["trigger_id"], referencedTableName: "counter_triggers", referencedColumnNames: ["id"], onDelete: "CASCADE", onUpdate: "CASCADE", }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("counter_trigger_states"); await queryRunner.dropTable("counter_triggers"); await queryRunner.dropTable("counter_values"); await queryRunner.dropTable("counters"); } } ================================================ FILE: backend/src/migrations/1617363975046-UpdateCounterTriggers.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey, TableIndex } from "typeorm"; export class UpdateCounterTriggers1617363975046 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // Since we're adding a non-nullable unique name column and existing triggers won't have that, clear the table first await queryRunner.query("DELETE FROM counter_triggers"); await queryRunner.addColumns("counter_triggers", [ new TableColumn({ name: "name", type: "varchar", length: "255", }), new TableColumn({ name: "reverse_comparison_op", type: "varchar", length: "16", }), new TableColumn({ name: "reverse_comparison_value", type: "int", }), ]); // Drop foreign key for counter_id -- needed to be able to drop the following unique index await queryRunner.dropForeignKey("counter_triggers", "FK_6bb47849ec95c87e58c5d3e6ae1"); // Index for ["counter_id", "comparison_op", "comparison_value"] await queryRunner.dropIndex("counter_triggers", "IDX_ddc8a6701f1234b926d35aebf3"); await queryRunner.createIndex( "counter_triggers", new TableIndex({ columnNames: ["counter_id", "name"], isUnique: true, }), ); // Recreate foreign key for counter_id await queryRunner.createForeignKey( "counter_triggers", new TableForeignKey({ columnNames: ["counter_id"], referencedTableName: "counters", referencedColumnNames: ["id"], onDelete: "CASCADE", onUpdate: "CASCADE", }), ); } public async down(queryRunner: QueryRunner): Promise { // Since we're going back to unique comparison op and comparison value in this reverse-migration, // clear table contents first so we don't run into any conflicts with triggers with different names but identical comparison op and comparison value await queryRunner.query("DELETE FROM counter_triggers"); // Drop foreign key for counter_id -- needed to be able to drop the following unique index await queryRunner.dropForeignKey("counter_triggers", "FK_6bb47849ec95c87e58c5d3e6ae1"); // Index for ["counter_id", "name"] await queryRunner.dropIndex("counter_triggers", "IDX_2ec128e1d74bedd0288b60cdd1"); await queryRunner.createIndex( "counter_triggers", new TableIndex({ columnNames: ["counter_id", "comparison_op", "comparison_value"], isUnique: true, }), ); // Recreate foreign key for counter_id await queryRunner.createForeignKey( "counter_triggers", new TableForeignKey({ columnNames: ["counter_id"], referencedTableName: "counters", referencedColumnNames: ["id"], onDelete: "CASCADE", onUpdate: "CASCADE", }), ); await queryRunner.dropColumn("counter_triggers", "reverse_comparison_value"); await queryRunner.dropColumn("counter_triggers", "reverse_comparison_op"); await queryRunner.dropColumn("counter_triggers", "name"); } } ================================================ FILE: backend/src/migrations/1622939525343-OrderReactionRoles.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; export class OrderReactionRoles1622939525343 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.addColumn( "reaction_roles", new TableColumn({ name: "order", type: "int", isNullable: true, }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("reaction_roles", "order"); } } ================================================ FILE: backend/src/migrations/1623018101018-CreateButtonRolesTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateButtonRolesTable1623018101018 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "button_roles", columns: [ { name: "guild_id", type: "bigint", isPrimary: true, }, { name: "channel_id", type: "bigint", isPrimary: true, }, { name: "message_id", type: "bigint", isPrimary: true, }, { name: "button_id", type: "varchar", length: "100", isPrimary: true, isUnique: true, }, { name: "button_group", type: "varchar", length: "100", }, { name: "button_name", type: "varchar", length: "100", }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("button_roles"); } } ================================================ FILE: backend/src/migrations/1628809879962-CreateContextMenuTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateContextMenuTable1628809879962 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "context_menus", columns: [ { name: "guild_id", type: "bigint", }, { name: "context_id", type: "bigint", isPrimary: true, isUnique: true, }, { name: "action_name", type: "varchar", length: "100", }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("context_menus"); } } ================================================ FILE: backend/src/migrations/1630837386329-AddExpiresAtToApiPermissions.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; export class AddExpiresAtToApiPermissions1630837386329 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.addColumns("api_permissions", [ new TableColumn({ name: "expires_at", type: "datetime", isNullable: true, default: null, }), ]); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("api_permissions", "expires_at"); } } ================================================ FILE: backend/src/migrations/1630837718830-CreateApiAuditLogTable.ts ================================================ import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm"; export class CreateApiAuditLogTable1630837718830 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "api_audit_log", columns: [ { name: "id", type: "int", unsigned: true, isPrimary: true, isGenerated: true, generationStrategy: "increment", }, { name: "guild_id", type: "bigint", }, { name: "author_id", type: "bigint", }, { name: "event_type", type: "varchar", length: "255", }, { name: "event_data", type: "longtext", }, { name: "created_at", type: "datetime", default: "(NOW())", }, ], indices: [ new TableIndex({ columnNames: ["guild_id", "author_id"], }), new TableIndex({ columnNames: ["guild_id", "event_type"], }), new TableIndex({ columnNames: ["created_at"], }), ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("api_audit_log"); } } ================================================ FILE: backend/src/migrations/1630840428694-AddTimestampsToAllowedGuilds.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; export class AddTimestampsToAllowedGuilds1630840428694 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.addColumns("allowed_guilds", [ new TableColumn({ name: "created_at", type: "datetime", default: "(NOW())", }), new TableColumn({ name: "updated_at", type: "datetime", default: "(NOW())", onUpdate: "CURRENT_TIMESTAMP", }), ]); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("allowed_guilds", "updated_at"); await queryRunner.dropColumn("allowed_guilds", "created_at"); } } ================================================ FILE: backend/src/migrations/1631474131804-AddIndexToIsBot.ts ================================================ import { MigrationInterface, QueryRunner, TableIndex } from "typeorm"; export class AddIndexToIsBot1631474131804 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createIndex( "messages", new TableIndex({ columnNames: ["is_bot"], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropIndex( "messages", new TableIndex({ columnNames: ["is_bot"], }), ); } } ================================================ FILE: backend/src/migrations/1632582078622-SplitScheduledPostsPostAtIndex.ts ================================================ import { MigrationInterface, QueryRunner, TableIndex } from "typeorm"; export class SplitScheduledPostsPostAtIndex1632582078622 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.dropIndex("scheduled_posts", "IDX_c383ecfbddd8b625a0912ded3e"); await queryRunner.createIndex( "scheduled_posts", new TableIndex({ columnNames: ["guild_id"], }), ); await queryRunner.createIndex( "scheduled_posts", new TableIndex({ columnNames: ["post_at"], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropIndex("scheduled_posts", "IDX_e3ce9a618354f29256712abc5c"); await queryRunner.dropIndex("scheduled_posts", "IDX_b30f532b58ec5caf116389486f"); await queryRunner.createIndex( "scheduled_posts", new TableIndex({ columnNames: ["guild_id", "post_at"], }), ); } } ================================================ FILE: backend/src/migrations/1632582299400-AddIndexToRemindersRemindAt.ts ================================================ import { MigrationInterface, QueryRunner, TableIndex } from "typeorm"; export class AddIndexToRemindersRemindAt1632582299400 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createIndex( "reminders", new TableIndex({ columnNames: ["remind_at"], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropIndex("reminders", "IDX_6f4e1a9db3410c43c7545ff060"); } } ================================================ FILE: backend/src/migrations/1634459708599-RemoveTagResponsesForeignKeys.ts ================================================ import { MigrationInterface, QueryRunner, TableForeignKey } from "typeorm"; export class RemoveTagResponsesForeignKeys1634459708599 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.dropForeignKey("tag_responses", "FK_5f5cf713420286acfa714b98312"); await queryRunner.dropForeignKey("tag_responses", "FK_a0da4586031d332a6bc298925e3"); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.createForeignKey( "tag_responses", new TableForeignKey({ name: "FK_5f5cf713420286acfa714b98312", columnNames: ["command_message_id"], referencedTableName: "messages", referencedColumnNames: ["id"], onDelete: "CASCADE", }), ); await queryRunner.createForeignKey( "tag_responses", new TableForeignKey({ name: "FK_a0da4586031d332a6bc298925e3", columnNames: ["response_message_id"], referencedTableName: "messages", referencedColumnNames: ["id"], onDelete: "CASCADE", }), ); } } ================================================ FILE: backend/src/migrations/1634563901575-CreatePhishermanCacheTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreatePhishermanCacheTable1634563901575 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "phisherman_cache", columns: [ { name: "id", type: "int", isPrimary: true, isGenerated: true, generationStrategy: "increment", }, { name: "domain", type: "varchar", length: "255", isUnique: true, }, { name: "data", type: "text", }, { name: "expires_at", type: "datetime", }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("phisherman_cache"); } } ================================================ FILE: backend/src/migrations/1635596150234-CreatePhishermanKeyCacheTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreatePhishermanKeyCacheTable1635596150234 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "phisherman_key_cache", columns: [ { name: "id", type: "int", isPrimary: true, isGenerated: true, generationStrategy: "increment", }, { name: "hash", type: "varchar", length: "255", isUnique: true, }, { name: "is_valid", type: "tinyint", }, { name: "expires_at", type: "datetime", }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("phisherman_key_cache"); } } ================================================ FILE: backend/src/migrations/1635779678653-CreateWebhooksTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateWebhooksTable1635779678653 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "webhooks", columns: [ { name: "id", type: "bigint", isPrimary: true, }, { name: "guild_id", type: "bigint", }, { name: "channel_id", type: "bigint", }, { name: "token", type: "text", }, { name: "created_at", type: "datetime", default: "(NOW())", }, ], indices: [ { columnNames: ["channel_id"], isUnique: true, }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("webhooks"); } } ================================================ FILE: backend/src/migrations/1650709103864-CreateRoleQueueTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateRoleQueueTable1650709103864 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "role_queue", columns: [ { name: "id", type: "int", isPrimary: true, isGenerated: true, generationStrategy: "increment", }, { name: "guild_id", type: "bigint", }, { name: "user_id", type: "bigint", }, { name: "role_id", type: "bigint", }, { name: "should_add", type: "boolean", }, { name: "priority", type: "smallint", default: 0, }, ], indices: [ { columnNames: ["guild_id"], }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("role_queue"); } } ================================================ FILE: backend/src/migrations/1650712828384-CreateRoleButtonsTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateRoleButtonsTable1650712828384 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "role_buttons", columns: [ { name: "id", type: "int", isPrimary: true, isGenerated: true, generationStrategy: "increment", }, { name: "guild_id", type: "bigint", }, { name: "name", type: "varchar", length: "255", }, { name: "channel_id", type: "bigint", }, { name: "message_id", type: "bigint", }, { name: "hash", type: "text", }, ], indices: [ { columnNames: ["guild_id", "name"], isUnique: true, }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("role_buttons"); } } ================================================ FILE: backend/src/migrations/1650721020704-RemoveButtonRolesTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class RemoveButtonRolesTable1650721020704 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("button_roles"); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "button_roles", columns: [ { name: "guild_id", type: "bigint", isPrimary: true, }, { name: "channel_id", type: "bigint", isPrimary: true, }, { name: "message_id", type: "bigint", isPrimary: true, }, { name: "button_id", type: "varchar", length: "100", isPrimary: true, isUnique: true, }, { name: "button_group", type: "varchar", length: "100", }, { name: "button_name", type: "varchar", length: "100", }, ], }), ); } } ================================================ FILE: backend/src/migrations/1680354053183-AddTimeoutColumnsToMutes.ts ================================================ import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeorm"; export class AddTimeoutColumnsToMutes1680354053183 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.addColumns("mutes", [ new TableColumn({ name: "type", type: "tinyint", unsigned: true, default: 1, // The value for "Role" mute at the time of this migration }), new TableColumn({ name: "mute_role", type: "bigint", unsigned: true, isNullable: true, default: null, }), new TableColumn({ name: "timeout_expires_at", type: "datetime", isNullable: true, default: null, }), ]); await queryRunner.createIndex( "mutes", new TableIndex({ columnNames: ["type"], }), ); await queryRunner.createIndex( "mutes", new TableIndex({ columnNames: ["timeout_expires_at"], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropColumn("mutes", "type"); await queryRunner.dropColumn("mutes", "mute_role"); } } ================================================ FILE: backend/src/migrations/1682788165866-CreateMemberCacheTable.ts ================================================ import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateMemberCacheTable1682788165866 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ name: "member_cache", columns: [ { name: "id", type: "int", isPrimary: true, isGenerated: true, generationStrategy: "increment", }, { name: "guild_id", type: "bigint", }, { name: "user_id", type: "bigint", }, { name: "username", type: "varchar", length: "255", }, { name: "nickname", type: "varchar", length: "255", isNullable: true, }, { name: "roles", type: "text", }, { name: "last_seen", type: "date", }, { name: "delete_at", type: "datetime", isNullable: true, default: null, }, ], indices: [ { columnNames: ["guild_id", "user_id"], isUnique: true, }, { columnNames: ["last_seen"], }, { columnNames: ["delete_at"], }, ], }), ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.dropTable("member_cache"); } } ================================================ FILE: backend/src/paths.ts ================================================ import path from "path"; import pkgUp from "pkg-up"; const backendPackageJson = pkgUp.sync({ cwd: import.meta.dirname, }) as string; export const backendDir = path.dirname(backendPackageJson); export const rootDir = path.resolve(backendDir, ".."); ================================================ FILE: backend/src/pluginUtils.ts ================================================ /** * @file Utility functions that are plugin-instance-specific (i.e. use PluginData) */ import { BitField, BitFieldResolvable, ChatInputCommandInteraction, CommandInteraction, GuildMember, InteractionEditReplyOptions, InteractionReplyOptions, InteractionResponse, Message, MessageCreateOptions, MessageEditOptions, MessageFlags, MessageFlagsString, ModalSubmitInteraction, PermissionsBitField, TextBasedChannel, } from "discord.js"; import { AnyPluginData, BasePluginData, CommandContext, ExtendedMatchParams, GuildPluginData, helpers, PluginConfigManager, Vety, } from "vety"; import { z } from "zod"; import { isStaff } from "./staff.js"; import { Tail } from "./utils/typeUtils.js"; const { getMemberLevel } = helpers; export function canActOn( pluginData: GuildPluginData, member1: GuildMember, member2: GuildMember, allowSameLevel = false, allowAdmins = false, ) { if (member2.id === pluginData.client.user!.id) { return false; } const isOwnerOrAdmin = member2.id === member2.guild.ownerId || member2.permissions.has(PermissionsBitField.Flags.Administrator); if (isOwnerOrAdmin && !allowAdmins) { return false; } const ourLevel = getMemberLevel(pluginData, member1); const memberLevel = getMemberLevel(pluginData, member2); return allowSameLevel ? ourLevel >= memberLevel : ourLevel > memberLevel; } export async function hasPermission( pluginData: AnyPluginData, permission: string, matchParams: ExtendedMatchParams, ) { const config = await pluginData.config.getMatchingConfig(matchParams); return helpers.hasPermission(config, permission); } export type GenericCommandSource = Message | CommandInteraction | ModalSubmitInteraction; export function isContextInteraction( context: GenericCommandSource, ): context is CommandInteraction | ModalSubmitInteraction { return context instanceof CommandInteraction || context instanceof ModalSubmitInteraction; } export function isContextMessage(context: GenericCommandSource): context is Message { return context instanceof Message; } export async function getContextChannel(context: GenericCommandSource): Promise { if (isContextInteraction(context)) { return context.channel; } if (context instanceof Message) { return context.channel; } throw new Error("Unknown context type"); } export function getContextChannelId(context: GenericCommandSource): string | null { return context.channelId; } export async function fetchContextChannel(context: GenericCommandSource) { if (!context.guild) { throw new Error("Missing context guild"); } const channelId = getContextChannelId(context); if (!channelId) { throw new Error("Missing context channel ID"); } return (await context.guild.channels.fetch(channelId))!; } function flagsWithEphemeral( flags: BitFieldResolvable, ephemeral: boolean, ): BitFieldResolvable, TType | MessageFlags.Ephemeral> { if (!ephemeral) { return flags; } return new BitField(flags).add(MessageFlags.Ephemeral) as any; } export type ContextResponseOptions = MessageCreateOptions & InteractionReplyOptions & InteractionEditReplyOptions; export type ContextResponse = Message | InteractionResponse; export async function sendContextResponse( context: GenericCommandSource, content: string | ContextResponseOptions, ephemeral = false, ): Promise { if (isContextInteraction(context)) { const options = { ...(typeof content === "string" ? { content: content } : content), fetchReply: true }; if (context.replied) { return context.followUp({ ...options, flags: flagsWithEphemeral(options.flags, ephemeral), }); } if (context.deferred) { return context.editReply(options); } const replyResult = await context.reply({ ...options, flags: flagsWithEphemeral(options.flags, ephemeral), withResponse: true, }); return replyResult.resource!.message!; } const contextChannel = await fetchContextChannel(context); if (!contextChannel?.isSendable()) { throw new Error("Context channel does not exist or is not sendable"); } return contextChannel.send(content); } export type ContextResponseEditOptions = MessageEditOptions & InteractionEditReplyOptions; export function editContextResponse( response: ContextResponse, content: string | ContextResponseEditOptions, ): Promise { return response.edit(content); } export async function deleteContextResponse(response: ContextResponse): Promise { await response.delete(); } export async function getConfigForContext>( config: PluginConfigManager, context: GenericCommandSource, ): Promise> { if (context instanceof ChatInputCommandInteraction) { // TODO: Support for modal interactions (here and Vety) return config.getForInteraction(context); } const channel = await getContextChannel(context); const member = isContextMessage(context) && context.inGuild() ? await resolveMessageMember(context) : null; return config.getMatchingConfig({ channel, member, }); } export function getBaseUrl(pluginData: AnyPluginData) { const vety = pluginData.getVetyInstance() as Vety; // @ts-expect-error return vety.getGlobalConfig().url; } export function isOwner(pluginData: AnyPluginData, userId: string) { const vety = pluginData.getVetyInstance() as Vety; // @ts-expect-error const owners = vety.getGlobalConfig()?.owners; if (!owners) { return false; } return owners.includes(userId); } export const isStaffPreFilter = (_, context: CommandContext) => { return isStaff(context.message.author.id); }; type AnyFn = (...args: any[]) => any; /** * Creates a public plugin function out of a function with pluginData as the first parameter */ export function mapToPublicFn(inputFn: T) { return (pluginData) => { return (...args: Tail>): ReturnType => { return inputFn(pluginData, ...args); }; }; } type FnWithPluginData = (pluginData: TPluginData, ...args: any[]) => any; export function makePublicFn, T extends FnWithPluginData>( pluginData: TPluginData, fn: T, ) { return (...args: Tail>): ReturnType => { return fn(pluginData, ...args); }; } export function resolveMessageMember(message: Message) { return Promise.resolve(message.member || message.guild.members.fetch(message.author.id)); } ================================================ FILE: backend/src/plugins/AutoDelete/AutoDeletePlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { AutoDeletePluginType, zAutoDeleteConfig } from "./types.js"; import { onMessageCreate } from "./util/onMessageCreate.js"; import { onMessageDelete } from "./util/onMessageDelete.js"; import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk.js"; export const AutoDeletePlugin = guildPlugin()({ name: "auto_delete", dependencies: () => [TimeAndDatePlugin, LogsPlugin], configSchema: zAutoDeleteConfig, beforeLoad(pluginData) { const { state, guild } = pluginData; state.guildSavedMessages = GuildSavedMessages.getGuildInstance(guild.id); state.guildLogs = new GuildLogs(guild.id); state.deletionQueue = []; state.nextDeletion = null; state.nextDeletionTimeout = null; state.maxDelayWarningSent = false; }, afterLoad(pluginData) { const { state } = pluginData; state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg); state.guildSavedMessages.events.on("create", state.onMessageCreateFn); state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg); state.guildSavedMessages.events.on("delete", state.onMessageDeleteFn); state.onMessageDeleteBulkFn = (msgs) => onMessageDeleteBulk(pluginData, msgs); state.guildSavedMessages.events.on("deleteBulk", state.onMessageDeleteBulkFn); }, beforeUnload(pluginData) { const { state } = pluginData; state.guildSavedMessages.events.off("create", state.onMessageCreateFn); state.guildSavedMessages.events.off("delete", state.onMessageDeleteFn); state.guildSavedMessages.events.off("deleteBulk", state.onMessageDeleteBulkFn); }, }); ================================================ FILE: backend/src/plugins/AutoDelete/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zAutoDeleteConfig } from "./types.js"; export const autoDeletePluginDocs: ZeppelinPluginDocs = { type: "stable", configSchema: zAutoDeleteConfig, prettyName: "Auto-delete", description: "Allows Zeppelin to auto-delete messages from a channel after a delay", configurationGuide: "Maximum deletion delay is currently 5 minutes", }; ================================================ FILE: backend/src/plugins/AutoDelete/types.ts ================================================ import { BasePluginType } from "vety"; import { z } from "zod"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { SavedMessage } from "../../data/entities/SavedMessage.js"; import { MINUTES, zDelayString } from "../../utils.js"; import Timeout = NodeJS.Timeout; export const MAX_DELAY = 5 * MINUTES; export interface IDeletionQueueItem { deleteAt: number; message: SavedMessage; } export const zAutoDeleteConfig = z.strictObject({ enabled: z.boolean().default(false), delay: zDelayString.default("5s"), }); export interface AutoDeletePluginType extends BasePluginType { configSchema: typeof zAutoDeleteConfig; state: { guildSavedMessages: GuildSavedMessages; guildLogs: GuildLogs; deletionQueue: IDeletionQueueItem[]; nextDeletion: number | null; nextDeletionTimeout: Timeout | null; maxDelayWarningSent: boolean; onMessageCreateFn; onMessageDeleteFn; onMessageDeleteBulkFn; }; } ================================================ FILE: backend/src/plugins/AutoDelete/util/addMessageToDeletionQueue.ts ================================================ import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { sorter } from "../../../utils.js"; import { AutoDeletePluginType } from "../types.js"; import { scheduleNextDeletion } from "./scheduleNextDeletion.js"; export function addMessageToDeletionQueue( pluginData: GuildPluginData, msg: SavedMessage, delay: number, ) { const deleteAt = Date.now() + delay; pluginData.state.deletionQueue.push({ deleteAt, message: msg }); pluginData.state.deletionQueue.sort(sorter("deleteAt")); scheduleNextDeletion(pluginData); } ================================================ FILE: backend/src/plugins/AutoDelete/util/deleteNextItem.ts ================================================ import { PermissionsBitField, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { LogType } from "../../../data/LogType.js"; import { logger } from "../../../logger.js"; import { resolveUser, verboseChannelMention } from "../../../utils.js"; import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { AutoDeletePluginType } from "../types.js"; import { scheduleNextDeletion } from "./scheduleNextDeletion.js"; export async function deleteNextItem(pluginData: GuildPluginData) { const [itemToDelete] = pluginData.state.deletionQueue.splice(0, 1); if (!itemToDelete) return; scheduleNextDeletion(pluginData); const channel = pluginData.guild.channels.cache.get(itemToDelete.message.channel_id as Snowflake); if (!channel || !("messages" in channel)) { // Channel does not exist or does not support messages, ignore return; } const logs = pluginData.getPlugin(LogsPlugin); const perms = channel.permissionsFor(pluginData.client.user!.id); if ( !hasDiscordPermissions(perms, PermissionsBitField.Flags.ViewChannel | PermissionsBitField.Flags.ReadMessageHistory) ) { logs.logBotAlert({ body: `Missing permissions to read messages or message history in auto-delete channel ${verboseChannelMention( channel, )}`, }); return; } if (!hasDiscordPermissions(perms, PermissionsBitField.Flags.ManageMessages)) { logs.logBotAlert({ body: `Missing permissions to delete messages in auto-delete channel ${verboseChannelMention(channel)}`, }); return; } const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); pluginData.state.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id); channel.messages.delete(itemToDelete.message.id as Snowflake).catch((err) => { if (err.code === 10008) { // "Unknown Message", probably already deleted by automod or another bot, ignore return; } logger.warn(err); }); const user = await resolveUser(pluginData.client, itemToDelete.message.user_id, "AutoDelete:deleteNextItem"); const messageDate = timeAndDate .inGuildTz(moment.utc(itemToDelete.message.data.timestamp, "x")) .format(timeAndDate.getDateFormat("pretty_datetime")); pluginData.getPlugin(LogsPlugin).logMessageDeleteAuto({ message: itemToDelete.message, user, channel, messageDate, }); } ================================================ FILE: backend/src/plugins/AutoDelete/util/onMessageCreate.ts ================================================ import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { convertDelayStringToMS, resolveMember } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { AutoDeletePluginType, MAX_DELAY } from "../types.js"; import { addMessageToDeletionQueue } from "./addMessageToDeletionQueue.js"; export async function onMessageCreate(pluginData: GuildPluginData, msg: SavedMessage) { const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id); const config = await pluginData.config.getMatchingConfig({ member, channelId: msg.channel_id }); if (config.enabled) { let delay = convertDelayStringToMS(config.delay)!; if (delay > MAX_DELAY) { delay = MAX_DELAY; if (!pluginData.state.maxDelayWarningSent) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`, }); pluginData.state.maxDelayWarningSent = true; } } addMessageToDeletionQueue(pluginData, msg, delay); } } ================================================ FILE: backend/src/plugins/AutoDelete/util/onMessageDelete.ts ================================================ import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { AutoDeletePluginType } from "../types.js"; import { scheduleNextDeletion } from "./scheduleNextDeletion.js"; export function onMessageDelete(pluginData: GuildPluginData, msg: SavedMessage) { const indexToDelete = pluginData.state.deletionQueue.findIndex((item) => item.message.id === msg.id); if (indexToDelete > -1) { pluginData.state.deletionQueue.splice(indexToDelete, 1); scheduleNextDeletion(pluginData); } } ================================================ FILE: backend/src/plugins/AutoDelete/util/onMessageDeleteBulk.ts ================================================ import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { AutoDeletePluginType } from "../types.js"; import { onMessageDelete } from "./onMessageDelete.js"; export function onMessageDeleteBulk(pluginData: GuildPluginData, messages: SavedMessage[]) { for (const msg of messages) { onMessageDelete(pluginData, msg); } } ================================================ FILE: backend/src/plugins/AutoDelete/util/scheduleNextDeletion.ts ================================================ import { GuildPluginData } from "vety"; import { AutoDeletePluginType } from "../types.js"; import { deleteNextItem } from "./deleteNextItem.js"; export function scheduleNextDeletion(pluginData: GuildPluginData) { if (pluginData.state.deletionQueue.length === 0) { clearTimeout(pluginData.state.nextDeletionTimeout!); return; } const firstDeleteAt = pluginData.state.deletionQueue[0].deleteAt; clearTimeout(pluginData.state.nextDeletionTimeout!); pluginData.state.nextDeletionTimeout = setTimeout(() => deleteNextItem(pluginData), firstDeleteAt - Date.now()); } ================================================ FILE: backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts ================================================ import { PluginOverride, guildPlugin } from "vety"; import { GuildAutoReactions } from "../../data/GuildAutoReactions.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { DisableAutoReactionsCmd } from "./commands/DisableAutoReactionsCmd.js"; import { NewAutoReactionsCmd } from "./commands/NewAutoReactionsCmd.js"; import { AddReactionsEvt } from "./events/AddReactionsEvt.js"; import { AutoReactionsPluginType, zAutoReactionsConfig } from "./types.js"; const defaultOverrides: Array> = [ { level: ">=100", config: { can_manage: true, }, }, ]; export const AutoReactionsPlugin = guildPlugin()({ name: "auto_reactions", // prettier-ignore dependencies: () => [ LogsPlugin, ], configSchema: zAutoReactionsConfig, defaultOverrides, // prettier-ignore messageCommands: [ NewAutoReactionsCmd, DisableAutoReactionsCmd, ], // prettier-ignore events: [ AddReactionsEvt, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); state.autoReactions = GuildAutoReactions.getGuildInstance(guild.id); state.cache = new Map(); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, }); ================================================ FILE: backend/src/plugins/AutoReactions/commands/DisableAutoReactionsCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { autoReactionsCmd } from "../types.js"; export const DisableAutoReactionsCmd = autoReactionsCmd({ trigger: "auto_reactions disable", permission: "can_manage", usage: "!auto_reactions disable 629990160477585428", signature: { channelId: ct.channelId(), }, async run({ message: msg, args, pluginData }) { const autoReaction = await pluginData.state.autoReactions.getForChannel(args.channelId); if (!autoReaction) { void pluginData.state.common.sendErrorMessage(msg, `Auto-reactions aren't enabled in <#${args.channelId}>`); return; } await pluginData.state.autoReactions.removeFromChannel(args.channelId); pluginData.state.cache.delete(args.channelId); void pluginData.state.common.sendSuccessMessage(msg, `Auto-reactions disabled in <#${args.channelId}>`); }, }); ================================================ FILE: backend/src/plugins/AutoReactions/commands/NewAutoReactionsCmd.ts ================================================ import { PermissionsBitField } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { canUseEmoji, customEmojiRegex, isEmoji } from "../../../utils.js"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; import { readChannelPermissions } from "../../../utils/readChannelPermissions.js"; import { autoReactionsCmd } from "../types.js"; const requiredPermissions = readChannelPermissions | PermissionsBitField.Flags.AddReactions; export const NewAutoReactionsCmd = autoReactionsCmd({ trigger: "auto_reactions", permission: "can_manage", usage: "!auto_reactions 629990160477585428 👍 👎", signature: { channel: ct.guildTextBasedChannel(), reactions: ct.string({ rest: true }), }, async run({ message: msg, args, pluginData }) { const finalReactions: string[] = []; const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const missingPermissions = getMissingChannelPermissions(me, args.channel, requiredPermissions); if (missingPermissions) { pluginData.state.common.sendErrorMessage( msg, `Cannot set auto-reactions for that channel. ${missingPermissionError(missingPermissions)}`, ); return; } for (const reaction of args.reactions) { if (!isEmoji(reaction)) { void pluginData.state.common.sendErrorMessage(msg, "One or more of the specified reactions were invalid!"); return; } let savedValue; const customEmojiMatch = reaction.match(customEmojiRegex); if (customEmojiMatch) { // Custom emoji if (!canUseEmoji(pluginData.client, customEmojiMatch[2])) { pluginData.state.common.sendErrorMessage( msg, "I can only use regular emojis and custom emojis from this server", ); return; } savedValue = `${customEmojiMatch[1]}:${customEmojiMatch[2]}`; } else { // Unicode emoji savedValue = reaction; } finalReactions.push(savedValue); } await pluginData.state.autoReactions.set(args.channel.id, finalReactions); pluginData.state.cache.delete(args.channel.id); void pluginData.state.common.sendSuccessMessage(msg, `Auto-reactions set for <#${args.channel.id}>`); }, }); ================================================ FILE: backend/src/plugins/AutoReactions/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zAutoReactionsConfig } from "./types.js"; export const autoReactionsPluginDocs: ZeppelinPluginDocs = { type: "stable", configSchema: zAutoReactionsConfig, prettyName: "Auto-reactions", description: trimPluginDescription(` Allows setting up automatic reactions to all new messages on a channel `), }; ================================================ FILE: backend/src/plugins/AutoReactions/events/AddReactionsEvt.ts ================================================ import { GuildTextBasedChannel, PermissionsBitField } from "discord.js"; import { AutoReaction } from "../../../data/entities/AutoReaction.js"; import { isDiscordAPIError, isDiscordJsTypeError } from "../../../utils.js"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; import { readChannelPermissions } from "../../../utils/readChannelPermissions.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { autoReactionsEvt } from "../types.js"; const p = PermissionsBitField.Flags; export const AddReactionsEvt = autoReactionsEvt({ event: "messageCreate", allowBots: true, allowSelf: true, async listener({ pluginData, args: { message } }) { const channel = (await message.guild?.channels.fetch(message.channelId)) as | GuildTextBasedChannel | null | undefined; if (!channel) { return; } let autoReaction: AutoReaction | null = null; const lock = await pluginData.locks.acquire(`auto-reactions-${channel.id}`); if (pluginData.state.cache.has(channel.id)) { autoReaction = pluginData.state.cache.get(channel.id) ?? null; } else { autoReaction = (await pluginData.state.autoReactions.getForChannel(channel.id)) ?? null; pluginData.state.cache.set(channel.id, autoReaction); } lock.unlock(); if (!autoReaction) { return; } const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; if (me) { const missingPermissions = getMissingChannelPermissions(me, channel, readChannelPermissions | p.AddReactions); if (missingPermissions) { const logs = pluginData.getPlugin(LogsPlugin); logs.logBotAlert({ body: `Cannot apply auto-reactions in <#${channel.id}>. ${missingPermissionError(missingPermissions)}`, }); return; } } for (const reaction of autoReaction.reactions) { try { await message.react(reaction); } catch (e) { if (isDiscordJsTypeError(e)) { const logs = pluginData.getPlugin(LogsPlugin); logs.logBotAlert({ body: `Could not apply auto-reactions in <#${channel.id}> for message \`${message.id}\`: ${e.message}.`, }); } else if (isDiscordAPIError(e)) { const logs = pluginData.getPlugin(LogsPlugin); if (e.code === 10008) { logs.logBotAlert({ body: `Could not apply auto-reactions in <#${channel.id}> for message \`${message.id}\`. Make sure nothing is deleting the message before the reactions are applied.`, }); } else { logs.logBotAlert({ body: `Could not apply auto-reactions in <#${channel.id}> for message \`${message.id}\`. Error code ${e.code}.`, }); } break; } else { throw e; } } } }, }); ================================================ FILE: backend/src/plugins/AutoReactions/types.ts ================================================ import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { GuildAutoReactions } from "../../data/GuildAutoReactions.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { AutoReaction } from "../../data/entities/AutoReaction.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zAutoReactionsConfig = z.strictObject({ can_manage: z.boolean().default(false), }); export interface AutoReactionsPluginType extends BasePluginType { configSchema: typeof zAutoReactionsConfig; state: { logs: GuildLogs; savedMessages: GuildSavedMessages; autoReactions: GuildAutoReactions; cache: Map; common: pluginUtils.PluginPublicInterface; }; } export const autoReactionsCmd = guildPluginMessageCommand(); export const autoReactionsEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/Automod/AutomodPlugin.ts ================================================ import { CooldownManager, guildPlugin } from "vety"; import { Queue } from "../../Queue.js"; import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels.js"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners.js"; import { MINUTES, SECONDS } from "../../utils.js"; import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap.js"; import { unregisterEventListenersFromMap } from "../../utils/unregisterEventListenersFromMap.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { CountersPlugin } from "../Counters/CountersPlugin.js"; import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { ModActionsPlugin } from "../ModActions/ModActionsPlugin.js"; import { MutesPlugin } from "../Mutes/MutesPlugin.js"; import { PhishermanPlugin } from "../Phisherman/PhishermanPlugin.js"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin.js"; import { AntiraidClearCmd } from "./commands/AntiraidClearCmd.js"; import { SetAntiraidCmd } from "./commands/SetAntiraidCmd.js"; import { ViewAntiraidCmd } from "./commands/ViewAntiraidCmd.js"; import { RunAutomodOnJoinEvt, RunAutomodOnLeaveEvt } from "./events/RunAutomodOnJoinLeaveEvt.js"; import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate.js"; import { runAutomodOnCounterTrigger } from "./events/runAutomodOnCounterTrigger.js"; import { runAutomodOnMessage } from "./events/runAutomodOnMessage.js"; import { runAutomodOnModAction } from "./events/runAutomodOnModAction.js"; import { RunAutomodOnThreadCreate, RunAutomodOnThreadDelete, RunAutomodOnThreadUpdate, } from "./events/runAutomodOnThreadEvents.js"; import { clearOldRecentNicknameChanges } from "./functions/clearOldNicknameChanges.js"; import { clearOldRecentActions } from "./functions/clearOldRecentActions.js"; import { clearOldRecentSpam } from "./functions/clearOldRecentSpam.js"; import { AutomodPluginType, zAutomodConfig } from "./types.js"; import { DebugAutomodCmd } from "./commands/DebugAutomodCmd.js"; export const AutomodPlugin = guildPlugin()({ name: "automod", // prettier-ignore dependencies: () => [ LogsPlugin, ModActionsPlugin, MutesPlugin, CountersPlugin, PhishermanPlugin, InternalPosterPlugin, RoleManagerPlugin, ], configSchema: zAutomodConfig, customOverrideCriteriaFunctions: { antiraid_level: (pluginData, matchParams, value) => { return value ? value === pluginData.state.cachedAntiraidLevel : false; }, }, // prettier-ignore events: [ RunAutomodOnJoinEvt, RunAutomodOnMemberUpdate, RunAutomodOnLeaveEvt, RunAutomodOnThreadCreate, RunAutomodOnThreadDelete, RunAutomodOnThreadUpdate // Messages use message events from SavedMessages, see onLoad below ], messageCommands: [AntiraidClearCmd, SetAntiraidCmd, ViewAntiraidCmd, DebugAutomodCmd], async beforeLoad(pluginData) { const { state, guild } = pluginData; state.queue = new Queue(); state.regexRunner = getRegExpRunner(`guild-${guild.id}`); state.recentActions = []; state.recentSpam = []; state.recentNicknameChanges = new Map(); state.ignoredRoleChanges = new Set(); state.cooldownManager = new CooldownManager(); state.logs = new GuildLogs(guild.id); state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); state.antiraidLevels = GuildAntiraidLevels.getGuildInstance(guild.id); state.archives = GuildArchives.getGuildInstance(guild.id); state.cachedAntiraidLevel = await state.antiraidLevels.get(); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, async afterLoad(pluginData) { const { state } = pluginData; state.clearRecentActionsInterval = setInterval(() => clearOldRecentActions(pluginData), 1 * MINUTES); state.clearRecentSpamInterval = setInterval(() => clearOldRecentSpam(pluginData), 1 * SECONDS); state.clearRecentNicknameChangesInterval = setInterval( () => clearOldRecentNicknameChanges(pluginData), 30 * SECONDS, ); state.onMessageCreateFn = (message) => runAutomodOnMessage(pluginData, message, false); state.savedMessages.events.on("create", state.onMessageCreateFn); state.onMessageUpdateFn = (message) => runAutomodOnMessage(pluginData, message, true); state.savedMessages.events.on("update", state.onMessageUpdateFn); const countersPlugin = pluginData.getPlugin(CountersPlugin); state.onCounterTrigger = (name, triggerName, channelId, userId) => { runAutomodOnCounterTrigger(pluginData, name, triggerName, channelId, userId, false); }; state.onCounterReverseTrigger = (name, triggerName, channelId, userId) => { runAutomodOnCounterTrigger(pluginData, name, triggerName, channelId, userId, true); }; countersPlugin.onCounterEvent("trigger", state.onCounterTrigger); countersPlugin.onCounterEvent("reverseTrigger", state.onCounterReverseTrigger); const modActionsEvents = pluginData.getPlugin(ModActionsPlugin).getEventEmitter(); state.modActionsListeners = new Map(); state.modActionsListeners.set("note", (userId: string) => runAutomodOnModAction(pluginData, "note", userId)); state.modActionsListeners.set("warn", (userId: string, reason: string | undefined, isAutomodAction: boolean) => runAutomodOnModAction(pluginData, "warn", userId, reason, isAutomodAction), ); state.modActionsListeners.set("kick", (userId: string, reason: string | undefined, isAutomodAction: boolean) => runAutomodOnModAction(pluginData, "kick", userId, reason, isAutomodAction), ); state.modActionsListeners.set("ban", (userId: string, reason: string | undefined, isAutomodAction: boolean) => runAutomodOnModAction(pluginData, "ban", userId, reason, isAutomodAction), ); state.modActionsListeners.set("unban", (userId: string) => runAutomodOnModAction(pluginData, "unban", userId)); registerEventListenersFromMap(modActionsEvents, state.modActionsListeners); const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter(); state.mutesListeners = new Map(); state.mutesListeners.set("mute", (userId: string, reason: string | undefined, isAutomodAction: boolean) => runAutomodOnModAction(pluginData, "mute", userId, reason, isAutomodAction), ); state.mutesListeners.set("unmute", (userId: string) => runAutomodOnModAction(pluginData, "unmute", userId)); registerEventListenersFromMap(mutesEvents, state.mutesListeners); }, async beforeUnload(pluginData) { const { state, guild } = pluginData; const countersPlugin = pluginData.getPlugin(CountersPlugin); if (state.onCounterTrigger) { countersPlugin.offCounterEvent("trigger", state.onCounterTrigger); } if (state.onCounterReverseTrigger) { countersPlugin.offCounterEvent("reverseTrigger", state.onCounterReverseTrigger); } const modActionsEvents = pluginData.getPlugin(ModActionsPlugin).getEventEmitter(); if (state.modActionsListeners) { unregisterEventListenersFromMap(modActionsEvents, state.modActionsListeners); } const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter(); if (state.mutesListeners) { unregisterEventListenersFromMap(mutesEvents, state.mutesListeners); } state.queue.clear(); discardRegExpRunner(`guild-${guild.id}`); if (state.clearRecentActionsInterval) { clearInterval(state.clearRecentActionsInterval); } if (state.clearRecentSpamInterval) { clearInterval(state.clearRecentSpamInterval); } if (state.clearRecentNicknameChangesInterval) { clearInterval(state.clearRecentNicknameChangesInterval); } if (state.onMessageCreateFn) { state.savedMessages.events.off("create", state.onMessageCreateFn); } if (state.onMessageUpdateFn) { state.savedMessages.events.off("update", state.onMessageUpdateFn); } }, }); ================================================ FILE: backend/src/plugins/Automod/actions/addRoles.ts ================================================ import { PermissionFlagsBits, Snowflake } from "discord.js"; import { z } from "zod"; import { nonNullish, unique, zSnowflake } from "../../../utils.js"; import { canAssignRole } from "../../../utils/canAssignRole.js"; import { getMissingPermissions } from "../../../utils/getMissingPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; import { ignoreRoleChange } from "../functions/ignoredRoleChanges.js"; import { automodAction } from "../helpers.js"; const p = PermissionFlagsBits; const configSchema = z.array(zSnowflake); export const AddRolesAction = automodAction({ configSchema, async apply({ pluginData, contexts, actionConfig, ruleName }) { const members = unique(contexts.map((c) => c.member).filter(nonNullish)); const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const missingPermissions = getMissingPermissions(me.permissions, p.ManageRoles); if (missingPermissions) { const logs = pluginData.getPlugin(LogsPlugin); logs.logBotAlert({ body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`, }); return; } const rolesToAssign: string[] = []; const rolesWeCannotAssign: string[] = []; for (const roleId of actionConfig) { if (canAssignRole(pluginData.guild, me, roleId)) { rolesToAssign.push(roleId); } else { rolesWeCannotAssign.push(roleId); } } if (rolesWeCannotAssign.length) { const roleNamesWeCannotAssign = rolesWeCannotAssign.map( (roleId) => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId, ); const logs = pluginData.getPlugin(LogsPlugin); logs.logBotAlert({ body: `Unable to assign the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotAssign.join( "**, **", )}**`, }); } await Promise.all( members.map(async (member) => { const currentMemberRoles = new Set(member.roles.cache.keys()); for (const roleId of rolesToAssign) { if (!currentMemberRoles.has(roleId)) { pluginData.getPlugin(RoleManagerPlugin).addRole(member.id, roleId); // TODO: Remove this and just ignore bot changes in general? ignoreRoleChange(pluginData, member.id, roleId); } } }), ); }, }); ================================================ FILE: backend/src/plugins/Automod/actions/addToCounter.ts ================================================ import { z } from "zod"; import { zBoundedCharacters } from "../../../utils.js"; import { CountersPlugin } from "../../Counters/CountersPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { automodAction } from "../helpers.js"; const configSchema = z.object({ counter: zBoundedCharacters(0, 100), amount: z.number(), }); export const AddToCounterAction = automodAction({ configSchema, async apply({ pluginData, contexts, actionConfig, ruleName }) { const countersPlugin = pluginData.getPlugin(CountersPlugin); if (!countersPlugin.counterExists(actionConfig.counter)) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``, }); return; } countersPlugin.changeCounterValue( actionConfig.counter, contexts[0].message?.channel_id || null, contexts[0].user?.id || null, actionConfig.amount, ); }, }); ================================================ FILE: backend/src/plugins/Automod/actions/alert.ts ================================================ import { Snowflake } from "discord.js"; import { z } from "zod"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer, renderTemplate, TemplateParseError, TemplateSafeValueContainer, } from "../../../templateFormatter.js"; import { chunkMessageLines, isTruthy, messageLink, validateAndParseMessageContent, verboseChannelMention, zAllowedMentions, zBoundedCharacters, zNullishToUndefined, zSnowflake, } from "../../../utils.js"; import { erisAllowedMentionsToDjsMentionOptions } from "../../../utils/erisAllowedMentionsToDjsMentionOptions.js"; import { messageIsEmpty } from "../../../utils/messageIsEmpty.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { InternalPosterPlugin } from "../../InternalPoster/InternalPosterPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { automodAction } from "../helpers.js"; const configSchema = z.object({ channel: zSnowflake, text: zBoundedCharacters(0, 4000), allowed_mentions: zNullishToUndefined(zAllowedMentions.nullable().default(null)), }); export const AlertAction = automodAction({ configSchema, async apply({ pluginData, contexts, actionConfig, ruleName, matchResult, prettyName }) { const channel = pluginData.guild.channels.cache.get(actionConfig.channel as Snowflake); const logs = pluginData.getPlugin(LogsPlugin); if (channel?.isTextBased()) { const text = actionConfig.text; const theMessageLink = contexts[0].message && messageLink(pluginData.guild.id, contexts[0].message.channel_id, contexts[0].message.id); const safeUsers = contexts.map((c) => (c.user ? userToTemplateSafeUser(c.user) : null)).filter(isTruthy); const safeUser = safeUsers[0]; const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", "); const logMessage = await logs.getLogMessage( LogType.AUTOMOD_ACTION, createTypedTemplateSafeValueContainer({ rule: ruleName, user: safeUser, users: safeUsers, actionsTaken, matchSummary: matchResult.summary ?? "", prettyName, }), ); let rendered; try { rendered = await renderTemplate( actionConfig.text, new TemplateSafeValueContainer({ rule: ruleName, user: safeUser, users: safeUsers, text, actionsTaken, matchSummary: matchResult.summary, prettyName, messageLink: theMessageLink, logMessage: validateAndParseMessageContent(logMessage)?.content, }), ); } catch (err) { if (err instanceof TemplateParseError) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Error in alert format of automod rule ${ruleName}: ${err.message}`, }); return; } throw err; } if (messageIsEmpty(rendered)) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Tried to send alert with an empty message for automod rule ${ruleName}`, }); return; } try { const poster = pluginData.getPlugin(InternalPosterPlugin); const chunks = chunkMessageLines(rendered); for (const chunk of chunks) { await poster.sendMessage(channel, { content: chunk, allowedMentions: erisAllowedMentionsToDjsMentionOptions(actionConfig.allowed_mentions), }); } } catch (err) { if (err.code === 50001) { logs.logBotAlert({ body: `Missing access to send alert to channel ${verboseChannelMention( channel, )} in automod rule **${ruleName}**`, }); } else { logs.logBotAlert({ body: `Error ${err.code || "UNKNOWN"} when sending alert to channel ${verboseChannelMention( channel, )} in automod rule **${ruleName}**`, }); } } } else { logs.logBotAlert({ body: `Invalid channel id \`${actionConfig.channel}\` for alert action in automod rule **${ruleName}**`, }); } }, }); ================================================ FILE: backend/src/plugins/Automod/actions/archiveThread.ts ================================================ import { AnyThreadChannel } from "discord.js"; import { z } from "zod"; import { noop } from "../../../utils.js"; import { automodAction } from "../helpers.js"; const configSchema = z.strictObject({}); export const ArchiveThreadAction = automodAction({ configSchema, async apply({ pluginData, contexts }) { const threads = contexts .filter((c) => c.message?.channel_id) .map((c) => pluginData.guild.channels.cache.get(c.message!.channel_id)) .filter((c): c is AnyThreadChannel => c?.isThread() ?? false); for (const thread of threads) { await thread.setArchived().catch(noop); } }, }); ================================================ FILE: backend/src/plugins/Automod/actions/availableActions.ts ================================================ import { AutomodActionBlueprint } from "../helpers.js"; import { AddRolesAction } from "./addRoles.js"; import { AddToCounterAction } from "./addToCounter.js"; import { AlertAction } from "./alert.js"; import { ArchiveThreadAction } from "./archiveThread.js"; import { BanAction } from "./ban.js"; import { ChangeNicknameAction } from "./changeNickname.js"; import { ChangePermsAction } from "./changePerms.js"; import { CleanAction } from "./clean.js"; import { KickAction } from "./kick.js"; import { LogAction } from "./log.js"; import { MuteAction } from "./mute.js"; import { PauseInvitesAction } from "./pauseInvites.js"; import { RemoveRolesAction } from "./removeRoles.js"; import { ReplyAction } from "./reply.js"; import { SetAntiraidLevelAction } from "./setAntiraidLevel.js"; import { SetCounterAction } from "./setCounter.js"; import { SetSlowmodeAction } from "./setSlowmode.js"; import { StartThreadAction } from "./startThread.js"; import { WarnAction } from "./warn.js"; export const availableActions = { clean: CleanAction, warn: WarnAction, mute: MuteAction, kick: KickAction, ban: BanAction, alert: AlertAction, change_nickname: ChangeNicknameAction, log: LogAction, add_roles: AddRolesAction, remove_roles: RemoveRolesAction, set_antiraid_level: SetAntiraidLevelAction, reply: ReplyAction, add_to_counter: AddToCounterAction, set_counter: SetCounterAction, set_slowmode: SetSlowmodeAction, start_thread: StartThreadAction, archive_thread: ArchiveThreadAction, change_perms: ChangePermsAction, pause_invites: PauseInvitesAction, } satisfies Record>; ================================================ FILE: backend/src/plugins/Automod/actions/ban.ts ================================================ import { z } from "zod"; import { convertDelayStringToMS, nonNullish, unique, zBoundedCharacters, zDelayString, zSnowflake, } from "../../../utils.js"; import { CaseArgs } from "../../Cases/types.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; import { zNotify } from "../constants.js"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods.js"; import { automodAction } from "../helpers.js"; const configSchema = z.strictObject({ reason: zBoundedCharacters(0, 4000).nullable().default(null), duration: zDelayString.nullable().default(null), notify: zNotify.nullable().default(null), notifyChannel: zSnowflake.nullable().default(null), deleteMessageDays: z.number().nullable().default(null), postInCaseLog: z.boolean().nullable().default(null), hide_case: z.boolean().nullable().default(false), }); export const BanAction = automodAction({ configSchema, async apply({ pluginData, contexts, actionConfig, matchResult }) { const reason = actionConfig.reason || "Kicked automatically"; const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; const deleteMessageDays = actionConfig.deleteMessageDays ?? undefined; const caseArgs: Partial = { modId: pluginData.client.user!.id, extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [], automatic: true, postInCaseLogOverride: actionConfig.postInCaseLog ?? undefined, hide: Boolean(actionConfig.hide_case), }; const userIdsToBan = unique(contexts.map((c) => c.user?.id).filter(nonNullish)); const modActions = pluginData.getPlugin(ModActionsPlugin); for (const userId of userIdsToBan) { await modActions.banUserId( userId, reason, reason, { contactMethods, caseArgs, deleteMessageDays, isAutomodAction: true, }, duration, ); } }, }); ================================================ FILE: backend/src/plugins/Automod/actions/changeNickname.ts ================================================ import { z } from "zod"; import { nonNullish, unique, zBoundedCharacters } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { automodAction } from "../helpers.js"; export const ChangeNicknameAction = automodAction({ configSchema: z.union([ zBoundedCharacters(0, 32), z.strictObject({ name: zBoundedCharacters(0, 32), }), ]), async apply({ pluginData, contexts, actionConfig }) { const members = unique(contexts.map((c) => c.member).filter(nonNullish)); for (const member of members) { if (pluginData.state.recentNicknameChanges.has(member.id)) continue; const newName = typeof actionConfig === "string" ? actionConfig : actionConfig.name; member.edit({ nick: newName }).catch(() => { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to change the nickname of \`${member.id}\``, }); }); pluginData.state.recentNicknameChanges.set(member.id, { timestamp: Date.now() }); } }, }); ================================================ FILE: backend/src/plugins/Automod/actions/changePerms.ts ================================================ import { PermissionsBitField, PermissionsString } from "discord.js"; import { U } from "ts-toolbelt"; import { z } from "zod"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { isValidSnowflake, keys, noop, zBoundedCharacters } from "../../../utils.js"; import { guildToTemplateSafeGuild, savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser, } from "../../../utils/templateSafeObjects.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { automodAction } from "../helpers.js"; type LegacyPermMap = Record; const legacyPermMap = { CREATE_INSTANT_INVITE: "CreateInstantInvite", KICK_MEMBERS: "KickMembers", BAN_MEMBERS: "BanMembers", ADMINISTRATOR: "Administrator", MANAGE_CHANNELS: "ManageChannels", MANAGE_GUILD: "ManageGuild", ADD_REACTIONS: "AddReactions", VIEW_AUDIT_LOG: "ViewAuditLog", PRIORITY_SPEAKER: "PrioritySpeaker", STREAM: "Stream", VIEW_CHANNEL: "ViewChannel", SEND_MESSAGES: "SendMessages", SEND_TTSMESSAGES: "SendTTSMessages", MANAGE_MESSAGES: "ManageMessages", EMBED_LINKS: "EmbedLinks", ATTACH_FILES: "AttachFiles", READ_MESSAGE_HISTORY: "ReadMessageHistory", MENTION_EVERYONE: "MentionEveryone", USE_EXTERNAL_EMOJIS: "UseExternalEmojis", VIEW_GUILD_INSIGHTS: "ViewGuildInsights", CONNECT: "Connect", SPEAK: "Speak", MUTE_MEMBERS: "MuteMembers", DEAFEN_MEMBERS: "DeafenMembers", MOVE_MEMBERS: "MoveMembers", USE_VAD: "UseVAD", CHANGE_NICKNAME: "ChangeNickname", MANAGE_NICKNAMES: "ManageNicknames", MANAGE_ROLES: "ManageRoles", MANAGE_WEBHOOKS: "ManageWebhooks", MANAGE_EMOJIS_AND_STICKERS: "ManageEmojisAndStickers", USE_APPLICATION_COMMANDS: "UseApplicationCommands", REQUEST_TO_SPEAK: "RequestToSpeak", MANAGE_EVENTS: "ManageEvents", MANAGE_THREADS: "ManageThreads", CREATE_PUBLIC_THREADS: "CreatePublicThreads", CREATE_PRIVATE_THREADS: "CreatePrivateThreads", USE_EXTERNAL_STICKERS: "UseExternalStickers", SEND_MESSAGES_IN_THREADS: "SendMessagesInThreads", USE_EMBEDDED_ACTIVITIES: "UseEmbeddedActivities", MODERATE_MEMBERS: "ModerateMembers", } satisfies LegacyPermMap; const realToLegacyMap = Object.entries(legacyPermMap).reduce((map, pair) => { map[pair[1]] = pair[0]; return map; }, {}) as Record; const permissionNames = keys(PermissionsBitField.Flags) as U.ListOf; const legacyPermissionNames = keys(legacyPermMap) as U.ListOf; const allPermissionNames = [...permissionNames, ...legacyPermissionNames] as const; const permissionTypeMap = allPermissionNames.reduce( (map, permName) => { map[permName] = z.boolean().nullable(); return map; }, {} as Record<(typeof allPermissionNames)[number], z.ZodNullable>, ); const zPermissionsMap = z.strictObject(permissionTypeMap); export const ChangePermsAction = automodAction({ configSchema: z.strictObject({ target: zBoundedCharacters(1, 2000), channel: zBoundedCharacters(1, 2000).nullable().default(null), perms: zPermissionsMap.partial(), }), async apply({ pluginData, contexts, actionConfig, ruleName }) { const user = contexts.find((c) => c.user)?.user; const message = contexts.find((c) => c.message)?.message; let target: string; try { target = await renderTemplate( actionConfig.target, new TemplateSafeValueContainer({ user: user ? userToTemplateSafeUser(user) : null, guild: guildToTemplateSafeGuild(pluginData.guild), message: message ? savedMessageToTemplateSafeSavedMessage(message) : null, }), ); } catch (err) { if (err instanceof TemplateParseError) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Error in target format of automod rule ${ruleName}: ${err.message}`, }); return; } throw err; } let channelId: string | null = null; if (actionConfig.channel) { try { channelId = await renderTemplate( actionConfig.channel, new TemplateSafeValueContainer({ user: user ? userToTemplateSafeUser(user) : null, guild: guildToTemplateSafeGuild(pluginData.guild), message: message ? savedMessageToTemplateSafeSavedMessage(message) : null, }), ); } catch (err) { if (err instanceof TemplateParseError) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Error in channel format of automod rule ${ruleName}: ${err.message}`, }); return; } throw err; } } const role = pluginData.guild.roles.resolve(target); if (!role) { const member = await pluginData.guild.members.fetch(target).catch(noop); if (!member) return; } if (channelId && isValidSnowflake(channelId)) { const channel = pluginData.guild.channels.resolve(channelId); if (!channel || channel.isThread()) return; const overwrite = channel.permissionOverwrites.cache.find((pw) => pw.id === target); const allow = new PermissionsBitField(overwrite?.allow ?? 0n).serialize(); const deny = new PermissionsBitField(overwrite?.deny ?? 0n).serialize(); const newPerms: Partial> = {}; for (const key in allow) { const legacyKey = realToLegacyMap[key]; const configEntry = actionConfig.perms[key] ?? actionConfig.perms[legacyKey]; if (typeof configEntry !== "undefined") { newPerms[key] = configEntry; continue; } if (allow[key]) { newPerms[key] = true; } else if (deny[key]) { newPerms[key] = false; } } // takes more code lines but looks cleaner imo let hasPerms = false; for (const key in newPerms) { if (typeof newPerms[key] === "boolean") { hasPerms = true; break; } } if (overwrite && !hasPerms) { await channel.permissionOverwrites.delete(target).catch(noop); return; } await channel.permissionOverwrites.edit(target, newPerms).catch(noop); return; } if (!role) return; const perms = new PermissionsBitField(role.permissions).serialize(); for (const key in actionConfig.perms) { const realKey = legacyPermMap[key] ?? key; perms[realKey] = actionConfig.perms[key]; } const permsArray = Object.keys(perms).filter((key) => perms[key]); await role.setPermissions(new PermissionsBitField(permsArray)).catch(noop); }, }); ================================================ FILE: backend/src/plugins/Automod/actions/clean.ts ================================================ import { GuildTextBasedChannel, Snowflake } from "discord.js"; import { z } from "zod"; import { LogType } from "../../../data/LogType.js"; import { noop } from "../../../utils.js"; import { automodAction } from "../helpers.js"; export const CleanAction = automodAction({ configSchema: z.boolean().default(false), async apply({ pluginData, contexts, ruleName }) { const messageIdsToDeleteByChannelId: Map = new Map(); for (const context of contexts) { if (context.message) { if (!messageIdsToDeleteByChannelId.has(context.message.channel_id)) { messageIdsToDeleteByChannelId.set(context.message.channel_id, []); } if (messageIdsToDeleteByChannelId.get(context.message.channel_id)!.includes(context.message.id)) { // FIXME: Debug // tslint:disable-next-line:no-console console.warn(`Message ID to delete was already present: ${pluginData.guild.name}, rule ${ruleName}`); continue; } messageIdsToDeleteByChannelId.get(context.message.channel_id)!.push(context.message.id); } } for (const [channelId, messageIds] of messageIdsToDeleteByChannelId.entries()) { for (const id of messageIds) { pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id); } const channel = pluginData.guild.channels.cache.get(channelId as Snowflake) as GuildTextBasedChannel; await channel.bulkDelete(messageIds as Snowflake[]).catch(noop); } }, }); ================================================ FILE: backend/src/plugins/Automod/actions/exampleAction.ts ================================================ import { z } from "zod"; import { zBoundedCharacters } from "../../../utils.js"; import { automodAction } from "../helpers.js"; export const ExampleAction = automodAction({ configSchema: z.strictObject({ someValue: zBoundedCharacters(0, 1000), }), // eslint-disable-next-line @typescript-eslint/no-unused-vars async apply({ pluginData, contexts, actionConfig }) { // TODO: Everything }, }); ================================================ FILE: backend/src/plugins/Automod/actions/kick.ts ================================================ import { z } from "zod"; import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils.js"; import { CaseArgs } from "../../Cases/types.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; import { zNotify } from "../constants.js"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods.js"; import { automodAction } from "../helpers.js"; export const KickAction = automodAction({ configSchema: z.strictObject({ reason: zBoundedCharacters(0, 4000).nullable().default(null), notify: zNotify.nullable().default(null), notifyChannel: zSnowflake.nullable().default(null), postInCaseLog: z.boolean().nullable().default(null), hide_case: z.boolean().nullable().default(false), }), async apply({ pluginData, contexts, actionConfig, matchResult }) { const reason = actionConfig.reason || "Kicked automatically"; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; const caseArgs: Partial = { modId: pluginData.client.user!.id, extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [], automatic: true, postInCaseLogOverride: actionConfig.postInCaseLog ?? undefined, hide: Boolean(actionConfig.hide_case), }; const userIdsToKick = unique(contexts.map((c) => c.user?.id).filter(nonNullish)); const membersToKick = await asyncMap(userIdsToKick, (id) => resolveMember(pluginData.client, pluginData.guild, id)); const modActions = pluginData.getPlugin(ModActionsPlugin); for (const member of membersToKick) { if (!member) continue; await modActions.kickMember(member, reason, reason, { contactMethods, caseArgs, isAutomodAction: true }); } }, }); ================================================ FILE: backend/src/plugins/Automod/actions/log.ts ================================================ import { z } from "zod"; import { isTruthy, unique } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { automodAction } from "../helpers.js"; export const LogAction = automodAction({ configSchema: z.boolean().default(true), async apply({ pluginData, contexts, ruleName, matchResult, prettyName }) { const users = unique(contexts.map((c) => c.user)).filter(isTruthy); const user = users[0]; const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", "); pluginData.getPlugin(LogsPlugin).logAutomodAction({ rule: ruleName, prettyName, user, users, actionsTaken, matchSummary: matchResult.summary ?? "", }); }, }); ================================================ FILE: backend/src/plugins/Automod/actions/mute.ts ================================================ import { z } from "zod"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; import { convertDelayStringToMS, nonNullish, unique, zBoundedCharacters, zDelayString, zSnowflake, } from "../../../utils.js"; import { CaseArgs } from "../../Cases/types.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { MutesPlugin } from "../../Mutes/MutesPlugin.js"; import { zNotify } from "../constants.js"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods.js"; import { automodAction } from "../helpers.js"; export const MuteAction = automodAction({ configSchema: z.strictObject({ reason: zBoundedCharacters(0, 4000).nullable().default(null), duration: zDelayString.nullable().default(null), notify: zNotify.nullable().default(null), notifyChannel: zSnowflake.nullable().default(null), remove_roles_on_mute: z .union([z.boolean(), z.array(zSnowflake)]) .nullable() .default(null), restore_roles_on_mute: z .union([z.boolean(), z.array(zSnowflake)]) .nullable() .default(null), postInCaseLog: z.boolean().nullable().default(null), hide_case: z.boolean().nullable().default(false), }), async apply({ pluginData, contexts, actionConfig, ruleName, matchResult }) { const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined; const reason = actionConfig.reason || "Muted automatically"; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; const rolesToRemove = actionConfig.remove_roles_on_mute; const rolesToRestore = actionConfig.restore_roles_on_mute; const caseArgs: Partial = { modId: pluginData.client.user!.id, extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [], automatic: true, postInCaseLogOverride: actionConfig.postInCaseLog ?? undefined, hide: Boolean(actionConfig.hide_case), }; const userIdsToMute = unique(contexts.map((c) => c.user?.id).filter(nonNullish)); const mutes = pluginData.getPlugin(MutesPlugin); for (const userId of userIdsToMute) { try { await mutes.muteUser( userId, duration, reason, reason, { contactMethods, caseArgs, isAutomodAction: true }, rolesToRemove, rolesToRestore, ); } catch (e) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to mute <@!${userId}> in Automod rule \`${ruleName}\` because a mute role has not been specified in server config`, }); } else { throw e; } } } }, }); ================================================ FILE: backend/src/plugins/Automod/actions/pauseInvites.ts ================================================ import { GuildFeature } from "discord.js"; import { z } from "zod"; import { automodAction } from "../helpers.js"; export const PauseInvitesAction = automodAction({ configSchema: z.strictObject({ paused: z.boolean(), }), async apply({ pluginData, actionConfig }) { const hasInvitesDisabled = pluginData.guild.features.includes(GuildFeature.InvitesDisabled); if (actionConfig.paused !== hasInvitesDisabled) { await pluginData.guild.disableInvites(actionConfig.paused); } }, }); ================================================ FILE: backend/src/plugins/Automod/actions/removeRoles.ts ================================================ import { PermissionFlagsBits, Snowflake } from "discord.js"; import { z } from "zod"; import { nonNullish, unique, zSnowflake } from "../../../utils.js"; import { canAssignRole } from "../../../utils/canAssignRole.js"; import { getMissingPermissions } from "../../../utils/getMissingPermissions.js"; import { memberRolesLock } from "../../../utils/lockNameHelpers.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { ignoreRoleChange } from "../functions/ignoredRoleChanges.js"; import { automodAction } from "../helpers.js"; const p = PermissionFlagsBits; export const RemoveRolesAction = automodAction({ configSchema: z.array(zSnowflake).default([]), async apply({ pluginData, contexts, actionConfig, ruleName }) { const members = unique(contexts.map((c) => c.member).filter(nonNullish)); const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const missingPermissions = getMissingPermissions(me.permissions, p.ManageRoles); if (missingPermissions) { const logs = pluginData.getPlugin(LogsPlugin); logs.logBotAlert({ body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`, }); return; } const rolesToRemove: string[] = []; const rolesWeCannotRemove: string[] = []; for (const roleId of actionConfig) { if (canAssignRole(pluginData.guild, me, roleId)) { rolesToRemove.push(roleId); } else { rolesWeCannotRemove.push(roleId); } } if (rolesWeCannotRemove.length) { const roleNamesWeCannotRemove = rolesWeCannotRemove.map( (roleId) => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId, ); const logs = pluginData.getPlugin(LogsPlugin); logs.logBotAlert({ body: `Unable to remove the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotRemove.join( "**, **", )}**`, }); } await Promise.all( members.map(async (member) => { const memberRoles = new Set(member.roles.cache.keys()); for (const roleId of rolesToRemove) { memberRoles.delete(roleId as Snowflake); ignoreRoleChange(pluginData, member.id, roleId); } if (memberRoles.size === member.roles.cache.size) { // No role changes return; } const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member)); const rolesArr = Array.from(memberRoles.values()); await member.edit({ roles: rolesArr, }); memberRoleLock.unlock(); }), ); }, }); ================================================ FILE: backend/src/plugins/Automod/actions/reply.ts ================================================ import { GuildTextBasedChannel, MessageCreateOptions, PermissionsBitField, Snowflake, User } from "discord.js"; import { z } from "zod"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { convertDelayStringToMS, noop, renderRecursively, unique, validateAndParseMessageContent, verboseChannelMention, zBoundedCharacters, zDelayString, zMessageContent, } from "../../../utils.js"; import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions.js"; import { messageIsEmpty } from "../../../utils/messageIsEmpty.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { automodAction } from "../helpers.js"; import { AutomodContext } from "../types.js"; export const ReplyAction = automodAction({ configSchema: z.union([ zBoundedCharacters(0, 4000), z.strictObject({ text: zMessageContent, auto_delete: z.union([zDelayString, z.number()]).nullable().default(null), inline: z.boolean().default(false), }), ]), async apply({ pluginData, contexts, actionConfig, ruleName }) { const contextsWithTextChannels = contexts .filter((c) => c.message?.channel_id) .filter((c) => { const channel = pluginData.guild.channels.cache.get(c.message!.channel_id as Snowflake); return channel?.isTextBased(); }); const contextsByChannelId = contextsWithTextChannels.reduce((map: Map, context) => { if (!map.has(context.message!.channel_id)) { map.set(context.message!.channel_id, []); } map.get(context.message!.channel_id)!.push(context); return map; }, new Map()); for (const [channelId, _contexts] of contextsByChannelId.entries()) { const users = unique(Array.from(new Set(_contexts.map((c) => c.user).filter(Boolean)))) as User[]; const user = users[0]; const renderReplyText = async (str: string) => renderTemplate( str, new TemplateSafeValueContainer({ user: userToTemplateSafeUser(user), }), ); let formatted: string | MessageCreateOptions; try { formatted = typeof actionConfig === "string" ? await renderReplyText(actionConfig) : ((await renderRecursively(actionConfig.text, renderReplyText)) as MessageCreateOptions); } catch (err) { if (err instanceof TemplateParseError) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Error in reply format of automod rule \`${ruleName}\`: ${err.message}`, }); return; } throw err; } if (formatted) { const channel = pluginData.guild.channels.cache.get(channelId as Snowflake) as GuildTextBasedChannel; // Check for basic Send Messages and View Channel permissions if ( !hasDiscordPermissions( channel.permissionsFor(pluginData.client.user!.id), PermissionsBitField.Flags.SendMessages | PermissionsBitField.Flags.ViewChannel, ) ) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions to reply in ${verboseChannelMention(channel)} in Automod rule \`${ruleName}\``, }); continue; } // If the message is an embed, check for embed permissions if ( typeof formatted !== "string" && !hasDiscordPermissions( channel.permissionsFor(pluginData.client.user!.id), PermissionsBitField.Flags.EmbedLinks, ) ) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions to reply **with an embed** in ${verboseChannelMention( channel, )} in Automod rule \`${ruleName}\``, }); continue; } const messageContent = validateAndParseMessageContent(formatted); const messageOpts: MessageCreateOptions = { ...messageContent, allowedMentions: { users: [user.id], }, }; if (typeof actionConfig !== "string" && actionConfig.inline) { messageOpts.reply = { failIfNotExists: false, messageReference: _contexts[0].message!.id, }; } if (messageIsEmpty(messageOpts)) { return; } const replyMsg = await channel.send(messageOpts); if (typeof actionConfig === "object" && actionConfig.auto_delete) { const delay = convertDelayStringToMS(String(actionConfig.auto_delete))!; setTimeout(() => replyMsg.deletable && replyMsg.delete().catch(noop), delay); } } } }, }); ================================================ FILE: backend/src/plugins/Automod/actions/setAntiraidLevel.ts ================================================ import { zBoundedCharacters } from "../../../utils.js"; import { setAntiraidLevel } from "../functions/setAntiraidLevel.js"; import { automodAction } from "../helpers.js"; export const SetAntiraidLevelAction = automodAction({ configSchema: zBoundedCharacters(0, 100).nullable(), async apply({ pluginData, actionConfig }) { setAntiraidLevel(pluginData, actionConfig ?? null); }, }); ================================================ FILE: backend/src/plugins/Automod/actions/setCounter.ts ================================================ import { z } from "zod"; import { MAX_COUNTER_VALUE, MIN_COUNTER_VALUE } from "../../../data/GuildCounters.js"; import { zBoundedCharacters } from "../../../utils.js"; import { CountersPlugin } from "../../Counters/CountersPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { automodAction } from "../helpers.js"; export const SetCounterAction = automodAction({ configSchema: z.strictObject({ counter: zBoundedCharacters(0, 100), value: z.number().min(MIN_COUNTER_VALUE).max(MAX_COUNTER_VALUE), }), async apply({ pluginData, contexts, actionConfig, ruleName }) { const countersPlugin = pluginData.getPlugin(CountersPlugin); if (!countersPlugin.counterExists(actionConfig.counter)) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown counter \`${actionConfig.counter}\` in \`set_counter\` action of Automod rule \`${ruleName}\``, }); return; } countersPlugin.setCounterValue( actionConfig.counter, contexts[0].message?.channel_id || null, contexts[0].user?.id || null, actionConfig.value, ); }, }); ================================================ FILE: backend/src/plugins/Automod/actions/setSlowmode.ts ================================================ import { ChannelType, GuildTextBasedChannel, Snowflake } from "discord.js"; import { z } from "zod"; import { convertDelayStringToMS, isDiscordAPIError, zDelayString, zSnowflake } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { automodAction } from "../helpers.js"; export const SetSlowmodeAction = automodAction({ configSchema: z.strictObject({ channels: z.array(zSnowflake).nullable().default([]), duration: zDelayString.nullable().default("10s"), }), async apply({ pluginData, actionConfig, contexts }) { const slowmodeMs = Math.max(actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : 0, 0); const channels: Snowflake[] = actionConfig.channels ?? []; if (channels.length === 0) { channels.push(...contexts.filter((c) => c.message?.channel_id).map((c) => c.message!.channel_id)); } for (const channelId of channels) { const channel = pluginData.guild.channels.cache.get(channelId as Snowflake); // Only text channels and text channels within categories support slowmodes if (!channel?.isTextBased() && channel?.type !== ChannelType.GuildCategory) { continue; } const channelsToSlowmode: GuildTextBasedChannel[] = []; if (channel.type === ChannelType.GuildCategory) { // Find all text channels within the category for (const ch of pluginData.guild.channels.cache.values()) { if (ch.parentId === channel.id && ch.type === ChannelType.GuildText) { channelsToSlowmode.push(ch); } } } else { channelsToSlowmode.push(channel); } const slowmodeSeconds = Math.ceil(slowmodeMs / 1000); try { for (const chan of channelsToSlowmode) { await chan.setRateLimitPerUser(slowmodeSeconds); } } catch (e) { // Check for invalid form body -> indicates duration was too large const errorMessage = isDiscordAPIError(e) && e.code === 50035 ? `Duration is greater than maximum native slowmode duration` : e.message; pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unable to set slowmode for channel ${channel.id} to ${slowmodeSeconds} seconds: ${errorMessage}`, }); } } }, }); ================================================ FILE: backend/src/plugins/Automod/actions/startThread.ts ================================================ import { ChannelType, GuildTextThreadCreateOptions, ThreadAutoArchiveDuration, ThreadChannel } from "discord.js"; import { z } from "zod"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { MINUTES, convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from "../../../utils.js"; import { savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { automodAction } from "../helpers.js"; const validThreadAutoArchiveDurations: ThreadAutoArchiveDuration[] = [ ThreadAutoArchiveDuration.OneHour, ThreadAutoArchiveDuration.OneDay, ThreadAutoArchiveDuration.ThreeDays, ThreadAutoArchiveDuration.OneWeek, ]; export const StartThreadAction = automodAction({ configSchema: z.strictObject({ name: zBoundedCharacters(1, 100).nullable(), auto_archive: zDelayString, private: z.boolean().default(false), slowmode: zDelayString.nullable().default(null), limit_per_channel: z.number().nullable().default(5), }), async apply({ pluginData, contexts, actionConfig, ruleName }) { // check if the message still exists, we don't want to create threads for deleted messages const threads = contexts.filter((c) => { if (!c.message || !c.user) return false; const channel = pluginData.guild.channels.cache.get(c.message.channel_id); if (channel?.type !== ChannelType.GuildText || !channel.isTextBased()) return false; // for some reason the typing here for channel.type defaults to ThreadChannelTypes (?) // check against max threads per channel if (actionConfig.limit_per_channel && actionConfig.limit_per_channel > 0) { const threadCount = channel.threads.cache.filter( (tr) => tr.ownerId === pluginData.client.user!.id && !tr.archived && tr.parentId === channel.id, ).size; if (threadCount >= actionConfig.limit_per_channel) return false; } return true; }); const archiveSet = actionConfig.auto_archive ? Math.ceil(Math.max(convertDelayStringToMS(actionConfig.auto_archive) ?? 0, 0) / MINUTES) : ThreadAutoArchiveDuration.OneDay; const autoArchive = validThreadAutoArchiveDurations.includes(archiveSet) ? (archiveSet as ThreadAutoArchiveDuration) : ThreadAutoArchiveDuration.OneHour; for (const threadContext of threads) { const channel = pluginData.guild.channels.cache.get(threadContext.message!.channel_id); if (!channel || !("threads" in channel) || channel.isThreadOnly()) continue; let threadName: string; try { threadName = await renderTemplate( actionConfig.name ?? "{user.renderedUsername}'s thread", new TemplateSafeValueContainer({ user: userToTemplateSafeUser(threadContext.user!), msg: savedMessageToTemplateSafeSavedMessage(threadContext.message!), }), ); } catch (err) { if (err instanceof TemplateParseError) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Error in thread name format of automod rule ${ruleName}: ${err.message}`, }); return; } throw err; } const threadOptions: GuildTextThreadCreateOptions = { name: threadName, autoArchiveDuration: autoArchive, startMessage: !actionConfig.private ? threadContext.message!.id : undefined, }; let thread: ThreadChannel | undefined; if (channel.type === ChannelType.GuildNews) { thread = await channel.threads .create({ ...threadOptions, type: ChannelType.AnnouncementThread, }) .catch(() => undefined); } else { thread = await channel.threads .create({ ...threadOptions, type: actionConfig.private ? ChannelType.PrivateThread : ChannelType.PublicThread, startMessage: !actionConfig.private ? threadContext.message!.id : undefined, }) .catch(() => undefined); } if (actionConfig.slowmode && thread) { const dur = Math.ceil(Math.max(convertDelayStringToMS(actionConfig.slowmode) ?? 0, 0) / 1000); if (dur > 0) { await thread.setRateLimitPerUser(dur).catch(noop); } } } }, }); ================================================ FILE: backend/src/plugins/Automod/actions/warn.ts ================================================ import { z } from "zod"; import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils.js"; import { CaseArgs } from "../../Cases/types.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; import { zNotify } from "../constants.js"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods.js"; import { automodAction } from "../helpers.js"; export const WarnAction = automodAction({ configSchema: z.strictObject({ reason: zBoundedCharacters(0, 4000).nullable().default(null), notify: zNotify.nullable().default(null), notifyChannel: zSnowflake.nullable().default(null), postInCaseLog: z.boolean().nullable().default(null), hide_case: z.boolean().nullable().default(false), }), async apply({ pluginData, contexts, actionConfig, matchResult }) { const reason = actionConfig.reason || "Warned automatically"; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; const caseArgs: Partial = { modId: pluginData.client.user!.id, extraNotes: matchResult.fullSummary ? [matchResult.fullSummary] : [], automatic: true, postInCaseLogOverride: actionConfig.postInCaseLog ?? undefined, hide: Boolean(actionConfig.hide_case), }; const userIdsToWarn = unique(contexts.map((c) => c.user?.id).filter(nonNullish)); const membersToWarn = await asyncMap(userIdsToWarn, (id) => resolveMember(pluginData.client, pluginData.guild, id)); const modActions = pluginData.getPlugin(ModActionsPlugin); for (const member of membersToWarn) { if (!member) continue; await modActions.warnMember(member, reason, reason, { contactMethods, caseArgs, isAutomodAction: true }); } }, }); ================================================ FILE: backend/src/plugins/Automod/commands/AntiraidClearCmd.ts ================================================ import { guildPluginMessageCommand } from "vety"; import { setAntiraidLevel } from "../functions/setAntiraidLevel.js"; import { AutomodPluginType } from "../types.js"; export const AntiraidClearCmd = guildPluginMessageCommand()({ trigger: ["antiraid clear", "antiraid reset", "antiraid none", "antiraid off"], permission: "can_set_antiraid", async run({ pluginData, message }) { await setAntiraidLevel(pluginData, null, message.author); void pluginData.state.common.sendSuccessMessage(message, "Anti-raid turned **off**"); }, }); ================================================ FILE: backend/src/plugins/Automod/commands/DebugAutomodCmd.ts ================================================ import { guildPluginMessageCommand } from "vety"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; import { runAutomod } from "../functions/runAutomod.js"; import { createChunkedMessage } from "../../../utils.js"; import { getOrFetchGuildMember } from "../../../utils/getOrFetchGuildMember.js"; import { getOrFetchUser } from "../../../utils/getOrFetchUser.js"; export const DebugAutomodCmd = guildPluginMessageCommand()({ trigger: "debug_automod", permission: "can_debug_automod", signature: { messageId: ct.string(), }, async run({ pluginData, message, args }) { const targetMessage = await pluginData.state.savedMessages.find(args.messageId); if (!targetMessage || targetMessage.guild_id !== pluginData.guild.id) { pluginData.state.common.sendErrorMessage(message, "Message not found"); return; } const member = await getOrFetchGuildMember(pluginData.guild, targetMessage.user_id); const user = await getOrFetchUser(pluginData.client, targetMessage.user_id); const context: AutomodContext = { timestamp: moment.utc(targetMessage.posted_at).valueOf(), message: targetMessage, user, member, }; const result = await runAutomod(pluginData, context, true); let resultText = `**${result.triggered ? "✔️ Triggered" : "❌ Not triggered"}**\n\nRules checked:\n\n`; for (const ruleResult of result.rulesChecked) { resultText += `**${ruleResult.ruleName}**\n`; if (ruleResult.outcome.success) { resultText += `\\- Matched trigger: ${ruleResult.outcome.matchedTrigger.name} (trigger #${ruleResult.outcome.matchedTrigger.num})\n`; } else { resultText += `\\- No match (${ruleResult.outcome.reason})\n`; } } createChunkedMessage(message.channel, resultText.trim()); }, }); ================================================ FILE: backend/src/plugins/Automod/commands/SetAntiraidCmd.ts ================================================ import { guildPluginMessageCommand } from "vety"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { setAntiraidLevel } from "../functions/setAntiraidLevel.js"; import { AutomodPluginType } from "../types.js"; export const SetAntiraidCmd = guildPluginMessageCommand()({ trigger: "antiraid", permission: "can_set_antiraid", signature: { level: ct.string(), }, async run({ pluginData, message, args }) { const config = pluginData.config.get(); if (!config.antiraid_levels.includes(args.level)) { pluginData.state.common.sendErrorMessage(message, "Unknown anti-raid level"); return; } await setAntiraidLevel(pluginData, args.level, message.author); pluginData.state.common.sendSuccessMessage(message, `Anti-raid level set to **${args.level}**`); }, }); ================================================ FILE: backend/src/plugins/Automod/commands/ViewAntiraidCmd.ts ================================================ import { guildPluginMessageCommand } from "vety"; import { AutomodPluginType } from "../types.js"; export const ViewAntiraidCmd = guildPluginMessageCommand()({ trigger: "antiraid", permission: "can_view_antiraid", async run({ pluginData, message }) { if (pluginData.state.cachedAntiraidLevel) { message.channel.send(`Anti-raid is set to **${pluginData.state.cachedAntiraidLevel}**`); } else { message.channel.send(`Anti-raid is **off**`); } }, }); ================================================ FILE: backend/src/plugins/Automod/constants.ts ================================================ import { z } from "zod"; import { MINUTES, SECONDS } from "../../utils.js"; export const RECENT_SPAM_EXPIRY_TIME = 10 * SECONDS; export const RECENT_ACTION_EXPIRY_TIME = 5 * MINUTES; export const RECENT_NICKNAME_CHANGE_EXPIRY_TIME = 5 * MINUTES; export enum RecentActionType { Message = 1, Mention, Link, Attachment, Emoji, Line, Character, VoiceChannelMove, MemberJoin, Sticker, MemberLeave, ThreadCreate, } export const zNotify = z.union([z.literal("dm"), z.literal("channel")]); ================================================ FILE: backend/src/plugins/Automod/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zAutomodConfig } from "./types.js"; export const automodPluginDocs: ZeppelinPluginDocs = { type: "stable", configSchema: zAutomodConfig, prettyName: "Automod", description: trimPluginDescription(` Allows specifying automated actions in response to triggers. Example use cases include word filtering and spam prevention. `), configurationGuide: trimPluginDescription(` The automod plugin is very customizable. For a full list of available triggers, actions, and their options, see Config schema at the bottom of this page. ### Simple word filter Removes any messages that contain the word 'banana' and sends a warning to the user. Moderators (level >= 50) are ignored by the filter based on the override. ~~~yml automod: config: rules: my_filter: triggers: - match_words: words: ['banana'] case_sensitive: false only_full_words: true actions: clean: true warn: reason: 'Do not talk about bananas!' overrides: - level: '>=50' config: rules: my_filter: enabled: false ~~~ ### Spam detection This example includes 2 filters: - The first one is triggered if a user sends 5 messages within 10 seconds OR 3 attachments within 60 seconds. The messages are deleted and the user is muted for 5 minutes. - The second filter is triggered if a user sends more than 2 emoji within 5 seconds. The messages are deleted but the user is not muted. Moderators are ignored by both filters based on the override. ~~~yml automod: config: rules: my_spam_filter: triggers: - message_spam: amount: 5 within: 10s - attachment_spam: amount: 3 within: 60s actions: clean: true mute: duration: 5m reason: 'Auto-muted for spam' my_second_filter: triggers: - emoji_spam: amount: 2 within: 5s actions: clean: true overrides: - level: '>=50' config: rules: my_spam_filter: enabled: false my_second_filter: enabled: false ~~~ ### Custom status alerts This example sends an alert any time a user with a matching custom status sends a message. ~~~yml automod: config: rules: bad_custom_statuses: triggers: - match_words: words: ['banana'] match_custom_status: true actions: alert: channel: "473087035574321152" text: |- Bad custom status on user <@!{user.id}>: {matchSummary} ~~~ `), }; ================================================ FILE: backend/src/plugins/Automod/events/RunAutomodOnJoinLeaveEvt.ts ================================================ import { guildPluginEventListener } from "vety"; import { RecentActionType } from "../constants.js"; import { runAutomod } from "../functions/runAutomod.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; export const RunAutomodOnJoinEvt = guildPluginEventListener()({ event: "guildMemberAdd", listener({ pluginData, args: { member } }) { const context: AutomodContext = { timestamp: Date.now(), user: member.user, member, joined: true, }; pluginData.state.queue.add(() => { pluginData.state.recentActions.push({ type: RecentActionType.MemberJoin, context, count: 1, identifier: null, }); runAutomod(pluginData, context); }); }, }); export const RunAutomodOnLeaveEvt = guildPluginEventListener()({ event: "guildMemberRemove", listener({ pluginData, args: { member } }) { const context: AutomodContext = { timestamp: Date.now(), partialMember: member, joined: true, }; pluginData.state.queue.add(() => { pluginData.state.recentActions.push({ type: RecentActionType.MemberLeave, context, count: 1, identifier: null, }); runAutomod(pluginData, context); }); }, }); ================================================ FILE: backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts ================================================ import { guildPluginEventListener } from "vety"; import { difference, isEqual } from "lodash-es"; import { runAutomod } from "../functions/runAutomod.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; export const RunAutomodOnMemberUpdate = guildPluginEventListener()({ event: "guildMemberUpdate", listener({ pluginData, args: { oldMember, newMember } }) { if (!oldMember) return; if (oldMember.partial) return; const oldRoles = [...oldMember.roles.cache.keys()]; const newRoles = [...newMember.roles.cache.keys()]; if (isEqual(oldRoles, newRoles)) return; const addedRoles = difference(newRoles, oldRoles); const removedRoles = difference(oldRoles, newRoles); if (addedRoles.length || removedRoles.length) { const context: AutomodContext = { timestamp: Date.now(), user: newMember.user, member: newMember, rolesChanged: { added: addedRoles, removed: removedRoles, }, }; pluginData.state.queue.add(() => { runAutomod(pluginData, context); }); } }, }); ================================================ FILE: backend/src/plugins/Automod/events/runAutomodOnAntiraidLevel.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { runAutomod } from "../functions/runAutomod.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; export async function runAutomodOnAntiraidLevel( pluginData: GuildPluginData, newLevel: string | null, oldLevel: string | null, user?: User, ) { const context: AutomodContext = { timestamp: Date.now(), antiraid: { level: newLevel, oldLevel, }, user, }; pluginData.state.queue.add(async () => { await runAutomod(pluginData, context); }); } ================================================ FILE: backend/src/plugins/Automod/events/runAutomodOnCounterTrigger.ts ================================================ import { GuildPluginData } from "vety"; import { resolveMember, resolveUser, UnknownUser } from "../../../utils.js"; import { CountersPlugin } from "../../Counters/CountersPlugin.js"; import { runAutomod } from "../functions/runAutomod.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; export async function runAutomodOnCounterTrigger( pluginData: GuildPluginData, counterName: string, triggerName: string, channelId: string | null, userId: string | null, reverse: boolean, ) { const user = userId ? await resolveUser(pluginData.client, userId, "Automod:runAutomodOnCounterTrigger") : undefined; const member = (userId && (await resolveMember(pluginData.client, pluginData.guild, userId))) || undefined; const prettyCounterName = pluginData.getPlugin(CountersPlugin).getPrettyNameForCounter(counterName); const prettyTriggerName = pluginData .getPlugin(CountersPlugin) .getPrettyNameForCounterTrigger(counterName, triggerName); const context: AutomodContext = { timestamp: Date.now(), counterTrigger: { counter: counterName, trigger: triggerName, prettyCounter: prettyCounterName, prettyTrigger: prettyTriggerName, channelId, userId, reverse, }, user: user instanceof UnknownUser ? undefined : user, member, // TODO: Channel }; pluginData.state.queue.add(async () => { await runAutomod(pluginData, context); }); } ================================================ FILE: backend/src/plugins/Automod/events/runAutomodOnMessage.ts ================================================ import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { performance } from "perf_hooks"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { profilingEnabled } from "../../../utils/easyProfiler.js"; import { addRecentActionsFromMessage } from "../functions/addRecentActionsFromMessage.js"; import { clearRecentActionsForMessage } from "../functions/clearRecentActionsForMessage.js"; import { runAutomod } from "../functions/runAutomod.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; import { getOrFetchGuildMember } from "../../../utils/getOrFetchGuildMember.js"; import { getOrFetchUser } from "../../../utils/getOrFetchUser.js"; import { incrementDebugCounter } from "../../../debugCounters.js"; export async function runAutomodOnMessage( pluginData: GuildPluginData, message: SavedMessage, isEdit: boolean, ) { incrementDebugCounter("automod:runAutomodOnMessage"); const member = await getOrFetchGuildMember(pluginData.guild, message.user_id); const user = await getOrFetchUser(pluginData.client, message.user_id); const context: AutomodContext = { timestamp: moment.utc(message.posted_at).valueOf(), message, user, member, }; pluginData.state.queue.add(async () => { const startTime = performance.now(); if (isEdit) { clearRecentActionsForMessage(pluginData, context); } addRecentActionsFromMessage(pluginData, context); await runAutomod(pluginData, context); if (profilingEnabled()) { pluginData .getVetyInstance() .profiler.addDataPoint(`automod:${pluginData.guild.id}`, performance.now() - startTime); } }); } ================================================ FILE: backend/src/plugins/Automod/events/runAutomodOnModAction.ts ================================================ import { GuildPluginData } from "vety"; import { resolveMember, resolveUser, UnknownUser } from "../../../utils.js"; import { ModActionType } from "../../ModActions/types.js"; import { runAutomod } from "../functions/runAutomod.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; export async function runAutomodOnModAction( pluginData: GuildPluginData, modAction: ModActionType, userId: string, reason?: string, isAutomodAction = false, ) { const [user, member] = await Promise.all([ resolveUser(pluginData.client, userId, "Automod:runAutomodOnModAction"), resolveMember(pluginData.client, pluginData.guild, userId), ]); const context: AutomodContext = { timestamp: Date.now(), user: user instanceof UnknownUser ? undefined : user, member: member ?? undefined, modAction: { type: modAction, reason, isAutomodAction, }, }; pluginData.state.queue.add(async () => { await runAutomod(pluginData, context); }); } ================================================ FILE: backend/src/plugins/Automod/events/runAutomodOnThreadEvents.ts ================================================ import { guildPluginEventListener } from "vety"; import { RecentActionType } from "../constants.js"; import { runAutomod } from "../functions/runAutomod.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; export const RunAutomodOnThreadCreate = guildPluginEventListener()({ event: "threadCreate", async listener({ pluginData, args: { thread } }) { const user = thread.ownerId ? await pluginData.client.users.fetch(thread.ownerId).catch(() => undefined) : undefined; const context: AutomodContext = { timestamp: Date.now(), threadChange: { created: thread, }, user, channel: thread, }; pluginData.state.queue.add(() => { pluginData.state.recentActions.push({ type: RecentActionType.ThreadCreate, context, count: 1, identifier: null, }); runAutomod(pluginData, context); }); }, }); export const RunAutomodOnThreadDelete = guildPluginEventListener()({ event: "threadDelete", async listener({ pluginData, args: { thread } }) { const user = thread.ownerId ? await pluginData.client.users.fetch(thread.ownerId).catch(() => undefined) : undefined; const context: AutomodContext = { timestamp: Date.now(), threadChange: { deleted: thread, }, user, channel: thread, }; pluginData.state.queue.add(() => { runAutomod(pluginData, context); }); }, }); export const RunAutomodOnThreadUpdate = guildPluginEventListener()({ event: "threadUpdate", async listener({ pluginData, args: { oldThread, newThread: thread } }) { const user = thread.ownerId ? await pluginData.client.users.fetch(thread.ownerId).catch(() => undefined) : undefined; const changes: AutomodContext["threadChange"] = {}; if (oldThread.archived !== thread.archived) { changes.archived = thread.archived ? thread : undefined; changes.unarchived = !thread.archived ? thread : undefined; } if (oldThread.locked !== thread.locked) { changes.locked = thread.locked ? thread : undefined; changes.unlocked = !thread.locked ? thread : undefined; } if (Object.keys(changes).length === 0) return; const context: AutomodContext = { timestamp: Date.now(), threadChange: changes, user, channel: thread, }; pluginData.state.queue.add(() => { runAutomod(pluginData, context); }); }, }); ================================================ FILE: backend/src/plugins/Automod/functions/addRecentActionsFromMessage.ts ================================================ import { GuildPluginData } from "vety"; import { getEmojiInString, getRoleMentions, getUrlsInString, getUserMentions } from "../../../utils.js"; import { RecentActionType } from "../constants.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; export function addRecentActionsFromMessage(pluginData: GuildPluginData, context: AutomodContext) { const message = context.message!; const globalIdentifier = message.user_id; const perChannelIdentifier = `${message.channel_id}-${message.user_id}`; pluginData.state.recentActions.push({ context, type: RecentActionType.Message, identifier: globalIdentifier, count: 1, }); pluginData.state.recentActions.push({ context, type: RecentActionType.Message, identifier: perChannelIdentifier, count: 1, }); const mentionCount = getUserMentions(message.data.content || "").length + getRoleMentions(message.data.content || "").length; if (mentionCount) { pluginData.state.recentActions.push({ context, type: RecentActionType.Mention, identifier: globalIdentifier, count: mentionCount, }); pluginData.state.recentActions.push({ context, type: RecentActionType.Mention, identifier: perChannelIdentifier, count: mentionCount, }); } const linkCount = getUrlsInString(message.data.content || "").length; if (linkCount) { pluginData.state.recentActions.push({ context, type: RecentActionType.Link, identifier: globalIdentifier, count: linkCount, }); pluginData.state.recentActions.push({ context, type: RecentActionType.Link, identifier: perChannelIdentifier, count: linkCount, }); } const attachmentCount = message.data.attachments && message.data.attachments.length; if (attachmentCount) { pluginData.state.recentActions.push({ context, type: RecentActionType.Attachment, identifier: globalIdentifier, count: attachmentCount, }); pluginData.state.recentActions.push({ context, type: RecentActionType.Attachment, identifier: perChannelIdentifier, count: attachmentCount, }); } const emojiCount = getEmojiInString(message.data.content || "").length; if (emojiCount) { pluginData.state.recentActions.push({ context, type: RecentActionType.Emoji, identifier: globalIdentifier, count: emojiCount, }); pluginData.state.recentActions.push({ context, type: RecentActionType.Emoji, identifier: perChannelIdentifier, count: emojiCount, }); } // + 1 is for the first line of the message (which doesn't have a line break) const lineCount = message.data.content ? (message.data.content.match(/\n/g) || []).length + 1 : 0; if (lineCount) { pluginData.state.recentActions.push({ context, type: RecentActionType.Line, identifier: globalIdentifier, count: lineCount, }); pluginData.state.recentActions.push({ context, type: RecentActionType.Line, identifier: perChannelIdentifier, count: lineCount, }); } const characterCount = [...(message.data.content || "")].length; if (characterCount) { pluginData.state.recentActions.push({ context, type: RecentActionType.Character, identifier: globalIdentifier, count: characterCount, }); pluginData.state.recentActions.push({ context, type: RecentActionType.Character, identifier: perChannelIdentifier, count: characterCount, }); } const stickerCount = (message.data.stickers || []).length; if (stickerCount) { pluginData.state.recentActions.push({ context, type: RecentActionType.Sticker, identifier: globalIdentifier, count: stickerCount, }); pluginData.state.recentActions.push({ context, type: RecentActionType.Sticker, identifier: perChannelIdentifier, count: stickerCount, }); } } ================================================ FILE: backend/src/plugins/Automod/functions/applyCooldown.ts ================================================ import { GuildPluginData } from "vety"; import { convertDelayStringToMS } from "../../../utils.js"; import { AutomodContext, AutomodPluginType, TRule } from "../types.js"; export function applyCooldown( pluginData: GuildPluginData, rule: TRule, ruleName: string, context: AutomodContext, ) { const cooldownKey = `${ruleName}-${context.user?.id}`; const cooldownTime = convertDelayStringToMS(rule.cooldown, "s"); if (cooldownTime) pluginData.state.cooldownManager.setCooldown(cooldownKey, cooldownTime); } ================================================ FILE: backend/src/plugins/Automod/functions/checkCooldown.ts ================================================ import { GuildPluginData } from "vety"; import { AutomodContext, AutomodPluginType, TRule } from "../types.js"; export function checkCooldown( pluginData: GuildPluginData, rule: TRule, ruleName: string, context: AutomodContext, ) { const cooldownKey = `${ruleName}-${context.user?.id}`; return pluginData.state.cooldownManager.isOnCooldown(cooldownKey); } ================================================ FILE: backend/src/plugins/Automod/functions/clearOldNicknameChanges.ts ================================================ import { GuildPluginData } from "vety"; import { RECENT_NICKNAME_CHANGE_EXPIRY_TIME } from "../constants.js"; import { AutomodPluginType } from "../types.js"; export function clearOldRecentNicknameChanges(pluginData: GuildPluginData) { const now = Date.now(); for (const [userId, { timestamp }] of pluginData.state.recentNicknameChanges) { if (timestamp + RECENT_NICKNAME_CHANGE_EXPIRY_TIME <= now) { pluginData.state.recentNicknameChanges.delete(userId); } } } ================================================ FILE: backend/src/plugins/Automod/functions/clearOldRecentActions.ts ================================================ import { GuildPluginData } from "vety"; import { startProfiling } from "../../../utils/easyProfiler.js"; import { RECENT_ACTION_EXPIRY_TIME } from "../constants.js"; import { AutomodPluginType } from "../types.js"; export function clearOldRecentActions(pluginData: GuildPluginData) { const stopProfiling = startProfiling(pluginData.getVetyInstance().profiler, "automod:fns:clearOldRecentActions"); const now = Date.now(); pluginData.state.recentActions = pluginData.state.recentActions.filter((info) => { return info.context.timestamp + RECENT_ACTION_EXPIRY_TIME > now; }); stopProfiling(); } ================================================ FILE: backend/src/plugins/Automod/functions/clearOldRecentSpam.ts ================================================ import { GuildPluginData } from "vety"; import { startProfiling } from "../../../utils/easyProfiler.js"; import { RECENT_SPAM_EXPIRY_TIME } from "../constants.js"; import { AutomodPluginType } from "../types.js"; export function clearOldRecentSpam(pluginData: GuildPluginData) { const stopProfiling = startProfiling(pluginData.getVetyInstance().profiler, "automod:fns:clearOldRecentSpam"); const now = Date.now(); pluginData.state.recentSpam = pluginData.state.recentSpam.filter((spam) => { return spam.timestamp + RECENT_SPAM_EXPIRY_TIME > now; }); stopProfiling(); } ================================================ FILE: backend/src/plugins/Automod/functions/clearRecentActionsForMessage.ts ================================================ import { GuildPluginData } from "vety"; import { startProfiling } from "../../../utils/easyProfiler.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; export function clearRecentActionsForMessage(pluginData: GuildPluginData, context: AutomodContext) { const stopProfiling = startProfiling( pluginData.getVetyInstance().profiler, "automod:fns:clearRecentActionsForMessage", ); const message = context.message!; const globalIdentifier = message.user_id; const perChannelIdentifier = `${message.channel_id}-${message.user_id}`; pluginData.state.recentActions = pluginData.state.recentActions.filter((act) => { return act.identifier !== globalIdentifier && act.identifier !== perChannelIdentifier; }); stopProfiling(); } ================================================ FILE: backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts ================================================ import { z } from "zod"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { humanizeDurationShort } from "../../../humanizeDuration.js"; import { getBaseUrl } from "../../../pluginUtils.js"; import { convertDelayStringToMS, sorter, zDelayString } from "../../../utils.js"; import { RecentActionType } from "../constants.js"; import { automodTrigger } from "../helpers.js"; import { findRecentSpam } from "./findRecentSpam.js"; import { getMatchingMessageRecentActions } from "./getMatchingMessageRecentActions.js"; import { getMessageSpamIdentifier } from "./getSpamIdentifier.js"; export interface TMessageSpamMatchResultType { archiveId: string; } const configSchema = z.strictObject({ amount: z.number().int(), within: zDelayString, per_channel: z.boolean().nullable().default(false), }); export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) { return automodTrigger()({ configSchema, async match({ pluginData, context, triggerConfig }) { if (!context.message) { return; } const spamIdentifier = getMessageSpamIdentifier(context.message, Boolean(triggerConfig.per_channel)); const recentSpam = findRecentSpam(pluginData, spamType, spamIdentifier); if (recentSpam) { if (recentSpam.archiveId) { await pluginData.state.archives.addSavedMessagesToArchive( recentSpam.archiveId, [context.message], pluginData.guild, ); } return { silentClean: true, extra: { archiveId: "" }, // FIXME: Fix up automod trigger match() typings so extra is not required when doing a silentClean }; } const within = convertDelayStringToMS(triggerConfig.within) ?? 0; const matchedSpam = getMatchingMessageRecentActions( pluginData, context.message, spamType, spamIdentifier, triggerConfig.amount, within, ); if (matchedSpam) { const messages = matchedSpam.recentActions .map((action) => action.context.message) .filter(Boolean) .sort(sorter("posted_at")) as SavedMessage[]; const archiveId = await pluginData.state.archives.createFromSavedMessages(messages, pluginData.guild); pluginData.state.recentSpam.push({ type: spamType, identifiers: [spamIdentifier], archiveId, timestamp: Date.now(), }); return { extraContexts: matchedSpam.recentActions .map((action) => action.context) .filter((_context) => _context !== context), extra: { archiveId, }, }; } }, renderMatchInformation({ pluginData, matchResult, triggerConfig }) { const baseUrl = getBaseUrl(pluginData); const archiveUrl = pluginData.state.archives.getUrl(baseUrl, matchResult.extra.archiveId); const withinMs = convertDelayStringToMS(triggerConfig.within); const withinStr = humanizeDurationShort(withinMs); return `Matched ${prettyName} spam (${triggerConfig.amount} in ${withinStr}): ${archiveUrl}`; }, }); } ================================================ FILE: backend/src/plugins/Automod/functions/findRecentSpam.ts ================================================ import { GuildPluginData } from "vety"; import { startProfiling } from "../../../utils/easyProfiler.js"; import { RecentActionType } from "../constants.js"; import { AutomodPluginType } from "../types.js"; export function findRecentSpam( pluginData: GuildPluginData, type: RecentActionType, identifier?: string, ) { const stopProfiling = startProfiling(pluginData.getVetyInstance().profiler, "automod:fns:findRecentSpam"); const result = pluginData.state.recentSpam.find((spam) => { return spam.type === type && (!identifier || spam.identifiers.includes(identifier)); }); stopProfiling(); return result; } ================================================ FILE: backend/src/plugins/Automod/functions/getMatchingMessageRecentActions.ts ================================================ import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { startProfiling } from "../../../utils/easyProfiler.js"; import { RecentActionType } from "../constants.js"; import { AutomodPluginType } from "../types.js"; import { getMatchingRecentActions } from "./getMatchingRecentActions.js"; export function getMatchingMessageRecentActions( pluginData: GuildPluginData, message: SavedMessage, type: RecentActionType, identifier: string, count: number, within: number, ) { const stopProfiling = startProfiling( pluginData.getVetyInstance().profiler, "automod:fns:getMatchingMessageRecentActions", ); const since = moment.utc(message.posted_at).valueOf() - within; const to = moment.utc(message.posted_at).valueOf(); const recentActions = getMatchingRecentActions(pluginData, type, identifier, since, to); const totalCount = recentActions.reduce((total, action) => total + action.count, 0); stopProfiling(); if (totalCount >= count) { return { recentActions, }; } } ================================================ FILE: backend/src/plugins/Automod/functions/getMatchingRecentActions.ts ================================================ import { GuildPluginData } from "vety"; import { startProfiling } from "../../../utils/easyProfiler.js"; import { RecentActionType } from "../constants.js"; import { AutomodPluginType } from "../types.js"; export function getMatchingRecentActions( pluginData: GuildPluginData, type: RecentActionType, identifier: string | null, since: number, to?: number, ) { const stopProfiling = startProfiling(pluginData.getVetyInstance().profiler, "automod:fns:getMatchingRecentActions"); to = to || Date.now(); const result = pluginData.state.recentActions.filter((action) => { return ( action.type === type && (!identifier || action.identifier === identifier) && action.context.timestamp >= since && action.context.timestamp <= to! && !action.context.actioned ); }); stopProfiling(); return result; } ================================================ FILE: backend/src/plugins/Automod/functions/getSpamIdentifier.ts ================================================ import { SavedMessage } from "../../../data/entities/SavedMessage.js"; export function getMessageSpamIdentifier(message: SavedMessage, perChannel: boolean) { return perChannel ? `${message.channel_id}-${message.user_id}` : message.user_id; } ================================================ FILE: backend/src/plugins/Automod/functions/getTextMatchPartialSummary.ts ================================================ import { ActivityType, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { messageSummary, verboseChannelMention } from "../../../utils.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; import { MatchableTextType } from "./matchMultipleTextTypesOnMessage.js"; export function getTextMatchPartialSummary( pluginData: GuildPluginData, type: MatchableTextType, context: AutomodContext, ) { if (type === "message") { const message = context.message!; const channel = pluginData.guild.channels.cache.get(message.channel_id as Snowflake); const channelMention = channel ? verboseChannelMention(channel) : `\`#${message.channel_id}\``; return `message in ${channelMention}:\n${messageSummary(message)}`; } else if (type === "embed") { const message = context.message!; const channel = pluginData.guild.channels.cache.get(message.channel_id as Snowflake); const channelMention = channel ? verboseChannelMention(channel) : `\`#${message.channel_id}\``; return `message embed in ${channelMention}:\n${messageSummary(message)}`; } else if (type === "username") { return `username: ${context.user!.username}`; } else if (type === "nickname") { return `nickname: ${context.member!.nickname}`; } else if (type === "visiblename") { const visibleName = context.member?.nickname || context.user!.username; return `visible name: ${visibleName}`; } else if (type === "customstatus") { return `custom status: ${context.member!.presence?.activities.find((a) => a.type === ActivityType.Custom)?.name}`; } } ================================================ FILE: backend/src/plugins/Automod/functions/ignoredRoleChanges.ts ================================================ import { GuildPluginData } from "vety"; import { MINUTES } from "../../../utils.js"; import { AutomodPluginType } from "../types.js"; const IGNORED_ROLE_CHANGE_LIFETIME = 5 * MINUTES; function cleanupIgnoredRoleChanges(pluginData: GuildPluginData) { const cutoff = Date.now() - IGNORED_ROLE_CHANGE_LIFETIME; for (const ignoredChange of pluginData.state.ignoredRoleChanges.values()) { if (ignoredChange.timestamp < cutoff) { pluginData.state.ignoredRoleChanges.delete(ignoredChange); } } } export function ignoreRoleChange(pluginData: GuildPluginData, memberId: string, roleId: string) { pluginData.state.ignoredRoleChanges.add({ memberId, roleId, timestamp: Date.now(), }); cleanupIgnoredRoleChanges(pluginData); } /** * @return Whether the role change should be ignored */ export function consumeIgnoredRoleChange( pluginData: GuildPluginData, memberId: string, roleId: string, ) { for (const ignoredChange of pluginData.state.ignoredRoleChanges.values()) { if (ignoredChange.memberId === memberId && ignoredChange.roleId === roleId) { pluginData.state.ignoredRoleChanges.delete(ignoredChange); return true; } } return false; } ================================================ FILE: backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts ================================================ import { ActivityType, Embed } from "discord.js"; import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { renderUsername, resolveMember } from "../../../utils.js"; import { DeepMutable } from "../../../utils/typeUtils.js"; import { AutomodPluginType } from "../types.js"; type TextTriggerWithMultipleMatchTypes = { match_messages: boolean; match_embeds: boolean; match_visible_names: boolean; match_usernames: boolean; match_nicknames: boolean; match_custom_status: boolean; }; export type MatchableTextType = "message" | "embed" | "visiblename" | "username" | "nickname" | "customstatus"; type YieldedContent = [MatchableTextType, string]; /** * Generator function that allows iterating through matchable pieces of text of a SavedMessage */ export async function* matchMultipleTextTypesOnMessage( pluginData: GuildPluginData, trigger: TextTriggerWithMultipleMatchTypes, msg: SavedMessage, ): AsyncIterableIterator { const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id); if (!member) return; if (trigger.match_messages && msg.data.content) { yield ["message", msg.data.content]; } if (trigger.match_embeds && msg.data.embeds?.length) { const copiedEmbed: DeepMutable = JSON.parse(JSON.stringify(msg.data.embeds[0])); if (copiedEmbed.video) { copiedEmbed.description = ""; // The description is not rendered, hence it doesn't need to be matched } yield ["embed", JSON.stringify(copiedEmbed)]; } if (trigger.match_visible_names) { yield ["visiblename", member.displayName || msg.data.author.username]; } if (trigger.match_usernames) { yield ["username", renderUsername(msg.data.author.username, msg.data.author.discriminator)]; } if (trigger.match_nicknames && member.nickname) { yield ["nickname", member.nickname]; } for (const activity of member.presence?.activities ?? []) { if (activity.type === ActivityType.Custom) { yield ["customstatus", `${activity.emoji} ${activity.name}`]; break; } } } ================================================ FILE: backend/src/plugins/Automod/functions/resolveActionContactMethods.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; import { UserNotificationMethod, disableUserNotificationStrings } from "../../../utils.js"; import { AutomodPluginType } from "../types.js"; export function resolveActionContactMethods( pluginData: GuildPluginData, actionConfig: { notify?: string | null; notifyChannel?: string | null; }, ): UserNotificationMethod[] { if (actionConfig.notify === "dm") { return [{ type: "dm" }]; } else if (actionConfig.notify === "channel") { if (!actionConfig.notifyChannel) { throw new RecoverablePluginError(ERRORS.NO_USER_NOTIFICATION_CHANNEL); } const channel = pluginData.guild.channels.cache.get(actionConfig.notifyChannel as Snowflake); if (!channel?.isTextBased()) { throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL); } return [{ type: "channel", channel }]; } else if (actionConfig.notify && disableUserNotificationStrings.includes(actionConfig.notify)) { return []; } return []; } ================================================ FILE: backend/src/plugins/Automod/functions/runAutomod.ts ================================================ import { GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { performance } from "perf_hooks"; import { calculateBlocking, profilingEnabled } from "../../../utils/easyProfiler.js"; import { availableActions } from "../actions/availableActions.js"; import { CleanAction } from "../actions/clean.js"; import { AutomodTriggerBlueprint, AutomodTriggerMatchResult } from "../helpers.js"; import { availableTriggers } from "../triggers/availableTriggers.js"; import { AutomodContext, AutomodPluginType, TRule } from "../types.js"; import { applyCooldown } from "./applyCooldown.js"; import { checkCooldown } from "./checkCooldown.js"; const ruleFailReason = { disabled: "rule is disabled", cooldown: "rule is on cooldown", doesNotAffectBots: "rule does not affect bots", doesNotAffectSelf: "rule does not affect self", unknownUser: "rule does not affect bots, and user is unknown", noMatch: "no triggers matched", }; interface MatchedTriggerResult { name: string; num: number; config: AutomodTriggerBlueprint; } interface RuleResultOutcomeSuccess { success: true; matchedTrigger: MatchedTriggerResult; } interface RuleResultOutcomeFailure { success: false; reason: (typeof ruleFailReason)[keyof typeof ruleFailReason]; } type RuleResultOutcome = RuleResultOutcomeSuccess | RuleResultOutcomeFailure; interface RuleResult { ruleName: string; config: TRule; outcome: RuleResultOutcome; } interface AutomodRunResult { triggered: boolean; rulesChecked: RuleResult[]; } export async function runAutomod( pluginData: GuildPluginData, context: AutomodContext, dryRun = false, ): Promise { const userId = context.user?.id || context.member?.id || context.message?.user_id; const user = context.user || (userId && pluginData.client.users!.cache.get(userId as Snowflake)); const member = context.member || (userId && pluginData.guild.members.cache.get(userId as Snowflake)) || null; const channelIdOrThreadId = context.message?.channel_id; const channelOrThread = context.channel ?? (channelIdOrThreadId ? (pluginData.guild.channels.cache.get(channelIdOrThreadId as Snowflake) as GuildTextBasedChannel) : null); const channelId = channelOrThread?.isThread() ? channelOrThread.parent?.id : channelIdOrThreadId; const threadId = channelOrThread?.isThread() ? channelOrThread.id : null; const channel = channelOrThread?.isThread() ? channelOrThread.parent : channelOrThread; const categoryId = channel?.parentId; const config = await pluginData.config.getMatchingConfig({ channelId, categoryId, threadId, userId, member, }); const result: AutomodRunResult = { triggered: false, rulesChecked: [], }; for (const [ruleName, rule] of Object.entries(config.rules)) { const prettyName = rule.pretty_name; const ruleResult: RuleResult = { ruleName, config: rule, outcome: { success: false, reason: ruleFailReason.noMatch }, }; result.rulesChecked.push(ruleResult); if (rule.enabled === false) { ruleResult.outcome = { success: false, reason: ruleFailReason.disabled }; continue; } if ( !rule.affects_bots && (!user || user.bot) && !context.counterTrigger && !context.antiraid && !context.threadChange?.deleted ) { if (user) { ruleResult.outcome = { success: false, reason: ruleFailReason.doesNotAffectBots }; } else { ruleResult.outcome = { success: false, reason: ruleFailReason.unknownUser }; } continue; } if (!rule.affects_self && userId && userId === pluginData.client.user?.id) { ruleResult.outcome = { success: false, reason: ruleFailReason.doesNotAffectSelf }; continue; } if (rule.cooldown && checkCooldown(pluginData, rule, ruleName, context)) { ruleResult.outcome = { success: false, reason: ruleFailReason.cooldown }; continue; } const ruleStartTime = performance.now(); let matchResult: AutomodTriggerMatchResult | null | undefined; let contexts: AutomodContext[] = []; let triggerNum = 0; triggerLoop: for (const triggerItem of rule.triggers) { for (const [triggerName, triggerConfig] of Object.entries(triggerItem)) { const triggerStartTime = performance.now(); const trigger = availableTriggers[triggerName]; triggerNum++; let getBlockingTime: ReturnType | null = null; if (profilingEnabled()) { getBlockingTime = calculateBlocking(); } matchResult = await trigger.match({ ruleName, pluginData, context, triggerConfig, }); if (profilingEnabled()) { const blockingTime = getBlockingTime?.() || 0; pluginData .getVetyInstance() .profiler.addDataPoint( `automod:${pluginData.guild.id}:${ruleName}:triggers:${triggerName}:blocking`, blockingTime, ); } if (matchResult) { if (rule.cooldown) applyCooldown(pluginData, rule, ruleName, context); contexts = [context, ...(matchResult.extraContexts || [])]; for (const _context of contexts) { _context.actioned = true; } if (matchResult.silentClean) { await CleanAction.apply({ ruleName, pluginData, contexts, actionConfig: true, matchResult, prettyName, }); return result; } matchResult.summary = (await trigger.renderMatchInformation({ ruleName, pluginData, contexts, matchResult, triggerConfig, })) ?? ""; matchResult.fullSummary = `Triggered automod rule **${prettyName ?? ruleName}**\n${ matchResult.summary }`.trim(); } if (profilingEnabled()) { pluginData .getVetyInstance() .profiler.addDataPoint( `automod:${pluginData.guild.id}:${ruleName}:triggers:${triggerName}`, performance.now() - triggerStartTime, ); } if (matchResult) { ruleResult.outcome = { success: true, matchedTrigger: { name: triggerName, num: triggerNum, config: trigger, }, }; break triggerLoop; } } } if (matchResult && !dryRun) { for (const [actionName, actionConfig] of Object.entries(rule.actions)) { if (actionConfig == null || actionConfig === false) { continue; } const actionStartTime = performance.now(); const action = availableActions[actionName]; action.apply({ ruleName, pluginData, contexts, actionConfig, matchResult, prettyName, }); if (profilingEnabled()) { pluginData .getVetyInstance() .profiler.addDataPoint( `automod:${pluginData.guild.id}:${ruleName}:actions:${actionName}`, performance.now() - actionStartTime, ); } } // Log all automod rules by default if (rule.actions.log == null) { availableActions.log.apply({ ruleName, pluginData, contexts, actionConfig: true, matchResult, prettyName, }); } } if (profilingEnabled()) { pluginData .getVetyInstance() .profiler.addDataPoint(`automod:${pluginData.guild.id}:${ruleName}`, performance.now() - ruleStartTime); } if (matchResult && !rule.allow_further_rules) { break; } } return result; } ================================================ FILE: backend/src/plugins/Automod/functions/setAntiraidLevel.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { runAutomodOnAntiraidLevel } from "../events/runAutomodOnAntiraidLevel.js"; import { AutomodPluginType } from "../types.js"; export async function setAntiraidLevel( pluginData: GuildPluginData, newLevel: string | null, user?: User, ) { const oldLevel = pluginData.state.cachedAntiraidLevel; pluginData.state.cachedAntiraidLevel = newLevel; await pluginData.state.antiraidLevels.set(newLevel); runAutomodOnAntiraidLevel(pluginData, newLevel, oldLevel, user); const logs = pluginData.getPlugin(LogsPlugin); if (user) { logs.logSetAntiraidUser({ level: newLevel ?? "off", user, }); } else { logs.logSetAntiraidAuto({ level: newLevel ?? "off", }); } } ================================================ FILE: backend/src/plugins/Automod/functions/sumRecentActionCounts.ts ================================================ import { RecentAction } from "../types.js"; export function sumRecentActionCounts(actions: RecentAction[]) { return actions.reduce((total, action) => total + action.count, 0); } ================================================ FILE: backend/src/plugins/Automod/helpers.ts ================================================ import { GuildPluginData } from "vety"; import { z, type ZodTypeAny } from "zod"; import { Awaitable } from "../../utils/typeUtils.js"; import { AutomodContext, AutomodPluginType } from "./types.js"; interface BaseAutomodTriggerMatchResult { extraContexts?: AutomodContext[]; silentClean?: boolean; // TODO: Maybe generalize to a "silent" value in general, which mutes alert/log summary?: string; fullSummary?: string; } export type AutomodTriggerMatchResult = unknown extends TExtra ? BaseAutomodTriggerMatchResult : BaseAutomodTriggerMatchResult & { extra: TExtra }; type AutomodTriggerMatchFn = (meta: { ruleName: string; pluginData: GuildPluginData; context: AutomodContext; triggerConfig: TConfigType; }) => Awaitable>; type AutomodTriggerRenderMatchInformationFn = (meta: { ruleName: string; pluginData: GuildPluginData; contexts: AutomodContext[]; triggerConfig: TConfigType; matchResult: AutomodTriggerMatchResult; }) => Awaitable; export interface AutomodTriggerBlueprint { configSchema: TConfigSchema; match: AutomodTriggerMatchFn, TMatchResultExtra>; renderMatchInformation: AutomodTriggerRenderMatchInformationFn, TMatchResultExtra>; } export function automodTrigger(): ( blueprint: AutomodTriggerBlueprint, ) => AutomodTriggerBlueprint; export function automodTrigger( blueprint: AutomodTriggerBlueprint, ): AutomodTriggerBlueprint; export function automodTrigger(...args) { if (args.length) { return args[0]; } else { return automodTrigger; } } type AutomodActionApplyFn = (meta: { ruleName: string; pluginData: GuildPluginData; contexts: AutomodContext[]; actionConfig: TConfigType; matchResult: AutomodTriggerMatchResult; prettyName: string | undefined; }) => Awaitable; export interface AutomodActionBlueprint { configSchema: TConfigSchema; apply: AutomodActionApplyFn>; } export function automodAction( blueprint: AutomodActionBlueprint, ): AutomodActionBlueprint { return blueprint; } ================================================ FILE: backend/src/plugins/Automod/triggers/antiraidLevel.ts ================================================ import { z } from "zod"; import { automodTrigger } from "../helpers.js"; interface AntiraidLevelTriggerResult {} const configSchema = z.strictObject({ level: z.nullable(z.string().max(100)), only_on_change: z.nullable(z.boolean()), }); export const AntiraidLevelTrigger = automodTrigger()({ configSchema, async match({ triggerConfig, context }) { if (!context.antiraid) { return; } if (context.antiraid.level !== triggerConfig.level) { return; } if ( triggerConfig.only_on_change && context.antiraid.oldLevel !== undefined && context.antiraid.level === context.antiraid.oldLevel ) { return; } return { extra: {}, }; }, renderMatchInformation({ contexts }) { const newLevel = contexts[0].antiraid!.level; return newLevel ? `Antiraid level was set to ${newLevel}` : `Antiraid was turned off`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/anyMessage.ts ================================================ import { Snowflake } from "discord.js"; import { z } from "zod"; import { verboseChannelMention } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; interface AnyMessageResultType {} const configSchema = z.strictObject({}); export const AnyMessageTrigger = automodTrigger()({ configSchema, async match({ context }) { if (!context.message) { return; } return { extra: {}, }; }, renderMatchInformation({ pluginData, contexts }) { const channel = pluginData.guild.channels.cache.get(contexts[0].message!.channel_id as Snowflake); return `Matched message (\`${contexts[0].message!.id}\`) in ${ channel ? verboseChannelMention(channel) : "Unknown Channel" }`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/attachmentSpam.ts ================================================ import { RecentActionType } from "../constants.js"; import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger.js"; export const AttachmentSpamTrigger = createMessageSpamTrigger(RecentActionType.Attachment, "attachment"); ================================================ FILE: backend/src/plugins/Automod/triggers/availableTriggers.ts ================================================ import { AutomodTriggerBlueprint } from "../helpers.js"; import { AntiraidLevelTrigger } from "./antiraidLevel.js"; import { AnyMessageTrigger } from "./anyMessage.js"; import { AttachmentSpamTrigger } from "./attachmentSpam.js"; import { BanTrigger } from "./ban.js"; import { CharacterSpamTrigger } from "./characterSpam.js"; import { CounterTrigger } from "./counterTrigger.js"; import { EmojiSpamTrigger } from "./emojiSpam.js"; import { HasAttachmentsTrigger } from "./hasAttachments.js"; import { KickTrigger } from "./kick.js"; import { LineSpamTrigger } from "./lineSpam.js"; import { LinkSpamTrigger } from "./linkSpam.js"; import { MatchAttachmentTypeTrigger } from "./matchAttachmentType.js"; import { MatchInvitesTrigger } from "./matchInvites.js"; import { MatchLinksTrigger } from "./matchLinks.js"; import { MatchMimeTypeTrigger } from "./matchMimeType.js"; import { MatchRegexTrigger } from "./matchRegex.js"; import { MatchWordsTrigger } from "./matchWords.js"; import { MemberJoinTrigger } from "./memberJoin.js"; import { MemberJoinSpamTrigger } from "./memberJoinSpam.js"; import { MemberLeaveTrigger } from "./memberLeave.js"; import { MentionSpamTrigger } from "./mentionSpam.js"; import { MessageSpamTrigger } from "./messageSpam.js"; import { MuteTrigger } from "./mute.js"; import { NoteTrigger } from "./note.js"; import { RoleAddedTrigger } from "./roleAdded.js"; import { RoleRemovedTrigger } from "./roleRemoved.js"; import { StickerSpamTrigger } from "./stickerSpam.js"; import { ThreadArchiveTrigger } from "./threadArchive.js"; import { ThreadCreateTrigger } from "./threadCreate.js"; import { ThreadCreateSpamTrigger } from "./threadCreateSpam.js"; import { ThreadDeleteTrigger } from "./threadDelete.js"; import { ThreadUnarchiveTrigger } from "./threadUnarchive.js"; import { UnbanTrigger } from "./unban.js"; import { UnmuteTrigger } from "./unmute.js"; import { WarnTrigger } from "./warn.js"; export const availableTriggers: Record> = { any_message: AnyMessageTrigger, match_words: MatchWordsTrigger, match_regex: MatchRegexTrigger, match_invites: MatchInvitesTrigger, match_links: MatchLinksTrigger, has_attachments: HasAttachmentsTrigger, match_attachment_type: MatchAttachmentTypeTrigger, match_mime_type: MatchMimeTypeTrigger, member_join: MemberJoinTrigger, member_leave: MemberLeaveTrigger, role_added: RoleAddedTrigger, role_removed: RoleRemovedTrigger, message_spam: MessageSpamTrigger, mention_spam: MentionSpamTrigger, link_spam: LinkSpamTrigger, attachment_spam: AttachmentSpamTrigger, emoji_spam: EmojiSpamTrigger, line_spam: LineSpamTrigger, character_spam: CharacterSpamTrigger, member_join_spam: MemberJoinSpamTrigger, sticker_spam: StickerSpamTrigger, thread_create_spam: ThreadCreateSpamTrigger, counter_trigger: CounterTrigger, note: NoteTrigger, warn: WarnTrigger, mute: MuteTrigger, unmute: UnmuteTrigger, kick: KickTrigger, ban: BanTrigger, unban: UnbanTrigger, antiraid_level: AntiraidLevelTrigger, thread_create: ThreadCreateTrigger, thread_delete: ThreadDeleteTrigger, thread_archive: ThreadArchiveTrigger, thread_unarchive: ThreadUnarchiveTrigger, }; ================================================ FILE: backend/src/plugins/Automod/triggers/ban.ts ================================================ import { z } from "zod"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface interface BanTriggerResultType {} const configSchema = z.strictObject({ manual: z.boolean().default(true), automatic: z.boolean().default(true), }); export const BanTrigger = automodTrigger()({ configSchema, async match({ context, triggerConfig }) { if (context.modAction?.type !== "ban") { return; } // If automatic && automatic turned off -> return if (context.modAction.isAutomodAction && !triggerConfig.automatic) return; // If manual && manual turned off -> return if (!context.modAction.isAutomodAction && !triggerConfig.manual) return; return { extra: {}, }; }, renderMatchInformation() { return `User was banned`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/characterSpam.ts ================================================ import { RecentActionType } from "../constants.js"; import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger.js"; export const CharacterSpamTrigger = createMessageSpamTrigger(RecentActionType.Character, "character"); ================================================ FILE: backend/src/plugins/Automod/triggers/counterTrigger.ts ================================================ import { z } from "zod"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line interface CounterTriggerResult {} const configSchema = z.strictObject({ counter: z.string().max(100), trigger: z.string().max(100), reverse: z.boolean().optional(), }); export const CounterTrigger = automodTrigger()({ configSchema, async match({ triggerConfig, context }) { if (!context.counterTrigger) { return; } if (context.counterTrigger.counter !== triggerConfig.counter) { return; } if (context.counterTrigger.trigger !== triggerConfig.trigger) { return; } const reverse = triggerConfig.reverse ?? false; if (context.counterTrigger.reverse !== reverse) { return; } return { extra: {}, }; }, renderMatchInformation({ contexts }) { let str = `Matched counter trigger \`${contexts[0].counterTrigger!.prettyCounter} / ${ contexts[0].counterTrigger!.prettyTrigger }\``; if (contexts[0].counterTrigger!.reverse) { str += " (reverse)"; } return str; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/emojiSpam.ts ================================================ import { RecentActionType } from "../constants.js"; import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger.js"; export const EmojiSpamTrigger = createMessageSpamTrigger(RecentActionType.Emoji, "emoji"); ================================================ FILE: backend/src/plugins/Automod/triggers/exampleTrigger.ts ================================================ import { z } from "zod"; import { automodTrigger } from "../helpers.js"; interface ExampleMatchResultType { isBanana: boolean; } const configSchema = z.strictObject({ allowedFruits: z.array(z.string().max(100)).max(50).default(["peach", "banana"]), }); export const ExampleTrigger = automodTrigger()({ configSchema, async match({ triggerConfig, context }) { const foundFruit = triggerConfig.allowedFruits.find((fruit) => context.message?.data.content === fruit); if (foundFruit) { return { extra: { isBanana: foundFruit === "banana", }, }; } }, renderMatchInformation({ matchResult }) { return `Matched fruit, isBanana: ${matchResult.extra.isBanana ? "yes" : "no"}`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/hasAttachments.ts ================================================ import { Snowflake } from "discord.js"; import z from "zod"; import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; interface HasAttachmentsMatchResult { hasAttachments: boolean; attachmentCount: number; } const configSchema = z.strictObject({ min_count: z.number().int().min(0).nullable().default(1), max_count: z.number().int().nullable().default(null), }); export const HasAttachmentsTrigger = automodTrigger()({ configSchema, async match({ context, triggerConfig }) { if (!context.message) { return; } if (triggerConfig.min_count == null && triggerConfig.max_count == null) { return; } const attachments = context.message.data.attachments; const attachmentCount = attachments?.length ?? 0; const hasAttachments = attachmentCount > 0; const matchesMinCount = triggerConfig.min_count != null ? attachmentCount >= triggerConfig.min_count : true; const matchesMaxCount = triggerConfig.max_count != null ? attachmentCount <= triggerConfig.max_count : true; if (matchesMinCount && matchesMaxCount) { return { extra: { hasAttachments, attachmentCount, }, }; } return null; }, renderMatchInformation({ pluginData, contexts, matchResult }) { const message = contexts[0].message!; const channel = pluginData.guild.channels.cache.get(message.channel_id as Snowflake); const prettyChannel = channel ? verboseChannelMention(channel) : "Unknown Channel"; const descriptor = matchResult.extra.hasAttachments ? "has" : "does not have"; return ( asSingleLine(` Matched message (\`${message.id}\`) that ${descriptor} attachments (${matchResult.extra.attachmentCount}) in ${prettyChannel}: `) + messageSummary(message) ); }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/kick.ts ================================================ import { z } from "zod"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface interface KickTriggerResultType {} const configSchema = z.strictObject({ manual: z.boolean().default(true), automatic: z.boolean().default(true), }); export const KickTrigger = automodTrigger()({ configSchema, async match({ context, triggerConfig }) { if (context.modAction?.type !== "kick") { return; } // If automatic && automatic turned off -> return if (context.modAction.isAutomodAction && !triggerConfig.automatic) return; // If manual && manual turned off -> return if (!context.modAction.isAutomodAction && !triggerConfig.manual) return; return { extra: {}, }; }, renderMatchInformation() { return `User was kicked`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/lineSpam.ts ================================================ import { RecentActionType } from "../constants.js"; import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger.js"; export const LineSpamTrigger = createMessageSpamTrigger(RecentActionType.Line, "line"); ================================================ FILE: backend/src/plugins/Automod/triggers/linkSpam.ts ================================================ import { RecentActionType } from "../constants.js"; import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger.js"; export const LinkSpamTrigger = createMessageSpamTrigger(RecentActionType.Link, "link"); ================================================ FILE: backend/src/plugins/Automod/triggers/matchAttachmentType.ts ================================================ import { escapeInlineCode, Snowflake } from "discord.js"; import { extname } from "path"; import { z } from "zod"; import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; interface MatchResultType { matchedType: string; mode: "blacklist" | "whitelist"; } const configSchema = z.strictObject({ whitelist_enabled: z.boolean().default(false), filetype_whitelist: z.array(z.string().max(32)).max(255).default([]), blacklist_enabled: z.boolean().default(false), filetype_blacklist: z.array(z.string().max(32)).max(255).default([]), }); export const MatchAttachmentTypeTrigger = automodTrigger()({ configSchema, async match({ context, triggerConfig: trigger }) { if (!context.message) { return; } if (!context.message.data.attachments) { return null; } for (const attachment of context.message.data.attachments) { const attachmentType = extname(new URL(attachment.url).pathname).slice(1).toLowerCase(); const blacklist = trigger.blacklist_enabled ? (trigger.filetype_blacklist || []).map((_t) => _t.toLowerCase()) : null; if (blacklist && blacklist.includes(attachmentType)) { return { extra: { matchedType: attachmentType, mode: "blacklist", }, }; } const whitelist = trigger.whitelist_enabled ? (trigger.filetype_whitelist || []).map((_t) => _t.toLowerCase()) : null; if (whitelist && !whitelist.includes(attachmentType)) { return { extra: { matchedType: attachmentType, mode: "whitelist", }, }; } } return null; }, renderMatchInformation({ pluginData, contexts, matchResult }) { const channel = pluginData.guild.channels.cache.get(contexts[0].message!.channel_id as Snowflake)!; const prettyChannel = verboseChannelMention(channel); return ( asSingleLine(` Matched attachment type \`${escapeInlineCode(matchResult.extra.matchedType)}\` (${matchResult.extra.mode === "blacklist" ? "blacklisted" : "not in whitelist"}) in message (\`${contexts[0].message!.id}\`) in ${prettyChannel}: `) + messageSummary(contexts[0].message!) ); }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/matchInvites.ts ================================================ import { z } from "zod"; import { getInviteCodesInString, GuildInvite, isGuildInvite, resolveInvite, zSnowflake } from "../../../utils.js"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage.js"; import { automodTrigger } from "../helpers.js"; interface MatchResultType { type: MatchableTextType; code: string; invite?: GuildInvite; } const configSchema = z.strictObject({ include_guilds: z.array(zSnowflake).max(255).optional(), exclude_guilds: z.array(zSnowflake).max(255).optional(), include_invite_codes: z.array(z.string().max(32)).max(255).optional(), exclude_invite_codes: z.array(z.string().max(32)).max(255).optional(), include_custom_invite_codes: z .array(z.string().max(32)) .max(255) .transform((arr) => arr.map((str) => str.toLowerCase())) .optional(), exclude_custom_invite_codes: z .array(z.string().max(32)) .max(255) .transform((arr) => arr.map((str) => str.toLowerCase())) .optional(), allow_group_dm_invites: z.boolean().default(false), match_messages: z.boolean().default(true), match_embeds: z.boolean().default(false), match_visible_names: z.boolean().default(false), match_usernames: z.boolean().default(false), match_nicknames: z.boolean().default(false), match_custom_status: z.boolean().default(false), }); export const MatchInvitesTrigger = automodTrigger()({ configSchema, async match({ pluginData, context, triggerConfig: trigger }) { if (!context.message) { return; } for await (const [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) { const inviteCodes = getInviteCodesInString(str); if (inviteCodes.length === 0) continue; const uniqueInviteCodes = Array.from(new Set(inviteCodes)); for (const code of uniqueInviteCodes) { if (trigger.include_invite_codes && trigger.include_invite_codes.includes(code)) { return { extra: { type, code } }; } if (trigger.exclude_invite_codes && !trigger.exclude_invite_codes.includes(code)) { return { extra: { type, code } }; } if (trigger.include_custom_invite_codes && trigger.include_custom_invite_codes.includes(code.toLowerCase())) { return { extra: { type, code } }; } if (trigger.exclude_custom_invite_codes && !trigger.exclude_custom_invite_codes.includes(code.toLowerCase())) { return { extra: { type, code } }; } } for (const code of uniqueInviteCodes) { const invite = await resolveInvite(pluginData.client, code); if (!invite || !isGuildInvite(invite)) return { extra: { type, code } }; if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) { return { extra: { type, code, invite } }; } if (trigger.exclude_guilds && !trigger.exclude_guilds.includes(invite.guild.id)) { return { extra: { type, code, invite } }; } } } return null; }, renderMatchInformation({ pluginData, contexts, matchResult }) { let matchedText; if (matchResult.extra.invite) { const invite = matchResult.extra.invite as GuildInvite; matchedText = `invite code \`${matchResult.extra.code}\` (**${invite.guild.name}**, \`${invite.guild.id}\`)`; } else { matchedText = `invite code \`${matchResult.extra.code}\``; } const partialSummary = getTextMatchPartialSummary(pluginData, matchResult.extra.type, contexts[0]); return `Matched ${matchedText} in ${partialSummary}`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/matchLinks.ts ================================================ import { escapeInlineCode } from "discord.js"; import { z } from "zod"; import { allowTimeout } from "../../../RegExpRunner.js"; import { getFishFishDomain } from "../../../data/FishFish.js"; import { getUrlsInString, inputPatternToRegExp, zRegex } from "../../../utils.js"; import { mergeRegexes } from "../../../utils/mergeRegexes.js"; import { mergeWordsIntoRegex } from "../../../utils/mergeWordsIntoRegex.js"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage.js"; import { automodTrigger } from "../helpers.js"; interface MatchResultType { type: MatchableTextType; link: string; details?: string; } const regexCache = new WeakMap(); const quickLinkCheck = /^https?:\/\//i; const configSchema = z.strictObject({ include_domains: z.array(z.string().max(255)).max(700).optional(), exclude_domains: z.array(z.string().max(255)).max(700).optional(), include_subdomains: z.boolean().default(true), include_words: z.array(z.string().max(2000)).max(700).optional(), exclude_words: z.array(z.string().max(2000)).max(700).optional(), include_regex: z .array(zRegex(z.string().max(2000))) .max(512) .optional(), exclude_regex: z .array(zRegex(z.string().max(2000))) .max(512) .optional(), phisherman: z .strictObject({ include_suspected: z.boolean().optional(), include_verified: z.boolean().optional(), }) .optional(), include_malicious: z.boolean().default(false), only_real_links: z.boolean().default(true), match_messages: z.boolean().default(true), match_embeds: z.boolean().default(true), match_visible_names: z.boolean().default(false), match_usernames: z.boolean().default(false), match_nicknames: z.boolean().default(false), match_custom_status: z.boolean().default(false), }); export const MatchLinksTrigger = automodTrigger()({ configSchema, async match({ pluginData, context, triggerConfig: trigger }) { if (!context.message) { return; } typeLoop: for await (const [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) { const links = getUrlsInString(str, true); for (const link of links) { // "real link" = a link that Discord highlights if (trigger.only_real_links && !quickLinkCheck.test(link.input)) { continue; } const normalizedHostname = link.hostname.toLowerCase(); // Exclude > Include // In order of specificity, regex > word > domain if (trigger.exclude_regex) { if (!regexCache.has(trigger.exclude_regex)) { const toCache = mergeRegexes( trigger.exclude_regex.map((pattern) => inputPatternToRegExp(pattern)), "i", ); regexCache.set(trigger.exclude_regex, toCache); } const regexes = regexCache.get(trigger.exclude_regex)!; for (const sourceRegex of regexes) { const matches = await pluginData.state.regexRunner.exec(sourceRegex, link.input).catch(allowTimeout); if (matches) { continue typeLoop; } } } if (trigger.include_regex) { if (!regexCache.has(trigger.include_regex)) { const toCache = mergeRegexes( trigger.include_regex.map((pattern) => inputPatternToRegExp(pattern)), "i", ); regexCache.set(trigger.include_regex, toCache); } const regexes = regexCache.get(trigger.include_regex)!; for (const sourceRegex of regexes) { const matches = await pluginData.state.regexRunner.exec(sourceRegex, link.input).catch(allowTimeout); if (matches) { return { extra: { type, link: link.input } }; } } } if (trigger.exclude_words) { if (!regexCache.has(trigger.exclude_words)) { const toCache = mergeWordsIntoRegex(trigger.exclude_words, "i"); regexCache.set(trigger.exclude_words, [toCache]); } const regexes = regexCache.get(trigger.exclude_words)!; for (const regex of regexes) { if (regex.test(link.input)) { continue typeLoop; } } } if (trigger.include_words) { if (!regexCache.has(trigger.include_words)) { const toCache = mergeWordsIntoRegex(trigger.include_words, "i"); regexCache.set(trigger.include_words, [toCache]); } const regexes = regexCache.get(trigger.include_words)!; for (const regex of regexes) { if (regex.test(link.input)) { return { extra: { type, link: link.input } }; } } } if (trigger.exclude_domains) { for (const domain of trigger.exclude_domains) { const normalizedDomain = domain.toLowerCase(); if (normalizedDomain === normalizedHostname) { continue typeLoop; } if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { continue typeLoop; } } return { extra: { type, link: link.toString() } }; } if (trigger.include_domains) { for (const domain of trigger.include_domains) { const normalizedDomain = domain.toLowerCase(); if (normalizedDomain === normalizedHostname) { return { extra: { type, link: domain } }; } if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { return { extra: { type, link: domain } }; } } } const includeMalicious = trigger.include_malicious || trigger.phisherman?.include_suspected || trigger.phisherman?.include_verified; if (includeMalicious) { const domainInfo = getFishFishDomain(normalizedHostname); if (domainInfo && domainInfo.category !== "safe") { return { extra: { type, link: link.input, details: `(known ${domainInfo.category} domain)`, }, }; } } } } return null; }, renderMatchInformation({ pluginData, contexts, matchResult }) { const partialSummary = getTextMatchPartialSummary(pluginData, matchResult.extra.type, contexts[0]); let information = `Matched link \`${escapeInlineCode(matchResult.extra.link)}\``; if (matchResult.extra.details) { information += ` ${matchResult.extra.details}`; } information += ` in ${partialSummary}`; return information; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/matchMimeType.ts ================================================ import { escapeInlineCode } from "discord.js"; import { z } from "zod"; import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; interface MatchResultType { matchedType: string; mode: "blacklist" | "whitelist"; } const configSchema = z.strictObject({ whitelist_enabled: z.boolean().default(false), mime_type_whitelist: z.array(z.string().max(32)).max(255).default([]), blacklist_enabled: z.boolean().default(false), mime_type_blacklist: z.array(z.string().max(32)).max(255).default([]), }); export const MatchMimeTypeTrigger = automodTrigger()({ configSchema, async match({ context, triggerConfig: trigger }) { if (!context.message) return; const { attachments } = context.message.data; if (!attachments) return null; for (const attachment of attachments) { const { contentType: rawContentType } = attachment; const contentType = (rawContentType || "").split(";")[0]; // Remove "; charset=utf8" and similar from the end const blacklist = trigger.blacklist_enabled ? (trigger.mime_type_blacklist ?? []).map((_t) => _t.toLowerCase()) : null; if (contentType && blacklist?.includes(contentType)) { return { extra: { matchedType: contentType, mode: "blacklist", }, }; } const whitelist = trigger.whitelist_enabled ? (trigger.mime_type_whitelist ?? []).map((_t) => _t.toLowerCase()) : null; if (whitelist && (!contentType || !whitelist.includes(contentType))) { return { extra: { matchedType: contentType || "", mode: "whitelist", }, }; } return null; } }, renderMatchInformation({ pluginData, contexts, matchResult }) { const { message } = contexts[0]; const channel = pluginData.guild.channels.resolve(message!.channel_id)!; const prettyChannel = verboseChannelMention(channel); const { matchedType, mode } = matchResult.extra; return ( asSingleLine(` Matched MIME type \`${escapeInlineCode(matchedType)}\` (${mode === "blacklist" ? "blacklisted" : "not in whitelist"}) in message (\`${message!.id}\`) in ${prettyChannel} `) + messageSummary(message!) ); }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/matchRegex.ts ================================================ import { z } from "zod"; import { allowTimeout } from "../../../RegExpRunner.js"; import { inputPatternToRegExp, zRegex } from "../../../utils.js"; import { mergeRegexes } from "../../../utils/mergeRegexes.js"; import { normalizeText } from "../../../utils/normalizeText.js"; import { stripMarkdown } from "../../../utils/stripMarkdown.js"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage.js"; import { automodTrigger } from "../helpers.js"; interface MatchResultType { pattern: string; type: MatchableTextType; } const configSchema = z.strictObject({ patterns: z.array(zRegex(z.string().max(2000))).max(512), case_sensitive: z.boolean().default(false), normalize: z.boolean().default(false), strip_markdown: z.boolean().default(false), match_messages: z.boolean().default(true), match_embeds: z.boolean().default(false), match_visible_names: z.boolean().default(false), match_usernames: z.boolean().default(false), match_nicknames: z.boolean().default(false), match_custom_status: z.boolean().default(false), }); const regexCache = new WeakMap(); export const MatchRegexTrigger = automodTrigger()({ configSchema, async match({ pluginData, context, triggerConfig: trigger }) { if (!context.message) { return; } if (!regexCache.has(trigger)) { const flags = trigger.case_sensitive ? "" : "i"; const toCache = mergeRegexes( trigger.patterns.map((pattern) => inputPatternToRegExp(pattern)), flags, ); regexCache.set(trigger, toCache); } const regexes = regexCache.get(trigger)!; for await (let [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) { if (trigger.strip_markdown) { str = stripMarkdown(str); } if (trigger.normalize) { str = normalizeText(str); } for (const regex of regexes) { const matches = await pluginData.state.regexRunner.exec(regex, str).catch(allowTimeout); if (matches?.length) { return { extra: { pattern: regex.source, type, }, }; } } } return null; }, renderMatchInformation({ pluginData, contexts, matchResult }) { const partialSummary = getTextMatchPartialSummary(pluginData, matchResult.extra.type, contexts[0]); return `Matched regex in ${partialSummary}`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/matchWords.ts ================================================ import escapeStringRegexp from "escape-string-regexp"; import { z } from "zod"; import { normalizeText } from "../../../utils/normalizeText.js"; import { stripMarkdown } from "../../../utils/stripMarkdown.js"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage.js"; import { automodTrigger } from "../helpers.js"; import { escapeInlineCode } from "discord.js"; interface MatchResultType { word: string; type: MatchableTextType; } const regexCache = new WeakMap(); const configSchema = z.strictObject({ words: z.array(z.string().max(2000)).max(1024), case_sensitive: z.boolean().default(false), only_full_words: z.boolean().default(true), normalize: z.boolean().default(false), loose_matching: z.boolean().default(false), loose_matching_threshold: z.number().int().default(1), strip_markdown: z.boolean().default(false), match_messages: z.boolean().default(true), match_embeds: z.boolean().default(false), match_visible_names: z.boolean().default(false), match_usernames: z.boolean().default(false), match_nicknames: z.boolean().default(false), match_custom_status: z.boolean().default(false), }); export const MatchWordsTrigger = automodTrigger()({ configSchema, async match({ pluginData, context, triggerConfig: trigger }) { if (!context.message) { return; } if (!regexCache.has(trigger)) { const looseMatchingThreshold = Math.min(Math.max(trigger.loose_matching_threshold, 1), 64); const patterns = trigger.words.map((word) => { let pattern; if (trigger.loose_matching) { pattern = [...word].map((c) => escapeStringRegexp(c)).join(`[\\s\\-_.,!?]{0,${looseMatchingThreshold}}`); } else { pattern = escapeStringRegexp(word); } if (trigger.only_full_words) { if (trigger.loose_matching) { pattern = `\\b(?:${pattern})\\b`; } else { pattern = `\\b${pattern}\\b`; } } return pattern; }); const mergedRegex = new RegExp(patterns.map((p) => `(${p})`).join("|"), trigger.case_sensitive ? "" : "i"); regexCache.set(trigger, [mergedRegex]); } const regexes = regexCache.get(trigger)!; for await (let [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) { if (trigger.strip_markdown) { str = stripMarkdown(str); } if (trigger.normalize) { str = normalizeText(str); } for (const regex of regexes) { const match = regex.exec(str); if (match) { const matchedWordIndex = match.slice(1).findIndex((group) => group !== undefined); const matchedWord = trigger.words[matchedWordIndex]; return { extra: { type, word: matchedWord, }, }; } } } return null; }, renderMatchInformation({ pluginData, contexts, matchResult }) { const partialSummary = getTextMatchPartialSummary(pluginData, matchResult.extra.type, contexts[0]); const wordInfo = matchResult.extra.word ? ` (\`${escapeInlineCode(matchResult.extra.word)}\`)` : ""; return `Matched word${wordInfo} in ${partialSummary}`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/memberJoin.ts ================================================ import { z } from "zod"; import { convertDelayStringToMS, zDelayString } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; const configSchema = z.strictObject({ only_new: z.boolean().default(false), new_threshold: zDelayString.default("1h"), }); export const MemberJoinTrigger = automodTrigger()({ configSchema, async match({ context, triggerConfig }) { if (!context.joined || !context.member) { return; } if (triggerConfig.only_new) { const threshold = Date.now() - convertDelayStringToMS(triggerConfig.new_threshold)!; return context.member.user.createdTimestamp >= threshold ? {} : null; } return {}; }, renderMatchInformation() { return ""; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/memberJoinSpam.ts ================================================ import { z } from "zod"; import { convertDelayStringToMS, zDelayString } from "../../../utils.js"; import { RecentActionType } from "../constants.js"; import { findRecentSpam } from "../functions/findRecentSpam.js"; import { getMatchingRecentActions } from "../functions/getMatchingRecentActions.js"; import { sumRecentActionCounts } from "../functions/sumRecentActionCounts.js"; import { automodTrigger } from "../helpers.js"; const configSchema = z.strictObject({ amount: z.number().int(), within: zDelayString, }); export const MemberJoinSpamTrigger = automodTrigger()({ configSchema, async match({ pluginData, context, triggerConfig }) { if (!context.joined || !context.member) { return; } const recentSpam = findRecentSpam(pluginData, RecentActionType.MemberJoin); if (recentSpam) { context.actioned = true; return {}; } const since = Date.now() - convertDelayStringToMS(triggerConfig.within)!; const matchingActions = getMatchingRecentActions(pluginData, RecentActionType.MemberJoin, null, since); const totalCount = sumRecentActionCounts(matchingActions); if (totalCount >= triggerConfig.amount) { const extraContexts = matchingActions.map((a) => a.context).filter((c) => c !== context); pluginData.state.recentSpam.push({ type: RecentActionType.MemberJoin, timestamp: Date.now(), archiveId: null, identifiers: [], }); return { extraContexts, }; } }, renderMatchInformation() { return ""; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/memberLeave.ts ================================================ import { z } from "zod"; import { automodTrigger } from "../helpers.js"; const configSchema = z.strictObject({}); export const MemberLeaveTrigger = automodTrigger()({ configSchema, async match({ context }) { if (!context.joined || !context.member) { return; } return {}; }, renderMatchInformation() { return ""; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/mentionSpam.ts ================================================ import { RecentActionType } from "../constants.js"; import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger.js"; export const MentionSpamTrigger = createMessageSpamTrigger(RecentActionType.Mention, "mention"); ================================================ FILE: backend/src/plugins/Automod/triggers/messageSpam.ts ================================================ import { RecentActionType } from "../constants.js"; import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger.js"; export const MessageSpamTrigger = createMessageSpamTrigger(RecentActionType.Message, "message"); ================================================ FILE: backend/src/plugins/Automod/triggers/mute.ts ================================================ import { z } from "zod"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface interface MuteTriggerResultType {} const configSchema = z.strictObject({ manual: z.boolean().default(true), automatic: z.boolean().default(true), }); export const MuteTrigger = automodTrigger()({ configSchema, async match({ context, triggerConfig }) { if (context.modAction?.type !== "mute") { return; } // If automatic && automatic turned off -> return if (context.modAction.isAutomodAction && !triggerConfig.automatic) return; // If manual && manual turned off -> return if (!context.modAction.isAutomodAction && !triggerConfig.manual) return; return { extra: {}, }; }, renderMatchInformation() { return `User was muted`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/note.ts ================================================ import { z } from "zod"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface interface NoteTriggerResultType {} const configSchema = z.strictObject({}); export const NoteTrigger = automodTrigger()({ configSchema, async match({ context }) { if (context.modAction?.type !== "note") { return; } return { extra: {}, }; }, renderMatchInformation() { return `Note was added on user`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/roleAdded.ts ================================================ import { Snowflake } from "discord.js"; import { z } from "zod"; import { renderUsername, zSnowflake } from "../../../utils.js"; import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges.js"; import { automodTrigger } from "../helpers.js"; interface RoleAddedMatchResult { matchedRoleId: string; } const configSchema = z.union([zSnowflake, z.array(zSnowflake).max(255)]).default([]); export const RoleAddedTrigger = automodTrigger()({ configSchema, async match({ triggerConfig, context, pluginData }) { if (!context.member || !context.rolesChanged || context.rolesChanged.added!.length === 0) { return; } const triggerRoles = Array.isArray(triggerConfig) ? triggerConfig : [triggerConfig]; for (const roleId of triggerRoles) { if (context.rolesChanged.added!.includes(roleId)) { if (consumeIgnoredRoleChange(pluginData, context.member.id, roleId)) { continue; } return { extra: { matchedRoleId: roleId, }, }; } } }, renderMatchInformation({ matchResult, pluginData, contexts }) { const role = pluginData.guild.roles.cache.get(matchResult.extra.matchedRoleId as Snowflake); const roleName = role?.name || "Unknown"; const member = contexts[0].member!; const memberName = `**${renderUsername(member)}** (\`${member.id}\`)`; return `Role ${roleName} (\`${matchResult.extra.matchedRoleId}\`) was added to ${memberName}`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/roleRemoved.ts ================================================ import { Snowflake } from "discord.js"; import { z } from "zod"; import { renderUsername, zSnowflake } from "../../../utils.js"; import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges.js"; import { automodTrigger } from "../helpers.js"; interface RoleAddedMatchResult { matchedRoleId: string; } const configSchema = z.union([zSnowflake, z.array(zSnowflake).max(255)]).default([]); export const RoleRemovedTrigger = automodTrigger()({ configSchema, async match({ triggerConfig, context, pluginData }) { if (!context.member || !context.rolesChanged || context.rolesChanged.removed!.length === 0) { return; } const triggerRoles = Array.isArray(triggerConfig) ? triggerConfig : [triggerConfig]; for (const roleId of triggerRoles) { if (consumeIgnoredRoleChange(pluginData, context.member.id, roleId)) { continue; } if (context.rolesChanged.removed!.includes(roleId)) { return { extra: { matchedRoleId: roleId, }, }; } } }, renderMatchInformation({ matchResult, pluginData, contexts }) { const role = pluginData.guild.roles.cache.get(matchResult.extra.matchedRoleId as Snowflake); const roleName = role?.name || "Unknown"; const member = contexts[0].member!; const memberName = `**${renderUsername(member)}** (\`${member.id}\`)`; return `Role ${roleName} (\`${matchResult.extra.matchedRoleId}\`) was removed from ${memberName}`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/stickerSpam.ts ================================================ import { RecentActionType } from "../constants.js"; import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger.js"; export const StickerSpamTrigger = createMessageSpamTrigger(RecentActionType.Sticker, "sticker"); ================================================ FILE: backend/src/plugins/Automod/triggers/threadArchive.ts ================================================ import { User, escapeBold, type Snowflake } from "discord.js"; import { z } from "zod"; import { renderUsername } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; interface ThreadArchiveResult { matchedThreadId: Snowflake; matchedThreadName: string; matchedThreadParentId: Snowflake; matchedThreadParentName: string; matchedThreadOwner: User | undefined; } const configSchema = z.strictObject({ locked: z.boolean().optional(), }); export const ThreadArchiveTrigger = automodTrigger()({ configSchema, async match({ context, triggerConfig }) { if (!context.threadChange?.archived) { return; } const thread = context.threadChange.archived; if (typeof triggerConfig.locked === "boolean" && thread.locked !== triggerConfig.locked) { return; } return { extra: { matchedThreadId: thread.id, matchedThreadName: thread.name, matchedThreadParentId: thread.parentId ?? "Unknown", matchedThreadParentName: thread.parent?.name ?? "Unknown", matchedThreadOwner: context.user, }, }; }, async renderMatchInformation({ matchResult }) { const threadId = matchResult.extra.matchedThreadId; const threadName = matchResult.extra.matchedThreadName; const threadOwner = matchResult.extra.matchedThreadOwner; const parentId = matchResult.extra.matchedThreadParentId; const parentName = matchResult.extra.matchedThreadParentName; const base = `Thread **#${threadName}** (\`${threadId}\`) has been archived in the **#${parentName}** (\`${parentId}\`) channel`; if (threadOwner) { return `${base} by **${escapeBold(renderUsername(threadOwner))}** (\`${threadOwner.id}\`)`; } return base; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/threadCreate.ts ================================================ import { User, escapeBold, type Snowflake } from "discord.js"; import { z } from "zod"; import { renderUsername } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; interface ThreadCreateResult { matchedThreadId: Snowflake; matchedThreadName: string; matchedThreadParentId: Snowflake; matchedThreadParentName: string; matchedThreadOwner: User | undefined; } const configSchema = z.strictObject({}); export const ThreadCreateTrigger = automodTrigger()({ configSchema, async match({ context }) { if (!context.threadChange?.created) { return; } const thread = context.threadChange.created; return { extra: { matchedThreadId: thread.id, matchedThreadName: thread.name, matchedThreadParentId: thread.parentId ?? "Unknown", matchedThreadParentName: thread.parent?.name ?? "Unknown", matchedThreadOwner: context.user, }, }; }, async renderMatchInformation({ matchResult }) { const threadId = matchResult.extra.matchedThreadId; const threadName = matchResult.extra.matchedThreadName; const threadOwner = matchResult.extra.matchedThreadOwner; const parentId = matchResult.extra.matchedThreadParentId; const parentName = matchResult.extra.matchedThreadParentName; const base = `Thread **#${threadName}** (\`${threadId}\`) has been created in the **#${parentName}** (\`${parentId}\`) channel`; if (threadOwner) { return `${base} by **${escapeBold(renderUsername(threadOwner))}** (\`${threadOwner.id}\`)`; } return base; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/threadCreateSpam.ts ================================================ import { z } from "zod"; import { convertDelayStringToMS, zDelayString } from "../../../utils.js"; import { RecentActionType } from "../constants.js"; import { findRecentSpam } from "../functions/findRecentSpam.js"; import { getMatchingRecentActions } from "../functions/getMatchingRecentActions.js"; import { sumRecentActionCounts } from "../functions/sumRecentActionCounts.js"; import { automodTrigger } from "../helpers.js"; const configSchema = z.strictObject({ amount: z.number().int(), within: zDelayString, }); export const ThreadCreateSpamTrigger = automodTrigger()({ configSchema, async match({ pluginData, context, triggerConfig }) { if (!context.threadChange?.created) { return; } const recentSpam = findRecentSpam(pluginData, RecentActionType.ThreadCreate); if (recentSpam) { context.actioned = true; return {}; } const since = Date.now() - convertDelayStringToMS(triggerConfig.within)!; const matchingActions = getMatchingRecentActions(pluginData, RecentActionType.ThreadCreate, null, since); const totalCount = sumRecentActionCounts(matchingActions); if (totalCount >= triggerConfig.amount) { const extraContexts = matchingActions.map((a) => a.context).filter((c) => c !== context); pluginData.state.recentSpam.push({ type: RecentActionType.ThreadCreate, timestamp: Date.now(), archiveId: null, identifiers: [], }); return { extraContexts, }; } }, renderMatchInformation() { return ""; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/threadDelete.ts ================================================ import { User, escapeBold, type Snowflake } from "discord.js"; import { z } from "zod"; import { renderUsername } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; interface ThreadDeleteResult { matchedThreadId: Snowflake; matchedThreadName: string; matchedThreadParentId: Snowflake; matchedThreadParentName: string; matchedThreadOwner: User | undefined; } const configSchema = z.strictObject({}); export const ThreadDeleteTrigger = automodTrigger()({ configSchema, async match({ context }) { if (!context.threadChange?.deleted) { return; } const thread = context.threadChange.deleted; return { extra: { matchedThreadId: thread.id, matchedThreadName: thread.name, matchedThreadParentId: thread.parentId ?? "Unknown", matchedThreadParentName: thread.parent?.name ?? "Unknown", matchedThreadOwner: context.user, }, }; }, renderMatchInformation({ matchResult }) { const threadId = matchResult.extra.matchedThreadId; const threadOwner = matchResult.extra.matchedThreadOwner; const threadName = matchResult.extra.matchedThreadName; const parentId = matchResult.extra.matchedThreadParentId; const parentName = matchResult.extra.matchedThreadParentName; if (threadOwner) { return `Thread **#${threadName ?? "Unknown"}** (\`${threadId}\`) created by **${escapeBold( renderUsername(threadOwner), )}** (\`${threadOwner.id}\`) in the **#${parentName}** (\`${parentId}\`) channel has been deleted`; } return `Thread **#${ threadName ?? "Unknown" }** (\`${threadId}\`) from the **#${parentName}** (\`${parentId}\`) channel has been deleted`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/threadUnarchive.ts ================================================ import { User, escapeBold, type Snowflake } from "discord.js"; import { z } from "zod"; import { renderUsername } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; interface ThreadUnarchiveResult { matchedThreadId: Snowflake; matchedThreadName: string; matchedThreadParentId: Snowflake; matchedThreadParentName: string; matchedThreadOwner: User | undefined; } const configSchema = z.strictObject({ locked: z.boolean().optional(), }); export const ThreadUnarchiveTrigger = automodTrigger()({ configSchema, async match({ context, triggerConfig }) { if (!context.threadChange?.unarchived) { return; } const thread = context.threadChange.unarchived; if (typeof triggerConfig.locked === "boolean" && thread.locked !== triggerConfig.locked) { return; } return { extra: { matchedThreadId: thread.id, matchedThreadName: thread.name, matchedThreadParentId: thread.parentId ?? "Unknown", matchedThreadParentName: thread.parent?.name ?? "Unknown", matchedThreadOwner: context.user, }, }; }, async renderMatchInformation({ matchResult }) { const threadId = matchResult.extra.matchedThreadId; const threadName = matchResult.extra.matchedThreadName; const threadOwner = matchResult.extra.matchedThreadOwner; const parentId = matchResult.extra.matchedThreadParentId; const parentName = matchResult.extra.matchedThreadParentName; const base = `Thread **#${threadName}** (\`${threadId}\`) has been unarchived in the **#${parentName}** (\`${parentId}\`) channel`; if (threadOwner) { return `${base} by **${escapeBold(renderUsername(threadOwner))}** (\`${threadOwner.id}\`)`; } return base; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/unban.ts ================================================ import { z } from "zod"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface interface UnbanTriggerResultType {} const configSchema = z.strictObject({}); export const UnbanTrigger = automodTrigger()({ configSchema, async match({ context }) { if (context.modAction?.type !== "unban") { return; } return { extra: {}, }; }, renderMatchInformation() { return `User was unbanned`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/unmute.ts ================================================ import { z } from "zod"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface interface UnmuteTriggerResultType {} const configSchema = z.strictObject({}); export const UnmuteTrigger = automodTrigger()({ configSchema, async match({ context }) { if (context.modAction?.type !== "unmute") { return; } return { extra: {}, }; }, renderMatchInformation() { return `User was unmuted`; }, }); ================================================ FILE: backend/src/plugins/Automod/triggers/warn.ts ================================================ import { z } from "zod"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface interface WarnTriggerResultType {} const configSchema = z.strictObject({ manual: z.boolean().default(true), automatic: z.boolean().default(true), }); export const WarnTrigger = automodTrigger()({ configSchema, async match({ context, triggerConfig }) { if (context.modAction?.type !== "warn") { return; } // If automatic && automatic turned off -> return if (context.modAction.isAutomodAction && !triggerConfig.automatic) return; // If manual && manual turned off -> return if (!context.modAction.isAutomodAction && !triggerConfig.manual) return; return { extra: {}, }; }, renderMatchInformation() { return `User was warned`; }, }); ================================================ FILE: backend/src/plugins/Automod/types.ts ================================================ import { GuildMember, GuildTextBasedChannel, PartialGuildMember, ThreadChannel, User } from "discord.js"; import { BasePluginType, CooldownManager, pluginUtils } from "vety"; import { z } from "zod"; import { Queue } from "../../Queue.js"; import { RegExpRunner } from "../../RegExpRunner.js"; import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels.js"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { SavedMessage } from "../../data/entities/SavedMessage.js"; import { entries, zBoundedRecord, zDelayString } from "../../utils.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { CounterEvents } from "../Counters/types.js"; import { ModActionType, ModActionsEvents } from "../ModActions/types.js"; import { MutesEvents } from "../Mutes/types.js"; import { availableActions } from "./actions/availableActions.js"; import { RecentActionType } from "./constants.js"; import { availableTriggers } from "./triggers/availableTriggers.js"; import Timeout = NodeJS.Timeout; export type ZTriggersMapHelper = { [TriggerName in keyof typeof availableTriggers]: (typeof availableTriggers)[TriggerName]["configSchema"]; }; const zTriggersMap = z .strictObject( entries(availableTriggers).reduce((map, [triggerName, trigger]) => { map[triggerName] = trigger.configSchema; return map; }, {} as ZTriggersMapHelper), ) .partial(); type ZActionsMapHelper = { [ActionName in keyof typeof availableActions]: (typeof availableActions)[ActionName]["configSchema"]; }; const zActionsMap = z .strictObject( entries(availableActions).reduce((map, [actionName, action]) => { // @ts-expect-error TS can't infer this properly but it works fine thanks to our helper map[actionName] = action.configSchema; return map; }, {} as ZActionsMapHelper), ) .partial(); const zRule = z.strictObject({ enabled: z.boolean().default(true), pretty_name: z.string().optional(), presets: z.array(z.string().max(100)).max(25).default([]), affects_bots: z.boolean().default(false), affects_self: z.boolean().default(false), cooldown: zDelayString.nullable().default(null), allow_further_rules: z.boolean().default(false), triggers: z.array(zTriggersMap), actions: zActionsMap, }); export type TRule = z.infer; export const zAutomodConfig = z.strictObject({ rules: zBoundedRecord(z.record(z.string().max(100), zRule), 0, 255).default({}), antiraid_levels: z.array(z.string().max(100)).max(10).default(["low", "medium", "high"]), can_set_antiraid: z.boolean().default(false), can_view_antiraid: z.boolean().default(false), can_debug_automod: z.boolean().default(false), }); export interface AutomodPluginType extends BasePluginType { configSchema: typeof zAutomodConfig; customOverrideCriteria: { antiraid_level?: string; }; state: { /** * Automod checks/actions are handled in a queue so we don't get overlap on the same user */ queue: Queue; /** * Per-server regex runner */ regexRunner: RegExpRunner; /** * Recent actions are used for spam triggers */ recentActions: RecentAction[]; clearRecentActionsInterval: Timeout; /** * After a spam trigger is tripped and the rule's action carried out, a unique identifier is placed here so further * spam (either messages that were sent before the bot managed to mute the user or, with global spam, other users * continuing to spam) is "included" in the same match and doesn't generate duplicate cases or logs. * Key: rule_name-match_identifier */ recentSpam: RecentSpam[]; clearRecentSpamInterval: Timeout; recentNicknameChanges: Map; clearRecentNicknameChangesInterval: Timeout; ignoredRoleChanges: Set<{ memberId: string; roleId: string; timestamp: number; }>; cachedAntiraidLevel: string | null; cooldownManager: CooldownManager; savedMessages: GuildSavedMessages; logs: GuildLogs; antiraidLevels: GuildAntiraidLevels; archives: GuildArchives; onMessageCreateFn: any; onMessageUpdateFn: any; onCounterTrigger: CounterEvents["trigger"]; onCounterReverseTrigger: CounterEvents["reverseTrigger"]; modActionsListeners: Map; mutesListeners: Map; common: pluginUtils.PluginPublicInterface; }; } export interface AutomodContext { timestamp: number; actioned?: boolean; counterTrigger?: { counter: string; trigger: string; prettyCounter: string; prettyTrigger: string; channelId: string | null; userId: string | null; reverse: boolean; }; user?: User; message?: SavedMessage; member?: GuildMember; partialMember?: GuildMember | PartialGuildMember; joined?: boolean; rolesChanged?: { added?: string[]; removed?: string[]; }; modAction?: { type: ModActionType; reason?: string; isAutomodAction: boolean; }; antiraid?: { level: string | null; oldLevel?: string | null; }; threadChange?: { created?: ThreadChannel; deleted?: ThreadChannel; archived?: ThreadChannel; unarchived?: ThreadChannel; locked?: ThreadChannel; unlocked?: ThreadChannel; }; channel?: GuildTextBasedChannel; } export interface RecentAction { type: RecentActionType; identifier: string | null; count: number; context: AutomodContext; } export interface RecentSpam { archiveId: string | null; type: RecentActionType; identifiers: string[]; timestamp: number; } ================================================ FILE: backend/src/plugins/BotControl/BotControlPlugin.ts ================================================ import { Snowflake, TextChannel } from "discord.js"; import { globalPlugin } from "vety"; import { AllowedGuilds } from "../../data/AllowedGuilds.js"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments.js"; import { Configs } from "../../data/Configs.js"; import { GuildArchives } from "../../data/GuildArchives.js"; import { getActiveReload, resetActiveReload } from "./activeReload.js"; import { AddDashboardUserCmd } from "./commands/AddDashboardUserCmd.js"; import { AddServerFromInviteCmd } from "./commands/AddServerFromInviteCmd.js"; import { AllowServerCmd } from "./commands/AllowServerCmd.js"; import { ChannelToServerCmd } from "./commands/ChannelToServerCmd.js"; import { DisallowServerCmd } from "./commands/DisallowServerCmd.js"; import { EligibleCmd } from "./commands/EligibleCmd.js"; import { LeaveServerCmd } from "./commands/LeaveServerCmd.js"; import { ListDashboardPermsCmd } from "./commands/ListDashboardPermsCmd.js"; import { ListDashboardUsersCmd } from "./commands/ListDashboardUsersCmd.js"; import { ProfilerDataCmd } from "./commands/ProfilerDataCmd.js"; import { RateLimitPerformanceCmd } from "./commands/RateLimitPerformanceCmd.js"; import { ReloadGlobalPluginsCmd } from "./commands/ReloadGlobalPluginsCmd.js"; import { ReloadServerCmd } from "./commands/ReloadServerCmd.js"; import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd.js"; import { RestPerformanceCmd } from "./commands/RestPerformanceCmd.js"; import { ServersCmd } from "./commands/ServersCmd.js"; import { BotControlPluginType, zBotControlConfig } from "./types.js"; import { DebugCountersCmd } from "./commands/DebugCountersCmd.js"; export const BotControlPlugin = globalPlugin()({ name: "bot_control", configSchema: zBotControlConfig, // prettier-ignore messageCommands: [ ReloadGlobalPluginsCmd, ServersCmd, LeaveServerCmd, ReloadServerCmd, AllowServerCmd, DisallowServerCmd, AddDashboardUserCmd, RemoveDashboardUserCmd, ListDashboardUsersCmd, ListDashboardPermsCmd, EligibleCmd, ProfilerDataCmd, RestPerformanceCmd, RateLimitPerformanceCmd, AddServerFromInviteCmd, ChannelToServerCmd, DebugCountersCmd, ], async afterLoad(pluginData) { const { state, client } = pluginData; state.archives = new GuildArchives(0); state.allowedGuilds = new AllowedGuilds(); state.configs = new Configs(); state.apiPermissionAssignments = new ApiPermissionAssignments(); const activeReload = getActiveReload(); if (activeReload) { const [guildId, channelId] = activeReload; resetActiveReload(); const guild = await client.guilds.fetch(guildId as Snowflake); if (guild) { const channel = guild.channels.cache.get(channelId as Snowflake); if (channel instanceof TextChannel) { void channel.send("Global plugins reloaded!"); } } } }, }); ================================================ FILE: backend/src/plugins/BotControl/activeReload.ts ================================================ let activeReload: [string, string] | null = null; export function getActiveReload() { return activeReload; } export function setActiveReload(guildId: string, channelId: string) { activeReload = [guildId, channelId]; } export function resetActiveReload() { activeReload = null; } ================================================ FILE: backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts ================================================ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isStaffPreFilter } from "../../../pluginUtils.js"; import { renderUsername } from "../../../utils.js"; import { botControlCmd } from "../types.js"; export const AddDashboardUserCmd = botControlCmd({ trigger: ["add_dashboard_user"], permission: null, config: { preFilters: [isStaffPreFilter], }, signature: { guildId: ct.string(), users: ct.resolvedUser({ rest: true }), }, async run({ pluginData, message: msg, args }) { const guild = await pluginData.state.allowedGuilds.find(args.guildId); if (!guild) { void msg.channel.send("Server is not using Zeppelin"); return; } for (const user of args.users) { const existingAssignment = await pluginData.state.apiPermissionAssignments.getByGuildAndUserId( args.guildId, user.id, ); if (existingAssignment) { continue; } await pluginData.state.apiPermissionAssignments.addUser(args.guildId, user.id, [ApiPermissions.EditConfig]); } const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`)`); msg.channel.send(`The following users were given dashboard access for **${guild.name}**:\n\n${userNameList}`); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts ================================================ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { DBDateFormat, isGuildInvite, resolveInvite } from "../../../utils.js"; import { isEligible } from "../functions/isEligible.js"; import { botControlCmd } from "../types.js"; export const AddServerFromInviteCmd = botControlCmd({ trigger: ["add_server_from_invite", "allow_server_from_invite", "adv"], permission: "can_add_server_from_invite", signature: { user: ct.resolvedUser(), inviteCode: ct.string(), }, async run({ pluginData, message: msg, args }) { const invite = await resolveInvite(pluginData.client, args.inviteCode, true); if (!invite || !isGuildInvite(invite)) { void msg.channel.send("Could not resolve invite"); // :D return; } const existing = await pluginData.state.allowedGuilds.find(invite.guild.id); if (existing) { void msg.channel.send("Server is already allowed!"); return; } const { result, explanation } = await isEligible(pluginData, args.user, invite); if (!result) { msg.channel.send(`Could not add server because it's not eligible: ${explanation}`); return; } await pluginData.state.allowedGuilds.add(invite.guild.id, { name: invite.guild.name }); await pluginData.state.configs.saveNewRevision(`guild-${invite.guild.id}`, "plugins: {}", msg.author.id); await pluginData.state.apiPermissionAssignments.addUser(invite.guild.id, args.user.id, [ ApiPermissions.ManageAccess, ]); if (args.user.id !== msg.author.id) { // Add temporary access to user who added server await pluginData.state.apiPermissionAssignments.addUser( invite.guild.id, msg.author.id, [ApiPermissions.ManageAccess], moment.utc().add(1, "hour").format(DBDateFormat), ); } msg.channel.send("Server was eligible and is now allowed to use Zeppelin!"); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/AllowServerCmd.ts ================================================ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isStaffPreFilter } from "../../../pluginUtils.js"; import { DBDateFormat, isSnowflake } from "../../../utils.js"; import { botControlCmd } from "../types.js"; export const AllowServerCmd = botControlCmd({ trigger: ["allow_server", "allowserver", "add_server", "addserver"], permission: null, config: { preFilters: [isStaffPreFilter], }, signature: { guildId: ct.string(), userId: ct.string({ required: false }), }, async run({ pluginData, message: msg, args }) { const existing = await pluginData.state.allowedGuilds.find(args.guildId); if (existing) { void msg.channel.send("Server is already allowed!"); return; } if (!isSnowflake(args.guildId)) { void msg.channel.send("Invalid server ID!"); return; } if (args.userId && !isSnowflake(args.userId)) { void msg.channel.send("Invalid user ID!"); return; } await pluginData.state.allowedGuilds.add(args.guildId); await pluginData.state.configs.saveNewRevision(`guild-${args.guildId}`, "plugins: {}", msg.author.id); if (args.userId) { await pluginData.state.apiPermissionAssignments.addUser(args.guildId, args.userId, [ApiPermissions.ManageAccess]); } if (args.userId !== msg.author.id) { // Add temporary access to user who added server await pluginData.state.apiPermissionAssignments.addUser( args.guildId, msg.author.id, [ApiPermissions.ManageAccess], moment.utc().add(1, "hour").format(DBDateFormat), ); } void msg.channel.send("Server is now allowed to use Zeppelin!"); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/ChannelToServerCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isStaffPreFilter } from "../../../pluginUtils.js"; import { botControlCmd } from "../types.js"; export const ChannelToServerCmd = botControlCmd({ trigger: ["channel_to_server", "channel2server"], permission: null, config: { preFilters: [isStaffPreFilter], }, signature: { channelId: ct.string(), }, async run({ pluginData, message: msg, args }) { const channel = pluginData.client.channels.cache.get(args.channelId); if (!channel) { void msg.channel.send("Channel not found in cache!"); return; } const channelName = channel.isVoiceBased() ? channel.name : `#${"name" in channel ? channel.name : channel.id}`; const guild = "guild" in channel ? channel.guild : null; const guildInfo = guild ? `${guild.name} (\`${guild.id}\`)` : "Not a server"; msg.channel.send(`**Channel:** ${channelName} (\`${channel.type}\`) (<#${channel.id}>)\n**Server:** ${guildInfo}`); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/DebugCountersCmd.ts ================================================ import moment from "moment-timezone"; import { GuildArchives } from "../../../data/GuildArchives.js"; import { getDebugCounterValues } from "../../../debugCounters.js"; import { getBaseUrl } from "../../../pluginUtils.js"; import { botControlCmd } from "../types.js"; type SortableDebugCounter = { name: string; count: number; }; export const DebugCountersCmd = botControlCmd({ trigger: ["debug_counters"], permission: "can_performance", signature: {}, async run({ pluginData, message: msg }) { const debugCounterValueMap = getDebugCounterValues(); const sortableDebugCounters: SortableDebugCounter[] = []; for (const [name, value] of debugCounterValueMap) { sortableDebugCounters.push({ name, count: value.count }); } sortableDebugCounters.sort((a, b) => b.count - a.count); const archives = GuildArchives.getGuildInstance("0"); const archiveId = await archives.create(JSON.stringify(sortableDebugCounters, null, 2), moment().add(1, "hour")); const archiveUrl = archives.getUrl(getBaseUrl(pluginData), archiveId); msg.channel.send(`Link: ${archiveUrl}`); }, }); // ================================================ FILE: backend/src/plugins/BotControl/commands/DisallowServerCmd.ts ================================================ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isStaffPreFilter } from "../../../pluginUtils.js"; import { noop } from "../../../utils.js"; import { botControlCmd } from "../types.js"; export const DisallowServerCmd = botControlCmd({ trigger: ["disallow_server", "disallowserver", "remove_server", "removeserver"], permission: null, config: { preFilters: [isStaffPreFilter], }, signature: { guildId: ct.string(), }, async run({ pluginData, message: msg, args }) { const existing = await pluginData.state.allowedGuilds.find(args.guildId); if (!existing) { void msg.channel.send("That server is not allowed in the first place!"); return; } await pluginData.state.allowedGuilds.remove(args.guildId); await pluginData.client.guilds.cache .get(args.guildId as Snowflake) ?.leave() .catch(noop); void msg.channel.send("Server removed!"); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/EligibleCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isGuildInvite, resolveInvite } from "../../../utils.js"; import { isEligible } from "../functions/isEligible.js"; import { botControlCmd } from "../types.js"; export const EligibleCmd = botControlCmd({ trigger: ["eligible", "is_eligible", "iseligible"], permission: "can_eligible", signature: { user: ct.resolvedUser(), inviteCode: ct.string(), }, async run({ pluginData, message: msg, args }) { const invite = await resolveInvite(pluginData.client, args.inviteCode, true); if (!invite || !isGuildInvite(invite)) { void msg.channel.send("Could not resolve invite"); return; } const { result, explanation } = await isEligible(pluginData, args.user, invite); if (result) { void msg.channel.send(`Server is eligible: ${explanation}`); return; } void msg.channel.send(`Server is **NOT** eligible: ${explanation}`); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/LeaveServerCmd.ts ================================================ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isStaffPreFilter } from "../../../pluginUtils.js"; import { botControlCmd } from "../types.js"; export const LeaveServerCmd = botControlCmd({ trigger: ["leave_server", "leave_guild"], permission: null, config: { preFilters: [isStaffPreFilter], }, signature: { guildId: ct.string(), }, async run({ pluginData, message: msg, args }) { if (!pluginData.client.guilds.cache.has(args.guildId as Snowflake)) { void msg.channel.send("I am not in that guild"); return; } const guildToLeave = await pluginData.client.guilds.fetch(args.guildId as Snowflake)!; const guildName = guildToLeave.name; try { await pluginData.client.guilds.cache.get(args.guildId as Snowflake)?.leave(); } catch (e) { void msg.channel.send(`Failed to leave guild: ${e.message}`); return; } void msg.channel.send(`Left guild **${guildName}**`); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { AllowedGuild } from "../../../data/entities/AllowedGuild.js"; import { ApiPermissionAssignment } from "../../../data/entities/ApiPermissionAssignment.js"; import { renderUsername, resolveUser } from "../../../utils.js"; import { botControlCmd } from "../types.js"; export const ListDashboardPermsCmd = botControlCmd({ trigger: ["list_dashboard_permissions", "list_dashboard_perms", "list_dash_permissions", "list_dash_perms"], permission: "can_list_dashboard_perms", signature: { guildId: ct.string({ option: true, shortcut: "g" }), user: ct.resolvedUser({ option: true, shortcut: "u" }), }, async run({ pluginData, message: msg, args }) { if (!args.user && !args.guildId) { void msg.channel.send("Must specify at least guildId, user, or both."); return; } let guild: AllowedGuild | null = null; if (args.guildId) { guild = await pluginData.state.allowedGuilds.find(args.guildId); if (!guild) { void msg.channel.send("Server is not using Zeppelin"); return; } } let existingUserAssignment: ApiPermissionAssignment[]; if (args.user) { existingUserAssignment = await pluginData.state.apiPermissionAssignments.getByUserId(args.user.id); if (existingUserAssignment.length === 0) { void msg.channel.send("The user has no assigned permissions."); return; } } let finalMessage = ""; // If we have user, always display which guilds they have permissions in (or only specified guild permissions) if (args.user) { const userInfo = `**${renderUsername(args.user)}** (\`${args.user.id}\`)`; for (const assignment of existingUserAssignment!) { if (guild != null && assignment.guild_id !== args.guildId) continue; const assignmentGuild = await pluginData.state.allowedGuilds.find(assignment.guild_id); const guildName = assignmentGuild?.name ?? "Unknown"; const guildInfo = `**${guildName}** (\`${assignment.guild_id}\`)`; finalMessage += `The user ${userInfo} has the following permissions on server ${guildInfo}:`; finalMessage += `\n${assignment.permissions.join("\n")}\n\n`; } if (finalMessage === "") { msg.channel.send(`The user ${userInfo} has no assigned permissions on the specified server.`); return; } // Else display all users that have permissions on the specified guild } else if (guild) { const guildInfo = `**${guild.name}** (\`${guild.id}\`)`; const existingGuildAssignment = await pluginData.state.apiPermissionAssignments.getByGuildId(guild.id); if (existingGuildAssignment.length === 0) { msg.channel.send(`The server ${guildInfo} has no assigned permissions.`); return; } finalMessage += `The server ${guildInfo} has the following assigned permissions:\n`; // Double \n for consistency with AddDashboardUserCmd for (const assignment of existingGuildAssignment) { const user = await resolveUser(pluginData.client, assignment.target_id, "BotControl:ListDashboardPermsCmd"); finalMessage += `\n**${renderUsername(user)}**, \`${assignment.target_id}\`: ${assignment.permissions.join( ", ", )}`; } } await msg.channel.send({ content: finalMessage.trim(), allowedMentions: {}, }); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { renderUsername, resolveUser } from "../../../utils.js"; import { botControlCmd } from "../types.js"; export const ListDashboardUsersCmd = botControlCmd({ trigger: ["list_dashboard_users"], permission: "can_list_dashboard_perms", signature: { guildId: ct.string(), }, async run({ pluginData, message: msg, args }) { const guild = await pluginData.state.allowedGuilds.find(args.guildId); if (!guild) { void msg.channel.send("Server is not using Zeppelin"); return; } const dashboardUsers = await pluginData.state.apiPermissionAssignments.getByGuildId(guild.id); const users = await Promise.all( dashboardUsers.map(async (perm) => ({ user: await resolveUser(pluginData.client, perm.target_id, "BotControl:ListDashboardUsersCmd"), permission: perm, })), ); const userNameList = users.map( ({ user, permission }) => `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`): ${permission.permissions.join(", ")}`, ); msg.channel.send({ content: `The following users have dashboard access for **${guild.name}**:\n\n${userNameList.join("\n")}`, allowedMentions: {}, }); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/ProfilerDataCmd.ts ================================================ import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { GuildArchives } from "../../../data/GuildArchives.js"; import { getBaseUrl } from "../../../pluginUtils.js"; import { sorter } from "../../../utils.js"; import { botControlCmd } from "../types.js"; const sortProps = { totalTime: "TOTAL TIME", averageTime: "AVERAGE TIME", count: "SAMPLES", }; export const ProfilerDataCmd = botControlCmd({ trigger: ["profiler_data"], permission: "can_performance", signature: { filter: ct.string({ required: false }), sort: ct.string({ option: true, required: false }), }, async run({ pluginData, message: msg, args }) { if (args.sort === "samples") { args.sort = "count"; } const sortProp = args.sort && sortProps[args.sort] ? args.sort : "totalTime"; const headerInfoItems = [`sorted by ${sortProps[sortProp]}`]; const profilerData = pluginData.getVetyInstance().profiler.getData(); let entries = Object.entries(profilerData); entries.sort(sorter((entry) => entry[1][sortProp], "DESC")); if (args.filter) { entries = entries.filter(([key]) => key.includes(args.filter)); headerInfoItems.push(`matching "${args.filter}"`); } const formattedEntries = entries.map(([key, data]) => { const dataLines = [ ["Total time", `${Math.round(data.totalTime)} ms`], ["Average time", `${Math.round(data.averageTime)} ms`], ["Samples", data.count], ]; return `${key}\n${dataLines.map((v) => ` ${v[0]}: ${v[1]}`).join("\n")}`; }); const formatted = `Profiler data, ${headerInfoItems.join(", ")}:\n\n${formattedEntries.join("\n\n")}`; const archives = GuildArchives.getGuildInstance("0"); const archiveId = await archives.create(formatted, moment().add(1, "hour")); const archiveUrl = archives.getUrl(getBaseUrl(pluginData), archiveId); msg.channel.send(`Link: ${archiveUrl}`); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts ================================================ import moment from "moment-timezone"; import { GuildArchives } from "../../../data/GuildArchives.js"; import { getBaseUrl } from "../../../pluginUtils.js"; import { getRateLimitStats } from "../../../rateLimitStats.js"; import { botControlCmd } from "../types.js"; export const RateLimitPerformanceCmd = botControlCmd({ trigger: ["rate_limit_performance"], permission: "can_performance", signature: {}, async run({ pluginData, message: msg }) { const logItems = getRateLimitStats(); if (logItems.length === 0) { void msg.channel.send(`No rate limits hit`); return; } logItems.reverse(); const formatted = logItems.map((item) => { const formattedTime = moment.utc(item.timestamp).format("YYYY-MM-DD HH:mm:ss.SSS"); const items: string[] = [`[${formattedTime}]`]; if (item.data.global) items.push("GLOBAL"); items.push(item.data.method.toUpperCase()); items.push(item.data.route); items.push(`stalled for ${item.data.timeToReset}ms`); items.push(`(max requests ${item.data.limit})`); return items.join(" "); }); const fullText = `Last ${logItems.length} rate limits hit:\n\n${formatted.join("\n")}`; const archives = GuildArchives.getGuildInstance("0"); const archiveId = await archives.create(fullText, moment().add(1, "hour")); const archiveUrl = archives.getUrl(getBaseUrl(pluginData), archiveId); msg.channel.send(`Link: ${archiveUrl}`); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/ReloadGlobalPluginsCmd.ts ================================================ import { isStaffPreFilter } from "../../../pluginUtils.js"; import { getActiveReload, setActiveReload } from "../activeReload.js"; import { botControlCmd } from "../types.js"; export const ReloadGlobalPluginsCmd = botControlCmd({ trigger: "bot_reload_global_plugins", permission: null, config: { preFilters: [isStaffPreFilter], }, async run({ pluginData, message }) { if (getActiveReload()) return; const guildId = "guild" in message.channel ? message.channel.guild.id : null; if (!guildId) { void message.channel.send("This command can only be used in a server"); return; } setActiveReload(guildId, message.channel.id); await message.channel.send("Reloading global plugins..."); pluginData.getVetyInstance().reloadGlobalContext(); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/ReloadServerCmd.ts ================================================ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isStaffPreFilter } from "../../../pluginUtils.js"; import { botControlCmd } from "../types.js"; export const ReloadServerCmd = botControlCmd({ trigger: ["reload_server", "reload_guild"], permission: null, config: { preFilters: [isStaffPreFilter], }, signature: { guildId: ct.anyId(), }, async run({ pluginData, message: msg, args }) { if (!pluginData.client.guilds.cache.has(args.guildId as Snowflake)) { void msg.channel.send("I am not in that guild"); return; } try { await pluginData.getVetyInstance().reloadGuild(args.guildId); } catch (e) { void msg.channel.send(`Failed to reload guild: ${e.message}`); return; } const guild = await pluginData.client.guilds.fetch(args.guildId as Snowflake); void msg.channel.send(`Reloaded guild **${guild?.name || "???"}**`); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isStaffPreFilter } from "../../../pluginUtils.js"; import { renderUsername } from "../../../utils.js"; import { botControlCmd } from "../types.js"; export const RemoveDashboardUserCmd = botControlCmd({ trigger: ["remove_dashboard_user"], permission: null, config: { preFilters: [isStaffPreFilter], }, signature: { guildId: ct.string(), users: ct.user({ rest: true }), }, async run({ pluginData, message: msg, args }) { const guild = await pluginData.state.allowedGuilds.find(args.guildId); if (!guild) { void msg.channel.send("Server is not using Zeppelin"); return; } for (const user of args.users) { const existingAssignment = await pluginData.state.apiPermissionAssignments.getByGuildAndUserId( args.guildId, user.id, ); if (!existingAssignment) { continue; } await pluginData.state.apiPermissionAssignments.removeUser(args.guildId, user.id); } const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`)`); msg.channel.send(`The following users were removed from the dashboard for **${guild.name}**:\n\n${userNameList}`); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/RestPerformanceCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { getTopRestCallStats } from "../../../restCallStats.js"; import { createChunkedMessage } from "../../../utils.js"; import { botControlCmd } from "../types.js"; const leadingPathRegex = /(?<=\().+\/backend\//g; export const RestPerformanceCmd = botControlCmd({ trigger: ["rest_performance"], permission: "can_performance", signature: { count: ct.number({ required: false }), }, async run({ message: msg, args }) { const count = Math.max(1, Math.min(25, args.count || 5)); const stats = getTopRestCallStats(count); const formatted = stats.map((callStats) => { const cleanSource = callStats.source.replace(leadingPathRegex, ""); return `**${callStats.count} calls**\n${callStats.method.toUpperCase()} ${callStats.path}\n${cleanSource}`; }); createChunkedMessage(msg.channel, `Top rest calls:\n\n${formatted.join("\n")}`); }, }); ================================================ FILE: backend/src/plugins/BotControl/commands/ServersCmd.ts ================================================ import escapeStringRegexp from "escape-string-regexp"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isStaffPreFilter } from "../../../pluginUtils.js"; import { createChunkedMessage, getUser, renderUsername, sorter } from "../../../utils.js"; import { botControlCmd } from "../types.js"; export const ServersCmd = botControlCmd({ trigger: ["servers", "guilds"], permission: null, config: { preFilters: [isStaffPreFilter], }, signature: { search: ct.string({ catchAll: true, required: false }), all: ct.switchOption({ def: false, shortcut: "a" }), initialized: ct.switchOption({ def: false, shortcut: "i" }), uninitialized: ct.switchOption({ def: false, shortcut: "u" }), }, async run({ pluginData, message: msg, args }) { const showList = Boolean(args.all || args.initialized || args.uninitialized || args.search); const search = args.search ? new RegExp([...args.search].map((s) => escapeStringRegexp(s)).join(".*"), "i") : null; const joinedGuilds = Array.from(pluginData.client.guilds.cache.values()); const loadedGuilds = pluginData.getVetyInstance().getLoadedGuilds(); const loadedGuildsMap = loadedGuilds.reduce((map, guildData) => map.set(guildData.guildId, guildData), new Map()); if (showList) { let filteredGuilds = Array.from(joinedGuilds); if (args.initialized) { filteredGuilds = filteredGuilds.filter((g) => loadedGuildsMap.has(g.id)); } if (args.uninitialized) { filteredGuilds = filteredGuilds.filter((g) => !loadedGuildsMap.has(g.id)); } if (args.search) { filteredGuilds = filteredGuilds.filter((g) => search!.test(`${g.id} ${g.name}`)); } if (filteredGuilds.length) { filteredGuilds.sort(sorter((g) => g.name.toLowerCase())); const longestId = filteredGuilds.reduce((longest, guild) => Math.max(longest, guild.id.length), 0); const lines = filteredGuilds.map((g) => { const paddedId = g.id.padEnd(longestId, " "); const owner = getUser(pluginData.client, g.ownerId); return `\`${paddedId}\` **${g.name}** (${g.memberCount} members) (owner **${renderUsername(owner)}** \`${ owner.id }\`)`; }); createChunkedMessage(msg.channel, lines.join("\n")); } else { msg.channel.send("No servers matched the filters"); } } else { const total = joinedGuilds.length; const initialized = joinedGuilds.filter((g) => loadedGuildsMap.has(g.id)).length; const unInitialized = total - initialized; msg.channel.send( `I am on **${total} total servers**, of which **${initialized} are initialized** and **${unInitialized} are not initialized**`, ); } }, }); ================================================ FILE: backend/src/plugins/BotControl/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zBotControlConfig } from "./types.js"; export const botControlPluginDocs: ZeppelinPluginDocs = { type: "stable", configSchema: zBotControlConfig, prettyName: "Bot control", description: trimPluginDescription(` Contains commands to manage allowed servers `), }; ================================================ FILE: backend/src/plugins/BotControl/functions/isEligible.ts ================================================ import { User } from "discord.js"; import { GlobalPluginData } from "vety"; import { GuildInvite } from "../../../utils.js"; import { BotControlPluginType } from "../types.js"; const REQUIRED_MEMBER_COUNT = 5000; export async function isEligible( pluginData: GlobalPluginData, user: User, invite: GuildInvite, ): Promise<{ result: boolean; explanation: string }> { if ((await pluginData.state.apiPermissionAssignments.getByUserId(user.id)).length) { return { result: true, explanation: "User is an existing bot operator", }; } if (invite.guild.features.includes("PARTNERED")) { return { result: true, explanation: "Server is partnered", }; } if (invite.guild.features.includes("VERIFIED")) { return { result: true, explanation: "Server is verified", }; } const memberCount = invite.memberCount || 0; if (memberCount >= REQUIRED_MEMBER_COUNT) { return { result: true, explanation: `Server has ${memberCount} members, which is equal or higher than the required ${REQUIRED_MEMBER_COUNT}`, }; } return { result: false, explanation: "Server does not meet requirements", }; } ================================================ FILE: backend/src/plugins/BotControl/types.ts ================================================ import { BasePluginType, globalPluginEventListener, globalPluginMessageCommand } from "vety"; import { z } from "zod"; import { AllowedGuilds } from "../../data/AllowedGuilds.js"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments.js"; import { Configs } from "../../data/Configs.js"; import { GuildArchives } from "../../data/GuildArchives.js"; import { zBoundedCharacters } from "../../utils.js"; export const zBotControlConfig = z.strictObject({ can_use: z.boolean().default(false), can_eligible: z.boolean().default(false), can_performance: z.boolean().default(false), can_add_server_from_invite: z.boolean().default(false), can_list_dashboard_perms: z.boolean().default(false), update_cmd: zBoundedCharacters(0, 2000).nullable().default(null), }); export interface BotControlPluginType extends BasePluginType { configSchema: typeof zBotControlConfig; state: { archives: GuildArchives; allowedGuilds: AllowedGuilds; apiPermissionAssignments: ApiPermissionAssignments; configs: Configs; }; } export const botControlCmd = globalPluginMessageCommand(); export const botControlEvt = globalPluginEventListener(); ================================================ FILE: backend/src/plugins/Cases/CasesPlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { makePublicFn } from "../../pluginUtils.js"; import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { createCase } from "./functions/createCase.js"; import { createCaseNote } from "./functions/createCaseNote.js"; import { getCaseEmbed } from "./functions/getCaseEmbed.js"; import { getCaseSummary } from "./functions/getCaseSummary.js"; import { getCaseTypeAmountForUserId } from "./functions/getCaseTypeAmountForUserId.js"; import { getRecentCasesByMod } from "./functions/getRecentCasesByMod.js"; import { getTotalCasesByMod } from "./functions/getTotalCasesByMod.js"; import { postCaseToCaseLogChannel } from "./functions/postToCaseLogChannel.js"; import { CasesPluginType, zCasesConfig } from "./types.js"; // The `any` cast here is to prevent TypeScript from locking up from the circular dependency function getLogsPlugin(): Promise { return import("../Logs/LogsPlugin.js") as Promise; } export const CasesPlugin = guildPlugin()({ name: "cases", dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getLogsPlugin()).LogsPlugin], configSchema: zCasesConfig, public(pluginData) { return { createCase: makePublicFn(pluginData, createCase), createCaseNote: makePublicFn(pluginData, createCaseNote), postCaseToCaseLogChannel: makePublicFn(pluginData, postCaseToCaseLogChannel), getCaseTypeAmountForUserId: makePublicFn(pluginData, getCaseTypeAmountForUserId), getTotalCasesByMod: makePublicFn(pluginData, getTotalCasesByMod), getRecentCasesByMod: makePublicFn(pluginData, getRecentCasesByMod), getCaseEmbed: makePublicFn(pluginData, getCaseEmbed), getCaseSummary: makePublicFn(pluginData, getCaseSummary), }; }, afterLoad(pluginData) { const { state, guild } = pluginData; state.logs = new GuildLogs(pluginData.guild.id); state.archives = GuildArchives.getGuildInstance(guild.id); state.cases = GuildCases.getGuildInstance(guild.id); }, }); ================================================ FILE: backend/src/plugins/Cases/caseAbbreviations.ts ================================================ import { CaseTypes } from "../../data/CaseTypes.js"; export const caseAbbreviations = { [CaseTypes.Ban]: "BAN", [CaseTypes.Unban]: "UNBN", [CaseTypes.Note]: "NOTE", [CaseTypes.Warn]: "WARN", [CaseTypes.Kick]: "KICK", [CaseTypes.Mute]: "MUTE", [CaseTypes.Unmute]: "UNMT", [CaseTypes.Deleted]: "DEL", [CaseTypes.Softban]: "SFTB", }; ================================================ FILE: backend/src/plugins/Cases/caseColors.ts ================================================ import { CaseTypes } from "../../data/CaseTypes.js"; export const caseColors: Record = { [CaseTypes.Ban]: 0xcb4314, [CaseTypes.Unban]: 0x9b59b6, [CaseTypes.Note]: 0x3498db, [CaseTypes.Warn]: 0xdae622, [CaseTypes.Mute]: 0xe6b122, [CaseTypes.Unmute]: 0xa175b3, [CaseTypes.Kick]: 0xe67e22, [CaseTypes.Deleted]: 0x000000, [CaseTypes.Softban]: 0xe67e22, }; ================================================ FILE: backend/src/plugins/Cases/caseIcons.ts ================================================ import { CaseTypes } from "../../data/CaseTypes.js"; // These emoji icons are hosted on the Hangar server // If you'd like your self-hosted instance to use these icons, check #add-your-bot on that server export const caseIcons: Record = { [CaseTypes.Ban]: "<:case_ban:906897178176393246>", [CaseTypes.Unban]: "<:case_unban:906897177824067665>", [CaseTypes.Note]: "<:case_note:906897177832476743>", [CaseTypes.Warn]: "<:case_warn:906897177840844832>", [CaseTypes.Kick]: "<:case_kick:906897178310639646>", [CaseTypes.Mute]: "<:case_mute:906897178147057664>", [CaseTypes.Unmute]: "<:case_unmute:906897177819881523>", [CaseTypes.Deleted]: "<:case_deleted:906897178209968148>", [CaseTypes.Softban]: "<:case_softban:906897177828278274>", }; ================================================ FILE: backend/src/plugins/Cases/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zCasesConfig } from "./types.js"; export const casesPluginDocs: ZeppelinPluginDocs = { type: "stable", configSchema: zCasesConfig, prettyName: "Cases", description: trimPluginDescription(` This plugin contains basic configuration for cases created by other plugins `), }; ================================================ FILE: backend/src/plugins/Cases/functions/createCase.ts ================================================ import type { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { logger } from "../../../logger.js"; import { renderUsername, resolveUser } from "../../../utils.js"; import { CaseArgs, CasesPluginType } from "../types.js"; import { createCaseNote } from "./createCaseNote.js"; import { postCaseToCaseLogChannel } from "./postToCaseLogChannel.js"; export async function createCase(pluginData: GuildPluginData, args: CaseArgs) { const user = await resolveUser(pluginData.client, args.userId, "Cases:createCase"); const name = renderUsername(user); const mod = await resolveUser(pluginData.client, args.modId, "Cases:createCase"); const modName = renderUsername(mod); let ppName: string | null = null; let ppId: Snowflake | null = null; if (args.ppId) { const pp = await resolveUser(pluginData.client, args.ppId, "Cases:createCase"); ppName = renderUsername(pp); ppId = pp.id; } if (args.auditLogId) { const existingAuditLogCase = await pluginData.state.cases.findByAuditLogId(args.auditLogId); if (existingAuditLogCase) { delete args.auditLogId; logger.warn(`Duplicate audit log ID for mod case: ${args.auditLogId}`); } } const createdCase = await pluginData.state.cases.create({ type: args.type, user_id: user.id, user_name: name, mod_id: mod.id, mod_name: modName, audit_log_id: args.auditLogId, pp_id: ppId, pp_name: ppName, is_hidden: Boolean(args.hide), }); if (args.reason || args.noteDetails?.length) { await createCaseNote(pluginData, { caseId: createdCase.id, modId: mod.id, body: args.reason || "", automatic: args.automatic, postInCaseLogOverride: false, noteDetails: args.noteDetails, }); } if (args.extraNotes) { for (const extraNote of args.extraNotes) { await createCaseNote(pluginData, { caseId: createdCase.id, modId: mod.id, body: extraNote, automatic: args.automatic, postInCaseLogOverride: false, }); } } const config = pluginData.config.get(); const shouldPostToCaseLogChannel = args.postInCaseLogOverride === true || ((!args.automatic || config.log_automatic_actions) && args.postInCaseLogOverride !== false); if (config.case_log_channel && shouldPostToCaseLogChannel) { await postCaseToCaseLogChannel(pluginData, createdCase); } return createdCase; } ================================================ FILE: backend/src/plugins/Cases/functions/createCaseNote.ts ================================================ import { GuildPluginData } from "vety"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; import { UnknownUser, renderUsername, resolveUser } from "../../../utils.js"; import { CaseNoteArgs, CasesPluginType } from "../types.js"; import { postCaseToCaseLogChannel } from "./postToCaseLogChannel.js"; import { resolveCaseId } from "./resolveCaseId.js"; export async function createCaseNote(pluginData: GuildPluginData, args: CaseNoteArgs): Promise { const theCase = await pluginData.state.cases.find(resolveCaseId(args.caseId)); if (!theCase) { throw new RecoverablePluginError(ERRORS.UNKNOWN_NOTE_CASE); } const mod = await resolveUser(pluginData.client, args.modId, "Cases:createCaseNote"); if (mod instanceof UnknownUser) { throw new RecoverablePluginError(ERRORS.INVALID_USER); } const modName = renderUsername(mod); let body = args.body; // Add note details to the beginning of the note if (args.noteDetails && args.noteDetails.length) { body = args.noteDetails.map((d) => `__[${d}]__`).join(" ") + " " + body; } await pluginData.state.cases.createNote(theCase.id, { mod_id: mod.id, mod_name: modName, body: body || "", }); if (theCase.mod_id == null) { // If the case has no moderator information, assume the first one to add a note to it did the action await pluginData.state.cases.update(theCase.id, { mod_id: mod.id, mod_name: modName, }); } const archiveLinkMatch = body && body.match(/(?<=\/archives\/)[a-zA-Z0-9-]+/g); if (archiveLinkMatch) { for (const archiveId of archiveLinkMatch) { pluginData.state.archives.makePermanent(archiveId); } } const modConfig = await pluginData.config.getForUser(mod); if ( args.postInCaseLogOverride === true || ((!args.automatic || modConfig.log_automatic_actions) && args.postInCaseLogOverride !== false) ) { await postCaseToCaseLogChannel(pluginData, theCase.id); } } ================================================ FILE: backend/src/plugins/Cases/functions/getCaseColor.ts ================================================ import { GuildPluginData } from "vety"; import { CaseTypes, CaseTypeToName } from "../../../data/CaseTypes.js"; import { caseColors } from "../caseColors.js"; import { CasesPluginType } from "../types.js"; export function getCaseColor(pluginData: GuildPluginData, caseType: CaseTypes) { return pluginData.config.get().case_colors?.[CaseTypeToName[caseType]] ?? caseColors[caseType]; } ================================================ FILE: backend/src/plugins/Cases/functions/getCaseEmbed.ts ================================================ import { escapeCodeBlock, InteractionEditReplyOptions, InteractionReplyOptions, MessageCreateOptions, MessageEditOptions, } from "discord.js"; import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { Case } from "../../../data/entities/Case.js"; import { chunkMessageLines, emptyEmbedValue, messageLink } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { CasesPluginType } from "../types.js"; import { getCaseColor } from "./getCaseColor.js"; import { resolveCaseId } from "./resolveCaseId.js"; export async function getCaseEmbed( pluginData: GuildPluginData, caseOrCaseId: Case | number, requestMemberId?: string, noOriginalCaseLink?: boolean, ): Promise { const theCase = await pluginData.state.cases.with("notes").find(resolveCaseId(caseOrCaseId)); if (!theCase) { throw new Error("Unknown case"); } const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); const createdAt = moment.utc(theCase.created_at); const actionTypeStr = CaseTypes[theCase.type].toUpperCase(); let userName = theCase.user_name; if (theCase.user_id && theCase.user_id !== "0") userName += `\n<@!${theCase.user_id}>`; let modName = theCase.mod_name; if (theCase.mod_id) modName += `\n<@!${theCase.mod_id}>`; const createdAtWithTz = requestMemberId ? await timeAndDate.inMemberTz(requestMemberId, createdAt) : timeAndDate.inGuildTz(createdAt); const embed: any = { title: `${actionTypeStr} - Case #${theCase.case_number}`, footer: { text: `Case created on ${createdAtWithTz.format(timeAndDate.getDateFormat("pretty_datetime"))}`, }, fields: [ { name: "User", value: userName, inline: true, }, { name: "Moderator", value: modName, inline: true, }, ], }; if (theCase.pp_id) { embed.fields[1].value += `\np.p. ${theCase.pp_name}\n<@!${theCase.pp_id}>`; } if (theCase.is_hidden) { embed.title += " (hidden)"; } embed.color = getCaseColor(pluginData, theCase.type); if (theCase.notes.length) { for (const note of theCase.notes) { const noteDate = moment.utc(note.created_at); let noteBody = escapeCodeBlock(note.body.trim()); if (noteBody === "") { noteBody = emptyEmbedValue; } const chunks = chunkMessageLines(noteBody, 1014); for (let i = 0; i < chunks.length; i++) { if (i === 0) { const noteDateWithTz = requestMemberId ? await timeAndDate.inMemberTz(requestMemberId, noteDate) : timeAndDate.inGuildTz(noteDate); const prettyNoteDate = noteDateWithTz.format(timeAndDate.getDateFormat("pretty_datetime")); embed.fields.push({ name: `${note.mod_name} at ${prettyNoteDate}:`, value: chunks[i], }); } else { embed.fields.push({ name: emptyEmbedValue, value: chunks[i], }); } } } } else { embed.fields.push({ name: "!!! THIS CASE HAS NO NOTES !!!", value: "\u200B", }); } if (theCase.log_message_id && noOriginalCaseLink !== false) { const [channelId, messageId] = theCase.log_message_id.split("-"); const link = messageLink(pluginData.guild.id, channelId, messageId); embed.fields.push({ name: emptyEmbedValue, value: `[Go to original case in case log channel](${link})`, }); } return { embeds: [embed] }; } ================================================ FILE: backend/src/plugins/Cases/functions/getCaseIcon.ts ================================================ import { GuildPluginData } from "vety"; import { CaseTypes, CaseTypeToName } from "../../../data/CaseTypes.js"; import { caseIcons } from "../caseIcons.js"; import { CasesPluginType } from "../types.js"; export function getCaseIcon(pluginData: GuildPluginData, caseType: CaseTypes) { return pluginData.config.get().case_icons?.[CaseTypeToName[caseType]] ?? caseIcons[caseType]; } ================================================ FILE: backend/src/plugins/Cases/functions/getCaseSummary.ts ================================================ import { GuildPluginData } from "vety"; import { splitMessageIntoChunks } from "vety/helpers"; import moment from "moment-timezone"; import { Case } from "../../../data/entities/Case.js"; import { convertDelayStringToMS, DBDateFormat, messageLink } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { caseAbbreviations } from "../caseAbbreviations.js"; import { CasesPluginType } from "../types.js"; import { getCaseIcon } from "./getCaseIcon.js"; const CASE_SUMMARY_REASON_MAX_LENGTH = 300; const INCLUDE_MORE_NOTES_THRESHOLD = 20; const UPDATE_STR = "**[Update]**"; export async function getCaseSummary( pluginData: GuildPluginData, caseOrCaseId: Case | number, withLinks = false, requestMemberId?: string, ): Promise { const config = pluginData.config.get(); const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); const caseId = caseOrCaseId instanceof Case ? caseOrCaseId.id : caseOrCaseId; const theCase = await pluginData.state.cases.with("notes").find(caseId); if (!theCase) return null; const firstNote = theCase.notes[0]; let reason = firstNote ? firstNote.body : ""; let leftoverNotes = Math.max(0, theCase.notes.length - 1); for (let i = 1; i < theCase.notes.length; i++) { if (reason.length >= CASE_SUMMARY_REASON_MAX_LENGTH - UPDATE_STR.length - INCLUDE_MORE_NOTES_THRESHOLD) break; reason += ` ${UPDATE_STR} ${theCase.notes[i].body}`; leftoverNotes--; } if (reason.length > CASE_SUMMARY_REASON_MAX_LENGTH) { const match = reason.slice(CASE_SUMMARY_REASON_MAX_LENGTH, 100).match(/(?:[.,!?\s]|$)/); const nextWhitespaceIndex = match ? CASE_SUMMARY_REASON_MAX_LENGTH + match.index! : CASE_SUMMARY_REASON_MAX_LENGTH; const reasonChunks = splitMessageIntoChunks(reason, nextWhitespaceIndex); reason = reasonChunks[0] + "..."; } const timestamp = moment.utc(theCase.created_at, DBDateFormat); const relativeTimeCutoff = convertDelayStringToMS(config.relative_time_cutoff)!; const useRelativeTime = config.show_relative_times && Date.now() - timestamp.valueOf() < relativeTimeCutoff; const timestampWithTz = requestMemberId ? await timeAndDate.inMemberTz(requestMemberId, timestamp) : timeAndDate.inGuildTz(timestamp); const prettyTimestamp = useRelativeTime ? moment.utc().to(timestamp) : timestampWithTz.format(timeAndDate.getDateFormat("date")); const icon = getCaseIcon(pluginData, theCase.type); let caseTitle = `\`#${theCase.case_number}\``; if (withLinks && theCase.log_message_id) { const [channelId, messageId] = theCase.log_message_id.split("-"); caseTitle = `[${caseTitle}](${messageLink(pluginData.guild.id, channelId, messageId)})`; } else { caseTitle = `\`${caseTitle}\``; } let caseType = (caseAbbreviations[theCase.type] || String(theCase.type)).toUpperCase(); caseType = (caseType + " ").slice(0, 4); let line = `${icon} **\`${caseType}\`** \`[${prettyTimestamp}]\` ${caseTitle} ${reason}`; if (leftoverNotes > 1) { line += ` *(+${leftoverNotes} ${leftoverNotes === 1 ? "note" : "notes"})*`; } if (theCase.is_hidden) { line += " *(hidden)*"; } return line.trim(); } ================================================ FILE: backend/src/plugins/Cases/functions/getCaseTypeAmountForUserId.ts ================================================ import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { CasesPluginType } from "../types.js"; export async function getCaseTypeAmountForUserId( pluginData: GuildPluginData, userID: string, type: CaseTypes, ): Promise { const cases = (await pluginData.state.cases.getByUserId(userID)).filter((c) => !c.is_hidden); let typeAmount = 0; if (cases.length > 0) { cases.forEach((singleCase) => { if (singleCase.type === type.valueOf()) { typeAmount++; } }); } return typeAmount; } ================================================ FILE: backend/src/plugins/Cases/functions/getRecentCasesByMod.ts ================================================ import { GuildPluginData } from "vety"; import { FindOptionsWhere } from "typeorm"; import { Case } from "../../../data/entities/Case.js"; import { CasesPluginType } from "../types.js"; export function getRecentCasesByMod( pluginData: GuildPluginData, modId: string, count: number, skip = 0, filters: Omit, "guild_id" | "mod_id" | "is_hidden"> = {}, ): Promise { return pluginData.state.cases.getRecentByModId(modId, count, skip, filters); } ================================================ FILE: backend/src/plugins/Cases/functions/getTotalCasesByMod.ts ================================================ import { GuildPluginData } from "vety"; import { FindOptionsWhere } from "typeorm"; import { Case } from "../../../data/entities/Case.js"; import { CasesPluginType } from "../types.js"; export function getTotalCasesByMod( pluginData: GuildPluginData, modId: string, filters: Omit, "guild_id" | "mod_id" | "is_hidden"> = {}, ): Promise { return pluginData.state.cases.getTotalCasesByModId(modId, filters); } ================================================ FILE: backend/src/plugins/Cases/functions/postToCaseLogChannel.ts ================================================ import { MessageCreateOptions, NewsChannel, RESTJSONErrorCodes, Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { Case } from "../../../data/entities/Case.js"; import { isDiscordAPIError } from "../../../utils.js"; import { InternalPosterPlugin } from "../../InternalPoster/InternalPosterPlugin.js"; import { InternalPosterMessageResult } from "../../InternalPoster/functions/sendMessage.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { CasesPluginType } from "../types.js"; import { getCaseEmbed } from "./getCaseEmbed.js"; import { resolveCaseId } from "./resolveCaseId.js"; export async function postToCaseLogChannel( pluginData: GuildPluginData, content: MessageCreateOptions, files?: MessageCreateOptions["files"], ): Promise { const caseLogChannelId = pluginData.config.get().case_log_channel; if (!caseLogChannelId) return null; const caseLogChannel = pluginData.guild.channels.cache.get(caseLogChannelId as Snowflake); // This doesn't use `!isText() || isThread()` because TypeScript had some issues inferring types from it if (!caseLogChannel || !(caseLogChannel instanceof TextChannel || caseLogChannel instanceof NewsChannel)) return null; let result: InternalPosterMessageResult | null = null; try { if (files != null) { content.files = files; } const poster = pluginData.getPlugin(InternalPosterPlugin); result = await poster.sendMessage(caseLogChannel, { ...content }); } catch (e) { if ( isDiscordAPIError(e) && (e.code === RESTJSONErrorCodes.MissingPermissions || e.code === RESTJSONErrorCodes.MissingAccess) ) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions to post mod cases in <#${caseLogChannel.id}>`, }); return null; } throw e; } return result; } export async function postCaseToCaseLogChannel( pluginData: GuildPluginData, caseOrCaseId: Case | number, ): Promise { const theCase = await pluginData.state.cases.find(resolveCaseId(caseOrCaseId)); if (!theCase) return; const caseEmbed = await getCaseEmbed(pluginData, caseOrCaseId, undefined, true); if (!caseEmbed) return; if (theCase.log_message_id) { const [channelId, messageId] = theCase.log_message_id.split("-"); try { const poster = pluginData.getPlugin(InternalPosterPlugin); const channel = pluginData.guild.channels.resolve(channelId as Snowflake); if (channel?.isTextBased()) { const message = await channel.messages.fetch(messageId); if (message) { await poster.editMessage(message, caseEmbed); } } return; } catch {} // eslint-disable-line no-empty } try { const postedMessage = await postToCaseLogChannel(pluginData, caseEmbed); if (postedMessage) { await pluginData.state.cases.update(theCase.id, { log_message_id: `${postedMessage.channelId}-${postedMessage.id}`, }); } } catch { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to post case #${theCase.case_number} to the case log channel`, }); return; } } ================================================ FILE: backend/src/plugins/Cases/functions/resolveCaseId.ts ================================================ import { Case } from "../../../data/entities/Case.js"; export function resolveCaseId(caseOrCaseId: Case | number): number { return caseOrCaseId instanceof Case ? caseOrCaseId.id : caseOrCaseId; } ================================================ FILE: backend/src/plugins/Cases/types.ts ================================================ import { BasePluginType } from "vety"; import { U } from "ts-toolbelt"; import { z } from "zod"; import { CaseNameToType, CaseTypes } from "../../data/CaseTypes.js"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { keys, zBoundedCharacters, zDelayString, zSnowflake } from "../../utils.js"; import { zColor } from "../../utils/zColor.js"; const caseKeys = keys(CaseNameToType) as U.ListOf; const caseColorsTypeMap = caseKeys.reduce( (map, key) => { map[key] = zColor; return map; }, {} as Record<(typeof caseKeys)[number], typeof zColor>, ); const caseIconsTypeMap = caseKeys.reduce( (map, key) => { map[key] = zBoundedCharacters(0, 100); return map; }, {} as Record<(typeof caseKeys)[number], z.ZodString>, ); export const zCasesConfig = z.strictObject({ log_automatic_actions: z.boolean().default(true), case_log_channel: zSnowflake.nullable().default(null), show_relative_times: z.boolean().default(true), relative_time_cutoff: zDelayString.default("1w"), case_colors: z.strictObject(caseColorsTypeMap).partial().nullable().default(null), case_icons: z.strictObject(caseIconsTypeMap).partial().nullable().default(null), }); export interface CasesPluginType extends BasePluginType { configSchema: typeof zCasesConfig; state: { logs: GuildLogs; cases: GuildCases; archives: GuildArchives; }; } /** * Can also be used as a config object for functions that create cases */ export type CaseArgs = { userId: string; modId: string; ppId?: string; type: CaseTypes; auditLogId?: string; reason?: string; automatic?: boolean; postInCaseLogOverride?: boolean; noteDetails?: string[]; extraNotes?: string[]; hide?: boolean; }; export type CaseNoteArgs = { caseId: number; modId: string; body: string; automatic?: boolean; postInCaseLogOverride?: boolean; noteDetails?: string[]; }; ================================================ FILE: backend/src/plugins/Censor/CensorPlugin.ts ================================================ import { PluginOverride, guildPlugin } from "vety"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { CensorPluginType, zCensorConfig } from "./types.js"; import { onMessageCreate } from "./util/onMessageCreate.js"; import { onMessageUpdate } from "./util/onMessageUpdate.js"; const defaultOverrides: Array> = [ { level: ">=50", config: { filter_zalgo: false, filter_invites: false, filter_domains: false, blocked_tokens: null, blocked_words: null, blocked_regex: null, }, }, ]; export const CensorPlugin = guildPlugin()({ name: "censor", dependencies: () => [LogsPlugin], configSchema: zCensorConfig, defaultOverrides, beforeLoad(pluginData) { const { state, guild } = pluginData; state.serverLogs = new GuildLogs(guild.id); state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); state.regexRunner = getRegExpRunner(`guild-${pluginData.guild.id}`); }, afterLoad(pluginData) { const { state } = pluginData; state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg); state.savedMessages.events.on("create", state.onMessageCreateFn); state.onMessageUpdateFn = (msg) => onMessageUpdate(pluginData, msg); state.savedMessages.events.on("update", state.onMessageUpdateFn); }, beforeUnload(pluginData) { const { state, guild } = pluginData; discardRegExpRunner(`guild-${guild.id}`); state.savedMessages.events.off("create", state.onMessageCreateFn); state.savedMessages.events.off("update", state.onMessageUpdateFn); }, }); ================================================ FILE: backend/src/plugins/Censor/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zCensorConfig } from "./types.js"; export const censorPluginDocs: ZeppelinPluginDocs = { type: "legacy", configSchema: zCensorConfig, prettyName: "Censor", description: trimPluginDescription(` Censor words, tokens, links, regex, etc. For more advanced filtering, check out the Automod plugin! `), }; ================================================ FILE: backend/src/plugins/Censor/types.ts ================================================ import { BasePluginType } from "vety"; import { z } from "zod"; import { RegExpRunner } from "../../RegExpRunner.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { zBoundedCharacters, zRegex, zSnowflake } from "../../utils.js"; export const zCensorConfig = z.strictObject({ filter_zalgo: z.boolean().default(false), filter_invites: z.boolean().default(false), invite_guild_whitelist: z.array(zSnowflake).nullable().default(null), invite_guild_blacklist: z.array(zSnowflake).nullable().default(null), invite_code_whitelist: z.array(zBoundedCharacters(0, 16)).nullable().default(null), invite_code_blacklist: z.array(zBoundedCharacters(0, 16)).nullable().default(null), allow_group_dm_invites: z.boolean().default(false), filter_domains: z.boolean().default(false), domain_whitelist: z.array(zBoundedCharacters(0, 255)).nullable().default(null), domain_blacklist: z.array(zBoundedCharacters(0, 255)).nullable().default(null), blocked_tokens: z.array(zBoundedCharacters(0, 2000)).nullable().default(null), blocked_words: z.array(zBoundedCharacters(0, 2000)).nullable().default(null), blocked_regex: z .array(zRegex(z.string().max(1000))) .nullable() .default(null), }); export interface CensorPluginType extends BasePluginType { configSchema: typeof zCensorConfig; state: { serverLogs: GuildLogs; savedMessages: GuildSavedMessages; regexRunner: RegExpRunner; onMessageCreateFn; onMessageUpdateFn; }; } ================================================ FILE: backend/src/plugins/Censor/util/applyFiltersToMsg.ts ================================================ import { Invite } from "discord.js"; import escapeStringRegexp from "escape-string-regexp"; import { GuildPluginData } from "vety"; import { allowTimeout } from "../../../RegExpRunner.js"; import { ZalgoRegex } from "../../../data/Zalgo.js"; import { ISavedMessageEmbedData, SavedMessage } from "../../../data/entities/SavedMessage.js"; import { getInviteCodesInString, getUrlsInString, inputPatternToRegExp, isGuildInvite, resolveInvite, resolveMember, } from "../../../utils.js"; import { CensorPluginType } from "../types.js"; import { censorMessage } from "./censorMessage.js"; type ManipulatedEmbedData = Partial; export async function applyFiltersToMsg( pluginData: GuildPluginData, savedMessage: SavedMessage, ): Promise { const member = await resolveMember(pluginData.client, pluginData.guild, savedMessage.user_id); const config = await pluginData.config.getMatchingConfig({ member, channelId: savedMessage.channel_id }); let messageContent = savedMessage.data.content || ""; if (savedMessage.data.attachments) messageContent += " " + JSON.stringify(savedMessage.data.attachments); if (savedMessage.data.embeds) { const embeds = (savedMessage.data.embeds as ManipulatedEmbedData[]).map((e) => structuredClone(e)); for (const embed of embeds) { if (embed.type === "video") { // Ignore video descriptions as they're not actually shown on the embed delete embed.description; } } messageContent += " " + JSON.stringify(embeds); } // Filter zalgo const filterZalgo = config.filter_zalgo; if (filterZalgo) { const result = ZalgoRegex.exec(messageContent); if (result) { censorMessage(pluginData, savedMessage, "zalgo detected"); return true; } } // Filter invites const filterInvites = config.filter_invites; if (filterInvites) { const inviteGuildWhitelist = config.invite_guild_whitelist; const inviteGuildBlacklist = config.invite_guild_blacklist; const inviteCodeWhitelist = config.invite_code_whitelist; const inviteCodeBlacklist = config.invite_code_blacklist; const allowGroupDMInvites = config.allow_group_dm_invites; const inviteCodes = getInviteCodesInString(messageContent); const invites: Array = await Promise.all( inviteCodes.map((code) => resolveInvite(pluginData.client, code)), ); for (const invite of invites) { // Always filter unknown invites if invite filtering is enabled if (invite == null) { censorMessage(pluginData, savedMessage, `unknown invite not found in whitelist`); return true; } if (!isGuildInvite(invite) && !allowGroupDMInvites) { censorMessage(pluginData, savedMessage, `group dm invites are not allowed`); return true; } if (isGuildInvite(invite)) { if (inviteGuildWhitelist && !inviteGuildWhitelist.includes(invite.guild!.id)) { censorMessage( pluginData, savedMessage, `invite guild (**${invite.guild!.name}** \`${invite.guild!.id}\`) not found in whitelist`, ); return true; } if (inviteGuildBlacklist && inviteGuildBlacklist.includes(invite.guild!.id)) { censorMessage( pluginData, savedMessage, `invite guild (**${invite.guild!.name}** \`${invite.guild!.id}\`) found in blacklist`, ); return true; } } if (inviteCodeWhitelist && !inviteCodeWhitelist.includes(invite.code)) { censorMessage(pluginData, savedMessage, `invite code (\`${invite.code}\`) not found in whitelist`); return true; } if (inviteCodeBlacklist && inviteCodeBlacklist.includes(invite.code)) { censorMessage(pluginData, savedMessage, `invite code (\`${invite.code}\`) found in blacklist`); return true; } } } // Filter domains const filterDomains = config.filter_domains; if (filterDomains) { const domainWhitelist = config.domain_whitelist; const domainBlacklist = config.domain_blacklist; const urls = getUrlsInString(messageContent); for (const thisUrl of urls) { if (domainWhitelist && !domainWhitelist.includes(thisUrl.hostname)) { censorMessage(pluginData, savedMessage, `domain (\`${thisUrl.hostname}\`) not found in whitelist`); return true; } if (domainBlacklist && domainBlacklist.includes(thisUrl.hostname)) { censorMessage(pluginData, savedMessage, `domain (\`${thisUrl.hostname}\`) found in blacklist`); return true; } } } // Filter tokens const blockedTokens = config.blocked_tokens || []; for (const token of blockedTokens) { if (messageContent.toLowerCase().includes(token.toLowerCase())) { censorMessage(pluginData, savedMessage, `blocked token (\`${token}\`) found`); return true; } } // Filter words const blockedWords = config.blocked_words || []; for (const word of blockedWords) { const regex = new RegExp(`\\b${escapeStringRegexp(word)}\\b`, "i"); if (regex.test(messageContent)) { censorMessage(pluginData, savedMessage, `blocked word (\`${word}\`) found`); return true; } } // Filter regex for (const pattern of config.blocked_regex || []) { const regex = inputPatternToRegExp(pattern); // We're testing both the original content and content + attachments/embeds here so regexes that use ^ and $ still match the regular content properly const matches = (await pluginData.state.regexRunner.exec(regex, savedMessage.data.content).catch(allowTimeout)) || (await pluginData.state.regexRunner.exec(regex, messageContent).catch(allowTimeout)); if (matches) { censorMessage(pluginData, savedMessage, `blocked regex (\`${regex.source}\`) found`); return true; } } return false; } ================================================ FILE: backend/src/plugins/Censor/util/censorMessage.ts ================================================ import { GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { resolveUser } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { CensorPluginType } from "../types.js"; export async function censorMessage( pluginData: GuildPluginData, savedMessage: SavedMessage, reason: string, ) { pluginData.state.serverLogs.ignoreLog(LogType.MESSAGE_DELETE, savedMessage.id); try { const resolvedChannel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake); if (resolvedChannel?.isTextBased()) await resolvedChannel.messages.delete(savedMessage.id as Snowflake); } catch { return; } const user = await resolveUser(pluginData.client, savedMessage.user_id, "Censor:censorMessage"); const channel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake)! as GuildTextBasedChannel; pluginData.getPlugin(LogsPlugin).logCensor({ user, channel, reason, message: savedMessage, }); } ================================================ FILE: backend/src/plugins/Censor/util/onMessageCreate.ts ================================================ import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { messageLock } from "../../../utils/lockNameHelpers.js"; import { CensorPluginType } from "../types.js"; import { applyFiltersToMsg } from "./applyFiltersToMsg.js"; export async function onMessageCreate(pluginData: GuildPluginData, savedMessage: SavedMessage) { if (savedMessage.is_bot) return; const lock = await pluginData.locks.acquire(messageLock(savedMessage)); const wasDeleted = await applyFiltersToMsg(pluginData, savedMessage); if (wasDeleted) { lock.interrupt(); } else { lock.unlock(); } } ================================================ FILE: backend/src/plugins/Censor/util/onMessageUpdate.ts ================================================ import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { messageLock } from "../../../utils/lockNameHelpers.js"; import { CensorPluginType } from "../types.js"; import { applyFiltersToMsg } from "./applyFiltersToMsg.js"; export async function onMessageUpdate(pluginData: GuildPluginData, savedMessage: SavedMessage) { if (savedMessage.is_bot) return; const lock = await pluginData.locks.acquire(messageLock(savedMessage)); const wasDeleted = await applyFiltersToMsg(pluginData, savedMessage); if (wasDeleted) { lock.interrupt(); } else { lock.unlock(); } } ================================================ FILE: backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts ================================================ import { guildPlugin } from "vety"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { ArchiveChannelCmd } from "./commands/ArchiveChannelCmd.js"; import { ChannelArchiverPluginType, zChannelArchiverPluginConfig } from "./types.js"; export const ChannelArchiverPlugin = guildPlugin()({ name: "channel_archiver", dependencies: () => [TimeAndDatePlugin], configSchema: zChannelArchiverPluginConfig, // prettier-ignore messageCommands: [ ArchiveChannelCmd, ], beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, }); ================================================ FILE: backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts ================================================ import { Snowflake } from "discord.js"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isOwner } from "../../../pluginUtils.js"; import { SECONDS, confirm, noop, renderUsername } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { rehostAttachment } from "../rehostAttachment.js"; import { channelArchiverCmd } from "../types.js"; const MAX_ARCHIVED_MESSAGES = 5000; const MAX_MESSAGES_PER_FETCH = 100; const PROGRESS_UPDATE_INTERVAL = 5 * SECONDS; export const ArchiveChannelCmd = channelArchiverCmd({ trigger: "archive_channel", permission: null, config: { preFilters: [ (command, context) => { return isOwner(context.pluginData, context.message.author.id); }, ], }, signature: { channel: ct.textChannel(), "attachment-channel": ct.textChannel({ option: true }), messages: ct.number({ option: true }), }, async run({ message: msg, args, pluginData }) { if (!args["attachment-channel"]) { const confirmed = await confirm(msg, msg.author.id, { content: "No `-attachment-channel` specified. Continue? Attachments will not be available in the log if their message is deleted.", }); if (!confirmed) { void pluginData.state.common.sendErrorMessage(msg, "Canceled"); return; } } const maxMessagesToArchive = args.messages ? Math.min(args.messages, MAX_ARCHIVED_MESSAGES) : MAX_ARCHIVED_MESSAGES; if (maxMessagesToArchive <= 0) return; const archiveLines: string[] = []; let archivedMessages = 0; let previousId: string | undefined; const startTime = Date.now(); const progressMsg = await msg.channel.send("Creating archive..."); const progressUpdateInterval = setInterval(() => { const secondsSinceStart = Math.round((Date.now() - startTime) / 1000); progressMsg .edit(`Creating archive...\n**Status:** ${archivedMessages} messages archived in ${secondsSinceStart} seconds`) .catch(() => clearInterval(progressUpdateInterval)); }, PROGRESS_UPDATE_INTERVAL); while (archivedMessages < maxMessagesToArchive) { const messagesToFetch = Math.min(MAX_MESSAGES_PER_FETCH, maxMessagesToArchive - archivedMessages); const messages = await args.channel.messages.fetch({ limit: messagesToFetch, before: previousId as Snowflake, }); if (messages.size === 0) break; for (const message of messages.values()) { const ts = moment.utc(message.createdTimestamp).format("YYYY-MM-DD HH:mm:ss"); let content = `[${ts}] [${message.author.id}] [${renderUsername(message.author)}]: ${ message.content || "" }`; if (message.attachments.size) { if (args["attachment-channel"]) { const rehostedAttachmentUrl = await rehostAttachment(message.attachments[0], args["attachment-channel"]); content += `\n-- Attachment: ${rehostedAttachmentUrl}`; } else { content += `\n-- Attachment: ${message.attachments[0].url}`; } } if (message.reactions.cache.size > 0) { const reactionCounts: string[] = []; for (const [emoji, info] of message.reactions.cache) { reactionCounts.push(`${info.count}x ${emoji}`); } content += `\n-- Reactions: ${reactionCounts.join(", ")}`; } archiveLines.push(content); previousId = message.id; archivedMessages++; } } clearInterval(progressUpdateInterval); archiveLines.reverse(); const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); const nowTs = timeAndDate.inGuildTz().format(timeAndDate.getDateFormat("pretty_datetime")); let result = `Archived ${archiveLines.length} messages from #${args.channel.name} at ${nowTs}`; result += `\n\n${archiveLines.join("\n")}\n`; progressMsg.delete().catch(noop); msg.channel.send({ content: "Archive created!", files: [ { attachment: Buffer.from(result), name: `archive-${args.channel.name}-${moment.utc().format("YYYY-MM-DD-HH-mm-ss")}.txt`, }, ], }); }, }); ================================================ FILE: backend/src/plugins/ChannelArchiver/rehostAttachment.ts ================================================ import { Attachment, GuildTextBasedChannel, MessageCreateOptions } from "discord.js"; import fs from "fs"; import { downloadFile } from "../../utils.js"; const fsp = fs.promises; const MAX_ATTACHMENT_REHOST_SIZE = 1024 * 1024 * 8; export async function rehostAttachment(attachment: Attachment, targetChannel: GuildTextBasedChannel): Promise { if (attachment.size > MAX_ATTACHMENT_REHOST_SIZE) { return "Attachment too big to rehost"; } let downloaded; try { downloaded = await downloadFile(attachment.url, 3); } catch { return "Failed to download attachment after 3 tries"; } try { const content: MessageCreateOptions = { content: `Rehost of attachment ${attachment.id}`, files: [{ name: attachment.name ? attachment.name : undefined, attachment: await fsp.readFile(downloaded.path) }], }; const rehostMessage = await targetChannel.send(content); return rehostMessage.attachments.values()[0].url; } catch { return "Failed to rehost attachment"; } } ================================================ FILE: backend/src/plugins/ChannelArchiver/types.ts ================================================ import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zChannelArchiverPluginConfig = z.strictObject({}); export interface ChannelArchiverPluginType extends BasePluginType { configSchema: typeof zChannelArchiverPluginConfig; state: { common: pluginUtils.PluginPublicInterface; }; } export const channelArchiverCmd = guildPluginMessageCommand(); ================================================ FILE: backend/src/plugins/CommandAliases/CommandAliasesPlugin.ts ================================================ import { guildPlugin } from "vety"; import { DispatchAliasEvt } from "./events/DispatchAliasEvt.js"; import { CommandAliasesPluginType, zCommandAliasesConfig } from "./types.js"; import { normalizeAliases } from "./functions/normalizeAliases.js"; import { buildAliasMatchers } from "./functions/buildAliasMatchers.js"; import { getGuildPrefix } from "../../utils/getGuildPrefix.js"; export const CommandAliasesPlugin = guildPlugin()({ name: "command_aliases", configSchema: zCommandAliasesConfig, beforeLoad(pluginData) { const prefix = getGuildPrefix(pluginData); const config = pluginData.config.get(); const normalizedAliases = normalizeAliases(config.aliases); pluginData.state.matchers = buildAliasMatchers(prefix, normalizedAliases); }, events: [DispatchAliasEvt], }); ================================================ FILE: backend/src/plugins/CommandAliases/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zCommandAliasesConfig } from "./types.js"; export const commandAliasesPluginDocs: ZeppelinPluginDocs = { type: "stable", prettyName: "Command Aliases", configSchema: zCommandAliasesConfig, description: "This plugin lets you create shortcuts for existing commands.", usageGuide: ` For example, you can make \`!b\` work the same as \`!ban\`, or \`!c\` work the same as \`!cases\`. ### Example \`\`\`yaml plugins: command_aliases: config: aliases: "b": "ban" "c": "cases" "b2": "ban -d 2" "ownerinfo": "info 754421392988045383" \`\`\` With this setup: - \`!b @User\` runs \`!ban @User\` - \`!c\` runs \`!cases\` - \`!b2 @User\` runs \`!ban -d 2 @User\` - \`!ownerinfo\` runs \`!info 754421392988045383\` ` }; ================================================ FILE: backend/src/plugins/CommandAliases/events/DispatchAliasEvt.ts ================================================ import { Message } from "discord.js"; import { commandAliasesEvt } from "../types.js"; export const DispatchAliasEvt = commandAliasesEvt({ event: "messageCreate", async listener({ args: { message: msg }, pluginData }) { if (!msg.guild || !msg.content) return; if (msg.author.bot || msg.webhookId) return; const matchers = pluginData.state.matchers ?? []; if (matchers.length === 0) return; const matchingAlias = matchers.find((matcher) => matcher.regex.test(msg.content)); if (!matchingAlias) return; const newContent = msg.content.replace(matchingAlias.regex, matchingAlias.replacement); if (newContent === msg.content) return; const copiedMessage = Object.create(msg); copiedMessage.content = newContent; await pluginData.getVetyInstance().dispatchMessageCommands(copiedMessage as Message); }, }); ================================================ FILE: backend/src/plugins/CommandAliases/functions/buildAliasMatchers.ts ================================================ import escapeStringRegexp from "escape-string-regexp"; import { NormalizedAlias } from "./normalizeAliases.js"; export interface AliasMatcher { regex: RegExp; replacement: string; } export function buildAliasMatchers(prefix: string, aliases: NormalizedAlias[]): AliasMatcher[] { return aliases.map((alias) => { const pattern = `^${escapeStringRegexp(prefix)}${escapeStringRegexp(alias.alias)}\\b`; return { regex: new RegExp(pattern, "i"), replacement: `${prefix}${alias.target}`, }; }); } ================================================ FILE: backend/src/plugins/CommandAliases/functions/normalizeAliases.ts ================================================ export interface NormalizedAlias { alias: string; target: string; } export function normalizeAliases(aliases: Record | undefined | null): NormalizedAlias[] { if (!aliases) { return []; } const normalized: NormalizedAlias[] = []; for (const [rawAlias, rawTarget] of Object.entries(aliases)) { const alias = rawAlias.trim(); const target = rawTarget.trim(); if (!alias || !target) { continue; } normalized.push({ alias, target, }); } return normalized; } ================================================ FILE: backend/src/plugins/CommandAliases/types.ts ================================================ import { BasePluginType, guildPluginEventListener } from "vety"; import z from "zod"; import { AliasMatcher } from "./functions/buildAliasMatchers.js"; export const zCommandAliasesConfig = z.strictObject({ aliases: z.record(z.string().min(1), z.string().min(1)).optional(), }); export interface CommandAliasesPluginType extends BasePluginType { configSchema: typeof zCommandAliasesConfig; state: { matchers: AliasMatcher[]; }; } export const commandAliasesEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/Common/CommonPlugin.ts ================================================ import { Attachment, MessageMentionOptions, SendableChannels, TextBasedChannel } from "discord.js"; import { guildPlugin } from "vety"; import { GenericCommandSource, makePublicFn, sendContextResponse } from "../../pluginUtils.js"; import { errorMessage, successMessage } from "../../utils.js"; import { getErrorEmoji, getSuccessEmoji } from "./functions/getEmoji.js"; import { CommonPluginType, zCommonConfig } from "./types.js"; export const CommonPlugin = guildPlugin()({ name: "common", dependencies: () => [], configSchema: zCommonConfig, public(pluginData) { return { getSuccessEmoji: makePublicFn(pluginData, getSuccessEmoji), getErrorEmoji: makePublicFn(pluginData, getErrorEmoji), sendSuccessMessage: async ( context: GenericCommandSource | SendableChannels, body: string, allowedMentions?: MessageMentionOptions, responseInteraction?: never, ephemeral = true, ) => { const emoji = getSuccessEmoji(pluginData); const formattedBody = successMessage(body, emoji); const content = allowedMentions ? { content: formattedBody, allowedMentions } : { content: formattedBody }; if ("isSendable" in context) { return context.send(content); } return sendContextResponse(context, content, ephemeral); }, sendErrorMessage: async ( context: GenericCommandSource | SendableChannels, body: string, allowedMentions?: MessageMentionOptions, responseInteraction?: never, ephemeral = true, ) => { const emoji = getErrorEmoji(pluginData); const formattedBody = errorMessage(body, emoji); const content = allowedMentions ? { content: formattedBody, allowedMentions } : { content: formattedBody }; if ("isSendable" in context) { return context.send(content); } return sendContextResponse(context, content, ephemeral); }, storeAttachmentsAsMessage: async (attachments: Attachment[], backupChannel?: TextBasedChannel | null) => { const attachmentChannelId = pluginData.config.get().attachment_storing_channel; const channel = attachmentChannelId ? ((pluginData.guild.channels.cache.get(attachmentChannelId) as TextBasedChannel) ?? backupChannel) : backupChannel; if (!channel) { throw new Error( "Cannot store attachments: no attachment storing channel configured, and no backup channel passed", ); } if (!channel.isSendable()) { throw new Error("Passed attachment storage channel is not sendable"); } return channel.send({ content: `Storing ${attachments.length} attachment${attachments.length === 1 ? "" : "s"}`, files: attachments.map((a) => a.url), }); }, }; }, }); ================================================ FILE: backend/src/plugins/Common/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zCommonConfig } from "./types.js"; export const commonPluginDocs: ZeppelinPluginDocs = { type: "stable", configSchema: zCommonConfig, prettyName: "Common", }; ================================================ FILE: backend/src/plugins/Common/functions/getEmoji.ts ================================================ import { GuildPluginData } from "vety"; import { env } from "../../../env.js"; import { CommonPluginType } from "../types.js"; export function getSuccessEmoji(pluginData: GuildPluginData) { return pluginData.config.get().success_emoji ?? env.DEFAULT_SUCCESS_EMOJI; } export function getErrorEmoji(pluginData: GuildPluginData) { return pluginData.config.get().error_emoji ?? env.DEFAULT_ERROR_EMOJI; } ================================================ FILE: backend/src/plugins/Common/types.ts ================================================ import { BasePluginType } from "vety"; import { z } from "zod"; export const zCommonConfig = z.strictObject({ success_emoji: z.string().nullable().default(null), error_emoji: z.string().nullable().default(null), attachment_storing_channel: z.nullable(z.string()).default(null), }); export interface CommonPluginType extends BasePluginType { configSchema: typeof zCommonConfig; } ================================================ FILE: backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts ================================================ import { CooldownManager, guildPlugin } from "vety"; import { GuildLogs } from "../../data/GuildLogs.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { VoiceStateUpdateEvt } from "./events/VoiceStateUpdateEvt.js"; import { CompanionChannelsPluginType, zCompanionChannelsConfig } from "./types.js"; export const CompanionChannelsPlugin = guildPlugin()({ name: "companion_channels", dependencies: () => [LogsPlugin], configSchema: zCompanionChannelsConfig, events: [VoiceStateUpdateEvt], beforeLoad(pluginData) { pluginData.state.errorCooldownManager = new CooldownManager(); }, afterLoad(pluginData) { pluginData.state.serverLogs = new GuildLogs(pluginData.guild.id); }, }); ================================================ FILE: backend/src/plugins/CompanionChannels/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zCompanionChannelsConfig } from "./types.js"; export const companionChannelsPluginDocs: ZeppelinPluginDocs = { type: "stable", configSchema: zCompanionChannelsConfig, prettyName: "Companion channels", description: trimPluginDescription(` Set up 'companion channels' between text and voice channels. Once set up, any time a user joins one of the specified voice channels, they'll get channel permissions applied to them for the text channels. `), }; ================================================ FILE: backend/src/plugins/CompanionChannels/events/VoiceStateUpdateEvt.ts ================================================ import { handleCompanionPermissions } from "../functions/handleCompanionPermissions.js"; import { companionChannelsEvt } from "../types.js"; export const VoiceStateUpdateEvt = companionChannelsEvt({ event: "voiceStateUpdate", listener({ pluginData, args: { oldState, newState } }) { const oldChannel = oldState.channel; const newChannel = newState.channel; const memberId = newState.member?.id ?? oldState.member?.id; if (!memberId) { return; } handleCompanionPermissions(pluginData, memberId, newChannel, oldChannel); }, }); ================================================ FILE: backend/src/plugins/CompanionChannels/functions/getCompanionChannelOptsForVoiceChannelId.ts ================================================ import { StageChannel, VoiceChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { CompanionChannelsPluginType, TCompanionChannelOpts } from "../types.js"; const defaultCompanionChannelOpts: Partial = { enabled: true, }; export async function getCompanionChannelOptsForVoiceChannelId( pluginData: GuildPluginData, userId: string, voiceChannel: VoiceChannel | StageChannel, ): Promise { const config = await pluginData.config.getMatchingConfig({ userId, channelId: voiceChannel.id }); return Object.values(config.entries) .filter( (opts) => opts.voice_channel_ids.includes(voiceChannel.id) || (voiceChannel.parentId && opts.voice_channel_ids.includes(voiceChannel.parentId)), ) .map((opts) => Object.assign({}, defaultCompanionChannelOpts, opts)); } ================================================ FILE: backend/src/plugins/CompanionChannels/functions/handleCompanionPermissions.ts ================================================ import { PermissionsBitField, Snowflake, StageChannel, TextChannel, VoiceChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { MINUTES, isDiscordAPIError } from "../../../utils.js"; import { filterObject } from "../../../utils/filterObject.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { CompanionChannelsPluginType, TCompanionChannelOpts } from "../types.js"; import { getCompanionChannelOptsForVoiceChannelId } from "./getCompanionChannelOptsForVoiceChannelId.js"; const ERROR_COOLDOWN_KEY = "errorCooldown"; const ERROR_COOLDOWN = 5 * MINUTES; // The real limit is 500, but to be on the safer side, this is lower // Temporary fix until we move to role-based companion channels const MAX_OVERWRITES = 450; export async function handleCompanionPermissions( pluginData: GuildPluginData, userId: string, voiceChannel: VoiceChannel | StageChannel | null, oldChannel?: VoiceChannel | StageChannel | null, ) { if (pluginData.state.errorCooldownManager.isOnCooldown(ERROR_COOLDOWN_KEY)) { return; } const permsToDelete: Set = new Set(); // channelId[] const oldPerms: Map = new Map(); // channelId => permissions const permsToSet: Map = new Map(); // channelId => permissions const oldChannelOptsArr: TCompanionChannelOpts[] = oldChannel ? await getCompanionChannelOptsForVoiceChannelId(pluginData, userId, oldChannel) : []; const newChannelOptsArr: TCompanionChannelOpts[] = voiceChannel ? await getCompanionChannelOptsForVoiceChannelId(pluginData, userId, voiceChannel) : []; for (const oldChannelOpts of oldChannelOptsArr) { for (const channelId of oldChannelOpts.text_channel_ids) { oldPerms.set(channelId, oldChannelOpts.permissions); permsToDelete.add(channelId); } } for (const newChannelOpts of newChannelOptsArr) { for (const channelId of newChannelOpts.text_channel_ids) { if (oldPerms.get(channelId) !== newChannelOpts.permissions) { // Update text channel perms if the channel we transitioned from didn't already have the same text channel perms permsToSet.set(channelId, newChannelOpts.permissions); } if (permsToDelete.has(channelId)) { permsToDelete.delete(channelId); } } } const logs = pluginData.getPlugin(LogsPlugin); try { for (const channelId of permsToDelete) { const channel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!channel || !(channel instanceof TextChannel)) continue; pluginData.state.serverLogs.ignoreLog(LogType.CHANNEL_UPDATE, channelId, 3 * 1000); await channel.permissionOverwrites .resolve(userId as Snowflake) ?.delete(`Companion Channel for ${oldChannel!.id} | User Left`); } for (const [channelId, permissions] of permsToSet) { const channel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!channel || !(channel instanceof TextChannel)) continue; if (channel.permissionOverwrites.cache.size >= MAX_OVERWRITES) { logs.logBotAlert({ body: `Could not apply companion channel permissions for <#${channel.id}>: too many permissions`, }); continue; } pluginData.state.serverLogs.ignoreLog(LogType.CHANNEL_UPDATE, channelId, 3 * 1000); const fullSerialized = new PermissionsBitField(BigInt(permissions)).serialize(); const onlyAllowed = filterObject(fullSerialized, (v) => v === true); await channel.permissionOverwrites.create(userId, onlyAllowed, { reason: `Companion Channel for ${voiceChannel!.id} | User Joined`, }); } } catch (e) { if (isDiscordAPIError(e)) { if (e.code === 50001) { logs.logBotAlert({ body: `One of the companion channels can't be accessed. Pausing companion channels for 5 minutes or until the bot is reloaded on this server.`, }); pluginData.state.errorCooldownManager.setCooldown(ERROR_COOLDOWN_KEY, ERROR_COOLDOWN); return; } if (e.code === 50013) { logs.logBotAlert({ body: `Missing permissions to handle companion channels. Pausing companion channels for 5 minutes or until the bot is reloaded on this server.`, }); pluginData.state.errorCooldownManager.setCooldown(ERROR_COOLDOWN_KEY, ERROR_COOLDOWN); return; } } throw e; } } ================================================ FILE: backend/src/plugins/CompanionChannels/types.ts ================================================ import { BasePluginType, CooldownManager, guildPluginEventListener } from "vety"; import { z } from "zod"; import { GuildLogs } from "../../data/GuildLogs.js"; import { zBoundedCharacters, zSnowflake } from "../../utils.js"; export const zCompanionChannelOpts = z.strictObject({ voice_channel_ids: z.array(zSnowflake), text_channel_ids: z.array(zSnowflake), // See https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags permissions: z.number(), enabled: z.boolean().nullable().default(true), }); export type TCompanionChannelOpts = z.infer; export const zCompanionChannelsConfig = z.strictObject({ entries: z.record(zBoundedCharacters(0, 100), zCompanionChannelOpts).default({}), }); export interface CompanionChannelsPluginType extends BasePluginType { configSchema: typeof zCompanionChannelsConfig; state: { errorCooldownManager: CooldownManager; serverLogs: GuildLogs; }; } export const companionChannelsEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/ContextMenus/ContextMenuPlugin.ts ================================================ import { PluginOverride, guildPlugin } from "vety"; import { GuildCases } from "../../data/GuildCases.js"; import { CasesPlugin } from "../Cases/CasesPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { ModActionsPlugin } from "../ModActions/ModActionsPlugin.js"; import { MutesPlugin } from "../Mutes/MutesPlugin.js"; import { UtilityPlugin } from "../Utility/UtilityPlugin.js"; import { BanCmd } from "./commands/BanUserCtxCmd.js"; import { CleanCmd } from "./commands/CleanMessageCtxCmd.js"; import { ModMenuCmd } from "./commands/ModMenuUserCtxCmd.js"; import { MuteCmd } from "./commands/MuteUserCtxCmd.js"; import { NoteCmd } from "./commands/NoteUserCtxCmd.js"; import { WarnCmd } from "./commands/WarnUserCtxCmd.js"; import { ContextMenuPluginType, zContextMenusConfig } from "./types.js"; const defaultOverrides: Array> = [ { level: ">=50", config: { can_use: true, can_open_mod_menu: true, }, }, ]; export const ContextMenuPlugin = guildPlugin()({ name: "context_menu", dependencies: () => [CasesPlugin, MutesPlugin, ModActionsPlugin, LogsPlugin, UtilityPlugin], configSchema: zContextMenusConfig, defaultOverrides, contextMenuCommands: [ModMenuCmd, NoteCmd, WarnCmd, MuteCmd, BanCmd, CleanCmd], beforeLoad(pluginData) { const { state, guild } = pluginData; state.cases = GuildCases.getGuildInstance(guild.id); }, }); ================================================ FILE: backend/src/plugins/ContextMenus/actions/ban.ts ================================================ import { ActionRowBuilder, ButtonInteraction, ContextMenuCommandInteraction, ModalBuilder, ModalSubmitInteraction, TextInputBuilder, TextInputStyle, } from "discord.js"; import { GuildPluginData } from "vety"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { logger } from "../../../logger.js"; import { canActOn } from "../../../pluginUtils.js"; import { convertDelayStringToMS, renderUserUsername } from "../../../utils.js"; import { CaseArgs } from "../../Cases/types.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; import { updateAction } from "./update.js"; async function banAction( pluginData: GuildPluginData, duration: string | undefined, reason: string | undefined, evidence: string | undefined, target: string, interaction: ButtonInteraction | ContextMenuCommandInteraction, submitInteraction: ModalSubmitInteraction, ) { const interactionToReply = interaction.isButton() ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, member: executingMember, }); const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasBanPermission(executingMember, interaction.channelId))) { await interactionToReply.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); return; } const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { await interactionToReply.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); return; } const caseArgs: Partial = { modId: executingMember.id, }; const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; const result = await modactions.banUserId(target, reason, reason, { caseArgs }, durationMs); if (result.status === "failed") { await interactionToReply.editReply({ content: "Error: Failed to ban user", embeds: [], components: [] }); return; } const userName = renderUserUsername(targetMember.user); const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; const banMessage = `Banned **${userName}** ${ durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" } (Case #${result.case.case_number})${messageResultText}`; if (evidence) { await updateAction(pluginData, executingMember, result.case, evidence); } await interactionToReply.editReply({ content: banMessage, embeds: [], components: [] }); } export async function launchBanActionModal( pluginData: GuildPluginData, interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { const modalId = `${ModMenuActionType.BAN}:${interaction.id}`; const modal = new ModalBuilder().setCustomId(modalId).setTitle("Ban"); const durationIn = new TextInputBuilder() .setCustomId("duration") .setLabel("Duration (Optional)") .setRequired(false) .setStyle(TextInputStyle.Short); const reasonIn = new TextInputBuilder() .setCustomId("reason") .setLabel("Reason (Optional)") .setRequired(false) .setStyle(TextInputStyle.Paragraph); const evidenceIn = new TextInputBuilder() .setCustomId("evidence") .setLabel("Evidence (Optional)") .setRequired(false) .setStyle(TextInputStyle.Paragraph); const durationRow = new ActionRowBuilder().addComponents(durationIn); const reasonRow = new ActionRowBuilder().addComponents(reasonIn); const evidenceRow = new ActionRowBuilder().addComponents(evidenceIn); modal.addComponents(durationRow, reasonRow, evidenceRow); await interaction.showModal(modal); await interaction .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) .then(async (submitted) => { if (interaction.isButton()) { await submitted.deferUpdate().catch((err) => logger.error(`Ban interaction defer failed: ${err}`)); } else if (interaction.isContextMenuCommand()) { await submitted.deferReply({ ephemeral: true }); } const duration = submitted.fields.getTextInputValue("duration"); const reason = submitted.fields.getTextInputValue("reason"); const evidence = submitted.fields.getTextInputValue("evidence"); await banAction(pluginData, duration, reason, evidence, target, interaction, submitted); }); } ================================================ FILE: backend/src/plugins/ContextMenus/actions/clean.ts ================================================ import { ActionRowBuilder, Message, MessageContextMenuCommandInteraction, ModalBuilder, ModalSubmitInteraction, TextInputBuilder, TextInputStyle, } from "discord.js"; import { GuildPluginData } from "vety"; import { logger } from "../../../logger.js"; import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin.js"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; export async function cleanAction( pluginData: GuildPluginData, amount: number, target: string, targetMessage: Message, targetChannelId: string, interaction: ModalSubmitInteraction, ) { const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, member: executingMember, }); const utility = pluginData.getPlugin(UtilityPlugin); if (!userCfg.can_use || !(await utility.hasPermission(executingMember, targetChannelId, "can_clean"))) { await interaction .editReply({ content: "Cannot clean: insufficient permissions", embeds: [], components: [] }) .catch((err) => logger.error(`Clean interaction reply failed: ${err}`)); return; } const targetChannel = await pluginData.guild.channels.fetch(targetChannelId); if (!targetChannel?.isTextBased()) { await interaction .editReply({ content: "Cannot clean: target channel is not a text channel", embeds: [], components: [] }) .catch((err) => logger.error(`Clean interaction reply failed: ${err}`)); return; } await interaction .editReply({ content: `Cleaning ${amount} messages from ${target}...`, embeds: [], components: [], }) .catch((err) => logger.error(`Clean interaction reply failed: ${err}`)); const fetchMessagesResult = await utility.fetchChannelMessagesToClean(targetChannel, { count: amount, beforeId: targetMessage.id, }); if ("error" in fetchMessagesResult) { interaction.editReply(fetchMessagesResult.error); return; } if (fetchMessagesResult.messages.length > 0) { await utility.cleanMessages(targetChannel, fetchMessagesResult.messages, interaction.user); interaction.editReply( `Cleaned ${fetchMessagesResult.messages.length} ${ fetchMessagesResult.messages.length === 1 ? "message" : "messages" }`, ); } else { interaction.editReply("No messages to clean"); } } export async function launchCleanActionModal( pluginData: GuildPluginData, interaction: MessageContextMenuCommandInteraction, target: string, ) { const modalId = `${ModMenuActionType.CLEAN}:${interaction.id}`; const modal = new ModalBuilder().setCustomId(modalId).setTitle("Clean"); const amountIn = new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short); const amountRow = new ActionRowBuilder().addComponents(amountIn); modal.addComponents(amountRow); await interaction.showModal(modal); await interaction .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) .then(async (submitted) => { await submitted.deferReply({ ephemeral: true }); const amount = submitted.fields.getTextInputValue("amount"); if (isNaN(Number(amount))) { interaction.editReply({ content: `Error: Amount '${amount}' is invalid`, embeds: [], components: [] }); return; } await cleanAction( pluginData, Number(amount), target, interaction.targetMessage, interaction.channelId, submitted, ); }); } ================================================ FILE: backend/src/plugins/ContextMenus/actions/mute.ts ================================================ import { ActionRowBuilder, ButtonInteraction, ContextMenuCommandInteraction, ModalBuilder, ModalSubmitInteraction, TextInputBuilder, TextInputStyle, } from "discord.js"; import { GuildPluginData } from "vety"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { logger } from "../../../logger.js"; import { canActOn } from "../../../pluginUtils.js"; import { convertDelayStringToMS } from "../../../utils.js"; import { CaseArgs } from "../../Cases/types.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; import { MutesPlugin } from "../../Mutes/MutesPlugin.js"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; import { updateAction } from "./update.js"; async function muteAction( pluginData: GuildPluginData, duration: string | undefined, reason: string | undefined, evidence: string | undefined, target: string, interaction: ButtonInteraction | ContextMenuCommandInteraction, submitInteraction: ModalSubmitInteraction, ) { const interactionToReply = interaction.isButton() ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, member: executingMember, }); const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasMutePermission(executingMember, interaction.channelId))) { await interactionToReply.editReply({ content: "Cannot mute: insufficient permissions", embeds: [], components: [], }); return; } const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { await interactionToReply.editReply({ content: "Cannot mute: insufficient permissions", embeds: [], components: [], }); return; } const caseArgs: Partial = { modId: executingMember.id, }; const mutes = pluginData.getPlugin(MutesPlugin); const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; try { const result = await mutes.muteUser(target, durationMs, reason, reason, { caseArgs }); const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; const muteMessage = `Muted **${result.case!.user_name}** ${ durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" } (Case #${result.case!.case_number})${messageResultText}`; if (evidence) { await updateAction(pluginData, executingMember, result.case!, evidence); } await interactionToReply.editReply({ content: muteMessage, embeds: [], components: [] }); } catch (e) { await interactionToReply.editReply({ content: "Plugin error, please check your BOT_ALERTs", embeds: [], components: [], }); if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to mute <@!${target}> in ContextMenu action \`mute\` because a mute role has not been specified in server config`, }); } else { throw e; } } } export async function launchMuteActionModal( pluginData: GuildPluginData, interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { const modalId = `${ModMenuActionType.MUTE}:${interaction.id}`; const modal = new ModalBuilder().setCustomId(modalId).setTitle("Mute"); const durationIn = new TextInputBuilder() .setCustomId("duration") .setLabel("Duration (Optional)") .setRequired(false) .setStyle(TextInputStyle.Short); const reasonIn = new TextInputBuilder() .setCustomId("reason") .setLabel("Reason (Optional)") .setRequired(false) .setStyle(TextInputStyle.Paragraph); const evidenceIn = new TextInputBuilder() .setCustomId("evidence") .setLabel("Evidence (Optional)") .setRequired(false) .setStyle(TextInputStyle.Paragraph); const durationRow = new ActionRowBuilder().addComponents(durationIn); const reasonRow = new ActionRowBuilder().addComponents(reasonIn); const evidenceRow = new ActionRowBuilder().addComponents(evidenceIn); modal.addComponents(durationRow, reasonRow, evidenceRow); await interaction.showModal(modal); await interaction .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) .then(async (submitted) => { if (interaction.isButton()) { await submitted.deferUpdate().catch((err) => logger.error(`Mute interaction defer failed: ${err}`)); } else if (interaction.isContextMenuCommand()) { await submitted.deferReply({ ephemeral: true }); } const duration = submitted.fields.getTextInputValue("duration"); const reason = submitted.fields.getTextInputValue("reason"); const evidence = submitted.fields.getTextInputValue("evidence"); await muteAction(pluginData, duration, reason, evidence, target, interaction, submitted); }); } ================================================ FILE: backend/src/plugins/ContextMenus/actions/note.ts ================================================ import { ActionRowBuilder, ButtonInteraction, ContextMenuCommandInteraction, ModalBuilder, ModalSubmitInteraction, TextInputBuilder, TextInputStyle, } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { logger } from "../../../logger.js"; import { canActOn } from "../../../pluginUtils.js"; import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; import { renderUserUsername } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; async function noteAction( pluginData: GuildPluginData, reason: string, target: string, interaction: ButtonInteraction | ContextMenuCommandInteraction, submitInteraction: ModalSubmitInteraction, ) { const interactionToReply = interaction.isButton() ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, member: executingMember, }); const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasNotePermission(executingMember, interaction.channelId))) { await interactionToReply.editReply({ content: "Cannot note: insufficient permissions", embeds: [], components: [], }); return; } const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { await interactionToReply.editReply({ content: "Cannot note: insufficient permissions", embeds: [], components: [], }); return; } const casesPlugin = pluginData.getPlugin(CasesPlugin); const createdCase = await casesPlugin.createCase({ userId: target, modId: executingMember.id, type: CaseTypes.Note, reason, }); pluginData.getPlugin(LogsPlugin).logMemberNote({ mod: interaction.user, user: targetMember.user, caseNumber: createdCase.case_number, reason, }); const userName = renderUserUsername(targetMember.user); await interactionToReply.editReply({ content: `Note added on **${userName}** (Case #${createdCase.case_number})`, embeds: [], components: [], }); } export async function launchNoteActionModal( pluginData: GuildPluginData, interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { const modalId = `${ModMenuActionType.NOTE}:${interaction.id}`; const modal = new ModalBuilder().setCustomId(modalId).setTitle("Note"); const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Note").setStyle(TextInputStyle.Paragraph); const reasonRow = new ActionRowBuilder().addComponents(reasonIn); modal.addComponents(reasonRow); await interaction.showModal(modal); await interaction .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) .then(async (submitted) => { if (interaction.isButton()) { await submitted.deferUpdate().catch((err) => logger.error(`Note interaction defer failed: ${err}`)); } else if (interaction.isContextMenuCommand()) { await submitted.deferReply({ ephemeral: true }); } const reason = submitted.fields.getTextInputValue("reason"); await noteAction(pluginData, reason, target, interaction, submitted); }); } ================================================ FILE: backend/src/plugins/ContextMenus/actions/update.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { Case } from "../../../data/entities/Case.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { ContextMenuPluginType } from "../types.js"; export async function updateAction( pluginData: GuildPluginData, executingMember: GuildMember, theCase: Case, value: string, ) { const casesPlugin = pluginData.getPlugin(CasesPlugin); await casesPlugin.createCaseNote({ caseId: theCase.case_number, modId: executingMember.id, body: value, }); void pluginData.getPlugin(LogsPlugin).logCaseUpdate({ mod: executingMember.user, caseNumber: theCase.case_number, caseType: CaseTypes[theCase.type], note: value, }); } ================================================ FILE: backend/src/plugins/ContextMenus/actions/warn.ts ================================================ import { ActionRowBuilder, ButtonInteraction, ContextMenuCommandInteraction, ModalBuilder, ModalSubmitInteraction, TextInputBuilder, TextInputStyle, } from "discord.js"; import { GuildPluginData } from "vety"; import { logger } from "../../../logger.js"; import { canActOn } from "../../../pluginUtils.js"; import { renderUserUsername } from "../../../utils.js"; import { CaseArgs } from "../../Cases/types.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; import { updateAction } from "./update.js"; async function warnAction( pluginData: GuildPluginData, reason: string, evidence: string | undefined, target: string, interaction: ButtonInteraction | ContextMenuCommandInteraction, submitInteraction: ModalSubmitInteraction, ) { const interactionToReply = interaction.isButton() ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, member: executingMember, }); const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasWarnPermission(executingMember, interaction.channelId))) { await interactionToReply.editReply({ content: "Cannot warn: insufficient permissions", embeds: [], components: [], }); return; } const targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { await interactionToReply.editReply({ content: "Cannot warn: insufficient permissions", embeds: [], components: [], }); return; } const caseArgs: Partial = { modId: executingMember.id, }; const result = await modactions.warnMember(targetMember, reason, reason, { caseArgs }); if (result.status === "failed") { await interactionToReply.editReply({ content: "Error: Failed to warn user", embeds: [], components: [] }); return; } const userName = renderUserUsername(targetMember.user); const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; const muteMessage = `Warned **${userName}** (Case #${result.case.case_number})${messageResultText}`; if (evidence) { await updateAction(pluginData, executingMember, result.case, evidence); } await interactionToReply.editReply({ content: muteMessage, embeds: [], components: [] }); } export async function launchWarnActionModal( pluginData: GuildPluginData, interaction: ButtonInteraction | ContextMenuCommandInteraction, target: string, ) { const modalId = `${ModMenuActionType.WARN}:${interaction.id}`; const modal = new ModalBuilder().setCustomId(modalId).setTitle("Warn"); const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Reason").setStyle(TextInputStyle.Paragraph); const evidenceIn = new TextInputBuilder() .setCustomId("evidence") .setLabel("Evidence (Optional)") .setRequired(false) .setStyle(TextInputStyle.Paragraph); const reasonRow = new ActionRowBuilder().addComponents(reasonIn); const evidenceRow = new ActionRowBuilder().addComponents(evidenceIn); modal.addComponents(reasonRow, evidenceRow); await interaction.showModal(modal); await interaction .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) .then(async (submitted) => { if (interaction.isButton()) { await submitted.deferUpdate().catch((err) => logger.error(`Warn interaction defer failed: ${err}`)); } else if (interaction.isContextMenuCommand()) { await submitted.deferReply({ ephemeral: true }); } const reason = submitted.fields.getTextInputValue("reason"); const evidence = submitted.fields.getTextInputValue("evidence"); await warnAction(pluginData, reason, evidence, target, interaction, submitted); }); } ================================================ FILE: backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts ================================================ import { PermissionFlagsBits } from "discord.js"; import { guildPluginUserContextMenuCommand } from "vety"; import { launchBanActionModal } from "../actions/ban.js"; export const BanCmd = guildPluginUserContextMenuCommand({ name: "Ban", defaultMemberPermissions: PermissionFlagsBits.BanMembers.toString(), async run({ pluginData, interaction }) { await launchBanActionModal(pluginData, interaction, interaction.targetId); }, }); ================================================ FILE: backend/src/plugins/ContextMenus/commands/CleanMessageCtxCmd.ts ================================================ import { PermissionFlagsBits } from "discord.js"; import { guildPluginMessageContextMenuCommand } from "vety"; import { launchCleanActionModal } from "../actions/clean.js"; export const CleanCmd = guildPluginMessageContextMenuCommand({ name: "Clean", defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(), async run({ pluginData, interaction }) { await launchCleanActionModal(pluginData, interaction, interaction.targetId); }, }); ================================================ FILE: backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts ================================================ import { APIEmbed, ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ContextMenuCommandInteraction, GuildMember, PermissionFlagsBits, User, } from "discord.js"; import { GuildPluginData, guildPluginUserContextMenuCommand } from "vety"; import { Case } from "../../../data/entities/Case.js"; import { logger } from "../../../logger.js"; import { SECONDS, UnknownUser, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from "../../../utils.js"; import { asyncMap } from "../../../utils/async.js"; import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields.js"; import { getGuildPrefix } from "../../../utils/getGuildPrefix.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; import { getUserInfoEmbed } from "../../Utility/functions/getUserInfoEmbed.js"; import { launchBanActionModal } from "../actions/ban.js"; import { launchMuteActionModal } from "../actions/mute.js"; import { launchNoteActionModal } from "../actions/note.js"; import { launchWarnActionModal } from "../actions/warn.js"; import { ContextMenuPluginType, LoadModMenuPageFn, ModMenuActionOpts, ModMenuActionType, ModMenuNavigationType, } from "../types.js"; export const MODAL_TIMEOUT = 60 * SECONDS; const MOD_MENU_TIMEOUT = 60 * SECONDS; const CASES_PER_PAGE = 10; export const ModMenuCmd = guildPluginUserContextMenuCommand({ name: "Mod Menu", defaultMemberPermissions: PermissionFlagsBits.ViewAuditLog.toString(), async run({ pluginData, interaction }) { await interaction.deferReply({ ephemeral: true }); // Run permission checks for executing user. const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, member: executingMember, }); if (!userCfg.can_use || !userCfg.can_open_mod_menu) { await interaction.followUp({ content: "Error: Insufficient Permissions" }); return; } const user = await resolveUser(pluginData.client, interaction.targetId, "ContextMenus:ModMenuCmd"); if (!user.id) { await interaction.followUp("Error: User not found"); return; } // Load cases and display mod menu const cases: Case[] = await pluginData.state.cases.with("notes").getByUserId(user.id); const userName = user instanceof UnknownUser && cases.length ? cases[cases.length - 1].user_name : renderUserUsername(user); const casesPlugin = pluginData.getPlugin(CasesPlugin); const totalCases = cases.length; const totalPages: number = Math.max(Math.ceil(totalCases / CASES_PER_PAGE), 1); const prefix = getGuildPrefix(pluginData); const infoEmbed = await getUserInfoEmbed(pluginData, user.id, false); displayModMenu( pluginData, interaction, totalPages, async (page) => { const pageCases: Case[] = await pluginData.state.cases .with("notes") .getRecentByUserId(user.id, CASES_PER_PAGE, (page - 1) * CASES_PER_PAGE); const lines = await asyncMap(pageCases, (c) => casesPlugin.getCaseSummary(c, true, interaction.targetId)); const firstCaseNum = (page - 1) * CASES_PER_PAGE + 1; const lastCaseNum = Math.min(page * CASES_PER_PAGE, totalCases); const title = lines.length == 0 ? `${userName}` : `Most recent cases for ${userName} | ${firstCaseNum}-${lastCaseNum} of ${totalCases}`; const embed = { author: { name: title, icon_url: user instanceof User ? user.displayAvatarURL() : undefined, }, fields: [ ...getChunkedEmbedFields( emptyEmbedValue, lines.length == 0 ? `No cases found for **${userName}**` : lines.join("\n"), ), { name: emptyEmbedValue, value: trimLines( lines.length == 0 ? "" : `Use \`${prefix}case \` to see more information about an individual case`, ), }, ], footer: { text: `Page ${page}/${totalPages}` }, } satisfies APIEmbed; return embed; }, infoEmbed, executingMember, ); }, }); async function displayModMenu( pluginData: GuildPluginData, interaction: ContextMenuCommandInteraction, totalPages: number, loadPage: LoadModMenuPageFn, infoEmbed: APIEmbed | null, executingMember: GuildMember, ) { if (interaction.deferred == false) { await interaction.deferReply().catch((err) => logger.error(`Mod menu interaction defer failed: ${err}`)); } const firstButton = new ButtonBuilder() .setStyle(ButtonStyle.Secondary) .setEmoji("⏪") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.FIRST })) .setDisabled(true); const prevButton = new ButtonBuilder() .setStyle(ButtonStyle.Secondary) .setEmoji("⬅") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.PREV })) .setDisabled(true); const infoButton = new ButtonBuilder() .setStyle(ButtonStyle.Primary) .setLabel("Info") .setEmoji("ℹ") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO })) .setDisabled(infoEmbed != null ? false : true); const nextButton = new ButtonBuilder() .setStyle(ButtonStyle.Secondary) .setEmoji("➡") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.NEXT })) .setDisabled(totalPages > 1 ? false : true); const lastButton = new ButtonBuilder() .setStyle(ButtonStyle.Secondary) .setEmoji("⏩") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.LAST })) .setDisabled(totalPages > 1 ? false : true); const navigationButtons = [firstButton, prevButton, infoButton, nextButton, lastButton] satisfies ButtonBuilder[]; const modactions = pluginData.getPlugin(ModActionsPlugin); const moderationButtons = [ new ButtonBuilder() .setStyle(ButtonStyle.Primary) .setLabel("Note") .setEmoji("📝") .setDisabled(!(await modactions.hasNotePermission(executingMember, interaction.channelId))) .setCustomId(serializeCustomId({ action: ModMenuActionType.NOTE, target: interaction.targetId })), new ButtonBuilder() .setStyle(ButtonStyle.Primary) .setLabel("Warn") .setEmoji("⚠️") .setDisabled(!(await modactions.hasWarnPermission(executingMember, interaction.channelId))) .setCustomId(serializeCustomId({ action: ModMenuActionType.WARN, target: interaction.targetId })), new ButtonBuilder() .setStyle(ButtonStyle.Primary) .setLabel("Mute") .setEmoji("🔇") .setDisabled(!(await modactions.hasMutePermission(executingMember, interaction.channelId))) .setCustomId(serializeCustomId({ action: ModMenuActionType.MUTE, target: interaction.targetId })), new ButtonBuilder() .setStyle(ButtonStyle.Primary) .setLabel("Ban") .setEmoji("🚫") .setDisabled(!(await modactions.hasBanPermission(executingMember, interaction.channelId))) .setCustomId(serializeCustomId({ action: ModMenuActionType.BAN, target: interaction.targetId })), ] satisfies ButtonBuilder[]; const navigationRow = new ActionRowBuilder().addComponents(navigationButtons); const moderationRow = new ActionRowBuilder().addComponents(moderationButtons); let page = 1; await interaction .editReply({ embeds: [await loadPage(page)], components: [navigationRow, moderationRow], }) .then(async (currentPage) => { const collector = await currentPage.createMessageComponentCollector({ time: MOD_MENU_TIMEOUT, }); collector.on("collect", async (i) => { const opts = deserializeCustomId(i.customId); if (opts.action == ModMenuActionType.PAGE) { await i.deferUpdate().catch((err) => logger.error(`Mod menu defer failed: ${err}`)); } // Update displayed embed if any navigation buttons were used if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.INFO && infoEmbed != null) { infoButton .setLabel("Cases") .setEmoji("📋") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.CASES })); firstButton.setDisabled(true); prevButton.setDisabled(true); nextButton.setDisabled(true); lastButton.setDisabled(true); await i .editReply({ embeds: [infoEmbed], components: [navigationRow, moderationRow], }) .catch((err) => logger.error(`Mod menu info view failed: ${err}`)); } else if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.CASES) { infoButton .setLabel("Info") .setEmoji("ℹ") .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO })); updateNavButtonState(firstButton, prevButton, nextButton, lastButton, page, totalPages); await i .editReply({ embeds: [await loadPage(page)], components: [navigationRow, moderationRow], }) .catch((err) => logger.error(`Mod menu cases view failed: ${err}`)); } else if (opts.action == ModMenuActionType.PAGE) { let pageDelta = 0; switch (opts.target) { case ModMenuNavigationType.PREV: pageDelta = -1; break; case ModMenuNavigationType.NEXT: pageDelta = 1; break; } let newPage = 1; if (opts.target == ModMenuNavigationType.PREV || opts.target == ModMenuNavigationType.NEXT) { newPage = Math.max(Math.min(page + pageDelta, totalPages), 1); } else if (opts.target == ModMenuNavigationType.FIRST) { newPage = 1; } else if (opts.target == ModMenuNavigationType.LAST) { newPage = totalPages; } if (newPage != page) { updateNavButtonState(firstButton, prevButton, nextButton, lastButton, newPage, totalPages); await i .editReply({ embeds: [await loadPage(newPage)], components: [navigationRow, moderationRow], }) .catch((err) => logger.error(`Mod menu navigation failed: ${err}`)); page = newPage; } } else if (opts.action == ModMenuActionType.NOTE) { await launchNoteActionModal(pluginData, i as ButtonInteraction, opts.target); } else if (opts.action == ModMenuActionType.WARN) { await launchWarnActionModal(pluginData, i as ButtonInteraction, opts.target); } else if (opts.action == ModMenuActionType.MUTE) { await launchMuteActionModal(pluginData, i as ButtonInteraction, opts.target); } else if (opts.action == ModMenuActionType.BAN) { await launchBanActionModal(pluginData, i as ButtonInteraction, opts.target); } collector.resetTimer(); }); // Remove components on timeout. collector.on("end", async (_, reason) => { if (reason !== "messageDelete") { await interaction .editReply({ components: [], }) .catch((err) => logger.error(`Mod menu timeout failed: ${err}`)); } }); }) .catch((err) => logger.error(`Mod menu setup failed: ${err}`)); } function serializeCustomId(opts: ModMenuActionOpts) { return `${opts.action}:${opts.target}`; } function deserializeCustomId(customId: string): ModMenuActionOpts { const opts: ModMenuActionOpts = { action: customId.split(":")[0] as ModMenuActionType, target: customId.split(":")[1], }; return opts; } function updateNavButtonState( firstButton: ButtonBuilder, prevButton: ButtonBuilder, nextButton: ButtonBuilder, lastButton: ButtonBuilder, currentPage: number, totalPages: number, ) { if (currentPage > 1) { firstButton.setDisabled(false); prevButton.setDisabled(false); } else { firstButton.setDisabled(true); prevButton.setDisabled(true); } if (currentPage == totalPages) { nextButton.setDisabled(true); lastButton.setDisabled(true); } else { nextButton.setDisabled(false); lastButton.setDisabled(false); } } ================================================ FILE: backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts ================================================ import { PermissionFlagsBits } from "discord.js"; import { guildPluginUserContextMenuCommand } from "vety"; import { launchMuteActionModal } from "../actions/mute.js"; export const MuteCmd = guildPluginUserContextMenuCommand({ name: "Mute", defaultMemberPermissions: PermissionFlagsBits.ModerateMembers.toString(), async run({ pluginData, interaction }) { await launchMuteActionModal(pluginData, interaction, interaction.targetId); }, }); ================================================ FILE: backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts ================================================ import { PermissionFlagsBits } from "discord.js"; import { guildPluginUserContextMenuCommand } from "vety"; import { launchNoteActionModal } from "../actions/note.js"; export const NoteCmd = guildPluginUserContextMenuCommand({ name: "Note", defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(), async run({ pluginData, interaction }) { await launchNoteActionModal(pluginData, interaction, interaction.targetId); }, }); ================================================ FILE: backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts ================================================ import { PermissionFlagsBits } from "discord.js"; import { guildPluginUserContextMenuCommand } from "vety"; import { launchWarnActionModal } from "../actions/warn.js"; export const WarnCmd = guildPluginUserContextMenuCommand({ name: "Warn", defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(), async run({ pluginData, interaction }) { await launchWarnActionModal(pluginData, interaction, interaction.targetId); }, }); ================================================ FILE: backend/src/plugins/ContextMenus/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zContextMenusConfig } from "./types.js"; export const contextMenuPluginDocs: ZeppelinPluginDocs = { type: "stable", configSchema: zContextMenusConfig, prettyName: "Context menu", }; ================================================ FILE: backend/src/plugins/ContextMenus/types.ts ================================================ import { APIEmbed, Awaitable } from "discord.js"; import { BasePluginType } from "vety"; import { z } from "zod"; import { GuildCases } from "../../data/GuildCases.js"; export const zContextMenusConfig = z.strictObject({ can_use: z.boolean().default(false), can_open_mod_menu: z.boolean().default(false), }); export interface ContextMenuPluginType extends BasePluginType { configSchema: typeof zContextMenusConfig; state: { cases: GuildCases; }; } export const enum ModMenuActionType { PAGE = "page", NOTE = "note", WARN = "warn", CLEAN = "clean", MUTE = "mute", BAN = "ban", } export const enum ModMenuNavigationType { FIRST = "first", PREV = "prev", NEXT = "next", LAST = "last", INFO = "info", CASES = "cases", } export interface ModMenuActionOpts { action: ModMenuActionType; target: string; } export type LoadModMenuPageFn = (page: number) => Awaitable; ================================================ FILE: backend/src/plugins/Counters/CountersPlugin.ts ================================================ import { EventEmitter } from "events"; import { PluginOverride, guildPlugin } from "vety"; import { GuildCounters } from "../../data/GuildCounters.js"; import { CounterTrigger, buildCounterConditionString, getReverseCounterComparisonOp, parseCounterConditionString, } from "../../data/entities/CounterTrigger.js"; import { makePublicFn } from "../../pluginUtils.js"; import { MINUTES, convertDelayStringToMS } from "../../utils.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { AddCounterCmd } from "./commands/AddCounterCmd.js"; import { CountersListCmd } from "./commands/CountersListCmd.js"; import { ResetAllCounterValuesCmd } from "./commands/ResetAllCounterValuesCmd.js"; import { ResetCounterCmd } from "./commands/ResetCounterCmd.js"; import { SetCounterCmd } from "./commands/SetCounterCmd.js"; import { ViewCounterCmd } from "./commands/ViewCounterCmd.js"; import { changeCounterValue } from "./functions/changeCounterValue.js"; import { counterExists } from "./functions/counterExists.js"; import { decayCounter } from "./functions/decayCounter.js"; import { getPrettyNameForCounter } from "./functions/getPrettyNameForCounter.js"; import { getPrettyNameForCounterTrigger } from "./functions/getPrettyNameForCounterTrigger.js"; import { offCounterEvent } from "./functions/offCounterEvent.js"; import { onCounterEvent } from "./functions/onCounterEvent.js"; import { setCounterValue } from "./functions/setCounterValue.js"; import { CountersPluginType, zCountersConfig } from "./types.js"; const DECAY_APPLY_INTERVAL = 5 * MINUTES; const defaultOverrides: Array> = [ { level: ">=50", config: { can_view: true, }, }, { level: ">=100", config: { can_edit: true, }, }, ]; /** * The Counters plugin keeps track of simple integer values that are tied to a user, channel, both, or neither — "counters". * These values can be changed using the functions in the plugin's public interface. * These values can also be set to automatically decay over time. * * Triggers can be registered that check for a specific condition, e.g. "when this counter is over 100". * Triggers are checked against every time a counter's value changes, and will emit an event when triggered. * A single trigger can only trigger once per user/channel/in general, depending on how specific the counter is (e.g. a per-user trigger can only trigger once per user). * After being triggered, a trigger is "reset" if the counter value no longer matches the trigger (e.g. drops to 100 or below in the above example). After this, that trigger can be triggered again. */ export const CountersPlugin = guildPlugin()({ name: "counters", configSchema: zCountersConfig, defaultOverrides, public(pluginData) { return { counterExists: makePublicFn(pluginData, counterExists), changeCounterValue: makePublicFn(pluginData, changeCounterValue), setCounterValue: makePublicFn(pluginData, setCounterValue), getPrettyNameForCounter: makePublicFn(pluginData, getPrettyNameForCounter), getPrettyNameForCounterTrigger: makePublicFn(pluginData, getPrettyNameForCounterTrigger), onCounterEvent: makePublicFn(pluginData, onCounterEvent), offCounterEvent: makePublicFn(pluginData, offCounterEvent), }; }, // prettier-ignore messageCommands: [ CountersListCmd, ViewCounterCmd, AddCounterCmd, SetCounterCmd, ResetCounterCmd, ResetAllCounterValuesCmd, ], async beforeLoad(pluginData) { const { state, guild } = pluginData; state.counters = new GuildCounters(guild.id); state.events = new EventEmitter() as any; state.counterTriggersByCounterId = new Map(); const activeTriggerIds: number[] = []; // Initialize and store the IDs of each of the counters internally state.counterIds = {}; const config = pluginData.config.get(); for (const [counterName, counter] of Object.entries(config.counters)) { const dbCounter = await state.counters.findOrCreateCounter(counterName, counter.per_channel, counter.per_user); state.counterIds[counterName] = dbCounter.id; const thisCounterTriggers: CounterTrigger[] = []; state.counterTriggersByCounterId.set(dbCounter.id, thisCounterTriggers); // Initialize triggers for (const [triggerName, trigger] of Object.entries(counter.triggers)) { const parsedCondition = parseCounterConditionString(trigger.condition)!; const rawReverseCondition = trigger.reverse_condition || buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]); const parsedReverseCondition = parseCounterConditionString(rawReverseCondition)!; const counterTrigger = await state.counters.initCounterTrigger( dbCounter.id, triggerName, parsedCondition[0], parsedCondition[1], parsedReverseCondition[0], parsedReverseCondition[1], ); activeTriggerIds.push(counterTrigger.id); thisCounterTriggers.push(counterTrigger); } } // Mark old/unused counters to be deleted later await state.counters.markUnusedCountersToBeDeleted([...Object.values(state.counterIds)]); // Mark old/unused triggers to be deleted later await state.counters.markUnusedTriggersToBeDeleted(activeTriggerIds); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, async afterLoad(pluginData) { const { state } = pluginData; const config = pluginData.config.get(); // Start decay timers state.decayTimers = []; for (const [counterName, counter] of Object.entries(config.counters)) { if (!counter.decay) { continue; } const decay = counter.decay; const decayPeriodMs = convertDelayStringToMS(decay.every)!; if (decayPeriodMs === 0) { continue; } state.decayTimers.push( setInterval(() => { decayCounter(pluginData, counterName, decayPeriodMs, decay.amount); }, DECAY_APPLY_INTERVAL), ); } }, beforeUnload(pluginData) { const { state } = pluginData; if (state.decayTimers) { for (const interval of state.decayTimers) { clearInterval(interval); } } (state.events as any).removeAllListeners(); }, }); ================================================ FILE: backend/src/plugins/Counters/commands/AddCounterCmd.ts ================================================ import { Snowflake, TextChannel } from "discord.js"; import { guildPluginMessageCommand } from "vety"; import { waitForReply } from "vety/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { UnknownUser, resolveUser } from "../../../utils.js"; import { changeCounterValue } from "../functions/changeCounterValue.js"; import { CountersPluginType } from "../types.js"; export const AddCounterCmd = guildPluginMessageCommand()({ trigger: ["counters add", "counter add", "addcounter"], permission: "can_edit", signature: [ { counterName: ct.string(), amount: ct.number(), }, { counterName: ct.string(), user: ct.resolvedUser(), amount: ct.number(), }, { counterName: ct.string(), channel: ct.textChannel(), amount: ct.number(), }, { counterName: ct.string(), channel: ct.textChannel(), user: ct.resolvedUser(), amount: ct.number(), }, { counterName: ct.string(), user: ct.resolvedUser(), channel: ct.textChannel(), amount: ct.number(), }, ], async run({ pluginData, message, args }) { const config = await pluginData.config.getForMessage(message); const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_edit === false) { void pluginData.state.common.sendErrorMessage(message, `Missing permissions to edit this counter's value`); return; } if (args.channel && !counter.per_channel) { void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`); return; } if (args.user && !counter.per_user) { void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`); return; } let channel = args.channel; if (!channel && counter.per_channel) { message.channel.send(`Which channel's counter value would you like to add to?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake); if (!potentialChannel || !(potentialChannel instanceof TextChannel)) { void pluginData.state.common.sendErrorMessage(message, "Channel is not a text channel, cancelling"); return; } channel = potentialChannel; } let user = args.user; if (!user && counter.per_user) { message.channel.send(`Which user's counter value would you like to add to?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialUser = await resolveUser(pluginData.client, reply.content, "Counters:AddCounterCmd"); if (!potentialUser || potentialUser instanceof UnknownUser) { void pluginData.state.common.sendErrorMessage(message, "Unknown user, cancelling"); return; } user = potentialUser; } let amount = args.amount; if (!amount) { message.channel.send("How much would you like to add to the counter's value?"); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialAmount = parseInt(reply.content, 10); if (!potentialAmount) { void pluginData.state.common.sendErrorMessage(message, "Not a number, cancelling"); return; } amount = potentialAmount; } await changeCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, amount); const newValue = await pluginData.state.counters.getCurrentValue(counterId, channel?.id ?? null, user?.id ?? null); if (channel && user) { message.channel.send( `Added ${amount} to **${args.counterName}** for <@!${user.id}> in <#${channel.id}>. The value is now ${newValue}.`, ); } else if (channel) { message.channel.send( `Added ${amount} to **${args.counterName}** in <#${channel.id}>. The value is now ${newValue}.`, ); } else if (user) { message.channel.send( `Added ${amount} to **${args.counterName}** for <@!${user.id}>. The value is now ${newValue}.`, ); } else { message.channel.send(`Added ${amount} to **${args.counterName}**. The value is now ${newValue}.`); } }, }); ================================================ FILE: backend/src/plugins/Counters/commands/CountersListCmd.ts ================================================ import { guildPluginMessageCommand } from "vety"; import { trimMultilineString, ucfirst } from "../../../utils.js"; import { getGuildPrefix } from "../../../utils/getGuildPrefix.js"; import { CountersPluginType } from "../types.js"; export const CountersListCmd = guildPluginMessageCommand()({ trigger: ["counters list", "counter list", "counters"], permission: "can_view", signature: {}, async run({ pluginData, message }) { const config = await pluginData.config.getForMessage(message); const countersToShow = Object.entries(config.counters).filter(([, c]) => c.can_view !== false); if (!countersToShow.length) { void pluginData.state.common.sendErrorMessage(message, "No counters are configured for this server"); return; } const counterLines = countersToShow.map(([counterName, counter]) => { const title = counter.pretty_name ? `**${counter.pretty_name}** (\`${counterName}\`)` : `\`${counterName}\``; const types: string[] = []; if (counter.per_user) types.push("per user"); if (counter.per_channel) types.push("per channel"); const typeInfo = types.length ? types.join(", ") : "global"; const decayInfo = counter.decay ? `decays ${counter.decay.amount} every ${counter.decay.every}` : null; const info = [typeInfo, decayInfo].filter(Boolean); return `${title}\n${ucfirst(info.join("; "))}`; }); const hintLines = [`Use \`${getGuildPrefix(pluginData)}counters view \` to view a counter's value`]; if (config.can_edit) { hintLines.push(`Use \`${getGuildPrefix(pluginData)}counters set \` to change a counter's value`); } if (config.can_reset_all) { hintLines.push(`Use \`${getGuildPrefix(pluginData)}counters reset_all \` to reset a counter entirely`); } message.channel.send( trimMultilineString(` ${counterLines.join("\n\n")} ${hintLines.join("\n")} `), ); }, }); ================================================ FILE: backend/src/plugins/Counters/commands/ResetAllCounterValuesCmd.ts ================================================ import { guildPluginMessageCommand } from "vety"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { confirm, noop, trimMultilineString } from "../../../utils.js"; import { resetAllCounterValues } from "../functions/resetAllCounterValues.js"; import { CountersPluginType } from "../types.js"; export const ResetAllCounterValuesCmd = guildPluginMessageCommand()({ trigger: ["counters reset_all"], permission: "can_reset_all", signature: { counterName: ct.string(), }, async run({ pluginData, message, args }) { const config = await pluginData.config.getForMessage(message); const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_reset_all === false) { void pluginData.state.common.sendErrorMessage( message, `Missing permissions to reset all of this counter's values`, ); return; } const confirmed = await confirm(message, message.author.id, { content: trimMultilineString(` Do you want to reset **ALL** values for counter **${args.counterName}**? This will reset the counter for **all** users and channels. **Note:** This will *not* trigger any triggers or counter triggers. `), }); if (!confirmed) { void pluginData.state.common.sendErrorMessage(message, "Cancelled"); return; } const loadingMessage = await message.channel .send(`Resetting counter **${args.counterName}**. This might take a while. Please don't reload the config.`) .catch(() => null); await resetAllCounterValues(pluginData, args.counterName); loadingMessage?.delete().catch(noop); void pluginData.state.common.sendSuccessMessage( message, `All counter values for **${args.counterName}** have been reset`, ); pluginData.getVetyInstance().reloadGuild(pluginData.guild.id); }, }); ================================================ FILE: backend/src/plugins/Counters/commands/ResetCounterCmd.ts ================================================ import { Snowflake, TextChannel } from "discord.js"; import { guildPluginMessageCommand } from "vety"; import { waitForReply } from "vety/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { UnknownUser, resolveUser } from "../../../utils.js"; import { setCounterValue } from "../functions/setCounterValue.js"; import { CountersPluginType } from "../types.js"; export const ResetCounterCmd = guildPluginMessageCommand()({ trigger: ["counters reset", "counter reset", "resetcounter"], permission: "can_edit", signature: [ { counterName: ct.string(), }, { counterName: ct.string(), user: ct.resolvedUser(), }, { counterName: ct.string(), channel: ct.textChannel(), }, { counterName: ct.string(), channel: ct.textChannel(), user: ct.resolvedUser(), }, { counterName: ct.string(), user: ct.resolvedUser(), channel: ct.textChannel(), }, ], async run({ pluginData, message, args }) { const config = await pluginData.config.getForMessage(message); const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_edit === false) { void pluginData.state.common.sendErrorMessage(message, `Missing permissions to reset this counter's value`); return; } if (args.channel && !counter.per_channel) { void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`); return; } if (args.user && !counter.per_user) { void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`); return; } let channel = args.channel; if (!channel && counter.per_channel) { message.channel.send(`Which channel's counter value would you like to reset?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake); if (!potentialChannel || !(potentialChannel instanceof TextChannel)) { void pluginData.state.common.sendErrorMessage(message, "Channel is not a text channel, cancelling"); return; } channel = potentialChannel; } let user = args.user; if (!user && counter.per_user) { message.channel.send(`Which user's counter value would you like to reset?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialUser = await resolveUser(pluginData.client, reply.content, "Counters:ResetCounterCmd"); if (!potentialUser || potentialUser instanceof UnknownUser) { void pluginData.state.common.sendErrorMessage(message, "Unknown user, cancelling"); return; } user = potentialUser; } await setCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, counter.initial_value); if (channel && user) { message.channel.send(`Reset **${args.counterName}** for <@!${user.id}> in <#${channel.id}>`); } else if (channel) { message.channel.send(`Reset **${args.counterName}** in <#${channel.id}>`); } else if (user) { message.channel.send(`Reset **${args.counterName}** for <@!${user.id}>`); } else { message.channel.send(`Reset **${args.counterName}**`); } }, }); ================================================ FILE: backend/src/plugins/Counters/commands/SetCounterCmd.ts ================================================ import { Snowflake, TextChannel } from "discord.js"; import { guildPluginMessageCommand } from "vety"; import { waitForReply } from "vety/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { UnknownUser, resolveUser } from "../../../utils.js"; import { setCounterValue } from "../functions/setCounterValue.js"; import { CountersPluginType } from "../types.js"; export const SetCounterCmd = guildPluginMessageCommand()({ trigger: ["counters set", "counter set", "setcounter"], permission: "can_edit", signature: [ { counterName: ct.string(), value: ct.number(), }, { counterName: ct.string(), user: ct.resolvedUser(), value: ct.number(), }, { counterName: ct.string(), channel: ct.textChannel(), value: ct.number(), }, { counterName: ct.string(), channel: ct.textChannel(), user: ct.resolvedUser(), value: ct.number(), }, { counterName: ct.string(), user: ct.resolvedUser(), channel: ct.textChannel(), value: ct.number(), }, ], async run({ pluginData, message, args }) { const config = await pluginData.config.getForMessage(message); const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_edit === false) { void pluginData.state.common.sendErrorMessage(message, `Missing permissions to edit this counter's value`); return; } if (args.channel && !counter.per_channel) { void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`); return; } if (args.user && !counter.per_user) { void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`); return; } let channel = args.channel; if (!channel && counter.per_channel) { message.channel.send(`Which channel's counter value would you like to change?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake); if (!potentialChannel || !(potentialChannel instanceof TextChannel)) { void pluginData.state.common.sendErrorMessage(message, "Channel is not a text channel, cancelling"); return; } channel = potentialChannel; } let user = args.user; if (!user && counter.per_user) { message.channel.send(`Which user's counter value would you like to change?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialUser = await resolveUser(pluginData.client, reply.content, "Counters:SetCounterCmd"); if (!potentialUser || potentialUser instanceof UnknownUser) { void pluginData.state.common.sendErrorMessage(message, "Unknown user, cancelling"); return; } user = potentialUser; } let value = args.value; if (!value) { message.channel.send("What would you like to set the counter's value to?"); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialValue = parseInt(reply.content, 10); if (Number.isNaN(potentialValue)) { void pluginData.state.common.sendErrorMessage(message, "Not a number, cancelling"); return; } value = potentialValue; } if (value < 0) { void pluginData.state.common.sendErrorMessage(message, "Cannot set counter value below 0"); return; } await setCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, value); if (channel && user) { message.channel.send(`Set **${args.counterName}** for <@!${user.id}> in <#${channel.id}> to ${value}`); } else if (channel) { message.channel.send(`Set **${args.counterName}** in <#${channel.id}> to ${value}`); } else if (user) { message.channel.send(`Set **${args.counterName}** for <@!${user.id}> to ${value}`); } else { message.channel.send(`Set **${args.counterName}** to ${value}`); } }, }); ================================================ FILE: backend/src/plugins/Counters/commands/ViewCounterCmd.ts ================================================ import { Snowflake } from "discord.js"; import { guildPluginMessageCommand } from "vety"; import { waitForReply } from "vety/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { resolveUser, UnknownUser } from "../../../utils.js"; import { CountersPluginType } from "../types.js"; export const ViewCounterCmd = guildPluginMessageCommand()({ trigger: ["counters view", "counter view", "viewcounter", "counter"], permission: "can_view", signature: [ { counterName: ct.string(), }, { counterName: ct.string(), user: ct.resolvedUser(), }, { counterName: ct.string(), channel: ct.guildTextBasedChannel(), }, { counterName: ct.string(), channel: ct.guildTextBasedChannel(), user: ct.resolvedUser(), }, { counterName: ct.string(), user: ct.resolvedUser(), channel: ct.guildTextBasedChannel(), }, ], async run({ pluginData, message, args }) { const config = await pluginData.config.getForMessage(message); const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_view === false) { void pluginData.state.common.sendErrorMessage(message, `Missing permissions to view this counter's value`); return; } if (args.channel && !counter.per_channel) { void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`); return; } if (args.user && !counter.per_user) { void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`); return; } let channel = args.channel; if (!channel && counter.per_channel) { message.channel.send(`Which channel's counter value would you like to view?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake); if (!potentialChannel?.isTextBased()) { void pluginData.state.common.sendErrorMessage(message, "Channel is not a text channel, cancelling"); return; } channel = potentialChannel; } let user = args.user; if (!user && counter.per_user) { message.channel.send(`Which user's counter value would you like to view?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialUser = await resolveUser(pluginData.client, reply.content, "Counters:ViewCounterCmd"); if (!potentialUser || potentialUser instanceof UnknownUser) { void pluginData.state.common.sendErrorMessage(message, "Unknown user, cancelling"); return; } user = potentialUser; } const value = await pluginData.state.counters.getCurrentValue(counterId, channel?.id ?? null, user?.id ?? null); const finalValue = value ?? counter.initial_value; if (channel && user) { message.channel.send(`**${args.counterName}** for <@!${user.id}> in <#${channel.id}> is ${finalValue}`); } else if (channel) { message.channel.send(`**${args.counterName}** in <#${channel.id}> is ${finalValue}`); } else if (user) { message.channel.send(`**${args.counterName}** for <@!${user.id}> is ${finalValue}`); } else { message.channel.send(`**${args.counterName}** is ${finalValue}`); } }, }); ================================================ FILE: backend/src/plugins/Counters/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zCountersConfig } from "./types.js"; export const countersPluginDocs: ZeppelinPluginDocs = { type: "stable", configSchema: zCountersConfig, prettyName: "Counters", description: "Keep track of per-user, per-channel, or global numbers and trigger specific actions based on this number", configurationGuide: "See Counters setup guide", }; ================================================ FILE: backend/src/plugins/Counters/functions/changeCounterValue.ts ================================================ import { GuildPluginData } from "vety"; import { counterIdLock } from "../../../utils/lockNameHelpers.js"; import { CountersPluginType } from "../types.js"; import { checkCounterTrigger } from "./checkCounterTrigger.js"; import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger.js"; export async function changeCounterValue( pluginData: GuildPluginData, counterName: string, channelId: string | null, userId: string | null, change: number, ) { const config = pluginData.config.get(); const counter = config.counters[counterName]; if (!counter) { throw new Error(`Unknown counter: ${counterName}`); } if (counter.per_channel && !channelId) { throw new Error(`Counter is per channel but no channel ID was supplied`); } if (counter.per_user && !userId) { throw new Error(`Counter is per user but no user ID was supplied`); } channelId = counter.per_channel ? channelId : null; userId = counter.per_user ? userId : null; const counterId = pluginData.state.counterIds[counterName]; const lock = await pluginData.locks.acquire(counterIdLock(counterId)); await pluginData.state.counters.changeCounterValue(counterId, channelId, userId, change, counter.initial_value); // Check for trigger matches, if any, when the counter value changes const triggers = pluginData.state.counterTriggersByCounterId.get(counterId); if (triggers) { const triggersArr = Array.from(triggers.values()); await Promise.all( triggersArr.map((trigger) => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)), ); await Promise.all( triggersArr.map((trigger) => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)), ); } lock.unlock(); } ================================================ FILE: backend/src/plugins/Counters/functions/checkAllValuesForReverseTrigger.ts ================================================ import { GuildPluginData } from "vety"; import { CounterTrigger } from "../../../data/entities/CounterTrigger.js"; import { CountersPluginType } from "../types.js"; import { emitCounterEvent } from "./emitCounterEvent.js"; export async function checkAllValuesForReverseTrigger( pluginData: GuildPluginData, counterName: string, counterTrigger: CounterTrigger, ) { const triggeredContexts = await pluginData.state.counters.checkAllValuesForReverseTrigger(counterTrigger); for (const context of triggeredContexts) { emitCounterEvent(pluginData, "reverseTrigger", counterName, counterTrigger.name, context.channelId, context.userId); } } ================================================ FILE: backend/src/plugins/Counters/functions/checkAllValuesForTrigger.ts ================================================ import { GuildPluginData } from "vety"; import { CounterTrigger } from "../../../data/entities/CounterTrigger.js"; import { CountersPluginType } from "../types.js"; import { emitCounterEvent } from "./emitCounterEvent.js"; export async function checkAllValuesForTrigger( pluginData: GuildPluginData, counterName: string, counterTrigger: CounterTrigger, ) { const triggeredContexts = await pluginData.state.counters.checkAllValuesForTrigger(counterTrigger); for (const context of triggeredContexts) { emitCounterEvent(pluginData, "trigger", counterName, counterTrigger.name, context.channelId, context.userId); } } ================================================ FILE: backend/src/plugins/Counters/functions/checkCounterTrigger.ts ================================================ import { GuildPluginData } from "vety"; import { CounterTrigger } from "../../../data/entities/CounterTrigger.js"; import { CountersPluginType } from "../types.js"; import { emitCounterEvent } from "./emitCounterEvent.js"; export async function checkCounterTrigger( pluginData: GuildPluginData, counterName: string, counterTrigger: CounterTrigger, channelId: string | null, userId: string | null, ) { const triggered = await pluginData.state.counters.checkForTrigger(counterTrigger, channelId, userId); if (triggered) { await emitCounterEvent(pluginData, "trigger", counterName, counterTrigger.name, channelId, userId); } } ================================================ FILE: backend/src/plugins/Counters/functions/checkReverseCounterTrigger.ts ================================================ import { GuildPluginData } from "vety"; import { CounterTrigger } from "../../../data/entities/CounterTrigger.js"; import { CountersPluginType } from "../types.js"; import { emitCounterEvent } from "./emitCounterEvent.js"; export async function checkReverseCounterTrigger( pluginData: GuildPluginData, counterName: string, counterTrigger: CounterTrigger, channelId: string | null, userId: string | null, ) { const triggered = await pluginData.state.counters.checkForReverseTrigger(counterTrigger, channelId, userId); if (triggered) { await emitCounterEvent(pluginData, "reverseTrigger", counterName, counterTrigger.name, channelId, userId); } } ================================================ FILE: backend/src/plugins/Counters/functions/counterExists.ts ================================================ import { GuildPluginData } from "vety"; import { CountersPluginType } from "../types.js"; export function counterExists(pluginData: GuildPluginData, counterName: string) { const config = pluginData.config.get(); return config.counters[counterName] != null; } ================================================ FILE: backend/src/plugins/Counters/functions/decayCounter.ts ================================================ import { GuildPluginData } from "vety"; import { counterIdLock } from "../../../utils/lockNameHelpers.js"; import { CountersPluginType } from "../types.js"; import { checkAllValuesForReverseTrigger } from "./checkAllValuesForReverseTrigger.js"; import { checkAllValuesForTrigger } from "./checkAllValuesForTrigger.js"; export async function decayCounter( pluginData: GuildPluginData, counterName: string, decayPeriodMS: number, decayAmount: number, ) { const config = pluginData.config.get(); const counter = config.counters[counterName]; if (!counter) { throw new Error(`Unknown counter: ${counterName}`); } const counterId = pluginData.state.counterIds[counterName]; const lock = await pluginData.locks.acquire(counterIdLock(counterId)); await pluginData.state.counters.decay(counterId, decayPeriodMS, decayAmount); // Check for trigger matches, if any, when the counter value changes const triggers = pluginData.state.counterTriggersByCounterId.get(counterId); if (triggers) { const triggersArr = Array.from(triggers.values()); await Promise.all(triggersArr.map((trigger) => checkAllValuesForTrigger(pluginData, counterName, trigger))); await Promise.all(triggersArr.map((trigger) => checkAllValuesForReverseTrigger(pluginData, counterName, trigger))); } lock.unlock(); } ================================================ FILE: backend/src/plugins/Counters/functions/emitCounterEvent.ts ================================================ import { GuildPluginData } from "vety"; import { CounterEvents, CountersPluginType } from "../types.js"; export function emitCounterEvent( pluginData: GuildPluginData, event: TEvent, ...rest: Parameters ) { return pluginData.state.events.emit(event, ...rest); } ================================================ FILE: backend/src/plugins/Counters/functions/getPrettyNameForCounter.ts ================================================ import { GuildPluginData } from "vety"; import { CountersPluginType } from "../types.js"; export function getPrettyNameForCounter(pluginData: GuildPluginData, counterName: string) { const config = pluginData.config.get(); const counter = config.counters[counterName]; return counter ? counter.pretty_name || counterName : "Unknown Counter"; } ================================================ FILE: backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts ================================================ import { GuildPluginData } from "vety"; import { CountersPluginType } from "../types.js"; export function getPrettyNameForCounterTrigger( pluginData: GuildPluginData, counterName: string, triggerName: string, ) { const config = pluginData.config.get(); const counter = config.counters[counterName]; if (!counter) { return "Unknown Counter Trigger"; } const trigger = counter.triggers[triggerName]; return trigger ? trigger.pretty_name || triggerName : "Unknown Counter Trigger"; } ================================================ FILE: backend/src/plugins/Counters/functions/offCounterEvent.ts ================================================ import { GuildPluginData } from "vety"; import { CounterEventEmitter, CountersPluginType } from "../types.js"; export function offCounterEvent( pluginData: GuildPluginData, ...rest: Parameters ) { return pluginData.state.events.off(...rest); } ================================================ FILE: backend/src/plugins/Counters/functions/onCounterEvent.ts ================================================ import { GuildPluginData } from "vety"; import { CounterEvents, CountersPluginType } from "../types.js"; export function onCounterEvent( pluginData: GuildPluginData, event: TEvent, listener: CounterEvents[TEvent], ) { return pluginData.state.events.on(event, listener); } ================================================ FILE: backend/src/plugins/Counters/functions/resetAllCounterValues.ts ================================================ import { GuildPluginData } from "vety"; import { counterIdLock } from "../../../utils/lockNameHelpers.js"; import { CountersPluginType } from "../types.js"; export async function resetAllCounterValues(pluginData: GuildPluginData, counterName: string) { const config = pluginData.config.get(); const counter = config.counters[counterName]; if (!counter) { throw new Error(`Unknown counter: ${counterName}`); } const counterId = pluginData.state.counterIds[counterName]; const lock = await pluginData.locks.acquire(counterIdLock(counterId)); await pluginData.state.counters.resetAllCounterValues(counterId); lock.unlock(); } ================================================ FILE: backend/src/plugins/Counters/functions/setCounterValue.ts ================================================ import { GuildPluginData } from "vety"; import { counterIdLock } from "../../../utils/lockNameHelpers.js"; import { CountersPluginType } from "../types.js"; import { checkCounterTrigger } from "./checkCounterTrigger.js"; import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger.js"; export async function setCounterValue( pluginData: GuildPluginData, counterName: string, channelId: string | null, userId: string | null, value: number, ) { const config = pluginData.config.get(); const counter = config.counters[counterName]; if (!counter) { throw new Error(`Unknown counter: ${counterName}`); } if (counter.per_channel && !channelId) { throw new Error(`Counter is per channel but no channel ID was supplied`); } if (counter.per_user && !userId) { throw new Error(`Counter is per user but no user ID was supplied`); } channelId = counter.per_channel ? channelId : null; userId = counter.per_user ? userId : null; const counterId = pluginData.state.counterIds[counterName]; const lock = await pluginData.locks.acquire(counterIdLock(counterId)); await pluginData.state.counters.setCounterValue(counterId, channelId, userId, value); // Check for trigger matches, if any, when the counter value changes const triggers = pluginData.state.counterTriggersByCounterId.get(counterId); if (triggers) { const triggersArr = Array.from(triggers.values()); await Promise.all( triggersArr.map((trigger) => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)), ); await Promise.all( triggersArr.map((trigger) => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)), ); } lock.unlock(); } ================================================ FILE: backend/src/plugins/Counters/types.ts ================================================ import { EventEmitter } from "events"; import { BasePluginType, pluginUtils } from "vety"; import { z } from "zod"; import { GuildCounters, MAX_COUNTER_VALUE, MIN_COUNTER_VALUE } from "../../data/GuildCounters.js"; import { CounterTrigger, buildCounterConditionString, getReverseCounterComparisonOp, parseCounterConditionString, } from "../../data/entities/CounterTrigger.js"; import { zBoundedCharacters, zBoundedRecord, zDelayString } from "../../utils.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import Timeout = NodeJS.Timeout; const MAX_COUNTERS = 5; const MAX_TRIGGERS_PER_COUNTER = 5; export const zTrigger = z.strictObject({ // Dummy type because name gets replaced by the property key in transform() pretty_name: zBoundedCharacters(0, 100).nullable().default(null), condition: zBoundedCharacters(1, 64).refine((str) => parseCounterConditionString(str) !== null, { message: "Invalid counter trigger condition", }), reverse_condition: zBoundedCharacters(1, 64) .refine((str) => parseCounterConditionString(str) !== null, { message: "Invalid counter trigger reverse condition", }) .optional(), }); const zTriggerFromString = zBoundedCharacters(0, 100).transform((val, ctx) => { const parsedCondition = parseCounterConditionString(val); if (!parsedCondition) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid counter trigger condition", }); return z.NEVER; } return { pretty_name: null, condition: buildCounterConditionString(parsedCondition[0], parsedCondition[1]), reverse_condition: buildCounterConditionString( getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1], ), }; }); const zTriggerInput = z.union([zTrigger, zTriggerFromString]); export const zCounter = z.strictObject({ pretty_name: zBoundedCharacters(0, 100).nullable().default(null), per_channel: z.boolean().default(false), per_user: z.boolean().default(false), initial_value: z.number().min(MIN_COUNTER_VALUE).max(MAX_COUNTER_VALUE).default(0), triggers: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zTriggerInput), 1, MAX_TRIGGERS_PER_COUNTER), decay: z .strictObject({ amount: z.number(), every: zDelayString, }) .nullable() .default(null), can_view: z.boolean().nullable().default(null), can_edit: z.boolean().nullable().default(null), can_reset_all: z.boolean().nullable().default(null), }); export const zCountersConfig = z.strictObject({ counters: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zCounter), 0, MAX_COUNTERS).default({}), can_view: z.boolean().default(false), can_edit: z.boolean().default(false), can_reset_all: z.boolean().default(false), }); export interface CounterEvents { trigger: (counterName: string, triggerName: string, channelId: string | null, userId: string | null) => void; reverseTrigger: (counterName: string, triggerName: string, channelId: string | null, userId: string | null) => void; } export interface CounterEventEmitter extends EventEmitter { on(event: U, listener: CounterEvents[U]): this; emit(event: U, ...args: Parameters): boolean; } export interface CountersPluginType extends BasePluginType { configSchema: typeof zCountersConfig; state: { counters: GuildCounters; counterIds: Record; decayTimers: Timeout[]; events: CounterEventEmitter; counterTriggersByCounterId: Map; common: pluginUtils.PluginPublicInterface; }; } ================================================ FILE: backend/src/plugins/CustomEvents/ActionError.ts ================================================ export class ActionError extends Error {} ================================================ FILE: backend/src/plugins/CustomEvents/CustomEventsPlugin.ts ================================================ import { GuildChannel, GuildMember, User } from "discord.js"; import { guildPlugin, guildPluginMessageCommand, parseSignature } from "vety"; import { TSignature } from "knub-command-manager"; import { commandTypes } from "../../commandTypes.js"; import { TemplateSafeValueContainer, createTypedTemplateSafeValueContainer } from "../../templateFormatter.js"; import { UnknownUser } from "../../utils.js"; import { isScalar } from "../../utils/isScalar.js"; import { channelToTemplateSafeChannel, memberToTemplateSafeMember, messageToTemplateSafeMessage, userToTemplateSafeUser, } from "../../utils/templateSafeObjects.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { runEvent } from "./functions/runEvent.js"; import { CustomEventsPluginType, zCustomEventsConfig } from "./types.js"; export const CustomEventsPlugin = guildPlugin()({ name: "custom_events", dependencies: () => [LogsPlugin], configSchema: zCustomEventsConfig, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, afterLoad(pluginData) { const config = pluginData.config.get(); for (const [key, event] of Object.entries(config.events)) { if (event.trigger.type === "command") { const signature: TSignature = event.trigger.params ? parseSignature(event.trigger.params, commandTypes) : {}; const eventCommand = guildPluginMessageCommand()({ trigger: event.trigger.name, permission: `events.${key}.trigger.can_use`, signature, run({ message, args }) { const safeArgs = new TemplateSafeValueContainer(); for (const [argKey, argValue] of Object.entries(args as Record)) { if (argValue instanceof User || argValue instanceof UnknownUser) { safeArgs[argKey] = userToTemplateSafeUser(argValue); } else if (argValue instanceof GuildMember) { safeArgs[argKey] = memberToTemplateSafeMember(argValue); } else if (argValue instanceof GuildChannel && argValue.isTextBased()) { safeArgs[argKey] = channelToTemplateSafeChannel(argValue); } else if (isScalar(argValue)) { safeArgs[argKey] = argValue; } } const values = createTypedTemplateSafeValueContainer({ ...safeArgs, msg: messageToTemplateSafeMessage(message), }); runEvent(pluginData, event, { msg: message, args }, values); }, }); pluginData.messageCommands.add(eventCommand); } } }, beforeUnload() { // TODO: Run clearTriggers() once we actually have something there }, }); ================================================ FILE: backend/src/plugins/CustomEvents/actions/addRoleAction.ts ================================================ import { GuildPluginData } from "vety"; import { z } from "zod"; import { canActOn } from "../../../pluginUtils.js"; import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveMember, zBoundedCharacters, zSnowflake } from "../../../utils.js"; import { ActionError } from "../ActionError.js"; import { catchTemplateError } from "../catchTemplateError.js"; import { CustomEventsPluginType, TCustomEvent } from "../types.js"; export const zAddRoleAction = z.strictObject({ type: z.literal("add_role"), target: zBoundedCharacters(0, 100), role: z.union([zSnowflake, z.array(zSnowflake)]), }); export type TAddRoleAction = z.infer; export async function addRoleAction( pluginData: GuildPluginData, action: TAddRoleAction, values: TemplateSafeValueContainer, event: TCustomEvent, eventData: any, ) { const targetId = await catchTemplateError( () => renderTemplate(action.target, values, false), "Invalid target format", ); const target = await resolveMember(pluginData.client, pluginData.guild, targetId); if (!target) throw new ActionError(`Unknown target member: ${targetId}`); if (event.trigger.type === "command" && !canActOn(pluginData, eventData.msg.member, target)) { throw new ActionError("Missing permissions"); } const rolesToAdd = (Array.isArray(action.role) ? action.role : [action.role]).filter( (id) => !target.roles.cache.has(id), ); if (rolesToAdd.length === 0) { throw new ActionError("Target already has the role(s) specified"); } await target.roles.add(rolesToAdd); } ================================================ FILE: backend/src/plugins/CustomEvents/actions/createCaseAction.ts ================================================ import { GuildPluginData } from "vety"; import { z } from "zod"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter.js"; import { zBoundedCharacters } from "../../../utils.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { ActionError } from "../ActionError.js"; import { catchTemplateError } from "../catchTemplateError.js"; import { CustomEventsPluginType, TCustomEvent } from "../types.js"; export const zCreateCaseAction = z.strictObject({ type: z.literal("create_case"), case_type: zBoundedCharacters(0, 32), mod: zBoundedCharacters(0, 100), target: zBoundedCharacters(0, 100), reason: zBoundedCharacters(0, 4000), }); export type TCreateCaseAction = z.infer; export async function createCaseAction( pluginData: GuildPluginData, action: TCreateCaseAction, values: TemplateSafeValueContainer, event: TCustomEvent, eventData: any, // eslint-disable-line @typescript-eslint/no-unused-vars ) { const modId = await catchTemplateError(() => renderTemplate(action.mod, values, false), "Invalid mod format"); const targetId = await catchTemplateError( () => renderTemplate(action.target, values, false), "Invalid target format", ); const reason = await catchTemplateError(() => renderTemplate(action.reason, values, false), "Invalid reason format"); if (CaseTypes[action.case_type] == null) { throw new ActionError(`Invalid case type: ${action.type}`); } const casesPlugin = pluginData.getPlugin(CasesPlugin); await casesPlugin!.createCase({ userId: targetId, modId, type: CaseTypes[action.case_type], reason: `__[${event.name}]__ ${reason}`, }); } ================================================ FILE: backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { z } from "zod"; import { TemplateSafeValueContainer } from "../../../templateFormatter.js"; import { convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from "../../../utils.js"; import { ActionError } from "../ActionError.js"; import { CustomEventsPluginType, TCustomEvent } from "../types.js"; export const zMakeRoleMentionableAction = z.strictObject({ type: z.literal("make_role_mentionable"), role: zBoundedCharacters(0, 100), timeout: zDelayString, }); export type TMakeRoleMentionableAction = z.infer; export async function makeRoleMentionableAction( pluginData: GuildPluginData, action: TMakeRoleMentionableAction, values: TemplateSafeValueContainer, event: TCustomEvent, eventData: any, // eslint-disable-line @typescript-eslint/no-unused-vars ) { const role = pluginData.guild.roles.cache.get(action.role as Snowflake); if (!role) { throw new ActionError(`Unknown role: ${role}`); } await role.setMentionable(true, `Custom event: ${event.name}`); const timeout = convertDelayStringToMS(action.timeout)!; setTimeout(() => { role.setMentionable(false, `Custom event: ${event.name}`).catch(noop); }, timeout); } ================================================ FILE: backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { z } from "zod"; import { TemplateSafeValueContainer } from "../../../templateFormatter.js"; import { zSnowflake } from "../../../utils.js"; import { ActionError } from "../ActionError.js"; import { CustomEventsPluginType, TCustomEvent } from "../types.js"; export const zMakeRoleUnmentionableAction = z.strictObject({ type: z.literal("make_role_unmentionable"), role: zSnowflake, }); export type TMakeRoleUnmentionableAction = z.infer; export async function makeRoleUnmentionableAction( pluginData: GuildPluginData, action: TMakeRoleUnmentionableAction, values: TemplateSafeValueContainer, event: TCustomEvent, eventData: any, // eslint-disable-line @typescript-eslint/no-unused-vars ) { const role = pluginData.guild.roles.cache.get(action.role as Snowflake); if (!role) { throw new ActionError(`Unknown role: ${role}`); } await role.setMentionable(false, `Custom event: ${event.name}`); } ================================================ FILE: backend/src/plugins/CustomEvents/actions/messageAction.ts ================================================ import { Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { z } from "zod"; import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { zBoundedCharacters } from "../../../utils.js"; import { ActionError } from "../ActionError.js"; import { catchTemplateError } from "../catchTemplateError.js"; import { CustomEventsPluginType } from "../types.js"; export const zMessageAction = z.strictObject({ type: z.literal("message"), channel: zBoundedCharacters(0, 100), content: zBoundedCharacters(0, 4000), }); export type TMessageAction = z.infer; export async function messageAction( pluginData: GuildPluginData, action: TMessageAction, values: TemplateSafeValueContainer, ) { const targetChannelId = await catchTemplateError( () => renderTemplate(action.channel, values, false), "Invalid channel format", ); const targetChannel = pluginData.guild.channels.cache.get(targetChannelId as Snowflake); if (!targetChannel) throw new ActionError("Unknown target channel"); if (!(targetChannel instanceof TextChannel)) throw new ActionError("Target channel is not a text channel"); await targetChannel.send({ content: action.content }); } ================================================ FILE: backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts ================================================ import { Snowflake, VoiceChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { z } from "zod"; import { canActOn } from "../../../pluginUtils.js"; import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { resolveMember, zBoundedCharacters } from "../../../utils.js"; import { ActionError } from "../ActionError.js"; import { catchTemplateError } from "../catchTemplateError.js"; import { CustomEventsPluginType, TCustomEvent } from "../types.js"; export const zMoveToVoiceChannelAction = z.strictObject({ type: z.literal("move_to_vc"), target: zBoundedCharacters(0, 100), channel: zBoundedCharacters(0, 100), }); export type TMoveToVoiceChannelAction = z.infer; export async function moveToVoiceChannelAction( pluginData: GuildPluginData, action: TMoveToVoiceChannelAction, values: TemplateSafeValueContainer, event: TCustomEvent, eventData: any, ) { const targetId = await catchTemplateError( () => renderTemplate(action.target, values, false), "Invalid target format", ); const target = await resolveMember(pluginData.client, pluginData.guild, targetId); if (!target) throw new ActionError("Unknown target member"); if (event.trigger.type === "command" && !canActOn(pluginData, eventData.msg.member, target)) { throw new ActionError("Missing permissions"); } const targetChannelId = await catchTemplateError( () => renderTemplate(action.channel, values, false), "Invalid channel format", ); const targetChannel = pluginData.guild.channels.cache.get(targetChannelId as Snowflake); if (!targetChannel) throw new ActionError("Unknown target channel"); if (!(targetChannel instanceof VoiceChannel)) throw new ActionError("Target channel is not a voice channel"); if (!target.voice.channelId) return; await target.edit({ channel: targetChannel.id, }); } ================================================ FILE: backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts ================================================ import { PermissionsBitField, PermissionsString, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { z } from "zod"; import { TemplateSafeValueContainer } from "../../../templateFormatter.js"; import { zBoundedCharacters, zSnowflake } from "../../../utils.js"; import { ActionError } from "../ActionError.js"; import { CustomEventsPluginType, TCustomEvent } from "../types.js"; export const zSetChannelPermissionOverridesAction = z.strictObject({ type: z.literal("set_channel_permission_overrides"), channel: zBoundedCharacters(0, 100), overrides: z .array( z.strictObject({ type: z.union([z.literal("member"), z.literal("role")]), id: zSnowflake, allow: z.number(), deny: z.number(), }), ) .max(15), }); export type TSetChannelPermissionOverridesAction = z.infer; export async function setChannelPermissionOverridesAction( pluginData: GuildPluginData, action: TSetChannelPermissionOverridesAction, values: TemplateSafeValueContainer, // eslint-disable-line @typescript-eslint/no-unused-vars event: TCustomEvent, // eslint-disable-line @typescript-eslint/no-unused-vars eventData: any, // eslint-disable-line @typescript-eslint/no-unused-vars ) { const channel = pluginData.guild.channels.cache.get(action.channel as Snowflake); if (!channel || channel.isThread() || !("guild" in channel)) { throw new ActionError(`Unknown channel: ${action.channel}`); } for (const override of action.overrides) { const allow = new PermissionsBitField(BigInt(override.allow)).serialize(); const deny = new PermissionsBitField(BigInt(override.deny)).serialize(); const perms: Partial> = {}; for (const key in allow) { if (allow[key]) { perms[key] = true; } else if (deny[key]) { perms[key] = false; } } channel.permissionOverwrites.create(override.id as Snowflake, perms); /* await channel.permissionOverwrites overwritePermissions( [{ id: override.id, allow: BigInt(override.allow), deny: BigInt(override.deny), type: override.type }], `Custom event: ${event.name}`, ); */ } } ================================================ FILE: backend/src/plugins/CustomEvents/catchTemplateError.ts ================================================ import { TemplateParseError } from "../../templateFormatter.js"; import { ActionError } from "./ActionError.js"; export function catchTemplateError(fn: () => Promise, errorText: string): Promise { try { return fn(); } catch (err) { if (err instanceof TemplateParseError) { throw new ActionError(`${errorText}: ${err.message}`); } throw err; } } ================================================ FILE: backend/src/plugins/CustomEvents/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zCustomEventsConfig } from "./types.js"; export const customEventsPluginDocs: ZeppelinPluginDocs = { prettyName: "Custom events", type: "internal", configSchema: zCustomEventsConfig, }; ================================================ FILE: backend/src/plugins/CustomEvents/functions/runEvent.ts ================================================ import { GuildPluginData } from "vety"; import { TemplateSafeValueContainer } from "../../../templateFormatter.js"; import { ActionError } from "../ActionError.js"; import { addRoleAction } from "../actions/addRoleAction.js"; import { createCaseAction } from "../actions/createCaseAction.js"; import { makeRoleMentionableAction } from "../actions/makeRoleMentionableAction.js"; import { makeRoleUnmentionableAction } from "../actions/makeRoleUnmentionableAction.js"; import { messageAction } from "../actions/messageAction.js"; import { moveToVoiceChannelAction } from "../actions/moveToVoiceChannelAction.js"; import { setChannelPermissionOverridesAction } from "../actions/setChannelPermissionOverrides.js"; import { CustomEventsPluginType, TCustomEvent } from "../types.js"; export async function runEvent( pluginData: GuildPluginData, event: TCustomEvent, eventData: any, values: TemplateSafeValueContainer, ) { try { for (const action of event.actions) { if (action.type === "add_role") { await addRoleAction(pluginData, action, values, event, eventData); } else if (action.type === "create_case") { await createCaseAction(pluginData, action, values, event, eventData); } else if (action.type === "move_to_vc") { await moveToVoiceChannelAction(pluginData, action, values, event, eventData); } else if (action.type === "message") { await messageAction(pluginData, action, values); } else if (action.type === "make_role_mentionable") { await makeRoleMentionableAction(pluginData, action, values, event, eventData); } else if (action.type === "make_role_unmentionable") { await makeRoleUnmentionableAction(pluginData, action, values, event, eventData); } else if (action.type === "set_channel_permission_overrides") { await setChannelPermissionOverridesAction(pluginData, action, values, event, eventData); } } } catch (e) { if (e instanceof ActionError) { if (event.trigger.type === "command") { void pluginData.state.common.sendErrorMessage(eventData.msg, e.message); } else { // TODO: Where to log action errors from other kinds of triggers? } return; } throw e; } } ================================================ FILE: backend/src/plugins/CustomEvents/types.ts ================================================ import { BasePluginType, pluginUtils } from "vety"; import { z } from "zod"; import { zBoundedCharacters, zBoundedRecord } from "../../utils.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { zAddRoleAction } from "./actions/addRoleAction.js"; import { zCreateCaseAction } from "./actions/createCaseAction.js"; import { zMakeRoleMentionableAction } from "./actions/makeRoleMentionableAction.js"; import { zMakeRoleUnmentionableAction } from "./actions/makeRoleUnmentionableAction.js"; import { zMessageAction } from "./actions/messageAction.js"; import { zMoveToVoiceChannelAction } from "./actions/moveToVoiceChannelAction.js"; import { zSetChannelPermissionOverridesAction } from "./actions/setChannelPermissionOverrides.js"; const zCommandTrigger = z.strictObject({ type: z.literal("command"), name: zBoundedCharacters(0, 100), params: zBoundedCharacters(0, 255), can_use: z.boolean(), }); const zAnyTrigger = zCommandTrigger; // TODO: Make into a union once we have more triggers const zAnyAction = z.union([ zAddRoleAction, zCreateCaseAction, zMoveToVoiceChannelAction, zMessageAction, zMakeRoleMentionableAction, zMakeRoleUnmentionableAction, zSetChannelPermissionOverridesAction, ]); export const zCustomEvent = z.strictObject({ name: zBoundedCharacters(0, 100), trigger: zAnyTrigger, actions: z.array(zAnyAction).max(10), }); export type TCustomEvent = z.infer; export const zCustomEventsConfig = z.strictObject({ events: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zCustomEvent), 0, 100).default({}), }); export interface CustomEventsPluginType extends BasePluginType { configSchema: typeof zCustomEventsConfig; state: { clearTriggers: () => void; common: pluginUtils.PluginPublicInterface; }; } ================================================ FILE: backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts ================================================ import { Guild } from "discord.js"; import { GlobalPluginData, globalPlugin, globalPluginEventListener } from "vety"; import { AllowedGuilds } from "../../data/AllowedGuilds.js"; import { Configs } from "../../data/Configs.js"; import { env } from "../../env.js"; import { GuildAccessMonitorPluginType, zGuildAccessMonitorConfig } from "./types.js"; async function checkGuild(pluginData: GlobalPluginData, guild: Guild) { if (!(await pluginData.state.allowedGuilds.isAllowed(guild.id))) { // tslint:disable-next-line:no-console console.log(`Non-allowed server ${guild.name} (${guild.id}), leaving`); // guild.leave(); } } /** * Global plugin to monitor if Zeppelin is invited to a non-whitelisted server, and leave it */ export const GuildAccessMonitorPlugin = globalPlugin()({ name: "guild_access_monitor", configSchema: zGuildAccessMonitorConfig, events: [ globalPluginEventListener()({ event: "guildCreate", listener({ pluginData, args: { guild } }) { checkGuild(pluginData, guild); }, }), ], async beforeLoad(pluginData) { const { state } = pluginData; state.allowedGuilds = new AllowedGuilds(); const defaultAllowedServers = env.DEFAULT_ALLOWED_SERVERS || []; const configs = new Configs(); for (const serverId of defaultAllowedServers) { if (!(await state.allowedGuilds.isAllowed(serverId))) { // tslint:disable-next-line:no-console console.log(`Adding allowed-by-default server ${serverId} to the allowed servers`); await state.allowedGuilds.add(serverId); await configs.saveNewRevision(`guild-${serverId}`, "plugins: {}", 0); } } }, afterLoad(pluginData) { for (const guild of pluginData.client.guilds.cache.values()) { checkGuild(pluginData, guild); } }, }); ================================================ FILE: backend/src/plugins/GuildAccessMonitor/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zGuildAccessMonitorConfig } from "./types.js"; export const guildAccessMonitorPluginDocs: ZeppelinPluginDocs = { type: "stable", configSchema: zGuildAccessMonitorConfig, prettyName: "Bot control", description: trimPluginDescription(` Automatically leaves servers that are not on the list of allowed servers `), }; ================================================ FILE: backend/src/plugins/GuildAccessMonitor/types.ts ================================================ import { BasePluginType } from "vety"; import { z } from "zod"; import { AllowedGuilds } from "../../data/AllowedGuilds.js"; export const zGuildAccessMonitorConfig = z.strictObject({}); export interface GuildAccessMonitorPluginType extends BasePluginType { configSchema: typeof zGuildAccessMonitorConfig; state: { allowedGuilds: AllowedGuilds; }; } ================================================ FILE: backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts ================================================ import { globalPlugin } from "vety"; import { Configs } from "../../data/Configs.js"; import { reloadChangedGuilds } from "./functions/reloadChangedGuilds.js"; import { GuildConfigReloaderPluginType, zGuildConfigReloaderPluginConfig } from "./types.js"; export const GuildConfigReloaderPlugin = globalPlugin()({ name: "guild_config_reloader", configSchema: zGuildConfigReloaderPluginConfig, async beforeLoad(pluginData) { const { state } = pluginData; state.guildConfigs = new Configs(); state.highestConfigId = await state.guildConfigs.getHighestId(); }, afterLoad(pluginData) { reloadChangedGuilds(pluginData); }, beforeUnload(pluginData) { clearTimeout(pluginData.state.nextCheckTimeout); pluginData.state.unloaded = true; }, }); ================================================ FILE: backend/src/plugins/GuildConfigReloader/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zGuildConfigReloaderPluginConfig } from "./types.js"; export const guildConfigReloaderPluginDocs: ZeppelinPluginDocs = { prettyName: "Guild config reloader", type: "internal", configSchema: zGuildConfigReloaderPluginConfig, }; ================================================ FILE: backend/src/plugins/GuildConfigReloader/functions/reloadChangedGuilds.ts ================================================ import { Snowflake } from "discord.js"; import { GlobalPluginData } from "vety"; import { SECONDS } from "../../../utils.js"; import { GuildConfigReloaderPluginType } from "../types.js"; const CHECK_INTERVAL = 1 * SECONDS; export async function reloadChangedGuilds(pluginData: GlobalPluginData) { if (pluginData.state.unloaded) return; const changedConfigs = await pluginData.state.guildConfigs.getActiveLargerThanId(pluginData.state.highestConfigId); for (const item of changedConfigs) { if (!item.key.startsWith("guild-")) continue; const guildId = item.key.slice("guild-".length) as Snowflake; // tslint:disable-next-line:no-console console.log(`Config changed, reloading guild ${guildId}`); await pluginData.getVetyInstance().reloadGuild(guildId); if (item.id > pluginData.state.highestConfigId) { pluginData.state.highestConfigId = item.id; } } pluginData.state.nextCheckTimeout = setTimeout(() => reloadChangedGuilds(pluginData), CHECK_INTERVAL); } ================================================ FILE: backend/src/plugins/GuildConfigReloader/types.ts ================================================ import { BasePluginType } from "vety"; import { z } from "zod"; import { Configs } from "../../data/Configs.js"; import Timeout = NodeJS.Timeout; export const zGuildConfigReloaderPluginConfig = z.strictObject({}); export interface GuildConfigReloaderPluginType extends BasePluginType { configSchema: typeof zGuildConfigReloaderPluginConfig; state: { guildConfigs: Configs; unloaded: boolean; highestConfigId: number; nextCheckTimeout: Timeout; }; } ================================================ FILE: backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts ================================================ import { Guild } from "discord.js"; import { guildPlugin, guildPluginEventListener } from "vety"; import { AllowedGuilds } from "../../data/AllowedGuilds.js"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments.js"; import { MINUTES } from "../../utils.js"; import { GuildInfoSaverPluginType, zGuildInfoSaverConfig } from "./types.js"; export const GuildInfoSaverPlugin = guildPlugin()({ name: "guild_info_saver", configSchema: zGuildInfoSaverConfig, events: [ guildPluginEventListener({ event: "guildUpdate", listener({ args }) { void updateGuildInfo(args.newGuild); }, }), ], afterLoad(pluginData) { void updateGuildInfo(pluginData.guild); pluginData.state.updateInterval = setInterval(() => updateGuildInfo(pluginData.guild), 60 * MINUTES); }, beforeUnload(pluginData) { clearInterval(pluginData.state.updateInterval); }, }); async function updateGuildInfo(guild: Guild) { if (!guild.name) { return; } const allowedGuilds = new AllowedGuilds(); const existingData = (await allowedGuilds.find(guild.id))!; allowedGuilds.updateInfo(guild.id, guild.name, guild.iconURL(), guild.ownerId); if (existingData.owner_id !== guild.ownerId || existingData.created_at === existingData.updated_at) { const apiPermissions = new ApiPermissionAssignments(); apiPermissions.applyOwnerChange(guild.id, guild.ownerId); } } ================================================ FILE: backend/src/plugins/GuildInfoSaver/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zGuildInfoSaverConfig } from "./types.js"; export const guildInfoSaverPluginDocs: ZeppelinPluginDocs = { prettyName: "Guild info saver", type: "internal", configSchema: zGuildInfoSaverConfig, }; ================================================ FILE: backend/src/plugins/GuildInfoSaver/types.ts ================================================ import { BasePluginType } from "vety"; import { z } from "zod"; export const zGuildInfoSaverConfig = z.strictObject({}); export interface GuildInfoSaverPluginType extends BasePluginType { configSchema: typeof zGuildInfoSaverConfig; state: { updateInterval: NodeJS.Timeout; }; } ================================================ FILE: backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildMemberCache } from "../../data/GuildMemberCache.js"; import { makePublicFn } from "../../pluginUtils.js"; import { SECONDS } from "../../utils.js"; import { cancelDeletionOnMemberJoin } from "./events/cancelDeletionOnMemberJoin.js"; import { removeMemberCacheOnMemberLeave } from "./events/removeMemberCacheOnMemberLeave.js"; import { updateMemberCacheOnMemberUpdate } from "./events/updateMemberCacheOnMemberUpdate.js"; import { updateMemberCacheOnMessage } from "./events/updateMemberCacheOnMessage.js"; import { updateMemberCacheOnRoleChange } from "./events/updateMemberCacheOnRoleChange.js"; import { updateMemberCacheOnVoiceStateUpdate } from "./events/updateMemberCacheOnVoiceStateUpdate.js"; import { getCachedMemberData } from "./functions/getCachedMemberData.js"; import { GuildMemberCachePluginType, zGuildMemberCacheConfig } from "./types.js"; const PENDING_SAVE_INTERVAL = 30 * SECONDS; export const GuildMemberCachePlugin = guildPlugin()({ name: "guild_member_cache", configSchema: zGuildMemberCacheConfig, events: [ updateMemberCacheOnMemberUpdate, updateMemberCacheOnMessage, updateMemberCacheOnVoiceStateUpdate, updateMemberCacheOnRoleChange, removeMemberCacheOnMemberLeave, cancelDeletionOnMemberJoin, ], public(pluginData) { return { getCachedMemberData: makePublicFn(pluginData, getCachedMemberData), }; }, beforeLoad(pluginData) { pluginData.state.memberCache = GuildMemberCache.getGuildInstance(pluginData.guild.id); // This won't leak memory... too much #trust pluginData.state.initialUpdatedMembers = new Set(); }, afterLoad(pluginData) { pluginData.state.saveInterval = setInterval( () => pluginData.state.memberCache.savePendingUpdates(), PENDING_SAVE_INTERVAL, ); }, async beforeUnload(pluginData) { clearInterval(pluginData.state.saveInterval); await pluginData.state.memberCache.savePendingUpdates(); }, }); ================================================ FILE: backend/src/plugins/GuildMemberCache/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zGuildMemberCacheConfig } from "./types.js"; export const guildMemberCachePluginDocs: ZeppelinPluginDocs = { prettyName: "Guild member cache", type: "internal", configSchema: zGuildMemberCacheConfig, }; ================================================ FILE: backend/src/plugins/GuildMemberCache/events/cancelDeletionOnMemberJoin.ts ================================================ import { guildPluginEventListener } from "vety"; import { GuildMemberCachePluginType } from "../types.js"; export const cancelDeletionOnMemberJoin = guildPluginEventListener()({ event: "guildMemberAdd", async listener({ pluginData, args: { member } }) { pluginData.state.memberCache.unmarkMemberForDeletion(member.id); }, }); ================================================ FILE: backend/src/plugins/GuildMemberCache/events/removeMemberCacheOnMemberLeave.ts ================================================ import { guildPluginEventListener } from "vety"; import { GuildMemberCachePluginType } from "../types.js"; export const removeMemberCacheOnMemberLeave = guildPluginEventListener()({ event: "guildMemberRemove", async listener({ pluginData, args: { member } }) { pluginData.state.memberCache.markMemberForDeletion(member.id); }, }); ================================================ FILE: backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnMemberUpdate.ts ================================================ import { guildPluginEventListener } from "vety"; import { updateMemberCacheForMember } from "../functions/updateMemberCacheForMember.js"; import { GuildMemberCachePluginType } from "../types.js"; export const updateMemberCacheOnMemberUpdate = guildPluginEventListener()({ event: "guildMemberUpdate", async listener({ pluginData, args: { newMember } }) { updateMemberCacheForMember(pluginData, newMember.id); }, }); ================================================ FILE: backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnMessage.ts ================================================ import { guildPluginEventListener } from "vety"; import { updateMemberCacheForMember } from "../functions/updateMemberCacheForMember.js"; import { GuildMemberCachePluginType } from "../types.js"; export const updateMemberCacheOnMessage = guildPluginEventListener()({ event: "messageCreate", listener({ pluginData, args }) { // Update each member once per guild load when we see a message from them if (pluginData.state.initialUpdatedMembers.has(args.message.author.id)) { return; } updateMemberCacheForMember(pluginData, args.message.author.id); pluginData.state.initialUpdatedMembers.add(args.message.author.id); }, }); ================================================ FILE: backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnRoleChange.ts ================================================ import { AuditLogEvent } from "discord.js"; import { guildPluginEventListener } from "vety"; import { updateMemberCacheForMember } from "../functions/updateMemberCacheForMember.js"; import { GuildMemberCachePluginType } from "../types.js"; export const updateMemberCacheOnRoleChange = guildPluginEventListener()({ event: "guildAuditLogEntryCreate", async listener({ pluginData, args: { auditLogEntry } }) { if (auditLogEntry.action !== AuditLogEvent.MemberRoleUpdate) { return; } updateMemberCacheForMember(pluginData, auditLogEntry.targetId!); }, }); ================================================ FILE: backend/src/plugins/GuildMemberCache/events/updateMemberCacheOnVoiceStateUpdate.ts ================================================ import { guildPluginEventListener } from "vety"; import { updateMemberCacheForMember } from "../functions/updateMemberCacheForMember.js"; import { GuildMemberCachePluginType } from "../types.js"; export const updateMemberCacheOnVoiceStateUpdate = guildPluginEventListener()({ event: "voiceStateUpdate", listener({ pluginData, args }) { const memberId = args.newState.member?.id; if (!memberId) { return; } // Update each member once per guild load when we see a message from them if (pluginData.state.initialUpdatedMembers.has(memberId)) { return; } updateMemberCacheForMember(pluginData, memberId); pluginData.state.initialUpdatedMembers.add(memberId); }, }); ================================================ FILE: backend/src/plugins/GuildMemberCache/functions/getCachedMemberData.ts ================================================ import { GuildPluginData } from "vety"; import { MemberCacheItem } from "../../../data/entities/MemberCacheItem.js"; import { GuildMemberCachePluginType } from "../types.js"; export function getCachedMemberData( pluginData: GuildPluginData, userId: string, ): Promise { return pluginData.state.memberCache.getCachedMemberData(userId); } ================================================ FILE: backend/src/plugins/GuildMemberCache/functions/updateMemberCacheForMember.ts ================================================ import { GuildPluginData } from "vety"; import { GuildMemberCachePluginType } from "../types.js"; export async function updateMemberCacheForMember( pluginData: GuildPluginData, userId: string, ) { const upToDateMember = await pluginData.guild.members.fetch(userId); const roles = Array.from(upToDateMember.roles.cache.keys()) // Filter out @everyone role .filter((roleId) => roleId !== pluginData.guild.id); pluginData.state.memberCache.setCachedMemberData(upToDateMember.id, { username: upToDateMember.user.username, nickname: upToDateMember.nickname, roles, }); } ================================================ FILE: backend/src/plugins/GuildMemberCache/types.ts ================================================ import { BasePluginType } from "vety"; import { z } from "zod"; import { GuildMemberCache } from "../../data/GuildMemberCache.js"; export const zGuildMemberCacheConfig = z.strictObject({}); export interface GuildMemberCachePluginType extends BasePluginType { configSchema: typeof zGuildMemberCacheConfig; state: { memberCache: GuildMemberCache; saveInterval: NodeJS.Timeout; initialUpdatedMembers: Set; }; } ================================================ FILE: backend/src/plugins/InternalPoster/InternalPosterPlugin.ts ================================================ import { guildPlugin } from "vety"; import { Queue } from "../../Queue.js"; import { Webhooks } from "../../data/Webhooks.js"; import { makePublicFn } from "../../pluginUtils.js"; import { editMessage } from "./functions/editMessage.js"; import { sendMessage } from "./functions/sendMessage.js"; import { InternalPosterPluginType, zInternalPosterConfig } from "./types.js"; export const InternalPosterPlugin = guildPlugin()({ name: "internal_poster", configSchema: zInternalPosterConfig, public(pluginData) { return { sendMessage: makePublicFn(pluginData, sendMessage), editMessage: makePublicFn(pluginData, editMessage), }; }, async beforeLoad(pluginData) { const { state } = pluginData; state.webhooks = new Webhooks(); state.queue = new Queue(); state.missingPermissions = false; state.webhookClientCache = new Map(); }, }); ================================================ FILE: backend/src/plugins/InternalPoster/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zInternalPosterConfig } from "./types.js"; export const internalPosterPluginDocs: ZeppelinPluginDocs = { prettyName: "Internal poster", type: "internal", configSchema: zInternalPosterConfig, }; ================================================ FILE: backend/src/plugins/InternalPoster/functions/editMessage.ts ================================================ import { Message, MessageEditOptions, WebhookClient, WebhookMessageEditOptions } from "discord.js"; import { GuildPluginData } from "vety"; import { isDiscordAPIError, noop } from "../../../utils.js"; import { InternalPosterPluginType } from "../types.js"; /** * Sends a message using a webhook or direct API requests, preferring webhooks when possible. */ export async function editMessage( pluginData: GuildPluginData, message: Message, content: MessageEditOptions & WebhookMessageEditOptions, ): Promise { const channel = message.channel; if (!channel.isTextBased()) { return; } await pluginData.state.queue.add(async () => { if (message.webhookId) { const webhook = await pluginData.state.webhooks.find(message.webhookId); if (!webhook) { // Webhook message but we're missing the token -> can't edit return; } const webhookClient = new WebhookClient({ id: webhook.id, token: webhook.token, }); await webhookClient.editMessage(message.id, content).catch(async (err) => { // Unknown Webhook, remove from DB if (isDiscordAPIError(err) && err.code === 10015) { await pluginData.state.webhooks.delete(webhookClient.id); return; } throw err; }); return; } await message.edit(content).catch(noop); }); } ================================================ FILE: backend/src/plugins/InternalPoster/functions/getOrCreateWebhookClientForChannel.ts ================================================ import { WebhookClient } from "discord.js"; import { GuildPluginData } from "vety"; import { InternalPosterPluginType } from "../types.js"; import { getOrCreateWebhookForChannel, WebhookableChannel } from "./getOrCreateWebhookForChannel.js"; export async function getOrCreateWebhookClientForChannel( pluginData: GuildPluginData, channel: WebhookableChannel, ): Promise { if (!pluginData.state.webhookClientCache.has(channel.id)) { const webhookInfo = await getOrCreateWebhookForChannel(pluginData, channel); if (webhookInfo) { const client = new WebhookClient({ id: webhookInfo[0], token: webhookInfo[1], }); pluginData.state.webhookClientCache.set(channel.id, client); } else { pluginData.state.webhookClientCache.set(channel.id, null); } } return pluginData.state.webhookClientCache.get(channel.id) ?? null; } ================================================ FILE: backend/src/plugins/InternalPoster/functions/getOrCreateWebhookForChannel.ts ================================================ import { GuildBasedChannel, PermissionsBitField } from "discord.js"; import { GuildPluginData } from "vety"; import { isDiscordAPIError } from "../../../utils.js"; import { InternalPosterPluginType } from "../types.js"; type WebhookInfo = [id: string, token: string]; export type WebhookableChannel = Extract any }>; export function channelIsWebhookable(channel: GuildBasedChannel): channel is WebhookableChannel { return "createWebhook" in channel; } export async function getOrCreateWebhookForChannel( pluginData: GuildPluginData, channel: WebhookableChannel, ): Promise { // Database cache const fromDb = await pluginData.state.webhooks.findByChannelId(channel.id); if (fromDb) { return [fromDb.id, fromDb.token]; } if (pluginData.state.missingPermissions) { return null; } // Create new webhook const member = pluginData.client.user && pluginData.guild.members.cache.get(pluginData.client.user.id); if (!member || member.permissions.has(PermissionsBitField.Flags.ManageWebhooks)) { try { const webhook = await channel.createWebhook({ name: `Zephook ${channel.id}` }); await pluginData.state.webhooks.create({ id: webhook.id, guild_id: pluginData.guild.id, channel_id: channel.id, token: webhook.token!, }); return [webhook.id, webhook.token!]; } catch (err) { // tslint:disable-next-line:no-console console.warn(`Error when trying to create webhook for ${pluginData.guild.id}/${channel.id}: ${err.message}`); if (isDiscordAPIError(err) && err.code === 50013) { pluginData.state.missingPermissions = true; } return null; } } return null; } ================================================ FILE: backend/src/plugins/InternalPoster/functions/sendMessage.ts ================================================ import { GuildTextBasedChannel, MessageCreateOptions, WebhookClient } from "discord.js"; import { GuildPluginData } from "vety"; import { isDiscordAPIError } from "../../../utils.js"; import { InternalPosterPluginType } from "../types.js"; import { getOrCreateWebhookClientForChannel } from "./getOrCreateWebhookClientForChannel.js"; import { channelIsWebhookable } from "./getOrCreateWebhookForChannel.js"; export type InternalPosterMessageResult = { id: string; channelId: string; }; async function sendDirectly( channel: GuildTextBasedChannel, content: MessageCreateOptions, ): Promise { return channel.send(content).then((message) => ({ id: message.id, channelId: message.channelId, })); } /** * Sends a message using a webhook or direct API requests, preferring webhooks when possible. */ export async function sendMessage( pluginData: GuildPluginData, channel: GuildTextBasedChannel, content: MessageCreateOptions, ): Promise { return pluginData.state.queue.add(async () => { let webhookClient: WebhookClient | null = null; let threadId: string | undefined; if (channelIsWebhookable(channel)) { webhookClient = await getOrCreateWebhookClientForChannel(pluginData, channel); } else if (channel.isThread() && channelIsWebhookable(channel.parent!)) { webhookClient = await getOrCreateWebhookClientForChannel(pluginData, channel.parent!); threadId = channel.id; } if (!webhookClient) { return sendDirectly(channel, content); } return webhookClient .send({ threadId, ...content, ...(pluginData.client.user && { username: pluginData.client.user.username, avatarURL: pluginData.client.user.displayAvatarURL(), }), }) .then((apiMessage) => ({ id: apiMessage.id, channelId: apiMessage.channel_id, })) .catch(async (err) => { // Unknown Webhook if (isDiscordAPIError(err) && err.code === 10015) { await pluginData.state.webhooks.delete(webhookClient!.id); pluginData.state.webhookClientCache.delete(channel.id); // Fallback to regular message for this log message return sendDirectly(channel, content); } throw err; }); }); } ================================================ FILE: backend/src/plugins/InternalPoster/types.ts ================================================ import { WebhookClient } from "discord.js"; import { BasePluginType } from "vety"; import { z } from "zod"; import { Queue } from "../../Queue.js"; import { Webhooks } from "../../data/Webhooks.js"; export const zInternalPosterConfig = z.strictObject({}).default({}); export interface InternalPosterPluginType extends BasePluginType { configSchema: typeof zInternalPosterConfig; state: { queue: Queue; webhooks: Webhooks; missingPermissions: boolean; webhookClientCache: Map; }; } ================================================ FILE: backend/src/plugins/LocateUser/LocateUserPlugin.ts ================================================ import { guildPlugin } from "vety"; import { onGuildEvent } from "../../data/GuildEvents.js"; import { GuildVCAlerts } from "../../data/GuildVCAlerts.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { FollowCmd } from "./commands/FollowCmd.js"; import { DeleteFollowCmd, ListFollowCmd } from "./commands/ListFollowCmd.js"; import { WhereCmd } from "./commands/WhereCmd.js"; import { GuildBanRemoveAlertsEvt } from "./events/BanRemoveAlertsEvt.js"; import { VoiceStateUpdateAlertEvt } from "./events/SendAlertsEvts.js"; import { LocateUserPluginType, zLocateUserConfig } from "./types.js"; import { clearExpiredAlert } from "./utils/clearExpiredAlert.js"; import { fillActiveAlertsList } from "./utils/fillAlertsList.js"; export const LocateUserPlugin = guildPlugin()({ name: "locate_user", configSchema: zLocateUserConfig, defaultOverrides: [ { level: ">=50", config: { can_where: true, can_alert: true, }, }, ], // prettier-ignore messageCommands: [ WhereCmd, FollowCmd, ListFollowCmd, DeleteFollowCmd, ], // prettier-ignore events: [ VoiceStateUpdateAlertEvt, GuildBanRemoveAlertsEvt ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.alerts = GuildVCAlerts.getGuildInstance(guild.id); state.usersWithAlerts = []; }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, afterLoad(pluginData) { const { state, guild } = pluginData; state.unregisterGuildEventListener = onGuildEvent(guild.id, "expiredVCAlert", (alert) => clearExpiredAlert(pluginData, alert), ); fillActiveAlertsList(pluginData); }, beforeUnload(pluginData) { const { state } = pluginData; state.unregisterGuildEventListener?.(); }, }); ================================================ FILE: backend/src/plugins/LocateUser/commands/FollowCmd.ts ================================================ import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { registerExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop.js"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { MINUTES, SECONDS } from "../../../utils.js"; import { locateUserCmd } from "../types.js"; export const FollowCmd = locateUserCmd({ trigger: ["follow", "f"], description: "Sets up an alert that notifies you any time `` switches or joins voice channels", usage: "!f 108552944961454080", permission: "can_alert", signature: { member: ct.resolvedMember(), reminder: ct.string({ required: false, catchAll: true }), duration: ct.delay({ option: true, shortcut: "d" }), active: ct.bool({ option: true, shortcut: "a", isSwitch: true }), }, async run({ message: msg, args, pluginData }) { const time = args.duration || 10 * MINUTES; const alertTime = moment.utc().add(time, "millisecond"); const body = args.reminder || "None"; const active = args.active || false; if (time < 30 * SECONDS) { void pluginData.state.common.sendErrorMessage(msg, "Sorry, but the minimum duration for an alert is 30 seconds!"); return; } const alert = await pluginData.state.alerts.add( msg.author.id, args.member.id, msg.channel.id, alertTime.format("YYYY-MM-DD HH:mm:ss"), body, active, ); registerExpiringVCAlert(alert); if (!pluginData.state.usersWithAlerts.includes(args.member.id)) { pluginData.state.usersWithAlerts.push(args.member.id); } if (active) { void pluginData.state.common.sendSuccessMessage( msg, `Every time <@${args.member.id}> joins or switches VC in the next ${humanizeDuration( time, )} i will notify and move you.\nPlease make sure to be in a voice channel, otherwise i cannot move you!`, ); } else { void pluginData.state.common.sendSuccessMessage( msg, `Every time <@${args.member.id}> joins or switches VC in the next ${humanizeDuration(time)} i will notify you`, ); } }, }); ================================================ FILE: backend/src/plugins/LocateUser/commands/ListFollowCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { clearExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop.js"; import { createChunkedMessage, sorter } from "../../../utils.js"; import { locateUserCmd } from "../types.js"; export const ListFollowCmd = locateUserCmd({ trigger: ["follows", "fs"], description: "Displays all of your active alerts ordered by expiration time", usage: "!fs", permission: "can_alert", async run({ message: msg, pluginData }) { const alerts = await pluginData.state.alerts.getAlertsByRequestorId(msg.author.id); if (alerts.length === 0) { void pluginData.state.common.sendErrorMessage(msg, "You have no active alerts!"); return; } alerts.sort(sorter("expires_at")); const longestNum = (alerts.length + 1).toString().length; const lines = Array.from(alerts.entries()).map(([i, alert]) => { const num = i + 1; const paddedNum = num.toString().padStart(longestNum, " "); return `\`${paddedNum}.\` \`${alert.expires_at}\` **Target:** <@!${alert.user_id}> **Reminder:** \`${ alert.body }\` **Active:** ${alert.active.valueOf()}`; }); await createChunkedMessage(msg.channel, lines.join("\n")); }, }); export const DeleteFollowCmd = locateUserCmd({ trigger: ["follows delete", "fs d"], description: "Deletes the alert at the position .\nThe value needed for can be found using `!follows` (`!fs`)", usage: "!fs d ", permission: "can_alert", signature: { num: ct.number({ required: true }), }, async run({ message: msg, args, pluginData }) { const alerts = await pluginData.state.alerts.getAlertsByRequestorId(msg.author.id); alerts.sort(sorter("expires_at")); if (args.num > alerts.length || args.num <= 0) { void pluginData.state.common.sendErrorMessage(msg, "Unknown alert!"); return; } const toDelete = alerts[args.num - 1]; clearExpiringVCAlert(toDelete); await pluginData.state.alerts.delete(toDelete.id); void pluginData.state.common.sendSuccessMessage(msg, "Alert deleted"); }, }); ================================================ FILE: backend/src/plugins/LocateUser/commands/WhereCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { locateUserCmd } from "../types.js"; import { sendWhere } from "../utils/sendWhere.js"; export const WhereCmd = locateUserCmd({ trigger: ["where", "w"], description: "Posts an instant invite to the voice channel that `` is in", usage: "!w 108552944961454080", permission: "can_where", signature: { member: ct.resolvedMember(), }, async run({ message: msg, args, pluginData }) { sendWhere(pluginData, args.member, msg.channel, `<@${msg.author.id}> | `); }, }); ================================================ FILE: backend/src/plugins/LocateUser/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zLocateUserConfig } from "./types.js"; export const locateUserPluginDocs: ZeppelinPluginDocs = { prettyName: "Locate user", type: "stable", description: trimPluginDescription(` This plugin allows users with access to the commands the following: * Instantly receive an invite to the voice channel of a user * Be notified as soon as a user switches or joins a voice channel `), configSchema: zLocateUserConfig, }; ================================================ FILE: backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts ================================================ import { clearExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop.js"; import { locateUserEvt } from "../types.js"; export const GuildBanRemoveAlertsEvt = locateUserEvt({ event: "guildBanAdd", async listener(meta) { const alerts = await meta.pluginData.state.alerts.getAlertsByUserId(meta.args.ban.user.id); alerts.forEach((alert) => { clearExpiringVCAlert(alert); meta.pluginData.state.alerts.delete(alert.id); }); }, }); ================================================ FILE: backend/src/plugins/LocateUser/events/SendAlertsEvts.ts ================================================ import { Snowflake } from "discord.js"; import { locateUserEvt } from "../types.js"; import { sendAlerts } from "../utils/sendAlerts.js"; export const VoiceStateUpdateAlertEvt = locateUserEvt({ event: "voiceStateUpdate", async listener(meta) { const memberId = meta.args.oldState.member?.id ?? meta.args.newState.member?.id; if (!memberId) { return; } if (meta.args.newState.channel != null) { if (meta.pluginData.state.usersWithAlerts.includes(memberId)) { sendAlerts(meta.pluginData, memberId); } } else { const triggeredAlerts = await meta.pluginData.state.alerts.getAlertsByUserId(memberId); const voiceChannel = meta.args.oldState.channel!; triggeredAlerts.forEach((alert) => { const txtChannel = meta.pluginData.guild.channels.resolve(alert.channel_id as Snowflake); if (txtChannel?.isTextBased()) { txtChannel.send({ content: `🔴 <@!${alert.requestor_id}> the user <@!${alert.user_id}> disconnected out of \`${voiceChannel.name}\``, allowedMentions: { users: [alert.requestor_id as Snowflake] }, }); } }); } }, }); ================================================ FILE: backend/src/plugins/LocateUser/types.ts ================================================ import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { GuildVCAlerts } from "../../data/GuildVCAlerts.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zLocateUserConfig = z.strictObject({ can_where: z.boolean().default(false), can_alert: z.boolean().default(false), }); export interface LocateUserPluginType extends BasePluginType { configSchema: typeof zLocateUserConfig; state: { alerts: GuildVCAlerts; usersWithAlerts: string[]; unregisterGuildEventListener: () => void; common: pluginUtils.PluginPublicInterface; }; } export const locateUserCmd = guildPluginMessageCommand(); export const locateUserEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/LocateUser/utils/clearExpiredAlert.ts ================================================ import { GuildPluginData } from "vety"; import { VCAlert } from "../../../data/entities/VCAlert.js"; import { LocateUserPluginType } from "../types.js"; import { removeUserIdFromActiveAlerts } from "./removeUserIdFromActiveAlerts.js"; export async function clearExpiredAlert(pluginData: GuildPluginData, alert: VCAlert) { await pluginData.state.alerts.delete(alert.id); await removeUserIdFromActiveAlerts(pluginData, alert.user_id); } ================================================ FILE: backend/src/plugins/LocateUser/utils/createOrReuseInvite.ts ================================================ import { VoiceChannel } from "discord.js"; export async function createOrReuseInvite(vc: VoiceChannel) { const existingInvites = await vc.fetchInvites(); if (existingInvites.size !== 0) { return existingInvites.first()!; } else { return vc.createInvite(); } } ================================================ FILE: backend/src/plugins/LocateUser/utils/fillAlertsList.ts ================================================ import { GuildPluginData } from "vety"; import { LocateUserPluginType } from "../types.js"; export async function fillActiveAlertsList(pluginData: GuildPluginData) { const allAlerts = await pluginData.state.alerts.getAllGuildAlerts(); allAlerts.forEach((alert) => { if (!pluginData.state.usersWithAlerts.includes(alert.user_id)) { pluginData.state.usersWithAlerts.push(alert.user_id); } }); } ================================================ FILE: backend/src/plugins/LocateUser/utils/moveMember.ts ================================================ import { GuildMember, GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { LocateUserPluginType } from "../types.js"; export async function moveMember( pluginData: GuildPluginData, toMoveID: string, target: GuildMember, errorChannel: GuildTextBasedChannel, ) { const modMember: GuildMember = await pluginData.guild.members.fetch(toMoveID as Snowflake); if (modMember.voice.channelId != null) { try { await modMember.edit({ channel: target.voice.channelId, }); } catch { void pluginData.state.common.sendErrorMessage(errorChannel, "Failed to move you. Are you in a voice channel?"); return; } } else { void pluginData.state.common.sendErrorMessage(errorChannel, "Failed to move you. Are you in a voice channel?"); } } ================================================ FILE: backend/src/plugins/LocateUser/utils/removeUserIdFromActiveAlerts.ts ================================================ import { GuildPluginData } from "vety"; import { LocateUserPluginType } from "../types.js"; export async function removeUserIdFromActiveAlerts(pluginData: GuildPluginData, userId: string) { const index = pluginData.state.usersWithAlerts.indexOf(userId); if (index > -1) { pluginData.state.usersWithAlerts.splice(index, 1); } } ================================================ FILE: backend/src/plugins/LocateUser/utils/sendAlerts.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { resolveMember } from "../../../utils.js"; import { LocateUserPluginType } from "../types.js"; import { moveMember } from "./moveMember.js"; import { sendWhere } from "./sendWhere.js"; export async function sendAlerts(pluginData: GuildPluginData, userId: string) { const triggeredAlerts = await pluginData.state.alerts.getAlertsByUserId(userId); const member = await resolveMember(pluginData.client, pluginData.guild, userId); if (!member) return; triggeredAlerts.forEach((alert) => { const prepend = `<@!${alert.requestor_id}>, an alert requested by you has triggered!\nReminder: \`${alert.body}\`\n`; const txtChannel = pluginData.guild.channels.resolve(alert.channel_id as Snowflake); if (txtChannel?.isTextBased()) { sendWhere(pluginData, member, txtChannel, prepend); if (alert.active) { moveMember(pluginData, alert.requestor_id, member, txtChannel); } } }); } ================================================ FILE: backend/src/plugins/LocateUser/utils/sendWhere.ts ================================================ import { GuildMember, GuildTextBasedChannel, Invite, VoiceChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { getInviteLink } from "vety/helpers"; import { LocateUserPluginType } from "../types.js"; import { createOrReuseInvite } from "./createOrReuseInvite.js"; export async function sendWhere( pluginData: GuildPluginData, member: GuildMember, channel: GuildTextBasedChannel, prepend: string, ) { const voice = member.voice.channelId ? (pluginData.guild.channels.resolve(member.voice.channelId) as VoiceChannel) : null; if (voice == null) { channel.send(prepend + "That user is not in a channel"); } else { let invite: Invite; try { invite = await createOrReuseInvite(voice); } catch { void pluginData.state.common.sendErrorMessage(channel, "Cannot create an invite to that channel!"); return; } channel.send({ content: prepend + `<@${member.id}> is in the following channel: \`${voice.name}\` ${getInviteLink(invite)}`, allowedMentions: { parse: ["users"] }, }); } } ================================================ FILE: backend/src/plugins/Logs/LogsPlugin.ts ================================================ import { CooldownManager, guildPlugin } from "vety"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { LogType } from "../../data/LogType.js"; import { logger } from "../../logger.js"; import { makePublicFn } from "../../pluginUtils.js"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners.js"; import { createTypedTemplateSafeValueContainer } from "../../templateFormatter.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { LogsChannelCreateEvt, LogsChannelDeleteEvt, LogsChannelUpdateEvt } from "./events/LogsChannelModifyEvts.js"; import { LogsEmojiCreateEvt, LogsEmojiDeleteEvt, LogsEmojiUpdateEvt, LogsStickerCreateEvt, LogsStickerDeleteEvt, LogsStickerUpdateEvt, } from "./events/LogsEmojiAndStickerModifyEvts.js"; import { LogsGuildMemberAddEvt } from "./events/LogsGuildMemberAddEvt.js"; import { LogsGuildMemberRemoveEvt } from "./events/LogsGuildMemberRemoveEvt.js"; import { LogsRoleCreateEvt, LogsRoleDeleteEvt, LogsRoleUpdateEvt } from "./events/LogsRoleModifyEvts.js"; import { LogsStageInstanceCreateEvt, LogsStageInstanceDeleteEvt, LogsStageInstanceUpdateEvt, } from "./events/LogsStageInstanceModifyEvts.js"; import { LogsThreadCreateEvt, LogsThreadDeleteEvt, LogsThreadUpdateEvt } from "./events/LogsThreadModifyEvts.js"; import { LogsGuildMemberUpdateEvt } from "./events/LogsUserUpdateEvts.js"; import { LogsVoiceStateUpdateEvt } from "./events/LogsVoiceChannelEvts.js"; import { LogsPluginType, zLogsConfig } from "./types.js"; import { getLogMessage } from "./util/getLogMessage.js"; import { log } from "./util/log.js"; import { onMessageDelete } from "./util/onMessageDelete.js"; import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk.js"; import { onMessageUpdate } from "./util/onMessageUpdate.js"; import { escapeCodeBlock } from "discord.js"; import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin.js"; import { LogsGuildMemberRoleChangeEvt } from "./events/LogsGuildMemberRoleChangeEvt.js"; import { logAutomodAction } from "./logFunctions/logAutomodAction.js"; import { logBotAlert } from "./logFunctions/logBotAlert.js"; import { logCaseCreate } from "./logFunctions/logCaseCreate.js"; import { logCaseDelete } from "./logFunctions/logCaseDelete.js"; import { logCaseUpdate } from "./logFunctions/logCaseUpdate.js"; import { logCensor } from "./logFunctions/logCensor.js"; import { logChannelCreate } from "./logFunctions/logChannelCreate.js"; import { logChannelDelete } from "./logFunctions/logChannelDelete.js"; import { logChannelUpdate } from "./logFunctions/logChannelUpdate.js"; import { logClean } from "./logFunctions/logClean.js"; import { logDmFailed } from "./logFunctions/logDmFailed.js"; import { logEmojiCreate } from "./logFunctions/logEmojiCreate.js"; import { logEmojiDelete } from "./logFunctions/logEmojiDelete.js"; import { logEmojiUpdate } from "./logFunctions/logEmojiUpdate.js"; import { logMassBan } from "./logFunctions/logMassBan.js"; import { logMassMute } from "./logFunctions/logMassMute.js"; import { logMassUnban } from "./logFunctions/logMassUnban.js"; import { logMemberBan } from "./logFunctions/logMemberBan.js"; import { logMemberForceban } from "./logFunctions/logMemberForceban.js"; import { logMemberJoin } from "./logFunctions/logMemberJoin.js"; import { logMemberJoinWithPriorRecords } from "./logFunctions/logMemberJoinWithPriorRecords.js"; import { logMemberKick } from "./logFunctions/logMemberKick.js"; import { logMemberLeave } from "./logFunctions/logMemberLeave.js"; import { logMemberMute } from "./logFunctions/logMemberMute.js"; import { logMemberMuteExpired } from "./logFunctions/logMemberMuteExpired.js"; import { logMemberMuteRejoin } from "./logFunctions/logMemberMuteRejoin.js"; import { logMemberNickChange } from "./logFunctions/logMemberNickChange.js"; import { logMemberNote } from "./logFunctions/logMemberNote.js"; import { logMemberRestore } from "./logFunctions/logMemberRestore.js"; import { logMemberRoleAdd } from "./logFunctions/logMemberRoleAdd.js"; import { logMemberRoleChanges } from "./logFunctions/logMemberRoleChanges.js"; import { logMemberRoleRemove } from "./logFunctions/logMemberRoleRemove.js"; import { logMemberTimedBan } from "./logFunctions/logMemberTimedBan.js"; import { logMemberTimedMute } from "./logFunctions/logMemberTimedMute.js"; import { logMemberTimedUnban } from "./logFunctions/logMemberTimedUnban.js"; import { logMemberTimedUnmute } from "./logFunctions/logMemberTimedUnmute.js"; import { logMemberUnban } from "./logFunctions/logMemberUnban.js"; import { logMemberUnmute } from "./logFunctions/logMemberUnmute.js"; import { logMemberWarn } from "./logFunctions/logMemberWarn.js"; import { logMessageDelete } from "./logFunctions/logMessageDelete.js"; import { logMessageDeleteAuto } from "./logFunctions/logMessageDeleteAuto.js"; import { logMessageDeleteBare } from "./logFunctions/logMessageDeleteBare.js"; import { logMessageDeleteBulk } from "./logFunctions/logMessageDeleteBulk.js"; import { logMessageEdit } from "./logFunctions/logMessageEdit.js"; import { logMessageSpamDetected } from "./logFunctions/logMessageSpamDetected.js"; import { logOtherSpamDetected } from "./logFunctions/logOtherSpamDetected.js"; import { logPostedScheduledMessage } from "./logFunctions/logPostedScheduledMessage.js"; import { logRepeatedMessage } from "./logFunctions/logRepeatedMessage.js"; import { logRoleCreate } from "./logFunctions/logRoleCreate.js"; import { logRoleDelete } from "./logFunctions/logRoleDelete.js"; import { logRoleUpdate } from "./logFunctions/logRoleUpdate.js"; import { logScheduledMessage } from "./logFunctions/logScheduledMessage.js"; import { logScheduledRepeatedMessage } from "./logFunctions/logScheduledRepeatedMessage.js"; import { logSetAntiraidAuto } from "./logFunctions/logSetAntiraidAuto.js"; import { logSetAntiraidUser } from "./logFunctions/logSetAntiraidUser.js"; import { logStageInstanceCreate } from "./logFunctions/logStageInstanceCreate.js"; import { logStageInstanceDelete } from "./logFunctions/logStageInstanceDelete.js"; import { logStageInstanceUpdate } from "./logFunctions/logStageInstanceUpdate.js"; import { logStickerCreate } from "./logFunctions/logStickerCreate.js"; import { logStickerDelete } from "./logFunctions/logStickerDelete.js"; import { logStickerUpdate } from "./logFunctions/logStickerUpdate.js"; import { logThreadCreate } from "./logFunctions/logThreadCreate.js"; import { logThreadDelete } from "./logFunctions/logThreadDelete.js"; import { logThreadUpdate } from "./logFunctions/logThreadUpdate.js"; import { logVoiceChannelForceDisconnect } from "./logFunctions/logVoiceChannelForceDisconnect.js"; import { logVoiceChannelForceMove } from "./logFunctions/logVoiceChannelForceMove.js"; import { logVoiceChannelJoin } from "./logFunctions/logVoiceChannelJoin.js"; import { logVoiceChannelLeave } from "./logFunctions/logVoiceChannelLeave.js"; import { logVoiceChannelMove } from "./logFunctions/logVoiceChannelMove.js"; // The `any` cast here is to prevent TypeScript from locking up from the circular dependency function getCasesPlugin(): Promise { return import("../Cases/CasesPlugin.js") as Promise; } export const LogsPlugin = guildPlugin()({ name: "logs", dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getCasesPlugin()).CasesPlugin], configSchema: zLogsConfig, defaultOverrides: [ { level: ">=50", config: { // Legacy/deprecated, read comment on global ping_user option ping_user: false, }, }, ], events: [ LogsGuildMemberAddEvt, LogsGuildMemberRemoveEvt, LogsGuildMemberUpdateEvt, LogsChannelCreateEvt, LogsChannelDeleteEvt, LogsChannelUpdateEvt, LogsRoleCreateEvt, LogsRoleDeleteEvt, LogsRoleUpdateEvt, LogsVoiceStateUpdateEvt, LogsStageInstanceCreateEvt, LogsStageInstanceDeleteEvt, LogsStageInstanceUpdateEvt, LogsThreadCreateEvt, LogsThreadDeleteEvt, LogsThreadUpdateEvt, LogsEmojiCreateEvt, LogsEmojiDeleteEvt, LogsEmojiUpdateEvt, LogsStickerCreateEvt, LogsStickerDeleteEvt, LogsStickerUpdateEvt, LogsGuildMemberRoleChangeEvt, ], public(pluginData) { return { getLogMessage: makePublicFn(pluginData, getLogMessage), logAutomodAction: makePublicFn(pluginData, logAutomodAction), logBotAlert: makePublicFn(pluginData, logBotAlert), logCaseCreate: makePublicFn(pluginData, logCaseCreate), logCaseDelete: makePublicFn(pluginData, logCaseDelete), logCaseUpdate: makePublicFn(pluginData, logCaseUpdate), logCensor: makePublicFn(pluginData, logCensor), logChannelCreate: makePublicFn(pluginData, logChannelCreate), logChannelDelete: makePublicFn(pluginData, logChannelDelete), logChannelUpdate: makePublicFn(pluginData, logChannelUpdate), logClean: makePublicFn(pluginData, logClean), logEmojiCreate: makePublicFn(pluginData, logEmojiCreate), logEmojiDelete: makePublicFn(pluginData, logEmojiDelete), logEmojiUpdate: makePublicFn(pluginData, logEmojiUpdate), logMassBan: makePublicFn(pluginData, logMassBan), logMassMute: makePublicFn(pluginData, logMassMute), logMassUnban: makePublicFn(pluginData, logMassUnban), logMemberBan: makePublicFn(pluginData, logMemberBan), logMemberForceban: makePublicFn(pluginData, logMemberForceban), logMemberJoin: makePublicFn(pluginData, logMemberJoin), logMemberJoinWithPriorRecords: makePublicFn(pluginData, logMemberJoinWithPriorRecords), logMemberKick: makePublicFn(pluginData, logMemberKick), logMemberLeave: makePublicFn(pluginData, logMemberLeave), logMemberMute: makePublicFn(pluginData, logMemberMute), logMemberMuteExpired: makePublicFn(pluginData, logMemberMuteExpired), logMemberMuteRejoin: makePublicFn(pluginData, logMemberMuteRejoin), logMemberNickChange: makePublicFn(pluginData, logMemberNickChange), logMemberNote: makePublicFn(pluginData, logMemberNote), logMemberRestore: makePublicFn(pluginData, logMemberRestore), logMemberRoleAdd: makePublicFn(pluginData, logMemberRoleAdd), logMemberRoleChanges: makePublicFn(pluginData, logMemberRoleChanges), logMemberRoleRemove: makePublicFn(pluginData, logMemberRoleRemove), logMemberTimedBan: makePublicFn(pluginData, logMemberTimedBan), logMemberTimedMute: makePublicFn(pluginData, logMemberTimedMute), logMemberTimedUnban: makePublicFn(pluginData, logMemberTimedUnban), logMemberTimedUnmute: makePublicFn(pluginData, logMemberTimedUnmute), logMemberUnban: makePublicFn(pluginData, logMemberUnban), logMemberUnmute: makePublicFn(pluginData, logMemberUnmute), logMemberWarn: makePublicFn(pluginData, logMemberWarn), logMessageDelete: makePublicFn(pluginData, logMessageDelete), logMessageDeleteAuto: makePublicFn(pluginData, logMessageDeleteAuto), logMessageDeleteBare: makePublicFn(pluginData, logMessageDeleteBare), logMessageDeleteBulk: makePublicFn(pluginData, logMessageDeleteBulk), logMessageEdit: makePublicFn(pluginData, logMessageEdit), logMessageSpamDetected: makePublicFn(pluginData, logMessageSpamDetected), logOtherSpamDetected: makePublicFn(pluginData, logOtherSpamDetected), logPostedScheduledMessage: makePublicFn(pluginData, logPostedScheduledMessage), logRepeatedMessage: makePublicFn(pluginData, logRepeatedMessage), logRoleCreate: makePublicFn(pluginData, logRoleCreate), logRoleDelete: makePublicFn(pluginData, logRoleDelete), logRoleUpdate: makePublicFn(pluginData, logRoleUpdate), logScheduledMessage: makePublicFn(pluginData, logScheduledMessage), logScheduledRepeatedMessage: makePublicFn(pluginData, logScheduledRepeatedMessage), logSetAntiraidAuto: makePublicFn(pluginData, logSetAntiraidAuto), logSetAntiraidUser: makePublicFn(pluginData, logSetAntiraidUser), logStageInstanceCreate: makePublicFn(pluginData, logStageInstanceCreate), logStageInstanceDelete: makePublicFn(pluginData, logStageInstanceDelete), logStageInstanceUpdate: makePublicFn(pluginData, logStageInstanceUpdate), logStickerCreate: makePublicFn(pluginData, logStickerCreate), logStickerDelete: makePublicFn(pluginData, logStickerDelete), logStickerUpdate: makePublicFn(pluginData, logStickerUpdate), logThreadCreate: makePublicFn(pluginData, logThreadCreate), logThreadDelete: makePublicFn(pluginData, logThreadDelete), logThreadUpdate: makePublicFn(pluginData, logThreadUpdate), logVoiceChannelForceDisconnect: makePublicFn(pluginData, logVoiceChannelForceDisconnect), logVoiceChannelForceMove: makePublicFn(pluginData, logVoiceChannelForceMove), logVoiceChannelJoin: makePublicFn(pluginData, logVoiceChannelJoin), logVoiceChannelLeave: makePublicFn(pluginData, logVoiceChannelLeave), logVoiceChannelMove: makePublicFn(pluginData, logVoiceChannelMove), logDmFailed: makePublicFn(pluginData, logDmFailed), }; }, beforeLoad(pluginData) { const { state, guild } = pluginData; state.guildLogs = new GuildLogs(guild.id); state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); state.archives = GuildArchives.getGuildInstance(guild.id); state.cases = GuildCases.getGuildInstance(guild.id); state.buffers = new Map(); state.channelCooldowns = new CooldownManager(); state.regexRunner = getRegExpRunner(`guild-${pluginData.guild.id}`); }, afterLoad(pluginData) { const { state } = pluginData; state.logListener = ({ type, data }) => log(pluginData, type, data); state.guildLogs.on("log", state.logListener); state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg); state.savedMessages.events.on("delete", state.onMessageDeleteFn); state.onMessageDeleteBulkFn = (msg) => onMessageDeleteBulk(pluginData, msg); state.savedMessages.events.on("deleteBulk", state.onMessageDeleteBulkFn); state.onMessageUpdateFn = (newMsg, oldMsg) => onMessageUpdate(pluginData, newMsg, oldMsg); state.savedMessages.events.on("update", state.onMessageUpdateFn); state.regexRunnerRepeatedTimeoutListener = (regexSource, timeoutMs, failedTimes) => { logger.warn(`Disabled heavy regex temporarily: ${regexSource}`); log( pluginData, LogType.BOT_ALERT, createTypedTemplateSafeValueContainer({ body: ` The following regex has taken longer than ${timeoutMs}ms for ${failedTimes} times and has been temporarily disabled: `.trim() + "\n```" + escapeCodeBlock(regexSource) + "```", }), ); }; state.regexRunner.on("repeatedTimeout", state.regexRunnerRepeatedTimeoutListener); }, beforeUnload(pluginData) { const { state, guild } = pluginData; if (state.logListener) { state.guildLogs.removeListener("log", state.logListener); } if (state.onMessageDeleteFn) { state.savedMessages.events.off("delete", state.onMessageDeleteFn); } if (state.onMessageDeleteBulkFn) { state.savedMessages.events.off("deleteBulk", state.onMessageDeleteBulkFn); } if (state.onMessageUpdateFn) { state.savedMessages.events.off("update", state.onMessageUpdateFn); } if (state.regexRunnerRepeatedTimeoutListener) { state.regexRunner.off("repeatedTimeout", state.regexRunnerRepeatedTimeoutListener); } discardRegExpRunner(`guild-${guild.id}`); }, }); ================================================ FILE: backend/src/plugins/Logs/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zLogsConfig } from "./types.js"; export const logsPluginDocs: ZeppelinPluginDocs = { prettyName: "Logs", configSchema: zLogsConfig, type: "stable", }; ================================================ FILE: backend/src/plugins/Logs/events/LogsChannelModifyEvts.ts ================================================ import { TextChannel, VoiceChannel } from "discord.js"; import { differenceToString, getScalarDifference } from "../../../utils.js"; import { filterObject } from "../../../utils/filterObject.js"; import { logChannelCreate } from "../logFunctions/logChannelCreate.js"; import { logChannelDelete } from "../logFunctions/logChannelDelete.js"; import { logChannelUpdate } from "../logFunctions/logChannelUpdate.js"; import { logsEvt } from "../types.js"; export const LogsChannelCreateEvt = logsEvt({ event: "channelCreate", async listener(meta) { logChannelCreate(meta.pluginData, { channel: meta.args.channel, }); }, }); export const LogsChannelDeleteEvt = logsEvt({ event: "channelDelete", async listener(meta) { logChannelDelete(meta.pluginData, { channel: meta.args.channel, }); }, }); const validChannelDiffProps: Set = new Set([ "name", "parentId", "nsfw", "rateLimitPerUser", "topic", "bitrate", ]); export const LogsChannelUpdateEvt = logsEvt({ event: "channelUpdate", async listener(meta) { if (meta.args.oldChannel?.partial) { return; } const oldChannelDiffProps = filterObject(meta.args.oldChannel || {}, (v, k) => validChannelDiffProps.has(k)); const newChannelDiffProps = filterObject(meta.args.newChannel, (v, k) => validChannelDiffProps.has(k)); const diff = getScalarDifference(oldChannelDiffProps, newChannelDiffProps); const differenceString = differenceToString(diff); if (differenceString.trim() === "") { return; } logChannelUpdate(meta.pluginData, { oldChannel: meta.args.oldChannel, newChannel: meta.args.newChannel, differenceString, }); }, }); ================================================ FILE: backend/src/plugins/Logs/events/LogsEmojiAndStickerModifyEvts.ts ================================================ import { GuildEmoji, Sticker } from "discord.js"; import { differenceToString, getScalarDifference } from "../../../utils.js"; import { filterObject } from "../../../utils/filterObject.js"; import { logEmojiCreate } from "../logFunctions/logEmojiCreate.js"; import { logEmojiDelete } from "../logFunctions/logEmojiDelete.js"; import { logEmojiUpdate } from "../logFunctions/logEmojiUpdate.js"; import { logStickerCreate } from "../logFunctions/logStickerCreate.js"; import { logStickerDelete } from "../logFunctions/logStickerDelete.js"; import { logStickerUpdate } from "../logFunctions/logStickerUpdate.js"; import { logsEvt } from "../types.js"; export const LogsEmojiCreateEvt = logsEvt({ event: "emojiCreate", async listener(meta) { logEmojiCreate(meta.pluginData, { emoji: meta.args.emoji, }); }, }); export const LogsEmojiDeleteEvt = logsEvt({ event: "emojiDelete", async listener(meta) { logEmojiDelete(meta.pluginData, { emoji: meta.args.emoji, }); }, }); const validEmojiDiffProps: Set = new Set(["name"]); export const LogsEmojiUpdateEvt = logsEvt({ event: "emojiUpdate", async listener(meta) { const oldEmojiDiffProps = filterObject(meta.args.oldEmoji || {}, (v, k) => validEmojiDiffProps.has(k)); const newEmojiDiffProps = filterObject(meta.args.newEmoji, (v, k) => validEmojiDiffProps.has(k)); const diff = getScalarDifference(oldEmojiDiffProps, newEmojiDiffProps); const differenceString = differenceToString(diff); if (differenceString === "") { return; } logEmojiUpdate(meta.pluginData, { oldEmoji: meta.args.oldEmoji, newEmoji: meta.args.newEmoji, differenceString, }); }, }); export const LogsStickerCreateEvt = logsEvt({ event: "stickerCreate", async listener(meta) { logStickerCreate(meta.pluginData, { sticker: meta.args.sticker, }); }, }); export const LogsStickerDeleteEvt = logsEvt({ event: "stickerDelete", async listener(meta) { logStickerDelete(meta.pluginData, { sticker: meta.args.sticker, }); }, }); const validStickerDiffProps: Set = new Set(["name"]); export const LogsStickerUpdateEvt = logsEvt({ event: "stickerUpdate", async listener(meta) { const oldStickerDiffProps = filterObject(meta.args.oldSticker || {}, (v, k) => validStickerDiffProps.has(k)); const newStickerDiffProps = filterObject(meta.args.newSticker, (v, k) => validStickerDiffProps.has(k)); const diff = getScalarDifference(oldStickerDiffProps, newStickerDiffProps); const differenceString = differenceToString(diff); if (differenceString === "") { return; } logStickerUpdate(meta.pluginData, { oldSticker: meta.args.oldSticker, newSticker: meta.args.newSticker, differenceString, }); }, }); ================================================ FILE: backend/src/plugins/Logs/events/LogsGuildBanEvts.ts ================================================ import { AuditLogEvent } from "discord.js"; import { LogType } from "../../../data/LogType.js"; import { findMatchingAuditLogEntry } from "../../../utils/findMatchingAuditLogEntry.js"; import { logMemberBan } from "../logFunctions/logMemberBan.js"; import { logMemberUnban } from "../logFunctions/logMemberUnban.js"; import { logsEvt } from "../types.js"; import { isLogIgnored } from "../util/isLogIgnored.js"; export const LogsGuildBanAddEvt = logsEvt({ event: "guildBanAdd", async listener(meta) { const pluginData = meta.pluginData; const user = meta.args.ban.user; if (isLogIgnored(pluginData, LogType.MEMBER_BAN, user.id)) { return; } const relevantAuditLogEntry = await findMatchingAuditLogEntry( pluginData.guild, AuditLogEvent.MemberBanAdd, user.id, ); const mod = relevantAuditLogEntry?.executor ?? null; logMemberBan(meta.pluginData, { mod, user, caseNumber: 0, reason: "", }); }, }); export const LogsGuildBanRemoveEvt = logsEvt({ event: "guildBanRemove", async listener(meta) { const pluginData = meta.pluginData; const user = meta.args.ban.user; if (isLogIgnored(pluginData, LogType.MEMBER_UNBAN, user.id)) { return; } const relevantAuditLogEntry = await findMatchingAuditLogEntry( pluginData.guild, AuditLogEvent.MemberBanRemove, user.id, ); const mod = relevantAuditLogEntry?.executor ?? null; logMemberUnban(pluginData, { mod, userId: user.id, caseNumber: 0, reason: "", }); }, }); ================================================ FILE: backend/src/plugins/Logs/events/LogsGuildMemberAddEvt.ts ================================================ import { logMemberJoin } from "../logFunctions/logMemberJoin.js"; import { logsEvt } from "../types.js"; export const LogsGuildMemberAddEvt = logsEvt({ event: "guildMemberAdd", async listener(meta) { const pluginData = meta.pluginData; const member = meta.args.member; logMemberJoin(pluginData, { member, }); // TODO: Uncomment below once circular dependencies in Vety have been fixed // const cases = (await pluginData.state.cases.with("notes").getByUserId(member.id)).filter(c => !c.is_hidden); // cases.sort((a, b) => (a.created_at > b.created_at ? -1 : 1)); // // if (cases.length) { // const recentCaseLines: string[] = []; // const recentCases = cases.slice(0, 2); // const casesPlugin = pluginData.getPlugin(CasesPlugin); // for (const theCase of recentCases) { // recentCaseLines.push((await casesPlugin.getCaseSummary(theCase))!); // } // // let recentCaseSummary = recentCaseLines.join("\n"); // if (recentCases.length < cases.length) { // const remaining = cases.length - recentCases.length; // if (remaining === 1) { // recentCaseSummary += `\n*+${remaining} case*`; // } else { // recentCaseSummary += `\n*+${remaining} cases*`; // } // } // // logMemberJoinWithPriorRecords(pluginData, { // member, // recentCaseSummary, // }); // } }, }); ================================================ FILE: backend/src/plugins/Logs/events/LogsGuildMemberRemoveEvt.ts ================================================ import { logMemberLeave } from "../logFunctions/logMemberLeave.js"; import { logsEvt } from "../types.js"; export const LogsGuildMemberRemoveEvt = logsEvt({ event: "guildMemberRemove", async listener(meta) { logMemberLeave(meta.pluginData, { member: meta.args.member, }); }, }); ================================================ FILE: backend/src/plugins/Logs/events/LogsGuildMemberRoleChangeEvt.ts ================================================ import { APIRole, AuditLogChange, AuditLogEvent } from "discord.js"; import { guildPluginEventListener } from "vety"; import { resolveRole } from "../../../utils.js"; import { logMemberRoleAdd } from "../logFunctions/logMemberRoleAdd.js"; import { logMemberRoleRemove } from "../logFunctions/logMemberRoleRemove.js"; import { LogsPluginType } from "../types.js"; type RoleAddChange = AuditLogChange & { key: "$add"; new: Array>; }; function isRoleAddChange(change: AuditLogChange): change is RoleAddChange { return change.key === "$add"; } type RoleRemoveChange = AuditLogChange & { key: "$remove"; new: Array>; }; function isRoleRemoveChange(change: AuditLogChange): change is RoleRemoveChange { return change.key === "$remove"; } export const LogsGuildMemberRoleChangeEvt = guildPluginEventListener()({ event: "guildAuditLogEntryCreate", async listener({ pluginData, args: { auditLogEntry } }) { // Ignore the bot's own audit log events if (auditLogEntry.executorId === pluginData.client.user?.id) { return; } if (auditLogEntry.action !== AuditLogEvent.MemberRoleUpdate) { return; } const member = await pluginData.guild.members.fetch(auditLogEntry.targetId!); const mod = auditLogEntry.executorId ? await pluginData.client.users.fetch(auditLogEntry.executorId) : null; for (const change of auditLogEntry.changes) { if (isRoleAddChange(change)) { const addedRoles = change.new.map((r) => resolveRole(pluginData.guild, r.id)); logMemberRoleAdd(pluginData, { member, mod, roles: addedRoles, }); } if (isRoleRemoveChange(change)) { const removedRoles = change.new.map((r) => resolveRole(pluginData.guild, r.id)); logMemberRoleRemove(pluginData, { member, mod, roles: removedRoles, }); } } }, }); ================================================ FILE: backend/src/plugins/Logs/events/LogsRoleModifyEvts.ts ================================================ import { Role } from "discord.js"; import { differenceToString, getScalarDifference } from "../../../utils.js"; import { filterObject } from "../../../utils/filterObject.js"; import { logRoleCreate } from "../logFunctions/logRoleCreate.js"; import { logRoleDelete } from "../logFunctions/logRoleDelete.js"; import { logRoleUpdate } from "../logFunctions/logRoleUpdate.js"; import { logsEvt } from "../types.js"; export const LogsRoleCreateEvt = logsEvt({ event: "roleCreate", async listener(meta) { logRoleCreate(meta.pluginData, { role: meta.args.role, }); }, }); export const LogsRoleDeleteEvt = logsEvt({ event: "roleDelete", async listener(meta) { logRoleDelete(meta.pluginData, { role: meta.args.role, }); }, }); const validRoleDiffProps: Set = new Set(["name", "hoist", "color", "mentionable"]); export const LogsRoleUpdateEvt = logsEvt({ event: "roleUpdate", async listener(meta) { const oldRoleDiffProps = filterObject(meta.args.oldRole || {}, (v, k) => validRoleDiffProps.has(k)); const newRoleDiffProps = filterObject(meta.args.newRole, (v, k) => validRoleDiffProps.has(k)); const diff = getScalarDifference(oldRoleDiffProps, newRoleDiffProps); const differenceString = differenceToString(diff); logRoleUpdate(meta.pluginData, { newRole: meta.args.newRole, oldRole: meta.args.oldRole, differenceString, }); }, }); ================================================ FILE: backend/src/plugins/Logs/events/LogsStageInstanceModifyEvts.ts ================================================ import { StageChannel, StageInstance } from "discord.js"; import { differenceToString, getScalarDifference } from "../../../utils.js"; import { filterObject } from "../../../utils/filterObject.js"; import { logStageInstanceCreate } from "../logFunctions/logStageInstanceCreate.js"; import { logStageInstanceDelete } from "../logFunctions/logStageInstanceDelete.js"; import { logStageInstanceUpdate } from "../logFunctions/logStageInstanceUpdate.js"; import { logsEvt } from "../types.js"; export const LogsStageInstanceCreateEvt = logsEvt({ event: "stageInstanceCreate", async listener(meta) { const stageChannel = meta.args.stageInstance.channel ?? ((await meta.pluginData.guild.channels.fetch(meta.args.stageInstance.channelId)) as StageChannel); logStageInstanceCreate(meta.pluginData, { stageInstance: meta.args.stageInstance, stageChannel, }); }, }); export const LogsStageInstanceDeleteEvt = logsEvt({ event: "stageInstanceDelete", async listener(meta) { const stageChannel = meta.args.stageInstance.channel ?? ((await meta.pluginData.guild.channels.fetch(meta.args.stageInstance.channelId)) as StageChannel); logStageInstanceDelete(meta.pluginData, { stageInstance: meta.args.stageInstance, stageChannel, }); }, }); const validStageInstanceDiffProps: Set = new Set([ "topic", "privacyLevel", "discoverableDisabled", ]); export const LogsStageInstanceUpdateEvt = logsEvt({ event: "stageInstanceUpdate", async listener(meta) { const stageChannel = meta.args.newStageInstance.channel ?? ((await meta.pluginData.guild.channels.fetch(meta.args.newStageInstance.channelId)) as StageChannel); const oldStageInstanceDiffProps = filterObject(meta.args.oldStageInstance || {}, (v, k) => validStageInstanceDiffProps.has(k), ); const newStageInstanceDiffProps = filterObject(meta.args.newStageInstance, (v, k) => validStageInstanceDiffProps.has(k), ); const diff = getScalarDifference(oldStageInstanceDiffProps, newStageInstanceDiffProps); const differenceString = differenceToString(diff); logStageInstanceUpdate(meta.pluginData, { oldStageInstance: meta.args.oldStageInstance, newStageInstance: meta.args.newStageInstance, stageChannel, differenceString, }); }, }); ================================================ FILE: backend/src/plugins/Logs/events/LogsThreadModifyEvts.ts ================================================ import { ThreadChannel } from "discord.js"; import { differenceToString, getScalarDifference } from "../../../utils.js"; import { filterObject } from "../../../utils/filterObject.js"; import { logThreadCreate } from "../logFunctions/logThreadCreate.js"; import { logThreadDelete } from "../logFunctions/logThreadDelete.js"; import { logThreadUpdate } from "../logFunctions/logThreadUpdate.js"; import { logsEvt } from "../types.js"; export const LogsThreadCreateEvt = logsEvt({ event: "threadCreate", async listener(meta) { logThreadCreate(meta.pluginData, { thread: meta.args.thread, }); }, }); export const LogsThreadDeleteEvt = logsEvt({ event: "threadDelete", async listener(meta) { logThreadDelete(meta.pluginData, { thread: meta.args.thread, }); }, }); const validThreadDiffProps: Set = new Set(["name", "autoArchiveDuration", "rateLimitPerUser"]); export const LogsThreadUpdateEvt = logsEvt({ event: "threadUpdate", async listener(meta) { const oldThreadDiffProps = filterObject(meta.args.oldThread || {}, (v, k) => validThreadDiffProps.has(k)); const newThreadDiffProps = filterObject(meta.args.newThread, (v, k) => validThreadDiffProps.has(k)); const diff = getScalarDifference(oldThreadDiffProps, newThreadDiffProps); const differenceString = differenceToString(diff); logThreadUpdate(meta.pluginData, { oldThread: meta.args.oldThread, newThread: meta.args.newThread, differenceString, }); }, }); ================================================ FILE: backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts ================================================ import { logMemberNickChange } from "../logFunctions/logMemberNickChange.js"; import { logsEvt } from "../types.js"; export const LogsGuildMemberUpdateEvt = logsEvt({ event: "guildMemberUpdate", async listener(meta) { const pluginData = meta.pluginData; const oldMember = meta.args.oldMember; const member = meta.args.newMember; if (!oldMember || oldMember.partial) { return; } if (member.nickname !== oldMember.nickname) { logMemberNickChange(pluginData, { member, oldNick: oldMember.nickname != null ? oldMember.nickname : "", newNick: member.nickname != null ? member.nickname : "", }); } }, }); ================================================ FILE: backend/src/plugins/Logs/events/LogsVoiceChannelEvts.ts ================================================ import { logVoiceChannelJoin } from "../logFunctions/logVoiceChannelJoin.js"; import { logVoiceChannelLeave } from "../logFunctions/logVoiceChannelLeave.js"; import { logVoiceChannelMove } from "../logFunctions/logVoiceChannelMove.js"; import { logsEvt } from "../types.js"; export const LogsVoiceStateUpdateEvt = logsEvt({ event: "voiceStateUpdate", async listener(meta) { const oldChannel = meta.args.oldState.channel; const newChannel = meta.args.newState.channel; const member = meta.args.newState.member ?? meta.args.oldState.member; if (!member) { return; } if (!newChannel && oldChannel) { // Leave evt logVoiceChannelLeave(meta.pluginData, { member, channel: oldChannel, }); } else if (!oldChannel && newChannel) { // Join Evt logVoiceChannelJoin(meta.pluginData, { member, channel: newChannel, }); } else if (oldChannel && newChannel) { if (oldChannel.id === newChannel.id) return; logVoiceChannelMove(meta.pluginData, { member, oldChannel, newChannel, }); } }, }); ================================================ FILE: backend/src/plugins/Logs/logFunctions/logAutomodAction.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogAutomodActionData { rule: string; prettyName: string | undefined; user?: User | null; users: User[]; actionsTaken: string; matchSummary: string; } export function logAutomodAction(pluginData: GuildPluginData, data: LogAutomodActionData) { return log( pluginData, LogType.AUTOMOD_ACTION, createTypedTemplateSafeValueContainer({ rule: data.rule, prettyName: data.prettyName, user: data.user ? userToTemplateSafeUser(data.user) : null, users: data.users.map((user) => userToTemplateSafeUser(user)), actionsTaken: data.actionsTaken, matchSummary: data.matchSummary ?? "", }), { userId: data.user ? data.user.id : null, bot: data.user ? data.user.bot : false, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logBotAlert.ts ================================================ import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogBotAlertData { body: string; } export function logBotAlert(pluginData: GuildPluginData, data: LogBotAlertData) { return log( pluginData, LogType.BOT_ALERT, createTypedTemplateSafeValueContainer({ body: data.body, }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logCaseCreate.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogCaseCreateData { mod: User; userId: string; caseNum: number; caseType: string; reason: string; } export function logCaseCreate(pluginData: GuildPluginData, data: LogCaseCreateData) { return log( pluginData, LogType.CASE_CREATE, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), userId: data.userId, caseNum: data.caseNum, caseType: data.caseType, reason: data.reason, }), { userId: data.userId, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logCaseDelete.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { Case } from "../../../data/entities/Case.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { caseToTemplateSafeCase, memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogCaseDeleteData { mod: GuildMember; case: Case; } export function logCaseDelete(pluginData: GuildPluginData, data: LogCaseDeleteData) { return log( pluginData, LogType.CASE_DELETE, createTypedTemplateSafeValueContainer({ mod: memberToTemplateSafeMember(data.mod), case: caseToTemplateSafeCase(data.case), }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logCaseUpdate.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogCaseUpdateData { mod: User; caseNumber: number; caseType: string; note: string; } export function logCaseUpdate(pluginData: GuildPluginData, data: LogCaseUpdateData) { return log( pluginData, LogType.CASE_UPDATE, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), caseNumber: data.caseNumber, caseType: data.caseType, note: data.note, }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logCensor.ts ================================================ import { GuildTextBasedChannel, User } from "discord.js"; import { GuildPluginData } from "vety"; import { deactivateMentions, disableCodeBlocks } from "vety/helpers"; import { LogType } from "../../../data/LogType.js"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser, } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogCensorData { user: User | UnknownUser; channel: GuildTextBasedChannel; reason: string; message: SavedMessage; } export function logCensor(pluginData: GuildPluginData, data: LogCensorData) { return log( pluginData, LogType.CENSOR, createTypedTemplateSafeValueContainer({ user: userToTemplateSafeUser(data.user), channel: channelToTemplateSafeChannel(data.channel), reason: data.reason, message: savedMessageToTemplateSafeSavedMessage(data.message), messageText: disableCodeBlocks(deactivateMentions(data.message.data.content)), }), { userId: data.user.id, bot: data.user instanceof User ? data.user.bot : false, ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logChannelCreate.ts ================================================ import { GuildBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogChannelCreateData { channel: GuildBasedChannel; } export function logChannelCreate(pluginData: GuildPluginData, data: LogChannelCreateData) { return log( pluginData, LogType.CHANNEL_CREATE, createTypedTemplateSafeValueContainer({ channel: channelToTemplateSafeChannel(data.channel), }), { ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logChannelDelete.ts ================================================ import { GuildBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogChannelDeleteData { channel: GuildBasedChannel; } export function logChannelDelete(pluginData: GuildPluginData, data: LogChannelDeleteData) { return log( pluginData, LogType.CHANNEL_DELETE, createTypedTemplateSafeValueContainer({ channel: channelToTemplateSafeChannel(data.channel), }), { ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logChannelUpdate.ts ================================================ import { GuildBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogChannelUpdateData { oldChannel: GuildBasedChannel; newChannel: GuildBasedChannel; differenceString: string; } export function logChannelUpdate(pluginData: GuildPluginData, data: LogChannelUpdateData) { return log( pluginData, LogType.CHANNEL_UPDATE, createTypedTemplateSafeValueContainer({ oldChannel: channelToTemplateSafeChannel(data.oldChannel), newChannel: channelToTemplateSafeChannel(data.newChannel), differenceString: data.differenceString, }), { ...resolveChannelIds(data.newChannel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logClean.ts ================================================ import { GuildTextBasedChannel, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogCleanData { mod: User; channel: GuildTextBasedChannel; count: number; archiveUrl: string; } export function logClean(pluginData: GuildPluginData, data: LogCleanData) { return log( pluginData, LogType.CLEAN, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), channel: channelToTemplateSafeChannel(data.channel), count: data.count, archiveUrl: data.archiveUrl, }), { ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logDmFailed.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogDmFailedData { source: string; user: User | UnknownUser; } export function logDmFailed(pluginData: GuildPluginData, data: LogDmFailedData) { return log( pluginData, LogType.DM_FAILED, createTypedTemplateSafeValueContainer({ source: data.source, user: userToTemplateSafeUser(data.user), }), { userId: data.user.id, bot: data.user instanceof User ? data.user.bot : false, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logEmojiCreate.ts ================================================ import { Emoji } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { emojiToTemplateSafeEmoji } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogEmojiCreateData { emoji: Emoji; } export function logEmojiCreate(pluginData: GuildPluginData, data: LogEmojiCreateData) { return log( pluginData, LogType.EMOJI_CREATE, createTypedTemplateSafeValueContainer({ emoji: emojiToTemplateSafeEmoji(data.emoji), }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logEmojiDelete.ts ================================================ import { Emoji } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { emojiToTemplateSafeEmoji } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogEmojiDeleteData { emoji: Emoji; } export function logEmojiDelete(pluginData: GuildPluginData, data: LogEmojiDeleteData) { return log( pluginData, LogType.EMOJI_DELETE, createTypedTemplateSafeValueContainer({ emoji: emojiToTemplateSafeEmoji(data.emoji), }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logEmojiUpdate.ts ================================================ import { Emoji } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { emojiToTemplateSafeEmoji } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogEmojiUpdateData { oldEmoji: Emoji; newEmoji: Emoji; differenceString: string; } export function logEmojiUpdate(pluginData: GuildPluginData, data: LogEmojiUpdateData) { return log( pluginData, LogType.EMOJI_UPDATE, createTypedTemplateSafeValueContainer({ oldEmoji: emojiToTemplateSafeEmoji(data.oldEmoji), newEmoji: emojiToTemplateSafeEmoji(data.newEmoji), differenceString: data.differenceString, }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMassBan.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMassBanData { mod: User; count: number; reason: string; } export function logMassBan(pluginData: GuildPluginData, data: LogMassBanData) { return log( pluginData, LogType.MASSBAN, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), count: data.count, reason: data.reason, }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMassMute.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMassMuteData { mod: User; count: number; } export function logMassMute(pluginData: GuildPluginData, data: LogMassMuteData) { return log( pluginData, LogType.MASSMUTE, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), count: data.count, }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMassUnban.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMassUnbanData { mod: User; count: number; reason: string; } export function logMassUnban(pluginData: GuildPluginData, data: LogMassUnbanData) { return log( pluginData, LogType.MASSUNBAN, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), count: data.count, reason: data.reason, }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberBan.ts ================================================ import { PartialUser, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberBanData { mod: User | UnknownUser | PartialUser | null; user: User | UnknownUser; caseNumber: number; reason: string; } export function logMemberBan(pluginData: GuildPluginData, data: LogMemberBanData) { return log( pluginData, LogType.MEMBER_BAN, createTypedTemplateSafeValueContainer({ mod: data.mod ? userToTemplateSafeUser(data.mod) : null, user: userToTemplateSafeUser(data.user), caseNumber: data.caseNumber, reason: data.reason, }), { userId: data.user.id, bot: data.user instanceof User ? data.user.bot : false, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberForceban.ts ================================================ import { GuildMember, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberForcebanData { mod: GuildMember; userId: Snowflake; caseNumber: number; reason: string; } export function logMemberForceban(pluginData: GuildPluginData, data: LogMemberForcebanData) { return log( pluginData, LogType.MEMBER_FORCEBAN, createTypedTemplateSafeValueContainer({ mod: memberToTemplateSafeMember(data.mod), userId: data.userId, caseNumber: data.caseNumber, reason: data.reason, }), { userId: data.userId, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberJoin.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { LogType } from "../../../data/LogType.js"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberJoinData { member: GuildMember; } export function logMemberJoin(pluginData: GuildPluginData, data: LogMemberJoinData) { const newThreshold = moment.utc().valueOf() - 1000 * 60 * 60; const accountAge = humanizeDuration(moment.utc().valueOf() - data.member.user.createdTimestamp, { largest: 2, round: true, }); return log( pluginData, LogType.MEMBER_JOIN, createTypedTemplateSafeValueContainer({ member: memberToTemplateSafeMember(data.member), new: data.member.user.createdTimestamp >= newThreshold ? " :new:" : "", account_age: accountAge, account_age_ts: Math.round(data.member.user.createdTimestamp / 1000).toString(), }), { userId: data.member.id, bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberJoinWithPriorRecords.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberJoinWithPriorRecordsData { member: GuildMember; recentCaseSummary: string; } export function logMemberJoinWithPriorRecords( pluginData: GuildPluginData, data: LogMemberJoinWithPriorRecordsData, ) { return log( pluginData, LogType.MEMBER_JOIN_WITH_PRIOR_RECORDS, createTypedTemplateSafeValueContainer({ member: memberToTemplateSafeMember(data.member), recentCaseSummary: data.recentCaseSummary, }), { userId: data.member.id, bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberKick.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberKickData { mod: User | UnknownUser | null; user: User; caseNumber: number; reason: string; } export function logMemberKick(pluginData: GuildPluginData, data: LogMemberKickData) { return log( pluginData, LogType.MEMBER_KICK, createTypedTemplateSafeValueContainer({ mod: data.mod ? userToTemplateSafeUser(data.mod) : null, user: userToTemplateSafeUser(data.user), caseNumber: data.caseNumber, reason: data.reason, }), { userId: data.user.id, bot: data.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberLeave.ts ================================================ import { GuildMember, PartialGuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberLeaveData { member: GuildMember | PartialGuildMember; } export function logMemberLeave(pluginData: GuildPluginData, data: LogMemberLeaveData) { return log( pluginData, LogType.MEMBER_LEAVE, createTypedTemplateSafeValueContainer({ member: memberToTemplateSafeMember(data.member), }), { userId: data.member.id, bot: data.member.user?.bot ?? false, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberMute.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberMuteData { mod: User | UnknownUser; user: User | UnknownUser; caseNumber: number; reason: string; } export function logMemberMute(pluginData: GuildPluginData, data: LogMemberMuteData) { return log( pluginData, LogType.MEMBER_MUTE, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), user: userToTemplateSafeUser(data.user), caseNumber: data.caseNumber, reason: data.reason, }), { userId: data.user.id, bot: data.user instanceof User ? data.user.bot : false, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberMuteExpired.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { memberToTemplateSafeMember, TemplateSafeUnknownMember, TemplateSafeUnknownUser, } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberMuteExpiredData { member: GuildMember | UnknownUser; } export function logMemberMuteExpired(pluginData: GuildPluginData, data: LogMemberMuteExpiredData) { const member = data.member instanceof GuildMember ? memberToTemplateSafeMember(data.member) : new TemplateSafeUnknownMember({ ...data.member, user: new TemplateSafeUnknownUser({ ...data.member }), }); const roles = data.member instanceof GuildMember ? Array.from(data.member.roles.cache.keys()) : []; const bot = data.member instanceof GuildMember ? data.member.user.bot : false; return log( pluginData, LogType.MEMBER_MUTE_EXPIRED, createTypedTemplateSafeValueContainer({ member, }), { userId: data.member.id, roles, bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberMuteRejoin.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberMuteRejoinData { member: GuildMember; } export function logMemberMuteRejoin(pluginData: GuildPluginData, data: LogMemberMuteRejoinData) { return log( pluginData, LogType.MEMBER_MUTE_REJOIN, createTypedTemplateSafeValueContainer({ member: memberToTemplateSafeMember(data.member), }), { userId: data.member.id, bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberNickChange.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberNickChangeData { member: GuildMember; oldNick: string; newNick: string; } export function logMemberNickChange(pluginData: GuildPluginData, data: LogMemberNickChangeData) { return log( pluginData, LogType.MEMBER_NICK_CHANGE, createTypedTemplateSafeValueContainer({ member: memberToTemplateSafeMember(data.member), oldNick: data.oldNick, newNick: data.newNick, }), { userId: data.member.id, roles: Array.from(data.member.roles.cache.keys()), bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberNote.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberNoteData { mod: User; user: User | UnknownUser; caseNumber: number; reason: string; } export function logMemberNote(pluginData: GuildPluginData, data: LogMemberNoteData) { return log( pluginData, LogType.MEMBER_NOTE, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), user: userToTemplateSafeUser(data.user), caseNumber: data.caseNumber, reason: data.reason, }), { userId: data.user.id, bot: data.user instanceof User ? data.user.bot : false, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberRestore.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberRestoreData { member: GuildMember; restoredData: string; } export function logMemberRestore(pluginData: GuildPluginData, data: LogMemberRestoreData) { return log( pluginData, LogType.MEMBER_RESTORE, createTypedTemplateSafeValueContainer({ member: memberToTemplateSafeMember(data.member), restoredData: data.restoredData, }), { userId: data.member.id, roles: Array.from(data.member.roles.cache.keys()), bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberRoleAdd.ts ================================================ import { GuildMember, Role, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownRole } from "../../../utils.js"; import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberRoleAddData { mod: User | null; member: GuildMember; roles: Array; } export function logMemberRoleAdd(pluginData: GuildPluginData, data: LogMemberRoleAddData) { return log( pluginData, LogType.MEMBER_ROLE_ADD, createTypedTemplateSafeValueContainer({ mod: data.mod ? userToTemplateSafeUser(data.mod) : null, member: memberToTemplateSafeMember(data.member), roles: data.roles.map((r) => r.name).join(", "), }), { userId: data.member.id, roles: Array.from(data.member.roles.cache.keys()), bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberRoleChanges.ts ================================================ import { GuildMember, Role, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberRoleChangesData { mod: User | UnknownUser | null; member: GuildMember; addedRoles: Role[]; removedRoles: Role[]; } /** * @deprecated Use logMemberRoleAdd() and logMemberRoleRemove() instead */ export function logMemberRoleChanges(pluginData: GuildPluginData, data: LogMemberRoleChangesData) { return log( pluginData, LogType.MEMBER_ROLE_CHANGES, createTypedTemplateSafeValueContainer({ mod: data.mod ? userToTemplateSafeUser(data.mod) : null, member: memberToTemplateSafeMember(data.member), addedRoles: data.addedRoles.map((r) => r.name).join(", "), removedRoles: data.removedRoles.map((r) => r.name).join(", "), }), { userId: data.member.id, bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberRoleRemove.ts ================================================ import { GuildMember, Role, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownRole } from "../../../utils.js"; import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberRoleRemoveData { mod: User | null; member: GuildMember; roles: Array; } export function logMemberRoleRemove(pluginData: GuildPluginData, data: LogMemberRoleRemoveData) { return log( pluginData, LogType.MEMBER_ROLE_REMOVE, createTypedTemplateSafeValueContainer({ mod: data.mod ? userToTemplateSafeUser(data.mod) : null, member: memberToTemplateSafeMember(data.member), roles: data.roles.map((r) => r.name).join(", "), }), { userId: data.member.id, roles: Array.from(data.member.roles.cache.keys()), bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberTimedBan.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberTimedBanData { mod: User | UnknownUser; user: User | UnknownUser; banTime: string; caseNumber: number; reason: string; } export function logMemberTimedBan(pluginData: GuildPluginData, data: LogMemberTimedBanData) { return log( pluginData, LogType.MEMBER_TIMED_BAN, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), user: userToTemplateSafeUser(data.user), banTime: data.banTime, caseNumber: data.caseNumber, reason: data.reason, }), { userId: data.user.id, bot: data.user instanceof User ? data.user.bot : false, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberTimedMute.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberTimedMuteData { mod: User | UnknownUser; user: User | UnknownUser; time: string; caseNumber: number; reason: string; } export function logMemberTimedMute(pluginData: GuildPluginData, data: LogMemberTimedMuteData) { return log( pluginData, LogType.MEMBER_TIMED_MUTE, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), user: userToTemplateSafeUser(data.user), time: data.time, caseNumber: data.caseNumber, reason: data.reason, }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberTimedUnban.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberTimedUnbanData { mod: User | UnknownUser; userId: string; banTime: string; caseNumber: number; reason: string; } export function logMemberTimedUnban(pluginData: GuildPluginData, data: LogMemberTimedUnbanData) { return log( pluginData, LogType.MEMBER_TIMED_UNBAN, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), userId: data.userId, banTime: data.banTime, caseNumber: data.caseNumber, reason: data.reason, }), { userId: data.userId, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberTimedUnmute.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberTimedUnmuteData { mod: User; user: User | UnknownUser; time: string; caseNumber: number; reason: string; } export function logMemberTimedUnmute(pluginData: GuildPluginData, data: LogMemberTimedUnmuteData) { return log( pluginData, LogType.MEMBER_TIMED_UNMUTE, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), user: userToTemplateSafeUser(data.user), time: data.time, caseNumber: data.caseNumber, reason: data.reason, }), { userId: data.user.id, bot: data.user instanceof User ? data.user.bot : false, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberUnban.ts ================================================ import { PartialUser, Snowflake, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberUnbanData { mod: User | UnknownUser | PartialUser | null; userId: Snowflake; caseNumber: number; reason: string; } export function logMemberUnban(pluginData: GuildPluginData, data: LogMemberUnbanData) { return log( pluginData, LogType.MEMBER_UNBAN, createTypedTemplateSafeValueContainer({ mod: data.mod ? userToTemplateSafeUser(data.mod) : null, userId: data.userId, caseNumber: data.caseNumber, reason: data.reason, }), { userId: data.userId, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberUnmute.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberUnmuteData { mod: User; user: User | UnknownUser; caseNumber: number; reason: string; } export function logMemberUnmute(pluginData: GuildPluginData, data: LogMemberUnmuteData) { return log( pluginData, LogType.MEMBER_UNMUTE, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), user: userToTemplateSafeUser(data.user), caseNumber: data.caseNumber, reason: data.reason, }), { userId: data.user.id, bot: data.user instanceof User ? data.user.bot : false, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMemberWarn.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberWarnData { mod: GuildMember; member: GuildMember; caseNumber: number; reason: string; } export function logMemberWarn(pluginData: GuildPluginData, data: LogMemberWarnData) { return log( pluginData, LogType.MEMBER_WARN, createTypedTemplateSafeValueContainer({ mod: memberToTemplateSafeMember(data.mod), member: memberToTemplateSafeMember(data.member), caseNumber: data.caseNumber, reason: data.reason, }), { userId: data.member.id, roles: Array.from(data.member.roles.cache.keys()), bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMessageDelete.ts ================================================ import { GuildTextBasedChannel, User } from "discord.js"; import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { LogType } from "../../../data/LogType.js"; import { ISavedMessageAttachmentData, SavedMessage } from "../../../data/entities/SavedMessage.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser, useMediaUrls } from "../../../utils.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser, } from "../../../utils/templateSafeObjects.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; import { getMessageReplyLogInfo } from "../util/getMessageReplyLogInfo.js"; export interface LogMessageDeleteData { user: User | UnknownUser; channel: GuildTextBasedChannel; message: SavedMessage; } export async function logMessageDelete(pluginData: GuildPluginData, data: LogMessageDeleteData) { // Replace attachment URLs with media URLs if (data.message.data.attachments) { for (const attachment of data.message.data.attachments as ISavedMessageAttachmentData[]) { attachment.url = useMediaUrls(attachment.url); } } // See comment on FORMAT_NO_TIMESTAMP in types.ts const config = pluginData.config.get(); const timestampFormat = config.timestamp_format ?? undefined; const { replyInfo, reply } = await getMessageReplyLogInfo(pluginData, data.message); return log( pluginData, LogType.MESSAGE_DELETE, createTypedTemplateSafeValueContainer({ user: userToTemplateSafeUser(data.user), channel: channelToTemplateSafeChannel(data.channel), message: savedMessageToTemplateSafeSavedMessage(data.message), messageDate: pluginData .getPlugin(TimeAndDatePlugin) .inGuildTz(moment.utc(data.message.data.timestamp, "x")) .format(timestampFormat), replyInfo, reply, }), { userId: data.user.id, messageTextContent: data.message.data.content, bot: data.user instanceof User ? data.user.bot : false, ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMessageDeleteAuto.ts ================================================ import { GuildBasedChannel, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { ISavedMessageAttachmentData, SavedMessage } from "../../../data/entities/SavedMessage.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser, useMediaUrls } from "../../../utils.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser, } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; import { getMessageReplyLogInfo } from "../util/getMessageReplyLogInfo.js"; export interface LogMessageDeleteAutoData { message: SavedMessage; user: User | UnknownUser; channel: GuildBasedChannel; messageDate: string; } export async function logMessageDeleteAuto(pluginData: GuildPluginData, data: LogMessageDeleteAutoData) { if (data.message.data.attachments) { for (const attachment of data.message.data.attachments as ISavedMessageAttachmentData[]) { attachment.url = useMediaUrls(attachment.url); } } const { replyInfo, reply } = await getMessageReplyLogInfo(pluginData, data.message); return log( pluginData, LogType.MESSAGE_DELETE_AUTO, createTypedTemplateSafeValueContainer({ message: savedMessageToTemplateSafeSavedMessage(data.message), user: userToTemplateSafeUser(data.user), channel: channelToTemplateSafeChannel(data.channel), messageDate: data.messageDate, replyInfo, reply, }), { userId: data.user.id, bot: data.user instanceof User ? data.user.bot : false, ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMessageDeleteBare.ts ================================================ import { GuildTextBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMessageDeleteBareData { messageId: string; channel: GuildTextBasedChannel; } export function logMessageDeleteBare(pluginData: GuildPluginData, data: LogMessageDeleteBareData) { return log( pluginData, LogType.MESSAGE_DELETE_BARE, createTypedTemplateSafeValueContainer({ messageId: data.messageId, channel: channelToTemplateSafeChannel(data.channel), }), { ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMessageDeleteBulk.ts ================================================ import { GuildTextBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMessageDeleteBulkData { count: number; authorIds: string[]; channel: GuildTextBasedChannel; archiveUrl: string; } export function logMessageDeleteBulk(pluginData: GuildPluginData, data: LogMessageDeleteBulkData) { return log( pluginData, LogType.MESSAGE_DELETE_BULK, createTypedTemplateSafeValueContainer({ count: data.count, authorIds: data.authorIds, channel: channelToTemplateSafeChannel(data.channel), archiveUrl: data.archiveUrl, }), { ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMessageEdit.ts ================================================ import { GuildTextBasedChannel, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { UnknownUser } from "../../../utils.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser, } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMessageEditData { user: User | UnknownUser; channel: GuildTextBasedChannel; before: SavedMessage; after: SavedMessage; } export function logMessageEdit(pluginData: GuildPluginData, data: LogMessageEditData) { return log( pluginData, LogType.MESSAGE_EDIT, createTypedTemplateSafeValueContainer({ user: userToTemplateSafeUser(data.user), channel: channelToTemplateSafeChannel(data.channel), before: savedMessageToTemplateSafeSavedMessage(data.before), after: savedMessageToTemplateSafeSavedMessage(data.after), }), { userId: data.user.id, messageTextContent: data.after.data.content, bot: data.user instanceof User ? data.user.bot : false, ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logMessageSpamDetected.ts ================================================ import { GuildMember, GuildTextBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMessageSpamDetectedData { member: GuildMember; channel: GuildTextBasedChannel; description: string; limit: number; interval: number; archiveUrl: string; } export function logMessageSpamDetected(pluginData: GuildPluginData, data: LogMessageSpamDetectedData) { return log( pluginData, LogType.MESSAGE_SPAM_DETECTED, createTypedTemplateSafeValueContainer({ member: memberToTemplateSafeMember(data.member), channel: channelToTemplateSafeChannel(data.channel), description: data.description, limit: data.limit, interval: data.interval, archiveUrl: data.archiveUrl, }), { userId: data.member.id, roles: Array.from(data.member.roles.cache.keys()), bot: data.member.user.bot, ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logOtherSpamDetected.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogOtherSpamDetectedData { member: GuildMember; description: string; limit: number; interval: number; } export function logOtherSpamDetected(pluginData: GuildPluginData, data: LogOtherSpamDetectedData) { return log( pluginData, LogType.OTHER_SPAM_DETECTED, createTypedTemplateSafeValueContainer({ member: memberToTemplateSafeMember(data.member), description: data.description, limit: data.limit, interval: data.interval, }), { userId: data.member.id, bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts ================================================ import { GuildTextBasedChannel, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogPostedScheduledMessageData { author: User; channel: GuildTextBasedChannel; messageId: string; } export function logPostedScheduledMessage( pluginData: GuildPluginData, data: LogPostedScheduledMessageData, ) { return log( pluginData, LogType.POSTED_SCHEDULED_MESSAGE, createTypedTemplateSafeValueContainer({ author: userToTemplateSafeUser(data.author), channel: channelToTemplateSafeChannel(data.channel), messageId: data.messageId, }), { userId: data.author.id, bot: data.author.bot, ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logRepeatedMessage.ts ================================================ import { GuildTextBasedChannel, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogRepeatedMessageData { author: User; channel: GuildTextBasedChannel; datetime: string; date: string; time: string; repeatInterval: string; repeatDetails: string; } export function logRepeatedMessage(pluginData: GuildPluginData, data: LogRepeatedMessageData) { return log( pluginData, LogType.REPEATED_MESSAGE, createTypedTemplateSafeValueContainer({ author: userToTemplateSafeUser(data.author), channel: channelToTemplateSafeChannel(data.channel), datetime: data.datetime, date: data.date, time: data.time, repeatInterval: data.repeatInterval, repeatDetails: data.repeatDetails, }), { userId: data.author.id, bot: data.author.bot, ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logRoleCreate.ts ================================================ import { Role } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { roleToTemplateSafeRole } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogRoleCreateData { role: Role; } export function logRoleCreate(pluginData: GuildPluginData, data: LogRoleCreateData) { return log( pluginData, LogType.ROLE_CREATE, createTypedTemplateSafeValueContainer({ role: roleToTemplateSafeRole(data.role), }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logRoleDelete.ts ================================================ import { Role } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { roleToTemplateSafeRole } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogRoleDeleteData { role: Role; } export function logRoleDelete(pluginData: GuildPluginData, data: LogRoleDeleteData) { return log( pluginData, LogType.ROLE_DELETE, createTypedTemplateSafeValueContainer({ role: roleToTemplateSafeRole(data.role), }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logRoleUpdate.ts ================================================ import { Role } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { roleToTemplateSafeRole } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogRoleUpdateData { oldRole: Role; newRole: Role; differenceString: string; } export function logRoleUpdate(pluginData: GuildPluginData, data: LogRoleUpdateData) { return log( pluginData, LogType.ROLE_UPDATE, createTypedTemplateSafeValueContainer({ oldRole: roleToTemplateSafeRole(data.oldRole), newRole: roleToTemplateSafeRole(data.newRole), differenceString: data.differenceString, }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logScheduledMessage.ts ================================================ import { GuildTextBasedChannel, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogScheduledMessageData { author: User; channel: GuildTextBasedChannel; datetime: string; date: string; time: string; } export function logScheduledMessage(pluginData: GuildPluginData, data: LogScheduledMessageData) { return log( pluginData, LogType.SCHEDULED_MESSAGE, createTypedTemplateSafeValueContainer({ author: userToTemplateSafeUser(data.author), channel: channelToTemplateSafeChannel(data.channel), datetime: data.datetime, date: data.date, time: data.time, }), { userId: data.author.id, bot: data.author.bot, ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logScheduledRepeatedMessage.ts ================================================ import { GuildTextBasedChannel, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogScheduledRepeatedMessageData { author: User; channel: GuildTextBasedChannel; datetime: string; date: string; time: string; repeatInterval: string; repeatDetails: string; } export function logScheduledRepeatedMessage( pluginData: GuildPluginData, data: LogScheduledRepeatedMessageData, ) { return log( pluginData, LogType.SCHEDULED_REPEATED_MESSAGE, createTypedTemplateSafeValueContainer({ author: userToTemplateSafeUser(data.author), channel: channelToTemplateSafeChannel(data.channel), datetime: data.datetime, date: data.date, time: data.time, repeatInterval: data.repeatInterval, repeatDetails: data.repeatDetails, }), { userId: data.author.id, bot: data.author.bot, ...resolveChannelIds(data.channel), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logSetAntiraidAuto.ts ================================================ import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogSetAntiraidAutoData { level: string; } export function logSetAntiraidAuto(pluginData: GuildPluginData, data: LogSetAntiraidAutoData) { return log( pluginData, LogType.SET_ANTIRAID_AUTO, createTypedTemplateSafeValueContainer({ level: data.level, }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logSetAntiraidUser.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogSetAntiraidUserData { level: string; user: User; } export function logSetAntiraidUser(pluginData: GuildPluginData, data: LogSetAntiraidUserData) { return log( pluginData, LogType.SET_ANTIRAID_USER, createTypedTemplateSafeValueContainer({ level: data.level, user: userToTemplateSafeUser(data.user), }), { userId: data.user.id, bot: data.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logStageInstanceCreate.ts ================================================ import { StageChannel, StageInstance } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, stageToTemplateSafeStage } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogStageInstanceCreateData { stageInstance: StageInstance; stageChannel: StageChannel; } export function logStageInstanceCreate(pluginData: GuildPluginData, data: LogStageInstanceCreateData) { return log( pluginData, LogType.STAGE_INSTANCE_CREATE, createTypedTemplateSafeValueContainer({ stageInstance: stageToTemplateSafeStage(data.stageInstance), stageChannel: channelToTemplateSafeChannel(data.stageChannel), }), { ...resolveChannelIds(data.stageInstance.channel!), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logStageInstanceDelete.ts ================================================ import { StageChannel, StageInstance } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, stageToTemplateSafeStage } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogStageInstanceDeleteData { stageInstance: StageInstance; stageChannel: StageChannel; } export function logStageInstanceDelete(pluginData: GuildPluginData, data: LogStageInstanceDeleteData) { return log( pluginData, LogType.STAGE_INSTANCE_DELETE, createTypedTemplateSafeValueContainer({ stageInstance: stageToTemplateSafeStage(data.stageInstance), stageChannel: channelToTemplateSafeChannel(data.stageChannel), }), { ...resolveChannelIds(data.stageInstance.channel!), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logStageInstanceUpdate.ts ================================================ import { StageChannel, StageInstance } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, stageToTemplateSafeStage } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogStageInstanceUpdateData { oldStageInstance: StageInstance | null; newStageInstance: StageInstance; stageChannel: StageChannel; differenceString: string; } export function logStageInstanceUpdate(pluginData: GuildPluginData, data: LogStageInstanceUpdateData) { return log( pluginData, LogType.STAGE_INSTANCE_UPDATE, createTypedTemplateSafeValueContainer({ oldStageInstance: data.oldStageInstance ? stageToTemplateSafeStage(data.oldStageInstance) : null, newStageInstance: stageToTemplateSafeStage(data.newStageInstance), stageChannel: channelToTemplateSafeChannel(data.stageChannel), differenceString: data.differenceString, }), { ...resolveChannelIds(data.newStageInstance.channel!), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logStickerCreate.ts ================================================ import { Sticker } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { stickerToTemplateSafeSticker } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogStickerCreateData { sticker: Sticker; } export function logStickerCreate(pluginData: GuildPluginData, data: LogStickerCreateData) { return log( pluginData, LogType.STICKER_CREATE, createTypedTemplateSafeValueContainer({ sticker: stickerToTemplateSafeSticker(data.sticker), }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logStickerDelete.ts ================================================ import { Sticker } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { stickerToTemplateSafeSticker } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogStickerDeleteData { sticker: Sticker; } export function logStickerDelete(pluginData: GuildPluginData, data: LogStickerDeleteData) { return log( pluginData, LogType.STICKER_DELETE, createTypedTemplateSafeValueContainer({ sticker: stickerToTemplateSafeSticker(data.sticker), }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logStickerUpdate.ts ================================================ import { Sticker } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { stickerToTemplateSafeSticker } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogStickerUpdateData { oldSticker: Sticker; newSticker: Sticker; differenceString: string; } export function logStickerUpdate(pluginData: GuildPluginData, data: LogStickerUpdateData) { return log( pluginData, LogType.STICKER_UPDATE, createTypedTemplateSafeValueContainer({ oldSticker: stickerToTemplateSafeSticker(data.oldSticker), newSticker: stickerToTemplateSafeSticker(data.newSticker), differenceString: data.differenceString, }), {}, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logThreadCreate.ts ================================================ import { AnyThreadChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogThreadCreateData { thread: AnyThreadChannel; } export function logThreadCreate(pluginData: GuildPluginData, data: LogThreadCreateData) { return log( pluginData, LogType.THREAD_CREATE, createTypedTemplateSafeValueContainer({ thread: channelToTemplateSafeChannel(data.thread), }), { ...resolveChannelIds(data.thread), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logThreadDelete.ts ================================================ import { AnyThreadChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogThreadDeleteData { thread: AnyThreadChannel; } export function logThreadDelete(pluginData: GuildPluginData, data: LogThreadDeleteData) { return log( pluginData, LogType.THREAD_DELETE, createTypedTemplateSafeValueContainer({ thread: channelToTemplateSafeChannel(data.thread), }), { ...resolveChannelIds(data.thread), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logThreadUpdate.ts ================================================ import { AnyThreadChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogThreadUpdateData { oldThread: AnyThreadChannel; newThread: AnyThreadChannel; differenceString: string; } export function logThreadUpdate(pluginData: GuildPluginData, data: LogThreadUpdateData) { return log( pluginData, LogType.THREAD_UPDATE, createTypedTemplateSafeValueContainer({ oldThread: channelToTemplateSafeChannel(data.oldThread), newThread: channelToTemplateSafeChannel(data.newThread), differenceString: data.differenceString, }), { ...resolveChannelIds(data.newThread), }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logVoiceChannelForceDisconnect.ts ================================================ import { GuildMember, User, VoiceBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, memberToTemplateSafeMember, userToTemplateSafeUser, } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogVoiceChannelForceDisconnectData { mod: User; member: GuildMember; oldChannel: VoiceBasedChannel; } export function logVoiceChannelForceDisconnect( pluginData: GuildPluginData, data: LogVoiceChannelForceDisconnectData, ) { return log( pluginData, LogType.VOICE_CHANNEL_FORCE_DISCONNECT, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), member: memberToTemplateSafeMember(data.member), oldChannel: channelToTemplateSafeChannel(data.oldChannel), }), { userId: data.member.id, roles: Array.from(data.member.roles.cache.keys()), ...resolveChannelIds(data.oldChannel), bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logVoiceChannelForceMove.ts ================================================ import { GuildMember, User, VoiceBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, memberToTemplateSafeMember, userToTemplateSafeUser, } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogVoiceChannelForceMoveData { mod: User; member: GuildMember; oldChannel: VoiceBasedChannel; newChannel: VoiceBasedChannel; } export function logVoiceChannelForceMove( pluginData: GuildPluginData, data: LogVoiceChannelForceMoveData, ) { return log( pluginData, LogType.VOICE_CHANNEL_FORCE_MOVE, createTypedTemplateSafeValueContainer({ mod: userToTemplateSafeUser(data.mod), member: memberToTemplateSafeMember(data.member), oldChannel: channelToTemplateSafeChannel(data.oldChannel), newChannel: channelToTemplateSafeChannel(data.newChannel), }), { userId: data.member.id, roles: Array.from(data.member.roles.cache.keys()), ...resolveChannelIds(data.newChannel), bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logVoiceChannelJoin.ts ================================================ import { GuildMember, VoiceBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogVoiceChannelJoinData { member: GuildMember; channel: VoiceBasedChannel; } export function logVoiceChannelJoin(pluginData: GuildPluginData, data: LogVoiceChannelJoinData) { return log( pluginData, LogType.VOICE_CHANNEL_JOIN, createTypedTemplateSafeValueContainer({ member: memberToTemplateSafeMember(data.member), channel: channelToTemplateSafeChannel(data.channel), }), { userId: data.member.id, roles: Array.from(data.member.roles.cache.keys()), ...resolveChannelIds(data.channel), bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logVoiceChannelLeave.ts ================================================ import { GuildMember, VoiceBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogVoiceChannelLeaveData { member: GuildMember; channel: VoiceBasedChannel; } export function logVoiceChannelLeave(pluginData: GuildPluginData, data: LogVoiceChannelLeaveData) { return log( pluginData, LogType.VOICE_CHANNEL_LEAVE, createTypedTemplateSafeValueContainer({ member: memberToTemplateSafeMember(data.member), channel: channelToTemplateSafeChannel(data.channel), }), { userId: data.member.id, roles: Array.from(data.member.roles.cache.keys()), ...resolveChannelIds(data.channel), bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/logFunctions/logVoiceChannelMove.ts ================================================ import { GuildMember, VoiceBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { resolveChannelIds } from "../../../utils/resolveChannelIds.js"; import { channelToTemplateSafeChannel, memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogVoiceChannelMoveData { member: GuildMember; oldChannel: VoiceBasedChannel; newChannel: VoiceBasedChannel; } export function logVoiceChannelMove(pluginData: GuildPluginData, data: LogVoiceChannelMoveData) { return log( pluginData, LogType.VOICE_CHANNEL_MOVE, createTypedTemplateSafeValueContainer({ member: memberToTemplateSafeMember(data.member), oldChannel: channelToTemplateSafeChannel(data.oldChannel), newChannel: channelToTemplateSafeChannel(data.newChannel), }), { userId: data.member.id, roles: Array.from(data.member.roles.cache.keys()), ...resolveChannelIds(data.newChannel), bot: data.member.user.bot, }, ); } ================================================ FILE: backend/src/plugins/Logs/types.ts ================================================ import { BasePluginType, CooldownManager, guildPluginEventListener } from "vety"; import { z } from "zod"; import { RegExpRunner } from "../../RegExpRunner.js"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { LogType } from "../../data/LogType.js"; import { keys, zBoundedCharacters, zMessageContent, zRegex, zSnowflake } from "../../utils.js"; import { MessageBuffer } from "../../utils/MessageBuffer.js"; import { TemplateSafeCase, TemplateSafeChannel, TemplateSafeEmoji, TemplateSafeMember, TemplateSafeRole, TemplateSafeSavedMessage, TemplateSafeStage, TemplateSafeSticker, TemplateSafeUnknownMember, TemplateSafeUnknownUser, TemplateSafeUser, } from "../../utils/templateSafeObjects.js"; import { TemplateSafeValueContainer } from "../../templateFormatter.js"; import DefaultLogMessages from "../../data/DefaultLogMessages.json" with { type: "json" }; import { TemplateSafeValueContainer } from "templateFormatter.js"; const DEFAULT_BATCH_TIME = 1000; const MIN_BATCH_TIME = 250; const MAX_BATCH_TIME = 5000; // A bit of a workaround so we can pass LogType keys to z.enum() const zMessageContentWithDefault = zMessageContent.default(""); const logTypes = keys(LogType); const logTypeProps = logTypes.reduce( (map, type) => { map[type] = zMessageContent.default(DefaultLogMessages[type] || ""); return map; }, {} as Record, ); const zLogFormats = z.strictObject(logTypeProps); const zLogChannel = z.strictObject({ include: z.array(zBoundedCharacters(1, 255)).default([]), exclude: z.array(zBoundedCharacters(1, 255)).default([]), batched: z.boolean().default(true), batch_time: z.number().min(MIN_BATCH_TIME).max(MAX_BATCH_TIME).default(DEFAULT_BATCH_TIME), excluded_users: z.array(zSnowflake).nullable().default(null), excluded_message_regexes: z.array(zRegex(z.string())).nullable().default(null), excluded_channels: z.array(zSnowflake).nullable().default(null), excluded_categories: z.array(zSnowflake).nullable().default(null), excluded_threads: z.array(zSnowflake).nullable().default(null), exclude_bots: z.boolean().default(false), excluded_roles: z.array(zSnowflake).nullable().default(null), format: zLogFormats.partial().default({}), timestamp_format: z.string().nullable().default(null), include_embed_timestamp: z.boolean().nullable().default(null), }); export type TLogChannel = z.infer; const zLogChannelMap = z.record(zSnowflake, zLogChannel); export type TLogChannelMap = z.infer; export const zLogsConfig = z.strictObject({ channels: zLogChannelMap.default({}), format: zLogFormats.prefault({}), // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true ping_user: z.boolean().default(true), allow_user_mentions: z.boolean().default(false), timestamp_format: z.string().nullable().default("[]"), include_embed_timestamp: z.boolean().default(true), }); // Hacky way of allowing a """null""" default value for config.format.timestamp due to legacy io-ts reasons export const FORMAT_NO_TIMESTAMP = "__NO_TIMESTAMP__"; export interface LogsPluginType extends BasePluginType { configSchema: typeof zLogsConfig; state: { guildLogs: GuildLogs; savedMessages: GuildSavedMessages; archives: GuildArchives; cases: GuildCases; regexRunner: RegExpRunner; regexRunnerRepeatedTimeoutListener; logListener; buffers: Map; channelCooldowns: CooldownManager; onMessageDeleteFn; onMessageDeleteBulkFn; onMessageUpdateFn; }; } export const logsEvt = guildPluginEventListener(); export const LogTypeData = z.object({ [LogType.MEMBER_WARN]: z.object({ mod: z.instanceof(TemplateSafeMember), member: z.instanceof(TemplateSafeMember), caseNumber: z.number(), reason: z.string(), }), [LogType.MEMBER_MUTE]: z.object({ mod: z.instanceof(TemplateSafeUser), user: z.instanceof(TemplateSafeUser), caseNumber: z.number(), reason: z.string(), }), [LogType.MEMBER_UNMUTE]: z.object({ mod: z.instanceof(TemplateSafeUser), user: z.instanceof(TemplateSafeUser), caseNumber: z.number(), reason: z.string(), }), [LogType.MEMBER_MUTE_EXPIRED]: z.object({ member: z.instanceof(TemplateSafeMember).or(z.instanceof(TemplateSafeUnknownMember)), }), [LogType.MEMBER_KICK]: z.object({ mod: z.instanceof(TemplateSafeUser).or(z.null()), user: z.instanceof(TemplateSafeUser), caseNumber: z.number(), reason: z.string(), }), [LogType.MEMBER_BAN]: z.object({ mod: z.instanceof(TemplateSafeUser).or(z.null()), user: z.instanceof(TemplateSafeUser), caseNumber: z.number(), reason: z.string(), }), [LogType.MEMBER_UNBAN]: z.object({ mod: z.instanceof(TemplateSafeUser).or(z.null()), userId: z.string(), caseNumber: z.number(), reason: z.string(), }), [LogType.MEMBER_FORCEBAN]: z.object({ mod: z.instanceof(TemplateSafeUser), userId: z.string(), caseNumber: z.number(), reason: z.string(), }), [LogType.MEMBER_JOIN]: z.object({ member: z.instanceof(TemplateSafeMember), new: z.string(), account_age: z.string(), account_age_ts: z.string(), }), [LogType.MEMBER_LEAVE]: z.object({ member: z.instanceof(TemplateSafeMember), }), [LogType.MEMBER_ROLE_ADD]: z.object({ mod: z.instanceof(TemplateSafeUser).or(z.null()), member: z.instanceof(TemplateSafeMember), roles: z.string(), }), [LogType.MEMBER_ROLE_REMOVE]: z.object({ mod: z.instanceof(TemplateSafeUser).or(z.null()), member: z.instanceof(TemplateSafeMember), roles: z.string(), }), [LogType.MEMBER_NICK_CHANGE]: z.object({ member: z.instanceof(TemplateSafeMember), oldNick: z.string(), newNick: z.string(), }), [LogType.MEMBER_RESTORE]: z.object({ member: z.instanceof(TemplateSafeMember), restoredData: z.string(), }), [LogType.CHANNEL_CREATE]: z.object({ channel: z.instanceof(TemplateSafeChannel), }), [LogType.CHANNEL_DELETE]: z.object({ channel: z.instanceof(TemplateSafeChannel), }), [LogType.CHANNEL_UPDATE]: z.object({ oldChannel: z.instanceof(TemplateSafeChannel), newChannel: z.instanceof(TemplateSafeChannel), differenceString: z.string(), }), [LogType.THREAD_CREATE]: z.object({ thread: z.instanceof(TemplateSafeChannel), }), [LogType.THREAD_DELETE]: z.object({ thread: z.instanceof(TemplateSafeChannel), }), [LogType.THREAD_UPDATE]: z.object({ oldThread: z.instanceof(TemplateSafeChannel), newThread: z.instanceof(TemplateSafeChannel), differenceString: z.string(), }), [LogType.ROLE_CREATE]: z.object({ role: z.instanceof(TemplateSafeRole), }), [LogType.ROLE_DELETE]: z.object({ role: z.instanceof(TemplateSafeRole), }), [LogType.ROLE_UPDATE]: z.object({ oldRole: z.instanceof(TemplateSafeRole), newRole: z.instanceof(TemplateSafeRole), differenceString: z.string(), }), [LogType.MESSAGE_EDIT]: z.object({ user: z.instanceof(TemplateSafeUser), channel: z.instanceof(TemplateSafeChannel), before: z.instanceof(TemplateSafeSavedMessage), after: z.instanceof(TemplateSafeSavedMessage), }), [LogType.MESSAGE_DELETE]: z.object({ user: z.instanceof(TemplateSafeUser), channel: z.instanceof(TemplateSafeChannel), messageDate: z.string(), message: z.instanceof(TemplateSafeSavedMessage), replyInfo: z.string(), reply: z.instanceof(TemplateSafeValueContainer).nullable(), }), [LogType.MESSAGE_DELETE_BULK]: z.object({ count: z.number(), authorIds: z.array(z.string()), channel: z.instanceof(TemplateSafeChannel), archiveUrl: z.string(), }), [LogType.MESSAGE_DELETE_BARE]: z.object({ messageId: z.string(), channel: z.instanceof(TemplateSafeChannel), }), [LogType.VOICE_CHANNEL_JOIN]: z.object({ member: z.instanceof(TemplateSafeMember), channel: z.instanceof(TemplateSafeChannel), }), [LogType.VOICE_CHANNEL_LEAVE]: z.object({ member: z.instanceof(TemplateSafeMember), channel: z.instanceof(TemplateSafeChannel), }), [LogType.VOICE_CHANNEL_MOVE]: z.object({ member: z.instanceof(TemplateSafeMember), oldChannel: z.instanceof(TemplateSafeChannel), newChannel: z.instanceof(TemplateSafeChannel), }), [LogType.STAGE_INSTANCE_CREATE]: z.object({ stageInstance: z.instanceof(TemplateSafeStage), stageChannel: z.instanceof(TemplateSafeChannel), }), [LogType.STAGE_INSTANCE_DELETE]: z.object({ stageInstance: z.instanceof(TemplateSafeStage), stageChannel: z.instanceof(TemplateSafeChannel), }), [LogType.STAGE_INSTANCE_UPDATE]: z.object({ oldStageInstance: z.instanceof(TemplateSafeStage).or(z.null()), newStageInstance: z.instanceof(TemplateSafeStage), stageChannel: z.instanceof(TemplateSafeChannel), differenceString: z.string(), }), [LogType.EMOJI_CREATE]: z.object({ emoji: z.instanceof(TemplateSafeEmoji), }), [LogType.EMOJI_DELETE]: z.object({ emoji: z.instanceof(TemplateSafeEmoji), }), [LogType.EMOJI_UPDATE]: z.object({ oldEmoji: z.instanceof(TemplateSafeEmoji), newEmoji: z.instanceof(TemplateSafeEmoji), differenceString: z.string(), }), [LogType.STICKER_CREATE]: z.object({ sticker: z.instanceof(TemplateSafeSticker), }), [LogType.STICKER_DELETE]: z.object({ sticker: z.instanceof(TemplateSafeSticker), }), [LogType.STICKER_UPDATE]: z.object({ oldSticker: z.instanceof(TemplateSafeSticker), newSticker: z.instanceof(TemplateSafeSticker), differenceString: z.string(), }), [LogType.MESSAGE_SPAM_DETECTED]: z.object({ member: z.instanceof(TemplateSafeMember), channel: z.instanceof(TemplateSafeChannel), description: z.string(), limit: z.number(), interval: z.number(), archiveUrl: z.string(), }), [LogType.CENSOR]: z.object({ user: z.instanceof(TemplateSafeUser), channel: z.instanceof(TemplateSafeChannel), reason: z.string(), message: z.instanceof(TemplateSafeSavedMessage), messageText: z.string(), }), [LogType.CLEAN]: z.object({ mod: z.instanceof(TemplateSafeUser), channel: z.instanceof(TemplateSafeChannel), count: z.number(), archiveUrl: z.string(), }), [LogType.CASE_CREATE]: z.object({ mod: z.instanceof(TemplateSafeUser), userId: z.string(), caseNum: z.number(), caseType: z.string(), reason: z.string(), }), [LogType.MASSUNBAN]: z.object({ mod: z.instanceof(TemplateSafeUser), count: z.number(), reason: z.string(), }), [LogType.MASSBAN]: z.object({ mod: z.instanceof(TemplateSafeUser), count: z.number(), reason: z.string(), }), [LogType.MASSMUTE]: z.object({ mod: z.instanceof(TemplateSafeUser), count: z.number(), }), [LogType.MEMBER_TIMED_MUTE]: z.object({ mod: z.instanceof(TemplateSafeUser), user: z.instanceof(TemplateSafeUser), time: z.string(), caseNumber: z.number(), reason: z.string(), }), [LogType.MEMBER_TIMED_UNMUTE]: z.object({ mod: z.instanceof(TemplateSafeUser), user: z.instanceof(TemplateSafeUser), time: z.string(), caseNumber: z.number(), reason: z.string(), }), [LogType.MEMBER_TIMED_BAN]: z.object({ mod: z.instanceof(TemplateSafeUser), user: z.instanceof(TemplateSafeUser), banTime: z.string(), caseNumber: z.number(), reason: z.string(), }), [LogType.MEMBER_TIMED_UNBAN]: z.object({ mod: z.instanceof(TemplateSafeUser).or(z.instanceof(TemplateSafeUnknownUser)), userId: z.string(), banTime: z.string(), caseNumber: z.number(), reason: z.string(), }), [LogType.MEMBER_JOIN_WITH_PRIOR_RECORDS]: z.object({ member: z.instanceof(TemplateSafeMember), recentCaseSummary: z.string(), }), [LogType.OTHER_SPAM_DETECTED]: z.object({ member: z.instanceof(TemplateSafeMember), description: z.string(), limit: z.number(), interval: z.number(), }), [LogType.MEMBER_ROLE_CHANGES]: z.object({ mod: z.instanceof(TemplateSafeUser).or(z.instanceof(TemplateSafeUnknownUser)).or(z.null()), member: z.instanceof(TemplateSafeMember), addedRoles: z.string(), removedRoles: z.string(), }), [LogType.VOICE_CHANNEL_FORCE_MOVE]: z.object({ mod: z.instanceof(TemplateSafeUser), member: z.instanceof(TemplateSafeMember), oldChannel: z.instanceof(TemplateSafeChannel), newChannel: z.instanceof(TemplateSafeChannel), }), [LogType.VOICE_CHANNEL_FORCE_DISCONNECT]: z.object({ mod: z.instanceof(TemplateSafeUser), member: z.instanceof(TemplateSafeMember), oldChannel: z.instanceof(TemplateSafeChannel), }), [LogType.CASE_UPDATE]: z.object({ mod: z.instanceof(TemplateSafeUser), caseNumber: z.number(), caseType: z.string(), note: z.string(), }), [LogType.MEMBER_MUTE_REJOIN]: z.object({ member: z.instanceof(TemplateSafeMember), }), [LogType.SCHEDULED_MESSAGE]: z.object({ author: z.instanceof(TemplateSafeUser), channel: z.instanceof(TemplateSafeChannel), datetime: z.string(), date: z.string(), time: z.string(), }), [LogType.POSTED_SCHEDULED_MESSAGE]: z.object({ author: z.instanceof(TemplateSafeUser), channel: z.instanceof(TemplateSafeChannel), messageId: z.string(), }), [LogType.BOT_ALERT]: z.object({ body: z.string(), }), [LogType.AUTOMOD_ACTION]: z.object({ rule: z.string(), user: z.instanceof(TemplateSafeUser).nullable(), users: z.array(z.instanceof(TemplateSafeUser)), actionsTaken: z.string(), matchSummary: z.string(), }), [LogType.SCHEDULED_REPEATED_MESSAGE]: z.object({ author: z.instanceof(TemplateSafeUser), channel: z.instanceof(TemplateSafeChannel), datetime: z.string(), date: z.string(), time: z.string(), repeatInterval: z.string(), repeatDetails: z.string(), }), [LogType.REPEATED_MESSAGE]: z.object({ author: z.instanceof(TemplateSafeUser), channel: z.instanceof(TemplateSafeChannel), datetime: z.string(), date: z.string(), time: z.string(), repeatInterval: z.string(), repeatDetails: z.string(), }), [LogType.MESSAGE_DELETE_AUTO]: z.object({ message: z.instanceof(TemplateSafeSavedMessage), user: z.instanceof(TemplateSafeUser), channel: z.instanceof(TemplateSafeChannel), messageDate: z.string(), replyInfo: z.string(), reply: z.instanceof(TemplateSafeValueContainer).nullable(), }), [LogType.SET_ANTIRAID_USER]: z.object({ level: z.string(), user: z.instanceof(TemplateSafeUser), }), [LogType.SET_ANTIRAID_AUTO]: z.object({ level: z.string(), }), [LogType.MEMBER_NOTE]: z.object({ mod: z.instanceof(TemplateSafeUser), user: z.instanceof(TemplateSafeUser), caseNumber: z.number(), reason: z.string(), }), [LogType.CASE_DELETE]: z.object({ mod: z.instanceof(TemplateSafeMember), case: z.instanceof(TemplateSafeCase), }), [LogType.DM_FAILED]: z.object({ source: z.string(), user: z.instanceof(TemplateSafeUser).or(z.instanceof(TemplateSafeUnknownUser)), }), }); export type ILogTypeData = z.infer; ================================================ FILE: backend/src/plugins/Logs/util/getLogMessage.ts ================================================ import { MessageCreateOptions } from "discord.js"; import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { LogType } from "../../../data/LogType.js"; import { logger } from "../../../logger.js"; import { renderTemplate, TemplateParseError, TemplateSafeValueContainer, TypedTemplateSafeValueContainer, } from "../../../templateFormatter.js"; import { messageSummary, renderRecursively, resolveMember, validateAndParseMessageContent, verboseChannelMention, verboseUserMention, verboseUserName, } from "../../../utils.js"; import { getTemplateSafeMemberLevel, memberToTemplateSafeMember, TemplateSafeMember, TemplateSafeUser, } from "../../../utils/templateSafeObjects.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { ILogTypeData, LogsPluginType, TLogChannel } from "../types.js"; export async function getLogMessage( pluginData: GuildPluginData, type: TLogType, data: TypedTemplateSafeValueContainer, opts?: Pick, ): Promise { const config = pluginData.config.get(); const format = opts?.format?.[LogType[type]] || config.format[LogType[type]] || ""; if (format === "" || format == null) return null; // See comment on FORMAT_NO_TIMESTAMP in types.ts const timestampFormat = opts?.timestamp_format ?? config.timestamp_format; const includeEmbedTimestamp = opts?.include_embed_timestamp ?? config.include_embed_timestamp; const time = pluginData.getPlugin(TimeAndDatePlugin).inGuildTz(); const isoTimestamp = time.toISOString(); const timestamp = timestampFormat ? time.format(timestampFormat) : ""; const values = new TemplateSafeValueContainer({ ...data, timestamp, userMention: async (inputUserOrMember: unknown) => { if (!inputUserOrMember) { return ""; } const inputArray = Array.isArray(inputUserOrMember) ? inputUserOrMember : [inputUserOrMember]; // TODO: Resolve IDs to users/members const usersOrMembers = inputArray.filter( (v) => v instanceof TemplateSafeUser || v instanceof TemplateSafeMember, ) as Array; const mentions: string[] = []; for (const userOrMember of usersOrMembers) { let user; let member: TemplateSafeMember | null = null; if (userOrMember.user) { member = userOrMember as TemplateSafeMember; user = member.user; } else { user = userOrMember; const apiMember = await resolveMember(pluginData.client, pluginData.guild, user.id); if (apiMember) { member = memberToTemplateSafeMember(apiMember); } } const level = member ? getTemplateSafeMemberLevel(pluginData, member) : 0; const memberConfig = (await pluginData.config.getMatchingConfig({ level, memberRoles: member ? member.roles.map((r) => r.id) : [], userId: user.id, })) || ({} as any); // Revert to old behavior (verbose name w/o ping if allow_user_mentions is enabled (for whatever reason)) if (config.allow_user_mentions) { mentions.push(memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user)); } else { mentions.push(verboseUserMention(user)); } } return mentions.join(", "); }, channelMention: (channel) => { if (!channel) return ""; return verboseChannelMention(channel); }, messageSummary: (msg: SavedMessage) => { if (!msg) return ""; return messageSummary(msg); }, }); if (type === LogType.BOT_ALERT) { const valuesWithoutTmplEval = { ...values }; values.tmplEval = (str) => { return renderTemplate(str, valuesWithoutTmplEval); }; } const renderLogString = (str) => renderTemplate(str, values); let formatted; try { formatted = typeof format === "string" ? await renderLogString(format) : await renderRecursively(format, renderLogString); } catch (e) { if (e instanceof TemplateParseError) { logger.error(`Error when parsing template:\nError: ${e.message}\nTemplate: ${format}`); return null; } else { throw e; } } if (typeof formatted === "string") { formatted = formatted.trim(); } else if (formatted != null) { formatted = validateAndParseMessageContent(formatted); if (formatted.embeds && Array.isArray(formatted.embeds) && includeEmbedTimestamp) { for (const embed of formatted.embeds) { embed.timestamp = isoTimestamp; } } } return formatted; } ================================================ FILE: backend/src/plugins/Logs/util/getMessageReplyLogInfo.ts ================================================ import { GuildPluginData } from "vety"; import { ISavedMessageAttachmentData, SavedMessage } from "../../../data/entities/SavedMessage.js"; import { messageLink, messageSummary, useMediaUrls } from "../../../utils.js"; import { TemplateSafeValueContainer } from "../../../templateFormatter.js"; import { savedMessageToTemplateSafeSavedMessage, TemplateSafeSavedMessage } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; export interface MessageReplyLogInfo { replyInfo: string; reply: TemplateSafeValueContainer | null; } export async function getMessageReplyLogInfo( pluginData: GuildPluginData, message: SavedMessage, ): Promise { const reference = message.data.reference; if (!reference?.messageId || !reference.channelId) { return { replyInfo: "", reply: null }; } const link = messageLink(reference.guildId ?? message.guild_id, reference.channelId, reference.messageId); let replyInfo = `\n**Replied To:** [Jump to message](${link})`; const referencedMessage = await pluginData.state.savedMessages.find(reference.messageId, true); let timestamp: string | null = null; let summary: string | null = null; let timestampMs: number | null = null; let templateSafeMessage: TemplateSafeSavedMessage | null = null; if (referencedMessage) { if (referencedMessage.data.attachments) { for (const attachment of referencedMessage.data.attachments as ISavedMessageAttachmentData[]) { attachment.url = useMediaUrls(attachment.url); } } timestampMs = referencedMessage.data.timestamp; timestamp = ``; replyInfo += ` (posted at ${timestamp})`; summary = messageSummary(referencedMessage); if (summary) { replyInfo += `\n${summary}`; } templateSafeMessage = savedMessageToTemplateSafeSavedMessage(referencedMessage); } const reply = new TemplateSafeValueContainer({ link, timestamp, timestampMs, summary, message: templateSafeMessage, }); return { replyInfo, reply }; } ================================================ FILE: backend/src/plugins/Logs/util/isLogIgnored.ts ================================================ import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { LogsPluginType } from "../types.js"; export function isLogIgnored( pluginData: GuildPluginData, type: keyof typeof LogType, ignoreId: string, ) { return pluginData.state.guildLogs.isLogIgnored(type, ignoreId); } ================================================ FILE: backend/src/plugins/Logs/util/log.ts ================================================ import { APIEmbed, MessageMentionTypes, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { allowTimeout } from "../../../RegExpRunner.js"; import { LogType } from "../../../data/LogType.js"; import { TypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { MINUTES, inputPatternToRegExp, isDiscordAPIError } from "../../../utils.js"; import { MessageBuffer } from "../../../utils/MessageBuffer.js"; import { InternalPosterPlugin } from "../../InternalPoster/InternalPosterPlugin.js"; import { ILogTypeData, LogsPluginType, TLogChannel, TLogChannelMap } from "../types.js"; import { getLogMessage } from "./getLogMessage.js"; interface ExclusionData { userId?: Snowflake | null; bot?: boolean | null; roles?: Snowflake[] | null; channel?: Snowflake | null; category?: Snowflake | null; thread?: Snowflake | null; messageTextContent?: string | null; } const DEFAULT_BATCH_TIME = 1000; const MIN_BATCH_TIME = 250; const MAX_BATCH_TIME = 5000; async function shouldExclude( pluginData: GuildPluginData, opts: TLogChannel, exclusionData: ExclusionData, ): Promise { if (opts.excluded_users && exclusionData.userId && opts.excluded_users.includes(exclusionData.userId)) { return true; } if (opts.exclude_bots && exclusionData.bot) { return true; } if (opts.excluded_roles && exclusionData.roles) { for (const role of exclusionData.roles) { if (opts.excluded_roles.includes(role)) { return true; } } } if (opts.excluded_channels && exclusionData.channel && opts.excluded_channels.includes(exclusionData.channel)) { return true; } if (opts.excluded_categories && exclusionData.category && opts.excluded_categories.includes(exclusionData.category)) { return true; } if (opts.excluded_threads && exclusionData.thread && opts.excluded_threads.includes(exclusionData.thread)) { return true; } if (opts.excluded_message_regexes && exclusionData.messageTextContent) { for (const pattern of opts.excluded_message_regexes) { const regex = inputPatternToRegExp(pattern); const matches = await pluginData.state.regexRunner .exec(regex, exclusionData.messageTextContent) .catch(allowTimeout); if (matches) { return true; } } } return false; } export async function log( pluginData: GuildPluginData, type: TLogType, data: TypedTemplateSafeValueContainer, exclusionData: ExclusionData = {}, ) { const logChannels: TLogChannelMap = pluginData.config.get().channels; const typeStr = LogType[type]; for (const [channelId, opts] of Object.entries(logChannels)) { const channel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!channel?.isTextBased()) continue; if (pluginData.state.channelCooldowns.isOnCooldown(channelId)) continue; if (opts.include?.length && !opts.include.includes(typeStr)) continue; if (opts.exclude && opts.exclude.includes(typeStr)) continue; if (await shouldExclude(pluginData, opts, exclusionData)) continue; const message = await getLogMessage(pluginData, type, data, { format: opts.format, include_embed_timestamp: opts.include_embed_timestamp, timestamp_format: opts.timestamp_format, }); if (!message) return; // Initialize message buffer for this channel if (!pluginData.state.buffers.has(channelId)) { const batchTime = Math.min(Math.max(opts.batch_time ?? DEFAULT_BATCH_TIME, MIN_BATCH_TIME), MAX_BATCH_TIME); const internalPosterPlugin = pluginData.getPlugin(InternalPosterPlugin); pluginData.state.buffers.set( channelId, new MessageBuffer({ timeout: batchTime, textSeparator: "\n", consume: (part) => { const parse: MessageMentionTypes[] = pluginData.config.get().allow_user_mentions ? ["users"] : []; internalPosterPlugin .sendMessage(channel, { ...part, allowedMentions: { parse }, }) .catch((err) => { if (isDiscordAPIError(err)) { // Missing Access / Missing Permissions // TODO: Show/log this somewhere if (err.code === 50001 || err.code === 50013) { pluginData.state.channelCooldowns.setCooldown(channelId, 2 * MINUTES); return; } } // tslint:disable-next-line:no-console console.warn( `Error while sending ${typeStr} log to ${pluginData.guild.id}/${channelId}: ${err.message}`, ); }); }, }), ); } // Add log message to buffer const buffer = pluginData.state.buffers.get(channelId)!; buffer.push({ content: typeof message === "string" ? message : message.content || "", embeds: typeof message === "string" ? [] : ((message.embeds || []) as APIEmbed[]), }); } } ================================================ FILE: backend/src/plugins/Logs/util/onMessageDelete.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { resolveUser } from "../../../utils.js"; import { logMessageDelete } from "../logFunctions/logMessageDelete.js"; import { logMessageDeleteBare } from "../logFunctions/logMessageDeleteBare.js"; import { LogsPluginType } from "../types.js"; import { isLogIgnored } from "./isLogIgnored.js"; export async function onMessageDelete(pluginData: GuildPluginData, savedMessage: SavedMessage) { const user = await resolveUser(pluginData.client, savedMessage.user_id, "Logs:onMessageDelete"); const channel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake); if (!channel?.isTextBased()) { return; } if (isLogIgnored(pluginData, LogType.MESSAGE_DELETE, savedMessage.id)) { return; } if (user) { logMessageDelete(pluginData, { user, channel, message: savedMessage, }); } else { logMessageDeleteBare(pluginData, { messageId: savedMessage.id, channel, }); } } ================================================ FILE: backend/src/plugins/Logs/util/onMessageDeleteBulk.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { getBaseUrl } from "../../../pluginUtils.js"; import { logMessageDeleteBulk } from "../logFunctions/logMessageDeleteBulk.js"; import { LogsPluginType } from "../types.js"; import { isLogIgnored } from "./isLogIgnored.js"; export async function onMessageDeleteBulk(pluginData: GuildPluginData, savedMessages: SavedMessage[]) { if (isLogIgnored(pluginData, LogType.MESSAGE_DELETE, savedMessages[0].id)) { return; } const channel = pluginData.guild.channels.cache.get(savedMessages[0].channel_id as Snowflake); if (!channel?.isTextBased()) { return; } const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild); const archiveUrl = pluginData.state.archives.getUrl(getBaseUrl(pluginData), archiveId); const authorIds = Array.from(new Set(savedMessages.map((item) => `\`${item.user_id}\``))); logMessageDeleteBulk(pluginData, { count: savedMessages.length, authorIds, channel, archiveUrl, }); } ================================================ FILE: backend/src/plugins/Logs/util/onMessageUpdate.ts ================================================ import { EmbedData, GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { resolveUser } from "../../../utils.js"; import { logMessageEdit } from "../logFunctions/logMessageEdit.js"; import { LogsPluginType } from "../types.js"; export async function onMessageUpdate( pluginData: GuildPluginData, savedMessage: SavedMessage, oldSavedMessage: SavedMessage, ) { // To log a message update, either the message content or a rich embed has to change let logUpdate = false; const oldEmbedsToCompare = ((oldSavedMessage.data.embeds || []) as EmbedData[]) .map((e) => structuredClone(e)) .filter((e) => e.type === "rich"); const newEmbedsToCompare = ((savedMessage.data.embeds || []) as EmbedData[]) .map((e) => structuredClone(e)) .filter((e) => e.type === "rich"); for (const embed of [...oldEmbedsToCompare, ...newEmbedsToCompare]) { if (embed.thumbnail) { delete embed.thumbnail.width; delete embed.thumbnail.height; } if (embed.image) { delete embed.image.width; delete embed.image.height; } } if ( oldSavedMessage.data.content !== savedMessage.data.content || oldEmbedsToCompare.length !== newEmbedsToCompare.length || JSON.stringify(oldEmbedsToCompare) !== JSON.stringify(newEmbedsToCompare) ) { logUpdate = true; } if (!logUpdate) { return; } const user = await resolveUser(pluginData.client, savedMessage.user_id, "Logs:onMessageUpdate"); const channel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake)! as GuildTextBasedChannel; logMessageEdit(pluginData, { user, channel, before: oldSavedMessage, after: savedMessage, }); } ================================================ FILE: backend/src/plugins/MessageSaver/MessageSaverPlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { SaveMessagesToDBCmd } from "./commands/SaveMessagesToDB.js"; import { SavePinsToDBCmd } from "./commands/SavePinsToDB.js"; import { MessageCreateEvt, MessageDeleteBulkEvt, MessageDeleteEvt, MessageUpdateEvt, } from "./events/SaveMessagesEvts.js"; import { MessageSaverPluginType, zMessageSaverConfig } from "./types.js"; export const MessageSaverPlugin = guildPlugin()({ name: "message_saver", configSchema: zMessageSaverConfig, defaultOverrides: [ { level: ">=100", config: { can_manage: true, }, }, ], // prettier-ignore messageCommands: [ SaveMessagesToDBCmd, SavePinsToDBCmd, ], // prettier-ignore events: [ MessageCreateEvt, MessageUpdateEvt, MessageDeleteEvt, MessageDeleteBulkEvt, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, }); ================================================ FILE: backend/src/plugins/MessageSaver/commands/SaveMessagesToDB.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { saveMessagesToDB } from "../saveMessagesToDB.js"; import { messageSaverCmd } from "../types.js"; export const SaveMessagesToDBCmd = messageSaverCmd({ trigger: "save_messages_to_db", permission: "can_manage", source: "guild", signature: { channel: ct.textChannel(), ids: ct.string({ catchAll: true }), }, async run({ message: msg, args, pluginData }) { await msg.channel.send("Saving specified messages..."); const { savedCount, failed } = await saveMessagesToDB(pluginData, args.channel, args.ids.trim().split(" ")); if (failed.length) { void pluginData.state.common.sendSuccessMessage( msg, `Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(", ")}`, ); } else { void pluginData.state.common.sendSuccessMessage(msg, `Saved ${savedCount} messages!`); } }, }); ================================================ FILE: backend/src/plugins/MessageSaver/commands/SavePinsToDB.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { saveMessagesToDB } from "../saveMessagesToDB.js"; import { messageSaverCmd } from "../types.js"; export const SavePinsToDBCmd = messageSaverCmd({ trigger: "save_pins_to_db", permission: "can_manage", source: "guild", signature: { channel: ct.textChannel(), }, async run({ message: msg, args, pluginData }) { await msg.channel.send(`Saving pins from <#${args.channel.id}>...`); const pins = await args.channel.messages.fetchPinned(); const { savedCount, failed } = await saveMessagesToDB(pluginData, args.channel, [...pins.keys()]); if (failed.length) { void pluginData.state.common.sendSuccessMessage( msg, `Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(", ")}`, ); } else { void pluginData.state.common.sendSuccessMessage(msg, `Saved ${savedCount} messages!`); } }, }); ================================================ FILE: backend/src/plugins/MessageSaver/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zMessageSaverConfig } from "./types.js"; export const messageSaverPluginDocs: ZeppelinPluginDocs = { prettyName: "Message saver", type: "internal", configSchema: zMessageSaverConfig, }; ================================================ FILE: backend/src/plugins/MessageSaver/events/SaveMessagesEvts.ts ================================================ import { Message, MessageType } from "discord.js"; import { messageSaverEvt } from "../types.js"; const AFFECTED_MESSAGE_TYPES: MessageType[] = [MessageType.Default, MessageType.Reply, MessageType.ChatInputCommand]; export const MessageCreateEvt = messageSaverEvt({ event: "messageCreate", allowBots: true, allowSelf: true, async listener(meta) { // Don't save partial messages if (meta.args.message.partial) { return; } if (!AFFECTED_MESSAGE_TYPES.includes(meta.args.message.type)) { return; } await meta.pluginData.state.savedMessages.createFromMsg(meta.args.message); }, }); export const MessageUpdateEvt = messageSaverEvt({ event: "messageUpdate", allowBots: true, allowSelf: true, async listener(meta) { if (meta.args.newMessage.partial) { return; } if (!AFFECTED_MESSAGE_TYPES.includes(meta.args.newMessage.type)) { return; } await meta.pluginData.state.savedMessages.saveEditFromMsg(meta.args.newMessage as Message); }, }); export const MessageDeleteEvt = messageSaverEvt({ event: "messageDelete", allowBots: true, allowSelf: true, async listener(meta) { if (!meta.args.message.partial && !AFFECTED_MESSAGE_TYPES.includes(meta.args.message.type)) { return; } await meta.pluginData.state.savedMessages.markAsDeleted(meta.args.message.id); }, }); export const MessageDeleteBulkEvt = messageSaverEvt({ event: "messageDeleteBulk", allowBots: true, allowSelf: true, async listener(meta) { const affectedMessages = meta.args.messages.filter((m) => m.partial || AFFECTED_MESSAGE_TYPES.includes(m.type)); const ids = affectedMessages.map((m) => m.id); await meta.pluginData.state.savedMessages.markBulkAsDeleted(ids); }, }); ================================================ FILE: backend/src/plugins/MessageSaver/saveMessagesToDB.ts ================================================ import { GuildTextBasedChannel, Message } from "discord.js"; import { GuildPluginData } from "vety"; import { MessageSaverPluginType } from "./types.js"; export async function saveMessagesToDB( pluginData: GuildPluginData, channel: GuildTextBasedChannel, ids: string[], ) { const failed: string[] = []; for (const id of ids) { const savedMessage = await pluginData.state.savedMessages.find(id); if (savedMessage) continue; let thisMsg: Message; try { thisMsg = await channel.messages.fetch(id); if (!thisMsg) { failed.push(id); continue; } await pluginData.state.savedMessages.createFromMsg(thisMsg, { is_permanent: true }); } catch { failed.push(id); } } return { savedCount: ids.length - failed.length, failed, }; } ================================================ FILE: backend/src/plugins/MessageSaver/types.ts ================================================ import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zMessageSaverConfig = z.strictObject({ can_manage: z.boolean().default(false), }); export interface MessageSaverPluginType extends BasePluginType { configSchema: typeof zMessageSaverConfig; state: { savedMessages: GuildSavedMessages; common: pluginUtils.PluginPublicInterface; }; } export const messageSaverCmd = guildPluginMessageCommand(); export const messageSaverEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/ModActions/ModActionsPlugin.ts ================================================ import { Message } from "discord.js"; import { EventEmitter } from "events"; import { guildPlugin } from "vety"; import { Queue } from "../../Queue.js"; import { GuildCases } from "../../data/GuildCases.js"; import { onGuildEvent } from "../../data/GuildEvents.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildMutes } from "../../data/GuildMutes.js"; import { GuildTempbans } from "../../data/GuildTempbans.js"; import { makePublicFn, mapToPublicFn } from "../../pluginUtils.js"; import { MINUTES } from "../../utils.js"; import { CasesPlugin } from "../Cases/CasesPlugin.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { MutesPlugin } from "../Mutes/MutesPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { AddCaseMsgCmd } from "./commands/addcase/AddCaseMsgCmd.js"; import { AddCaseSlashCmd } from "./commands/addcase/AddCaseSlashCmd.js"; import { BanMsgCmd } from "./commands/ban/BanMsgCmd.js"; import { BanSlashCmd } from "./commands/ban/BanSlashCmd.js"; import { CaseMsgCmd } from "./commands/case/CaseMsgCmd.js"; import { CaseSlashCmd } from "./commands/case/CaseSlashCmd.js"; import { CasesModMsgCmd } from "./commands/cases/CasesModMsgCmd.js"; import { CasesSlashCmd } from "./commands/cases/CasesSlashCmd.js"; import { CasesUserMsgCmd } from "./commands/cases/CasesUserMsgCmd.js"; import { DeleteCaseMsgCmd } from "./commands/deletecase/DeleteCaseMsgCmd.js"; import { DeleteCaseSlashCmd } from "./commands/deletecase/DeleteCaseSlashCmd.js"; import { ForceBanMsgCmd } from "./commands/forceban/ForceBanMsgCmd.js"; import { ForceBanSlashCmd } from "./commands/forceban/ForceBanSlashCmd.js"; import { ForceMuteMsgCmd } from "./commands/forcemute/ForceMuteMsgCmd.js"; import { ForceMuteSlashCmd } from "./commands/forcemute/ForceMuteSlashCmd.js"; import { ForceUnmuteMsgCmd } from "./commands/forceunmute/ForceUnmuteMsgCmd.js"; import { ForceUnmuteSlashCmd } from "./commands/forceunmute/ForceUnmuteSlashCmd.js"; import { HideCaseMsgCmd } from "./commands/hidecase/HideCaseMsgCmd.js"; import { HideCaseSlashCmd } from "./commands/hidecase/HideCaseSlashCmd.js"; import { KickMsgCmd } from "./commands/kick/KickMsgCmd.js"; import { KickSlashCmd } from "./commands/kick/KickSlashCmd.js"; import { MassBanMsgCmd } from "./commands/massban/MassBanMsgCmd.js"; import { MassBanSlashCmd } from "./commands/massban/MassBanSlashCmd.js"; import { MassMuteMsgCmd } from "./commands/massmute/MassMuteMsgCmd.js"; import { MassMuteSlashSlashCmd } from "./commands/massmute/MassMuteSlashCmd.js"; import { MassUnbanMsgCmd } from "./commands/massunban/MassUnbanMsgCmd.js"; import { MassUnbanSlashCmd } from "./commands/massunban/MassUnbanSlashCmd.js"; import { MuteMsgCmd } from "./commands/mute/MuteMsgCmd.js"; import { MuteSlashCmd } from "./commands/mute/MuteSlashCmd.js"; import { NoteMsgCmd } from "./commands/note/NoteMsgCmd.js"; import { NoteSlashCmd } from "./commands/note/NoteSlashCmd.js"; import { UnbanMsgCmd } from "./commands/unban/UnbanMsgCmd.js"; import { UnbanSlashCmd } from "./commands/unban/UnbanSlashCmd.js"; import { UnhideCaseMsgCmd } from "./commands/unhidecase/UnhideCaseMsgCmd.js"; import { UnhideCaseSlashCmd } from "./commands/unhidecase/UnhideCaseSlashCmd.js"; import { UnmuteMsgCmd } from "./commands/unmute/UnmuteMsgCmd.js"; import { UnmuteSlashCmd } from "./commands/unmute/UnmuteSlashCmd.js"; import { UpdateMsgCmd } from "./commands/update/UpdateMsgCmd.js"; import { UpdateSlashCmd } from "./commands/update/UpdateSlashCmd.js"; import { WarnMsgCmd } from "./commands/warn/WarnMsgCmd.js"; import { WarnSlashCmd } from "./commands/warn/WarnSlashCmd.js"; import { AuditLogEvents } from "./events/AuditLogEvents.js"; import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt.js"; import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt.js"; import { PostAlertOnMemberJoinEvt } from "./events/PostAlertOnMemberJoinEvt.js"; import { banUserId } from "./functions/banUserId.js"; import { clearTempban } from "./functions/clearTempban.js"; import { hasBanPermission, hasMutePermission, hasNotePermission, hasWarnPermission, } from "./functions/hasModActionPerm.js"; import { kickMember } from "./functions/kickMember.js"; import { offModActionsEvent } from "./functions/offModActionsEvent.js"; import { onModActionsEvent } from "./functions/onModActionsEvent.js"; import { updateCase } from "./functions/updateCase.js"; import { warnMember } from "./functions/warnMember.js"; import { ModActionsPluginType, modActionsSlashGroup, zModActionsConfig } from "./types.js"; export const ModActionsPlugin = guildPlugin()({ name: "mod_actions", dependencies: () => [TimeAndDatePlugin, CasesPlugin, MutesPlugin, LogsPlugin], configSchema: zModActionsConfig, defaultOverrides: [ { level: ">=50", config: { can_note: true, can_warn: true, can_mute: true, can_kick: true, can_ban: true, can_unban: true, can_view: true, can_addcase: true, }, }, { level: ">=100", config: { can_massunban: true, can_massban: true, can_massmute: true, can_hidecase: true, can_act_as_other: true, }, }, ], events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt, AuditLogEvents], slashCommands: [ modActionsSlashGroup({ name: "mod", description: "Moderation actions", defaultMemberPermissions: "0", subcommands: [ AddCaseSlashCmd, BanSlashCmd, CaseSlashCmd, CasesSlashCmd, DeleteCaseSlashCmd, ForceBanSlashCmd, ForceMuteSlashCmd, ForceUnmuteSlashCmd, HideCaseSlashCmd, KickSlashCmd, MassBanSlashCmd, MassMuteSlashSlashCmd, MassUnbanSlashCmd, MuteSlashCmd, NoteSlashCmd, UnbanSlashCmd, UnhideCaseSlashCmd, UnmuteSlashCmd, UpdateSlashCmd, WarnSlashCmd, ], }), ], messageCommands: [ UpdateMsgCmd, NoteMsgCmd, WarnMsgCmd, MuteMsgCmd, ForceMuteMsgCmd, UnmuteMsgCmd, ForceUnmuteMsgCmd, KickMsgCmd, BanMsgCmd, UnbanMsgCmd, ForceBanMsgCmd, MassBanMsgCmd, MassMuteMsgCmd, MassUnbanMsgCmd, AddCaseMsgCmd, CaseMsgCmd, CasesUserMsgCmd, CasesModMsgCmd, HideCaseMsgCmd, UnhideCaseMsgCmd, DeleteCaseMsgCmd, ], public(pluginData) { return { warnMember: makePublicFn(pluginData, warnMember), kickMember: makePublicFn(pluginData, kickMember), banUserId: makePublicFn(pluginData, banUserId), updateCase: (msg: Message, caseNumber: number | null, note: string) => updateCase(pluginData, msg, msg.author, caseNumber ?? undefined, note, [...msg.attachments.values()]), hasNotePermission: makePublicFn(pluginData, hasNotePermission), hasWarnPermission: makePublicFn(pluginData, hasWarnPermission), hasMutePermission: makePublicFn(pluginData, hasMutePermission), hasBanPermission: makePublicFn(pluginData, hasBanPermission), on: mapToPublicFn(onModActionsEvent), off: mapToPublicFn(offModActionsEvent), getEventEmitter: () => pluginData.state.events, }; }, beforeLoad(pluginData) { const { state, guild } = pluginData; state.mutes = GuildMutes.getGuildInstance(guild.id); state.cases = GuildCases.getGuildInstance(guild.id); state.tempbans = GuildTempbans.getGuildInstance(guild.id); state.serverLogs = new GuildLogs(guild.id); state.unloaded = false; state.ignoredEvents = []; // Massbans can take a while depending on rate limits, // so we're giving each massban 15 minutes to complete before launching the next massban state.massbanQueue = new Queue(15 * MINUTES); state.events = new EventEmitter(); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, afterLoad(pluginData) { const { state, guild } = pluginData; state.unregisterGuildEventListener = onGuildEvent(guild.id, "expiredTempban", (tempban) => clearTempban(pluginData, tempban), ); }, beforeUnload(pluginData) { const { state } = pluginData; state.unloaded = true; state.unregisterGuildEventListener?.(); state.events.removeAllListeners(); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/addcase/AddCaseMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { CaseTypes } from "../../../../data/CaseTypes.js"; import { hasPermission } from "../../../../pluginUtils.js"; import { resolveUser } from "../../../../utils.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualAddCaseCmd } from "./actualAddCaseCmd.js"; const opts = { mod: ct.member({ option: true }), }; export const AddCaseMsgCmd = modActionsMsgCmd({ trigger: "addcase", permission: "can_addcase", description: "Add an arbitrary case to the specified user without taking any action", signature: [ { type: ct.string(), user: ct.string(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, ], async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user, "ModActions:AddCaseCmd"); if (!user.id) { pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } const member = msg.member || (await msg.guild.members.fetch(msg.author.id)); // The moderator who did the action is the message author or, if used, the specified -mod let mod = member; if (args.mod) { if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); return; } mod = args.mod; } // Verify the case type is valid const type: string = args.type[0].toUpperCase() + args.type.slice(1).toLowerCase(); if (!CaseTypes[type]) { pluginData.state.common.sendErrorMessage(msg, "Cannot add case: invalid case type"); return; } actualAddCaseCmd( pluginData, msg, member, mod, [...msg.attachments.values()], user, type as keyof CaseTypes, args.reason || "", ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/addcase/AddCaseSlashCmd.ts ================================================ import { GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { CaseTypes } from "../../../../data/CaseTypes.js"; import { hasPermission } from "../../../../pluginUtils.js"; import { resolveMember } from "../../../../utils.js"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualAddCaseCmd } from "./actualAddCaseCmd.js"; const opts = [ slashOptions.string({ name: "reason", description: "The reason", required: false }), slashOptions.user({ name: "mod", description: "The moderator to add this case as", required: false }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const AddCaseSlashCmd = modActionsSlashCmd({ name: "addcase", configPermission: "can_addcase", description: "Add an arbitrary case to the specified user without taking any action", allowDms: false, signature: [ slashOptions.string({ name: "type", description: "The type of case to add", required: true, choices: Object.keys(CaseTypes) .filter((key) => isNaN(Number(key))) .map((key) => ({ name: key, value: key })), }), slashOptions.user({ name: "user", description: "The user to add a case to", required: true }), ...opts, ], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); // The moderator who did the action is the message author or, if used, the specified -mod let mod = interaction.member as GuildMember; const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { channel: interaction.channel, member: interaction.member, }); if (options.mod) { if (!canActAsOther) { pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); return; } mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; } actualAddCaseCmd( pluginData, interaction, interaction.member as GuildMember, mod, attachments, options.user, options.type as keyof CaseTypes, options.reason || "", ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/addcase/actualAddCaseCmd.ts ================================================ import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../../data/CaseTypes.js"; import { Case } from "../../../../data/entities/Case.js"; import { canActOn } from "../../../../pluginUtils.js"; import { UnknownUser, renderUsername, resolveMember } from "../../../../utils.js"; import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; import { ModActionsPluginType } from "../../types.js"; export async function actualAddCaseCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, author: GuildMember, mod: GuildMember, attachments: Array, user: User | UnknownUser, type: keyof CaseTypes, reason: string, ) { if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { return; } // If the user exists as a guild member, make sure we can act on them first const member = await resolveMember(pluginData.client, pluginData.guild, user.id); if (member && !canActOn(pluginData, author, member)) { pluginData.state.common.sendErrorMessage(context, "Cannot add case on this user: insufficient permissions"); return; } const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); // Create the case const casesPlugin = pluginData.getPlugin(CasesPlugin); const theCase: Case = await casesPlugin.createCase({ userId: user.id, modId: mod.id, type: CaseTypes[type], reason: formattedReason, ppId: mod.id !== author.id ? author.id : undefined, }); if (user) { pluginData.state.common.sendSuccessMessage( context, `Case #${theCase.case_number} created for **${renderUsername(user)}**`, ); } else { pluginData.state.common.sendSuccessMessage(context, `Case #${theCase.case_number} created`); } // Log the action pluginData.getPlugin(LogsPlugin).logCaseCreate({ mod: mod.user, userId: user.id, caseNum: theCase.case_number, caseType: type.toUpperCase(), reason: formattedReason, }); } ================================================ FILE: backend/src/plugins/ModActions/commands/ban/BanMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { hasPermission } from "../../../../pluginUtils.js"; import { UserNotificationMethod, resolveUser } from "../../../../utils.js"; import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualBanCmd } from "./actualBanCmd.js"; const opts = { mod: ct.member({ option: true }), notify: ct.string({ option: true }), "notify-channel": ct.textChannel({ option: true }), "delete-days": ct.number({ option: true, shortcut: "d" }), }; export const BanMsgCmd = modActionsMsgCmd({ trigger: "ban", permission: "can_ban", description: "Ban or Tempban the specified member", signature: [ { user: ct.string(), time: ct.delay(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, { user: ct.string(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, ], async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user, "ModActions:BanMsgCmd"); if (!user.id) { pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } const member = msg.member || (await msg.guild.members.fetch(msg.author.id)); // The moderator who did the action is the message author or, if used, the specified -mod let mod = member; if (args.mod) { if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); return; } mod = args.mod; } let contactMethods: UserNotificationMethod[] | undefined; try { contactMethods = readContactMethodsFromArgs(args) ?? undefined; } catch (e) { pluginData.state.common.sendErrorMessage(msg, e.message); return; } actualBanCmd( pluginData, msg, user, args["time"] ? args["time"] : null, args.reason || "", [...msg.attachments.values()], member, mod, contactMethods, args["delete-days"] ?? undefined, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/ban/BanSlashCmd.ts ================================================ import { ChannelType, GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { hasPermission } from "../../../../pluginUtils.js"; import { UserNotificationMethod, convertDelayStringToMS, resolveMember } from "../../../../utils.js"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualBanCmd } from "./actualBanCmd.js"; const opts = [ slashOptions.string({ name: "time", description: "The duration of the ban", required: false }), slashOptions.string({ name: "reason", description: "The reason", required: false }), slashOptions.user({ name: "mod", description: "The moderator to ban as", required: false }), slashOptions.string({ name: "notify", description: "How to notify", required: false, choices: [ { name: "DM", value: "dm" }, { name: "Channel", value: "channel" }, ], }), slashOptions.channel({ name: "notify-channel", description: "The channel to notify in", channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], required: false, }), slashOptions.number({ name: "delete-days", description: "The number of days of messages to delete", required: false, }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const BanSlashCmd = modActionsSlashCmd({ name: "ban", configPermission: "can_ban", description: "Ban or Tempban the specified member", allowDms: false, signature: [slashOptions.user({ name: "user", description: "The user to ban", required: true }), ...opts], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); let mod = interaction.member as GuildMember; const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { channel: interaction.channel, member: interaction.member, }); if (options.mod) { if (!canActAsOther) { pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); return; } mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; } let contactMethods: UserNotificationMethod[] | undefined; try { contactMethods = readContactMethodsFromArgs(options) ?? undefined; } catch (e) { pluginData.state.common.sendErrorMessage(interaction, e.message); return; } const convertedTime = options.time ? convertDelayStringToMS(options.time) : null; if (options.time && !convertedTime) { pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); return; } actualBanCmd( pluginData, interaction, options.user, convertedTime, options.reason || "", attachments, interaction.member as GuildMember, mod, contactMethods, options["delete-days"] ?? undefined, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/ban/actualBanCmd.ts ================================================ import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; import { GuildPluginData } from "vety"; import { getMemberLevel } from "vety/helpers"; import { CaseTypes } from "../../../../data/CaseTypes.js"; import { clearExpiringTempban, registerExpiringTempban } from "../../../../data/loops/expiringTempbansLoop.js"; import { humanizeDuration } from "../../../../humanizeDuration.js"; import { canActOn, getContextChannel } from "../../../../pluginUtils.js"; import { UnknownUser, UserNotificationMethod, renderUsername, resolveMember } from "../../../../utils.js"; import { banLock } from "../../../../utils/lockNameHelpers.js"; import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; import { banUserId } from "../../functions/banUserId.js"; import { formatReasonWithAttachments, formatReasonWithMessageLinkForAttachments, } from "../../functions/formatReasonForAttachments.js"; import { isBanned } from "../../functions/isBanned.js"; import { ModActionsPluginType } from "../../types.js"; export async function actualBanCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, user: User | UnknownUser, time: number | null, reason: string, attachments: Attachment[], author: GuildMember, mod: GuildMember, contactMethods?: UserNotificationMethod[], deleteDays?: number, ) { if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { return; } const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id); const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments); // acquire a lock because of the needed user-inputs below (if banned/not on server) const lock = await pluginData.locks.acquire(banLock(user)); let forceban = false; const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); if (!memberToBan) { const banned = await isBanned(pluginData, user.id); if (!banned) { // Ask the mod if we should upgrade to a forceban as the user is not on the server const reply = await waitForButtonConfirm( context, { content: "User not on server, forceban instead?" }, { confirmText: "Yes", cancelText: "No", restrictToId: author.id }, ); if (!reply) { pluginData.state.common.sendErrorMessage(context, "User not on server, ban cancelled by moderator"); lock.unlock(); return; } else { forceban = true; } } else { // Abort if trying to ban user indefinitely if they are already banned indefinitely if (!existingTempban && !time) { pluginData.state.common.sendErrorMessage(context, `User is already banned indefinitely.`); return; } // Ask the mod if we should update the existing ban const reply = await waitForButtonConfirm( context, { content: "Failed to message the user. Log the warning anyway?" }, { confirmText: "Yes", cancelText: "No", restrictToId: author.id }, ); if (!reply) { pluginData.state.common.sendErrorMessage(context, "User already banned, update cancelled by moderator"); lock.unlock(); return; } // Update or add new tempban / remove old tempban if (time && time > 0) { if (existingTempban) { await pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id); } else { await pluginData.state.tempbans.addTempban(user.id, time, mod.id); } const tempban = (await pluginData.state.tempbans.findExistingTempbanForUserId(user.id))!; registerExpiringTempban(tempban); } else if (existingTempban) { clearExpiringTempban(existingTempban); pluginData.state.tempbans.clear(user.id); } // Create a new case for the updated ban since we never stored the old case id and log the action const casesPlugin = pluginData.getPlugin(CasesPlugin); const createdCase = await casesPlugin.createCase({ modId: mod.id, type: CaseTypes.Ban, userId: user.id, reason: formattedReason, noteDetails: [`Ban updated to ${time ? humanizeDuration(time) : "indefinite"}`], }); if (time) { pluginData.getPlugin(LogsPlugin).logMemberTimedBan({ mod: mod.user, user, caseNumber: createdCase.case_number, reason: formattedReason, banTime: humanizeDuration(time), }); } else { pluginData.getPlugin(LogsPlugin).logMemberBan({ mod: mod.user, user, caseNumber: createdCase.case_number, reason: formattedReason, }); } pluginData.state.common.sendSuccessMessage( context, `Ban updated to ${time ? "expire in " + humanizeDuration(time) + " from now" : "indefinite"}`, ); lock.unlock(); return; } } // Make sure we're allowed to ban this member if they are on the server if (!forceban && !canActOn(pluginData, author, memberToBan!)) { const ourLevel = getMemberLevel(pluginData, author); const targetLevel = getMemberLevel(pluginData, memberToBan!); pluginData.state.common.sendErrorMessage( context, `Cannot ban: target permission level is equal or higher to yours, ${targetLevel} >= ${ourLevel}`, ); lock.unlock(); return; } const matchingConfig = await pluginData.config.getMatchingConfig({ member: author, channel: await getContextChannel(context), }); const deleteMessageDays = deleteDays ?? matchingConfig.ban_delete_message_days; const banResult = await banUserId( pluginData, user.id, formattedReason, formattedReasonWithAttachments, { contactMethods, caseArgs: { modId: mod.id, ppId: mod.id !== author.id ? author.id : undefined, }, deleteMessageDays, modId: mod.id, }, time ?? undefined, ); if (banResult.status === "failed") { pluginData.state.common.sendErrorMessage(context, `Failed to ban member: ${banResult.error}`); lock.unlock(); return; } let forTime = ""; if (time && time > 0) { forTime = `for ${humanizeDuration(time)} `; } // Confirm the action to the moderator let response = ""; if (!forceban) { response = `Banned **${renderUsername(user)}** ${forTime}(Case #${banResult.case.case_number})`; if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`; } else { response = `Member forcebanned ${forTime}(Case #${banResult.case.case_number})`; } lock.unlock(); pluginData.state.common.sendSuccessMessage(context, response); } ================================================ FILE: backend/src/plugins/ModActions/commands/case/CaseMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualCaseCmd } from "./actualCaseCmd.js"; export const CaseMsgCmd = modActionsMsgCmd({ trigger: "case", permission: "can_view", description: "Show information about a specific case", signature: [ { caseNumber: ct.number(), }, ], async run({ pluginData, message: msg, args }) { actualCaseCmd(pluginData, msg, msg.author.id, args.caseNumber); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/case/CaseSlashCmd.ts ================================================ import { slashOptions } from "vety"; import { modActionsSlashCmd } from "../../types.js"; import { actualCaseCmd } from "./actualCaseCmd.js"; const opts = [ slashOptions.boolean({ name: "show", description: "To make the result visible to everyone", required: false }), ]; export const CaseSlashCmd = modActionsSlashCmd({ name: "case", configPermission: "can_view", description: "Show information about a specific case", allowDms: false, signature: [ slashOptions.number({ name: "case-number", description: "The number of the case to show", required: true }), ...opts, ], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: options.show !== true }); actualCaseCmd(pluginData, interaction, interaction.user.id, options["case-number"], options.show); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/case/actualCaseCmd.ts ================================================ import { ChatInputCommandInteraction, Message } from "discord.js"; import { GuildPluginData } from "vety"; import { sendContextResponse } from "../../../../pluginUtils.js"; import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; import { ModActionsPluginType } from "../../types.js"; export async function actualCaseCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, authorId: string, caseNumber: number, show?: boolean | null, ) { const theCase = await pluginData.state.cases.findByCaseNumber(caseNumber); if (!theCase) { void pluginData.state.common.sendErrorMessage(context, "Case not found", undefined, undefined, show !== true); return; } const casesPlugin = pluginData.getPlugin(CasesPlugin); const content = await casesPlugin.getCaseEmbed(theCase.id, authorId); void sendContextResponse(context, content, show !== true); } ================================================ FILE: backend/src/plugins/ModActions/commands/cases/CasesModMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { resolveMessageMember } from "../../../../pluginUtils.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualCasesCmd } from "./actualCasesCmd.js"; const opts = { mod: ct.userId({ option: true }), expand: ct.bool({ option: true, isSwitch: true, shortcut: "e" }), hidden: ct.bool({ option: true, isSwitch: true, shortcut: "h" }), reverseFilters: ct.switchOption({ def: false, shortcut: "r" }), notes: ct.switchOption({ def: false, shortcut: "n" }), warns: ct.switchOption({ def: false, shortcut: "w" }), mutes: ct.switchOption({ def: false, shortcut: "m" }), unmutes: ct.switchOption({ def: false, shortcut: "um" }), kicks: ct.switchOption({ def: false, shortcut: "k" }), bans: ct.switchOption({ def: false, shortcut: "b" }), unbans: ct.switchOption({ def: false, shortcut: "ub" }), show: ct.switchOption({ def: false, shortcut: "sh" }), }; export const CasesModMsgCmd = modActionsMsgCmd({ trigger: ["cases", "modlogs", "infractions"], permission: "can_view", description: "Show the most recent 5 cases by the specified -mod", signature: [ { ...opts, }, ], async run({ pluginData, message: msg, args }) { const member = await resolveMessageMember(msg); return actualCasesCmd( pluginData, msg, args.mod, null, member, args.notes, args.warns, args.mutes, args.unmutes, args.kicks, args.bans, args.unbans, args.reverseFilters, args.hidden, args.expand, args.show, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/cases/CasesSlashCmd.ts ================================================ import { GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { modActionsSlashCmd } from "../../types.js"; import { actualCasesCmd } from "./actualCasesCmd.js"; const opts = [ slashOptions.user({ name: "user", description: "The user to show cases for", required: false }), slashOptions.user({ name: "mod", description: "The mod to filter cases by", required: false }), slashOptions.boolean({ name: "expand", description: "Show each case individually", required: false }), slashOptions.boolean({ name: "hidden", description: "Whether or not to show hidden cases", required: false }), slashOptions.boolean({ name: "reverse-filters", description: "To treat case type filters as exclusive instead of inclusive", required: false, }), slashOptions.boolean({ name: "notes", description: "To filter notes", required: false }), slashOptions.boolean({ name: "warns", description: "To filter warns", required: false }), slashOptions.boolean({ name: "mutes", description: "To filter mutes", required: false }), slashOptions.boolean({ name: "unmutes", description: "To filter unmutes", required: false }), slashOptions.boolean({ name: "kicks", description: "To filter kicks", required: false }), slashOptions.boolean({ name: "bans", description: "To filter bans", required: false }), slashOptions.boolean({ name: "unbans", description: "To filter unbans", required: false }), slashOptions.boolean({ name: "show", description: "To make the result visible to everyone", required: false }), ]; export const CasesSlashCmd = modActionsSlashCmd({ name: "cases", configPermission: "can_view", description: "Show a list of cases the specified user has or the specified mod made", allowDms: false, signature: [...opts], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: options.show !== true }); return actualCasesCmd( pluginData, interaction, options.mod?.id ?? null, options.user, interaction.member as GuildMember, options.notes, options.warns, options.mutes, options.unmutes, options.kicks, options.bans, options.unbans, options["reverse-filters"], options.hidden, options.expand, options.show, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/cases/CasesUserMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { resolveMessageMember } from "../../../../pluginUtils.js"; import { resolveMember, resolveUser, UnknownUser } from "../../../../utils.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualCasesCmd } from "./actualCasesCmd.js"; const opts = { mod: ct.userId({ option: true }), expand: ct.bool({ option: true, isSwitch: true, shortcut: "e" }), hidden: ct.bool({ option: true, isSwitch: true, shortcut: "h" }), reverseFilters: ct.switchOption({ def: false, shortcut: "r" }), notes: ct.switchOption({ def: false, shortcut: "n" }), warns: ct.switchOption({ def: false, shortcut: "w" }), mutes: ct.switchOption({ def: false, shortcut: "m" }), unmutes: ct.switchOption({ def: false, shortcut: "um" }), kicks: ct.switchOption({ def: false, shortcut: "k" }), bans: ct.switchOption({ def: false, shortcut: "b" }), unbans: ct.switchOption({ def: false, shortcut: "ub" }), show: ct.switchOption({ def: false, shortcut: "sh" }), }; export const CasesUserMsgCmd = modActionsMsgCmd({ trigger: ["cases", "modlogs", "infractions"], permission: "can_view", description: "Show a list of cases the specified user has", signature: [ { user: ct.string(), ...opts, }, ], async run({ pluginData, message: msg, args }) { const user = (await resolveMember(pluginData.client, pluginData.guild, args.user)) || (await resolveUser(pluginData.client, args.user, "ModActions:CasesUserMsgCmd")); if (user instanceof UnknownUser) { pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } const member = await resolveMessageMember(msg); return actualCasesCmd( pluginData, msg, args.mod, user, member, args.notes, args.warns, args.mutes, args.unmutes, args.kicks, args.bans, args.unbans, args.reverseFilters, args.hidden, args.expand, args.show, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/cases/actualCasesCmd.ts ================================================ import { APIEmbed, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; import { GuildPluginData } from "vety"; import { FindOptionsWhere, In } from "typeorm"; import { CaseTypes } from "../../../../data/CaseTypes.js"; import { Case } from "../../../../data/entities/Case.js"; import { sendContextResponse } from "../../../../pluginUtils.js"; import { UnknownUser, chunkArray, emptyEmbedValue, renderUsername, resolveMember, resolveUser, trimLines, } from "../../../../utils.js"; import { asyncMap } from "../../../../utils/async.js"; import { createPaginatedMessage } from "../../../../utils/createPaginatedMessage.js"; import { getGuildPrefix } from "../../../../utils/getGuildPrefix.js"; import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; import { ModActionsPluginType } from "../../types.js"; const casesPerPage = 5; const maxExpandedCases = 8; async function sendExpandedCases( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, casesCount: number, cases: Case[], show: boolean | null, ) { if (casesCount > maxExpandedCases) { await sendContextResponse(context, { content: "Too many cases for expanded view. Please use compact view instead.", ephemeral: true, }); return; } const casesPlugin = pluginData.getPlugin(CasesPlugin); for (const theCase of cases) { const content = await casesPlugin.getCaseEmbed(theCase.id); await sendContextResponse(context, content, !show); } } async function casesUserCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, author: User, modId: string | null, user: GuildMember | User | UnknownUser, modName: string, typesToShow: CaseTypes[], hidden: boolean | null, expand: boolean | null, show: boolean | null, ) { const casesPlugin = pluginData.getPlugin(CasesPlugin); const casesFilters: Omit, "guild_id" | "user_id"> = { type: In(typesToShow) }; if (modId) { casesFilters.mod_id = modId; } const cases = await pluginData.state.cases.with("notes").getByUserId(user.id, casesFilters); const normalCases = cases.filter((c) => !c.is_hidden); const hiddenCases = cases.filter((c) => c.is_hidden); const userName = user instanceof UnknownUser && cases.length ? cases[cases.length - 1].user_name : renderUsername(user); if (cases.length === 0) { await sendContextResponse(context, { content: `No cases found for **${userName}**${modId ? ` by ${modName}` : ""}.`, ephemeral: !show, }); return; } const casesToDisplay = hidden ? cases : normalCases; if (!casesToDisplay.length) { await sendContextResponse(context, { content: `No normal cases found for **${userName}**. Use "-hidden" to show ${cases.length} hidden cases.`, ephemeral: !show, }); return; } if (expand) { await sendExpandedCases(pluginData, context, casesToDisplay.length, casesToDisplay, show); return; } // Compact view (= regular message with a preview of each case) const lines = await asyncMap(casesToDisplay, (c) => casesPlugin.getCaseSummary(c, true, author.id)); const prefix = getGuildPrefix(pluginData); const linesPerChunk = 10; const lineChunks = chunkArray(lines, linesPerChunk); const footerField = { name: emptyEmbedValue, value: trimLines(` Use \`${prefix}case \` to see more information about an individual case `), }; for (const [i, linesInChunk] of lineChunks.entries()) { const isLastChunk = i === lineChunks.length - 1; if (isLastChunk && !hidden && hiddenCases.length) { if (hiddenCases.length === 1) { linesInChunk.push(`*+${hiddenCases.length} hidden case, use "-hidden" to show it*`); } else { linesInChunk.push(`*+${hiddenCases.length} hidden cases, use "-hidden" to show them*`); } } const chunkStart = i * linesPerChunk + 1; const chunkEnd = Math.min((i + 1) * linesPerChunk, lines.length); const embed = { author: { name: lineChunks.length === 1 ? `Cases for ${userName}${modId ? ` by ${modName}` : ""} (${lines.length} total)` : `Cases ${chunkStart}–${chunkEnd} of ${lines.length} for ${userName}`, icon_url: user instanceof UnknownUser ? undefined : user.displayAvatarURL(), }, description: linesInChunk.join("\n"), fields: [...(isLastChunk ? [footerField] : [])], } satisfies APIEmbed; await sendContextResponse(context, { embeds: [embed], ephemeral: !show }); } } async function casesModCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, author: User, modId: string | null, mod: GuildMember | User | UnknownUser, modName: string, typesToShow: CaseTypes[], hidden: boolean | null, expand: boolean | null, show: boolean | null, ) { const casesPlugin = pluginData.getPlugin(CasesPlugin); const casesFilters = { type: In(typesToShow), is_hidden: !!hidden }; const totalCases = await casesPlugin.getTotalCasesByMod(modId ?? author.id, casesFilters); if (totalCases === 0) { pluginData.state.common.sendErrorMessage(context, `No cases by **${modName}**`, undefined, undefined, !show); return; } const totalPages = Math.max(Math.ceil(totalCases / casesPerPage), 1); const prefix = getGuildPrefix(pluginData); if (expand) { // Expanded view (= individual case embeds) const cases = totalCases > 8 ? [] : await casesPlugin.getRecentCasesByMod(modId ?? author.id, 8, 0, casesFilters); await sendExpandedCases(pluginData, context, totalCases, cases, show); return; } await createPaginatedMessage( pluginData.client, context, totalPages, async (page) => { const cases = await casesPlugin.getRecentCasesByMod( modId ?? author.id, casesPerPage, (page - 1) * casesPerPage, casesFilters, ); const lines = await asyncMap(cases, (c) => casesPlugin.getCaseSummary(c, true, author.id)); const firstCaseNum = (page - 1) * casesPerPage + 1; const lastCaseNum = firstCaseNum - 1 + Math.min(cases.length, casesPerPage); const title = `Most recent cases ${firstCaseNum}-${lastCaseNum} of ${totalCases} by ${modName}`; const embed = { author: { name: title, icon_url: mod instanceof UnknownUser ? undefined : mod.displayAvatarURL(), }, description: lines.join("\n"), fields: [ { name: emptyEmbedValue, value: trimLines(` Use \`${prefix}case \` to see more information about an individual case Use \`${prefix}cases \` to see a specific user's cases `), }, ], } satisfies APIEmbed; return { embeds: [embed], ephemeral: !show }; }, { limitToUserId: author.id, }, ); } export async function actualCasesCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, modId: string | null, user: GuildMember | User | UnknownUser | null, author: GuildMember, notes: boolean | null, warns: boolean | null, mutes: boolean | null, unmutes: boolean | null, kicks: boolean | null, bans: boolean | null, unbans: boolean | null, reverseFilters: boolean | null, hidden: boolean | null, expand: boolean | null, show: boolean | null, ) { const mod = modId ? (await resolveMember(pluginData.client, pluginData.guild, modId)) || (await resolveUser(pluginData.client, modId, "ModActions:actualCasesCmd")) : null; const modName = modId ? (mod instanceof UnknownUser ? modId : renderUsername(mod!)) : renderUsername(author); const allTypes = [ CaseTypes.Note, CaseTypes.Warn, CaseTypes.Mute, CaseTypes.Unmute, CaseTypes.Kick, CaseTypes.Ban, CaseTypes.Unban, ]; let typesToShow: CaseTypes[] = []; if (notes) typesToShow.push(CaseTypes.Note); if (warns) typesToShow.push(CaseTypes.Warn); if (mutes) typesToShow.push(CaseTypes.Mute); if (unmutes) typesToShow.push(CaseTypes.Unmute); if (kicks) typesToShow.push(CaseTypes.Kick); if (bans) typesToShow.push(CaseTypes.Ban); if (unbans) typesToShow.push(CaseTypes.Unban); if (typesToShow.length === 0) { typesToShow = allTypes; } else { if (reverseFilters) { typesToShow = allTypes.filter((t) => !typesToShow.includes(t)); } } user ? await casesUserCmd( pluginData, context, author.user, modId!, user, modName, typesToShow, hidden, expand, show === true, ) : await casesModCmd( pluginData, context, author.user, modId!, mod ?? author, modName, typesToShow, hidden, expand, show === true, ); } ================================================ FILE: backend/src/plugins/ModActions/commands/constants.ts ================================================ export const NUMBER_ATTACHMENTS_CASE_CREATION = 1; export const NUMBER_ATTACHMENTS_CASE_UPDATE = 3; ================================================ FILE: backend/src/plugins/ModActions/commands/deletecase/DeleteCaseMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { resolveMessageMember } from "../../../../pluginUtils.js"; import { trimLines } from "../../../../utils.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualDeleteCaseCmd } from "./actualDeleteCaseCmd.js"; export const DeleteCaseMsgCmd = modActionsMsgCmd({ trigger: ["delete_case", "deletecase"], permission: "can_deletecase", description: trimLines(` Delete the specified case. This operation can *not* be reversed. It is generally recommended to use \`!hidecase\` instead when possible. `), signature: { caseNumber: ct.number({ rest: true }), force: ct.switchOption({ def: false, shortcut: "f" }), }, async run({ pluginData, message, args }) { const member = await resolveMessageMember(message); actualDeleteCaseCmd(pluginData, message, member, args.caseNumber, args.force); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/deletecase/DeleteCaseSlashCmd.ts ================================================ import { GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { modActionsSlashCmd } from "../../types.js"; import { actualDeleteCaseCmd } from "./actualDeleteCaseCmd.js"; const opts = [slashOptions.boolean({ name: "force", description: "Whether or not to force delete", required: false })]; export const DeleteCaseSlashCmd = modActionsSlashCmd({ name: "deletecase", configPermission: "can_deletecase", description: "Delete the specified case. This operation can *not* be reversed.", allowDms: false, signature: [ slashOptions.string({ name: "case-number", description: "The number of the case to delete", required: true }), ...opts, ], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); actualDeleteCaseCmd( pluginData, interaction, interaction.member as GuildMember, options["case-number"].split(/\D+/).map(Number), !!options.force, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/deletecase/actualDeleteCaseCmd.ts ================================================ import { ChatInputCommandInteraction, GuildMember, Message } from "discord.js"; import { GuildPluginData } from "vety"; import { Case } from "../../../../data/entities/Case.js"; import { getContextChannel } from "../../../../pluginUtils.js"; import { confirm, renderUsername } from "../../../../utils.js"; import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; import { TimeAndDatePlugin } from "../../../TimeAndDate/TimeAndDatePlugin.js"; import { ModActionsPluginType } from "../../types.js"; export async function actualDeleteCaseCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, author: GuildMember, caseNumbers: number[], force: boolean, ) { const failed: number[] = []; const validCases: Case[] = []; let cancelled = 0; for (const num of caseNumbers) { const theCase = await pluginData.state.cases.findByCaseNumber(num); if (!theCase) { failed.push(num); continue; } validCases.push(theCase); } if (failed.length === caseNumbers.length) { pluginData.state.common.sendErrorMessage(context, "None of the cases were found!"); return; } for (const theCase of validCases) { if (!force) { const channel = await getContextChannel(context); if (!channel) { return; } const cases = pluginData.getPlugin(CasesPlugin); const embedContent = await cases.getCaseEmbed(theCase); const confirmed = await confirm(context, author.id, { ...embedContent, content: "Delete the following case?", }); if (!confirmed) { cancelled++; continue; } } const deletedByName = renderUsername(author); const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); const deletedAt = timeAndDate.inGuildTz().format(timeAndDate.getDateFormat("pretty_datetime")); await pluginData.state.cases.softDelete( theCase.id, author.id, deletedByName, `Case deleted by **${deletedByName}** (\`${author.id}\`) on ${deletedAt}`, ); const logs = pluginData.getPlugin(LogsPlugin); logs.logCaseDelete({ mod: author, case: theCase, }); } const failedAddendum = failed.length > 0 ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` : ""; const amt = validCases.length - cancelled; if (amt === 0) { pluginData.state.common.sendErrorMessage(context, "All deletions were cancelled, no cases were deleted."); return; } pluginData.state.common.sendSuccessMessage( context, `${amt} case${amt === 1 ? " was" : "s were"} deleted!${failedAddendum}`, ); } ================================================ FILE: backend/src/plugins/ModActions/commands/forceban/ForceBanMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { canActOn, hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; import { resolveMember, resolveUser } from "../../../../utils.js"; import { isBanned } from "../../functions/isBanned.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualForceBanCmd } from "./actualForceBanCmd.js"; const opts = { mod: ct.member({ option: true }), }; export const ForceBanMsgCmd = modActionsMsgCmd({ trigger: "forceban", permission: "can_ban", description: "Force-ban the specified user, even if they aren't on the server", signature: [ { user: ct.string(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, ], async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user, "ModActions:ForceBanMsgCmd"); if (!user.id) { pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } // If the user exists as a guild member, make sure we can act on them first const authorMember = await resolveMessageMember(msg); const targetMember = await resolveMember(pluginData.client, pluginData.guild, user.id); if (targetMember && !canActOn(pluginData, authorMember, targetMember)) { pluginData.state.common.sendErrorMessage(msg, "Cannot forceban this user: insufficient permissions"); return; } // Make sure the user isn't already banned const banned = await isBanned(pluginData, user.id); if (banned) { pluginData.state.common.sendErrorMessage(msg, `User is already banned`); return; } // The moderator who did the action is the message author or, if used, the specified -mod let mod = authorMember; if (args.mod) { if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); return; } mod = args.mod; } actualForceBanCmd(pluginData, msg, msg.author.id, user, args.reason, [...msg.attachments.values()], mod); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/forceban/ForceBanSlashCmd.ts ================================================ import { GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { hasPermission } from "../../../../pluginUtils.js"; import { convertDelayStringToMS, resolveMember } from "../../../../utils.js"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualForceBanCmd } from "./actualForceBanCmd.js"; const opts = [ slashOptions.string({ name: "reason", description: "The reason", required: false }), slashOptions.user({ name: "mod", description: "The moderator to ban as", required: false }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const ForceBanSlashCmd = modActionsSlashCmd({ name: "forceban", configPermission: "can_ban", description: "Force-ban the specified user, even if they aren't on the server", allowDms: false, signature: [slashOptions.user({ name: "user", description: "The user to ban", required: true }), ...opts], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); return; } let mod = interaction.member as GuildMember; const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { channel: interaction.channel, member: interaction.member, }); if (options.mod) { if (!canActAsOther) { pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); return; } mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; } const convertedTime = options.time ? convertDelayStringToMS(options.time) : null; if (options.time && !convertedTime) { pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); return; } actualForceBanCmd( pluginData, interaction, interaction.user.id, options.user, options.reason ?? "", attachments, mod, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/forceban/actualForceBanCmd.ts ================================================ import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake, User } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../../data/CaseTypes.js"; import { LogType } from "../../../../data/LogType.js"; import { DAYS, MINUTES, UnknownUser } from "../../../../utils.js"; import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; import { formatReasonWithAttachments, formatReasonWithMessageLinkForAttachments, } from "../../functions/formatReasonForAttachments.js"; import { ignoreEvent } from "../../functions/ignoreEvent.js"; import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; export async function actualForceBanCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, authorId: string, user: User | UnknownUser, reason: string, attachments: Array, mod: GuildMember, ) { if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { return; } const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments); ignoreEvent(pluginData, IgnoredEventType.Ban, user.id); pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id); try { // FIXME: Use banUserId()? await pluginData.guild.bans.create(user.id as Snowflake, { deleteMessageSeconds: (1 * DAYS) / MINUTES, reason: formattedReasonWithAttachments ?? undefined, }); } catch { pluginData.state.common.sendErrorMessage(context, "Failed to forceban member"); return; } // Create a case const casesPlugin = pluginData.getPlugin(CasesPlugin); const createdCase = await casesPlugin.createCase({ userId: user.id, modId: mod.id, type: CaseTypes.Ban, reason: formattedReason, ppId: mod.id !== authorId ? authorId : undefined, }); // Confirm the action pluginData.state.common.sendSuccessMessage(context, `Member forcebanned (Case #${createdCase.case_number})`); // Log the action pluginData.getPlugin(LogsPlugin).logMemberForceban({ mod, userId: user.id, caseNumber: createdCase.case_number, reason: formattedReason, }); pluginData.state.events.emit("ban", user.id, formattedReason); } ================================================ FILE: backend/src/plugins/ModActions/commands/forcemute/ForceMuteMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { canActOn, hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; import { resolveMember, resolveUser } from "../../../../utils.js"; import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualMuteCmd } from "../mute/actualMuteCmd.js"; const opts = { mod: ct.member({ option: true }), notify: ct.string({ option: true }), "notify-channel": ct.textChannel({ option: true }), }; export const ForceMuteMsgCmd = modActionsMsgCmd({ trigger: "forcemute", permission: "can_mute", description: "Force-mute the specified user, even if they're not on the server", signature: [ { user: ct.string(), time: ct.delay(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, { user: ct.string(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, ], async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user, "ModActions:ForceMuteMsgCmd"); if (!user.id) { pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } const authorMember = await resolveMessageMember(msg); const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); // Make sure we're allowed to mute this user if (memberToMute && !canActOn(pluginData, authorMember, memberToMute)) { pluginData.state.common.sendErrorMessage(msg, "Cannot mute: insufficient permissions"); return; } // The moderator who did the action is the message author or, if used, the specified -mod let mod = authorMember; let ppId: string | undefined; if (args.mod) { if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); return; } mod = args.mod; ppId = msg.author.id; } let contactMethods; try { contactMethods = readContactMethodsFromArgs(args); } catch (e) { pluginData.state.common.sendErrorMessage(msg, e.message); return; } actualMuteCmd( pluginData, msg, user, [...msg.attachments.values()], mod, ppId, "time" in args ? (args.time ?? undefined) : undefined, args.reason, contactMethods, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/forcemute/ForceMuteSlashCmd.ts ================================================ import { ChannelType, GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { hasPermission } from "../../../../pluginUtils.js"; import { UserNotificationMethod, convertDelayStringToMS, resolveMember } from "../../../../utils.js"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualMuteCmd } from "../mute/actualMuteCmd.js"; const opts = [ slashOptions.string({ name: "time", description: "The duration of the mute", required: false }), slashOptions.string({ name: "reason", description: "The reason", required: false }), slashOptions.user({ name: "mod", description: "The moderator to mute as", required: false }), slashOptions.string({ name: "notify", description: "How to notify", required: false, choices: [ { name: "DM", value: "dm" }, { name: "Channel", value: "channel" }, ], }), slashOptions.channel({ name: "notify-channel", description: "The channel to notify in", channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], required: false, }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const ForceMuteSlashCmd = modActionsSlashCmd({ name: "forcemute", configPermission: "can_mute", description: "Force-mute the specified user, even if they're not on the server", allowDms: false, signature: [slashOptions.user({ name: "user", description: "The user to mute", required: true }), ...opts], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); return; } let mod = interaction.member as GuildMember; let ppId: string | undefined; const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { channel: interaction.channel, member: interaction.member, }); if (options.mod) { if (!canActAsOther) { pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); return; } mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; ppId = interaction.user.id; } const convertedTime = options.time ? (convertDelayStringToMS(options.time) ?? undefined) : undefined; if (options.time && !convertedTime) { pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); return; } let contactMethods: UserNotificationMethod[] | undefined; try { contactMethods = readContactMethodsFromArgs(options) ?? undefined; } catch (e) { pluginData.state.common.sendErrorMessage(interaction, e.message); return; } actualMuteCmd( pluginData, interaction, options.user, attachments, mod, ppId, convertedTime, options.reason ?? "", contactMethods, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { canActOn, hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; import { resolveMember, resolveUser } from "../../../../utils.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualUnmuteCmd } from "../unmute/actualUnmuteCmd.js"; const opts = { mod: ct.member({ option: true }), }; export const ForceUnmuteMsgCmd = modActionsMsgCmd({ trigger: "forceunmute", permission: "can_mute", description: "Force-unmute the specified user, even if they're not on the server", signature: [ { user: ct.string(), time: ct.delay(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, { user: ct.string(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, ], async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user, "ModActions:ForceUnmuteMsgCmd"); if (!user.id) { pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } // Check if they're muted in the first place if (!(await pluginData.state.mutes.isMuted(user.id))) { pluginData.state.common.sendErrorMessage(msg, "Cannot unmute: member is not muted"); return; } const authorMember = await resolveMessageMember(msg); const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id); // Make sure we're allowed to unmute this member if (memberToUnmute && !canActOn(pluginData, authorMember, memberToUnmute)) { pluginData.state.common.sendErrorMessage(msg, "Cannot unmute: insufficient permissions"); return; } // The moderator who did the action is the message author or, if used, the specified -mod let mod = authorMember; let ppId: string | undefined; if (args.mod) { if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); return; } mod = args.mod; ppId = msg.author.id; } actualUnmuteCmd( pluginData, msg, user, [...msg.attachments.values()], mod, ppId, "time" in args ? (args.time ?? undefined) : undefined, args.reason, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteSlashCmd.ts ================================================ import { GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { hasPermission } from "../../../../pluginUtils.js"; import { convertDelayStringToMS, resolveMember } from "../../../../utils.js"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualUnmuteCmd } from "../unmute/actualUnmuteCmd.js"; const opts = [ slashOptions.string({ name: "time", description: "The duration of the unmute", required: false }), slashOptions.string({ name: "reason", description: "The reason", required: false }), slashOptions.user({ name: "mod", description: "The moderator to unmute as", required: false }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const ForceUnmuteSlashCmd = modActionsSlashCmd({ name: "forceunmute", configPermission: "can_mute", description: "Force-unmute the specified user, even if they're not on the server", allowDms: false, signature: [slashOptions.user({ name: "user", description: "The user to unmute", required: true }), ...opts], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); return; } let mod = interaction.member as GuildMember; let ppId: string | undefined; const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { channel: interaction.channel, member: interaction.member, }); if (options.mod) { if (!canActAsOther) { pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); return; } mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; ppId = interaction.user.id; } const convertedTime = options.time ? (convertDelayStringToMS(options.time) ?? undefined) : undefined; if (options.time && !convertedTime) { pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); return; } actualUnmuteCmd(pluginData, interaction, options.user, attachments, mod, ppId, convertedTime, options.reason ?? ""); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/hidecase/HideCaseMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualHideCaseCmd } from "./actualHideCaseCmd.js"; export const HideCaseMsgCmd = modActionsMsgCmd({ trigger: ["hide", "hidecase", "hide_case"], permission: "can_hidecase", description: "Hide the specified case so it doesn't appear in !cases or !info", signature: [ { caseNum: ct.number({ rest: true }), }, ], async run({ pluginData, message: msg, args }) { actualHideCaseCmd(pluginData, msg, args.caseNum); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/hidecase/HideCaseSlashCmd.ts ================================================ import { slashOptions } from "vety"; import { modActionsSlashCmd } from "../../types.js"; import { actualHideCaseCmd } from "./actualHideCaseCmd.js"; export const HideCaseSlashCmd = modActionsSlashCmd({ name: "hidecase", configPermission: "can_hidecase", description: "Hide the specified case so it doesn't appear in !cases or !info", allowDms: false, signature: [ slashOptions.string({ name: "case-number", description: "The number of the case to hide", required: true }), ], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); actualHideCaseCmd(pluginData, interaction, options["case-number"].split(/\D+/).map(Number)); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/hidecase/actualHideCaseCmd.ts ================================================ import { ChatInputCommandInteraction, Message } from "discord.js"; import { GuildPluginData } from "vety"; import { ModActionsPluginType } from "../../types.js"; export async function actualHideCaseCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, caseNumbers: number[], ) { const failed: number[] = []; for (const num of caseNumbers) { const theCase = await pluginData.state.cases.findByCaseNumber(num); if (!theCase) { failed.push(num); continue; } await pluginData.state.cases.setHidden(theCase.id, true); } if (failed.length === caseNumbers.length) { pluginData.state.common.sendErrorMessage(context, "None of the cases were found!"); return; } const failedAddendum = failed.length > 0 ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` : ""; const amt = caseNumbers.length - failed.length; pluginData.state.common.sendSuccessMessage( context, `${amt} case${amt === 1 ? " is" : "s are"} now hidden! Use \`unhidecase\` to unhide them.${failedAddendum}`, ); } ================================================ FILE: backend/src/plugins/ModActions/commands/kick/KickMsgCmd.ts ================================================ import { hasPermission } from "vety/helpers"; import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { resolveMessageMember } from "../../../../pluginUtils.js"; import { resolveUser } from "../../../../utils.js"; import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualKickCmd } from "./actualKickCmd.js"; const opts = { mod: ct.member({ option: true }), notify: ct.string({ option: true }), "notify-channel": ct.textChannel({ option: true }), clean: ct.bool({ option: true, isSwitch: true }), }; export const KickMsgCmd = modActionsMsgCmd({ trigger: "kick", permission: "can_kick", description: "Kick the specified member", signature: [ { user: ct.string(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, ], async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user, "ModActions:KickMsgCmd"); if (!user.id) { pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } const authorMember = await resolveMessageMember(msg); // The moderator who did the action is the message author or, if used, the specified -mod let mod = authorMember; if (args.mod) { if (!(await hasPermission(await pluginData.config.getForMessage(msg), "can_act_as_other"))) { pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); return; } mod = args.mod; } let contactMethods; try { contactMethods = readContactMethodsFromArgs(args); } catch (e) { pluginData.state.common.sendErrorMessage(msg, e.message); return; } actualKickCmd( pluginData, msg, authorMember, user, args.reason, [...msg.attachments.values()], mod, contactMethods, args.clean, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/kick/KickSlashCmd.ts ================================================ import { ChannelType, GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { hasPermission } from "../../../../pluginUtils.js"; import { UserNotificationMethod, resolveMember } from "../../../../utils.js"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualKickCmd } from "./actualKickCmd.js"; const opts = [ slashOptions.string({ name: "reason", description: "The reason", required: false }), slashOptions.user({ name: "mod", description: "The moderator to kick as", required: false }), slashOptions.string({ name: "notify", description: "How to notify", required: false, choices: [ { name: "DM", value: "dm" }, { name: "Channel", value: "channel" }, ], }), slashOptions.channel({ name: "notify-channel", description: "The channel to notify in", channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], required: false, }), slashOptions.boolean({ name: "clean", description: "Whether or not to delete the member's last messages", required: false, }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const KickSlashCmd = modActionsSlashCmd({ name: "kick", configPermission: "can_kick", description: "Kick the specified member", allowDms: false, signature: [slashOptions.user({ name: "user", description: "The user to kick", required: true }), ...opts], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); return; } let mod = interaction.member as GuildMember; const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { channel: interaction.channel, member: interaction.member, }); if (options.mod) { if (!canActAsOther) { pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); return; } mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; } let contactMethods: UserNotificationMethod[] | undefined; try { contactMethods = readContactMethodsFromArgs(options) ?? undefined; } catch (e) { pluginData.state.common.sendErrorMessage(interaction, e.message); return; } actualKickCmd( pluginData, interaction, interaction.member as GuildMember, options.user, options.reason || "", attachments, mod, contactMethods, options.clean, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/kick/actualKickCmd.ts ================================================ import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../../data/LogType.js"; import { canActOn } from "../../../../pluginUtils.js"; import { DAYS, SECONDS, UnknownUser, UserNotificationMethod, renderUsername, resolveMember, } from "../../../../utils.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; import { formatReasonWithAttachments, formatReasonWithMessageLinkForAttachments, } from "../../functions/formatReasonForAttachments.js"; import { ignoreEvent } from "../../functions/ignoreEvent.js"; import { isBanned } from "../../functions/isBanned.js"; import { kickMember } from "../../functions/kickMember.js"; import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; export async function actualKickCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, author: GuildMember, user: User | UnknownUser, reason: string, attachments: Attachment[], mod: GuildMember, contactMethods?: UserNotificationMethod[], clean?: boolean | null, ) { if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { return; } const memberToKick = await resolveMember(pluginData.client, pluginData.guild, user.id); if (!memberToKick) { const banned = await isBanned(pluginData, user.id); if (banned) { pluginData.state.common.sendErrorMessage(context, `User is banned`); } else { pluginData.state.common.sendErrorMessage(context, `User not found on the server`); } return; } // Make sure we're allowed to kick this member if (!canActOn(pluginData, author, memberToKick)) { pluginData.state.common.sendErrorMessage(context, "Cannot kick: insufficient permissions"); return; } const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments); const kickResult = await kickMember(pluginData, memberToKick, formattedReason, formattedReasonWithAttachments, { contactMethods, caseArgs: { modId: mod.id, ppId: mod.id !== author.id ? author.id : undefined, }, }); if (clean) { pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, memberToKick.id); ignoreEvent(pluginData, IgnoredEventType.Ban, memberToKick.id); try { await memberToKick.ban({ deleteMessageSeconds: (1 * DAYS) / SECONDS, reason: "kick -clean" }); } catch { pluginData.state.common.sendErrorMessage(context, "Failed to ban the user to clean messages (-clean)"); } pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, memberToKick.id); ignoreEvent(pluginData, IgnoredEventType.Unban, memberToKick.id); try { await pluginData.guild.bans.remove(memberToKick.id, "kick -clean"); } catch { pluginData.state.common.sendErrorMessage(context, "Failed to unban the user after banning them (-clean)"); } } if (kickResult.status === "failed") { pluginData.state.common.sendErrorMessage(context, `Failed to kick user`); return; } // Confirm the action to the moderator let response = `Kicked **${renderUsername(memberToKick.user)}** (Case #${kickResult.case.case_number})`; if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`; pluginData.state.common.sendSuccessMessage(context, response); } ================================================ FILE: backend/src/plugins/ModActions/commands/massban/MassBanMsgCmd.ts ================================================ import { waitForReply } from "vety/helpers"; import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { resolveMessageMember } from "../../../../pluginUtils.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualMassBanCmd } from "./actualMassBanCmd.js"; export const MassBanMsgCmd = modActionsMsgCmd({ trigger: "massban", permission: "can_massban", description: "Mass-ban a list of user IDs", signature: [ { userIds: ct.string({ rest: true }), }, ], async run({ pluginData, message: msg, args }) { // Ask for ban reason (cleaner this way instead of trying to cram it into the args) msg.reply("Ban reason? `cancel` to cancel"); const banReasonReply = await waitForReply(pluginData.client, msg.channel, msg.author.id); if (!banReasonReply || !banReasonReply.content || banReasonReply.content.toLowerCase().trim() === "cancel") { pluginData.state.common.sendErrorMessage(msg, "Cancelled"); return; } const authorMember = await resolveMessageMember(msg); actualMassBanCmd(pluginData, msg, args.userIds, authorMember, banReasonReply.content, [ ...banReasonReply.attachments.values(), ]); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/massban/MassBanSlashCmd.ts ================================================ import { GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualMassBanCmd } from "./actualMassBanCmd.js"; const opts = [ slashOptions.string({ name: "reason", description: "The reason", required: false }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const MassBanSlashCmd = modActionsSlashCmd({ name: "massban", configPermission: "can_massban", description: "Mass-ban a list of user IDs", allowDms: false, signature: [ slashOptions.string({ name: "user-ids", description: "The list of user IDs to ban", required: true }), ...opts, ], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); return; } actualMassBanCmd( pluginData, interaction, options["user-ids"].split(/\D+/), interaction.member as GuildMember, options.reason || "", attachments, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/massban/actualMassBanCmd.ts ================================================ import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../../data/CaseTypes.js"; import { LogType } from "../../../../data/LogType.js"; import { humanizeDurationShort } from "../../../../humanizeDuration.js"; import { canActOn, deleteContextResponse, editContextResponse, getConfigForContext, isContextInteraction, sendContextResponse, } from "../../../../pluginUtils.js"; import { DAYS, MINUTES, SECONDS, noop } from "../../../../utils.js"; import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; import { formatReasonWithAttachments, formatReasonWithMessageLinkForAttachments, } from "../../functions/formatReasonForAttachments.js"; import { ignoreEvent } from "../../functions/ignoreEvent.js"; import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; export async function actualMassBanCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, userIds: string[], author: GuildMember, reason: string, attachments: Attachment[], ) { // Limit to 100 users at once (arbitrary?) if (userIds.length > 100) { pluginData.state.common.sendErrorMessage(context, `Can only massban max 100 users at once`); return; } if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { return; } const banReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); const banReasonWithAttachments = formatReasonWithAttachments(reason, attachments); // Verify we can act on each of the users specified for (const userId of userIds) { const member = pluginData.guild.members.cache.get(userId as Snowflake); // TODO: Get members on demand? if (member && !canActOn(pluginData, author, member)) { pluginData.state.common.sendErrorMessage(context, "Cannot massban one or more users: insufficient permissions"); return; } } // Show a loading indicator since this can take a while const maxWaitTime = pluginData.state.massbanQueue.timeout * pluginData.state.massbanQueue.length; const maxWaitTimeFormatted = humanizeDurationShort(maxWaitTime, { round: true }); const initialLoadingText = pluginData.state.massbanQueue.length === 0 ? "Banning..." : `Massban queued. Waiting for previous massban to finish (max wait ${maxWaitTimeFormatted}).`; const loadingMsg = await sendContextResponse(context, initialLoadingText, true); const waitTimeStart = performance.now(); const waitingInterval = setInterval(() => { const waitTime = humanizeDurationShort(performance.now() - waitTimeStart, { round: true }); const waitMessageContent = `Massban queued. Still waiting for previous massban to finish (waited ${waitTime}).`; editContextResponse(loadingMsg, waitMessageContent).catch(() => clearInterval(waitingInterval)); }, 1 * MINUTES); pluginData.state.massbanQueue.add(async () => { clearInterval(waitingInterval); if (pluginData.state.unloaded) { await deleteContextResponse(loadingMsg); return; } editContextResponse(loadingMsg, "Banning...").catch(noop); // Ban each user and count failed bans (if any) const startTime = performance.now(); const failedBans: string[] = []; const casesPlugin = pluginData.getPlugin(CasesPlugin); const messageConfig = await getConfigForContext(pluginData.config, context); const deleteDays = messageConfig.ban_delete_message_days; for (const [i, userId] of userIds.entries()) { if (pluginData.state.unloaded) { break; } try { // Ignore automatic ban cases and logs // We create our own cases below and post a single "mass banned" log instead ignoreEvent(pluginData, IgnoredEventType.Ban, userId, 30 * MINUTES); pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId, 30 * MINUTES); await pluginData.guild.bans.create(userId as Snowflake, { deleteMessageSeconds: (deleteDays * DAYS) / SECONDS, reason: banReasonWithAttachments, }); await casesPlugin.createCase({ userId, modId: author.id, type: CaseTypes.Ban, reason: `Mass ban: ${banReason}`, postInCaseLogOverride: false, }); pluginData.state.events.emit("ban", userId, banReason); } catch { failedBans.push(userId); } // Send a status update every 10 bans if ((i + 1) % 10 === 0) { const newLoadingMessageContent = `Banning... ${i + 1}/${userIds.length}`; if (isContextInteraction(context)) { void context.editReply(newLoadingMessageContent).catch(noop); } else { loadingMsg.edit(newLoadingMessageContent).catch(noop); } } } const totalTime = performance.now() - startTime; const formattedTimeTaken = humanizeDurationShort(totalTime, { round: true }); if (!isContextInteraction(context)) { // Clear loading indicator loadingMsg.delete().catch(noop); } const successfulBanCount = userIds.length - failedBans.length; if (successfulBanCount === 0) { // All bans failed - don't create a log entry and notify the user pluginData.state.common.sendErrorMessage(context, "All bans failed. Make sure the IDs are valid."); } else { // Some or all bans were successful. Create a log entry for the mass ban and notify the user. pluginData.getPlugin(LogsPlugin).logMassBan({ mod: author.user, count: successfulBanCount, reason: banReason, }); if (failedBans.length) { pluginData.state.common.sendSuccessMessage( context, `Banned ${successfulBanCount} users in ${formattedTimeTaken}, ${failedBans.length} failed: ${failedBans.join( " ", )}`, ); } else { pluginData.state.common.sendSuccessMessage( context, `Banned ${successfulBanCount} users successfully in ${formattedTimeTaken}`, ); } } }); } ================================================ FILE: backend/src/plugins/ModActions/commands/massmute/MassMuteMsgCmd.ts ================================================ import { waitForReply } from "vety/helpers"; import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { resolveMessageMember } from "../../../../pluginUtils.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualMassMuteCmd } from "./actualMassMuteCmd.js"; export const MassMuteMsgCmd = modActionsMsgCmd({ trigger: "massmute", permission: "can_massmute", description: "Mass-mute a list of user IDs", signature: [ { userIds: ct.string({ rest: true }), }, ], async run({ pluginData, message: msg, args }) { // Ask for mute reason msg.reply("Mute reason? `cancel` to cancel"); const muteReasonReceived = await waitForReply(pluginData.client, msg.channel, msg.author.id); if ( !muteReasonReceived || !muteReasonReceived.content || muteReasonReceived.content.toLowerCase().trim() === "cancel" ) { pluginData.state.common.sendErrorMessage(msg, "Cancelled"); return; } const member = await resolveMessageMember(msg); actualMassMuteCmd(pluginData, msg, args.userIds, member, muteReasonReceived.content, [ ...muteReasonReceived.attachments.values(), ]); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/massmute/MassMuteSlashCmd.ts ================================================ import { GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualMassMuteCmd } from "./actualMassMuteCmd.js"; const opts = [ slashOptions.string({ name: "reason", description: "The reason", required: false }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const MassMuteSlashSlashCmd = modActionsSlashCmd({ name: "massmute", configPermission: "can_massmute", description: "Mass-mute a list of user IDs", allowDms: false, signature: [ slashOptions.string({ name: "user-ids", description: "The list of user IDs to mute", required: true }), ...opts, ], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); return; } actualMassMuteCmd( pluginData, interaction, options["user-ids"].split(/\D+/), interaction.member as GuildMember, options.reason || "", attachments, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/massmute/actualMassMuteCmd.ts ================================================ import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../../data/LogType.js"; import { logger } from "../../../../logger.js"; import { canActOn, deleteContextResponse, isContextInteraction, sendContextResponse } from "../../../../pluginUtils.js"; import { noop } from "../../../../utils.js"; import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; import { formatReasonWithAttachments, formatReasonWithMessageLinkForAttachments, } from "../../functions/formatReasonForAttachments.js"; import { ModActionsPluginType } from "../../types.js"; export async function actualMassMuteCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, userIds: string[], author: GuildMember, reason: string, attachments: Attachment[], ) { // Limit to 100 users at once (arbitrary?) if (userIds.length > 100) { pluginData.state.common.sendErrorMessage(context, `Can only massmute max 100 users at once`); return; } if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { return; } const muteReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); const muteReasonWithAttachments = formatReasonWithAttachments(reason, attachments); // Verify we can act upon all users for (const userId of userIds) { const member = pluginData.guild.members.cache.get(userId as Snowflake); if (member && !canActOn(pluginData, author, member)) { pluginData.state.common.sendErrorMessage(context, "Cannot massmute one or more users: insufficient permissions"); return; } } // Ignore automatic mute cases and logs for these users // We'll create our own cases below and post a single "mass muted" log instead userIds.forEach((userId) => { // Use longer timeouts since this can take a while pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_MUTE, userId, 120 * 1000); }); // Show loading indicator const loadingMsg = await sendContextResponse(context, "Muting...", true); // Mute everyone and count fails const modId = author.id; const failedMutes: string[] = []; const mutesPlugin = pluginData.getPlugin(MutesPlugin); for (const userId of userIds) { try { await mutesPlugin.muteUser(userId, 0, `Mass mute: ${muteReason}`, `Mass mute: ${muteReasonWithAttachments}`, { caseArgs: { modId, }, }); } catch (e) { logger.info(e); failedMutes.push(userId); } } if (!isContextInteraction(context)) { // Clear loading indicator deleteContextResponse(loadingMsg).catch(noop); } const successfulMuteCount = userIds.length - failedMutes.length; if (successfulMuteCount === 0) { // All mutes failed pluginData.state.common.sendErrorMessage(context, "All mutes failed. Make sure the IDs are valid."); } else { // Success on all or some mutes pluginData.getPlugin(LogsPlugin).logMassMute({ mod: author.user, count: successfulMuteCount, }); if (failedMutes.length) { pluginData.state.common.sendSuccessMessage( context, `Muted ${successfulMuteCount} users, ${failedMutes.length} failed: ${failedMutes.join(" ")}`, ); } else { pluginData.state.common.sendSuccessMessage(context, `Muted ${successfulMuteCount} users successfully`); } } } ================================================ FILE: backend/src/plugins/ModActions/commands/massunban/MassUnbanMsgCmd.ts ================================================ import { waitForReply } from "vety/helpers"; import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { resolveMessageMember } from "../../../../pluginUtils.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualMassUnbanCmd } from "./actualMassUnbanCmd.js"; export const MassUnbanMsgCmd = modActionsMsgCmd({ trigger: "massunban", permission: "can_massunban", description: "Mass-unban a list of user IDs", signature: [ { userIds: ct.string({ rest: true }), }, ], async run({ pluginData, message: msg, args }) { // Ask for unban reason (cleaner this way instead of trying to cram it into the args) msg.reply("Unban reason? `cancel` to cancel"); const unbanReasonReply = await waitForReply(pluginData.client, msg.channel, msg.author.id); if (!unbanReasonReply || !unbanReasonReply.content || unbanReasonReply.content.toLowerCase().trim() === "cancel") { pluginData.state.common.sendErrorMessage(msg, "Cancelled"); return; } const member = await resolveMessageMember(msg); actualMassUnbanCmd(pluginData, msg, args.userIds, member, unbanReasonReply.content, [ ...unbanReasonReply.attachments.values(), ]); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/massunban/MassUnbanSlashCmd.ts ================================================ import { GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualMassUnbanCmd } from "./actualMassUnbanCmd.js"; const opts = [ slashOptions.string({ name: "reason", description: "The reason", required: false }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const MassUnbanSlashCmd = modActionsSlashCmd({ name: "massunban", configPermission: "can_massunban", description: "Mass-unban a list of user IDs", allowDms: false, signature: [ slashOptions.string({ name: "user-ids", description: "The list of user IDs to unban", required: true }), ...opts, ], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); return; } actualMassUnbanCmd( pluginData, interaction, options["user-ids"].split(/[\s,\r\n]+/), interaction.member as GuildMember, options.reason || "", attachments, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/massunban/actualMassUnbanCmd.ts ================================================ import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../../data/CaseTypes.js"; import { LogType } from "../../../../data/LogType.js"; import { deleteContextResponse, isContextInteraction, sendContextResponse } from "../../../../pluginUtils.js"; import { MINUTES, noop } from "../../../../utils.js"; import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; import { ignoreEvent } from "../../functions/ignoreEvent.js"; import { isBanned } from "../../functions/isBanned.js"; import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; export async function actualMassUnbanCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, userIds: string[], author: GuildMember, reason: string, attachments: Attachment[], ) { // Limit to 100 users at once (arbitrary?) if (userIds.length > 100) { pluginData.state.common.sendErrorMessage(context, `Can only mass-unban max 100 users at once`); return; } if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { return; } const unbanReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); // Ignore automatic unban cases and logs for these users // We'll create our own cases below and post a single "mass unbanned" log instead userIds.forEach((userId) => { // Use longer timeouts since this can take a while ignoreEvent(pluginData, IgnoredEventType.Unban, userId, 2 * MINUTES); pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, userId, 2 * MINUTES); }); // Show a loading indicator since this can take a while const loadingMsg = await sendContextResponse(context, { content: "Unbanning...", ephemeral: true }); // Unban each user and count failed unbans (if any) const failedUnbans: Array<{ userId: string; reason: UnbanFailReasons }> = []; const casesPlugin = pluginData.getPlugin(CasesPlugin); for (const userId of userIds) { if (!(await isBanned(pluginData, userId))) { failedUnbans.push({ userId, reason: UnbanFailReasons.NOT_BANNED }); continue; } try { await pluginData.guild.bans.remove(userId as Snowflake, unbanReason ?? undefined); await casesPlugin.createCase({ userId, modId: author.id, type: CaseTypes.Unban, reason: `Mass unban: ${unbanReason}`, postInCaseLogOverride: false, }); } catch { failedUnbans.push({ userId, reason: UnbanFailReasons.UNBAN_FAILED }); } } if (!isContextInteraction(context)) { // Clear loading indicator await deleteContextResponse(loadingMsg).catch(noop); } const successfulUnbanCount = userIds.length - failedUnbans.length; if (successfulUnbanCount === 0) { // All unbans failed - don't create a log entry and notify the user pluginData.state.common.sendErrorMessage(context, "All unbans failed. Make sure the IDs are valid and banned."); } else { // Some or all unbans were successful. Create a log entry for the mass unban and notify the user. pluginData.getPlugin(LogsPlugin).logMassUnban({ mod: author.user, count: successfulUnbanCount, reason: unbanReason, }); if (failedUnbans.length) { const notBanned = failedUnbans.filter((x) => x.reason === UnbanFailReasons.NOT_BANNED); const unbanFailed = failedUnbans.filter((x) => x.reason === UnbanFailReasons.UNBAN_FAILED); let failedMsg = ""; if (notBanned.length > 0) { failedMsg += `${notBanned.length}x ${UnbanFailReasons.NOT_BANNED}:`; notBanned.forEach((fail) => { failedMsg += " " + fail.userId; }); } if (unbanFailed.length > 0) { failedMsg += `\n${unbanFailed.length}x ${UnbanFailReasons.UNBAN_FAILED}:`; unbanFailed.forEach((fail) => { failedMsg += " " + fail.userId; }); } pluginData.state.common.sendSuccessMessage( context, `Unbanned ${successfulUnbanCount} users, ${failedUnbans.length} failed:\n${failedMsg}`, ); } else { pluginData.state.common.sendSuccessMessage(context, `Unbanned ${successfulUnbanCount} users successfully`); } } } enum UnbanFailReasons { NOT_BANNED = "Not banned", UNBAN_FAILED = "Unban failed", } ================================================ FILE: backend/src/plugins/ModActions/commands/mute/MuteMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { canActOn, hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; import { resolveMember, resolveUser } from "../../../../utils.js"; import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; import { isBanned } from "../../functions/isBanned.js"; import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualMuteCmd } from "./actualMuteCmd.js"; const opts = { mod: ct.member({ option: true }), notify: ct.string({ option: true }), "notify-channel": ct.textChannel({ option: true }), }; export const MuteMsgCmd = modActionsMsgCmd({ trigger: "mute", permission: "can_mute", description: "Mute the specified member", signature: [ { user: ct.string(), time: ct.delay(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, { user: ct.string(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, ], async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user, "ModActions:MuteMsgCmd"); if (!user.id) { pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } const authorMember = await resolveMessageMember(msg); const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); if (!memberToMute) { const _isBanned = await isBanned(pluginData, user.id); const prefix = pluginData.fullConfig.prefix; if (_isBanned) { pluginData.state.common.sendErrorMessage( msg, `User is banned. Use \`${prefix}forcemute\` if you want to mute them anyway.`, ); return; } else { // Ask the mod if we should upgrade to a forcemute as the user is not on the server const reply = await waitForButtonConfirm( msg, { content: "User not found on the server, forcemute instead?" }, { confirmText: "Yes", cancelText: "No", restrictToId: authorMember.id }, ); if (!reply) { pluginData.state.common.sendErrorMessage(msg, "User not on server, mute cancelled by moderator"); return; } } } // Make sure we're allowed to mute this member if (memberToMute && !canActOn(pluginData, authorMember, memberToMute)) { pluginData.state.common.sendErrorMessage(msg, "Cannot mute: insufficient permissions"); return; } // The moderator who did the action is the message author or, if used, the specified -mod let mod = authorMember; let ppId: string | undefined; if (args.mod) { if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); return; } mod = args.mod; ppId = msg.author.id; } let contactMethods; try { contactMethods = readContactMethodsFromArgs(args); } catch (e) { pluginData.state.common.sendErrorMessage(msg, e.message); return; } actualMuteCmd( pluginData, msg, user, [...msg.attachments.values()], mod, ppId, "time" in args ? (args.time ?? undefined) : undefined, args.reason, contactMethods, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/mute/MuteSlashCmd.ts ================================================ import { ChannelType, GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { canActOn, hasPermission } from "../../../../pluginUtils.js"; import { UserNotificationMethod, convertDelayStringToMS, resolveMember } from "../../../../utils.js"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; import { isBanned } from "../../functions/isBanned.js"; import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualMuteCmd } from "./actualMuteCmd.js"; const opts = [ slashOptions.string({ name: "time", description: "The duration of the mute", required: false }), slashOptions.string({ name: "reason", description: "The reason", required: false }), slashOptions.user({ name: "mod", description: "The moderator to mute as", required: false }), slashOptions.string({ name: "notify", description: "How to notify", required: false, choices: [ { name: "DM", value: "dm" }, { name: "Channel", value: "channel" }, ], }), slashOptions.channel({ name: "notify-channel", description: "The channel to notify in", channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], required: false, }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const MuteSlashCmd = modActionsSlashCmd({ name: "mute", configPermission: "can_mute", description: "Mute the specified member", allowDms: false, signature: [slashOptions.user({ name: "user", description: "The user to mute", required: true }), ...opts], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); const memberToMute = await resolveMember(pluginData.client, pluginData.guild, options.user.id); if (!memberToMute) { const _isBanned = await isBanned(pluginData, options.user.id); const prefix = pluginData.fullConfig.prefix; if (_isBanned) { pluginData.state.common.sendErrorMessage( interaction, `User is banned. Use \`${prefix}forcemute\` if you want to mute them anyway.`, ); return; } else { // Ask the mod if we should upgrade to a forcemute as the user is not on the server const reply = await waitForButtonConfirm( interaction, { content: "User not found on the server, forcemute instead?" }, { confirmText: "Yes", cancelText: "No", restrictToId: interaction.user.id }, ); if (!reply) { pluginData.state.common.sendErrorMessage(interaction, "User not on server, mute cancelled by moderator"); return; } } } // Make sure we're allowed to mute this member if (memberToMute && !canActOn(pluginData, interaction.member as GuildMember, memberToMute)) { pluginData.state.common.sendErrorMessage(interaction, "Cannot mute: insufficient permissions"); return; } let mod = interaction.member as GuildMember; let ppId: string | undefined; const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { channel: interaction.channel, member: interaction.member, }); if (options.mod) { if (!canActAsOther) { pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); return; } mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; ppId = interaction.user.id; } const convertedTime = options.time ? (convertDelayStringToMS(options.time) ?? undefined) : undefined; if (options.time && !convertedTime) { pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); return; } let contactMethods: UserNotificationMethod[] | undefined; try { contactMethods = readContactMethodsFromArgs(options) ?? undefined; } catch (e) { pluginData.state.common.sendErrorMessage(interaction, e.message); return; } actualMuteCmd( pluginData, interaction, options.user, attachments, mod, ppId, convertedTime, options.reason, contactMethods, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/mute/actualMuteCmd.ts ================================================ import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; import { GuildPluginData } from "vety"; import { ERRORS, RecoverablePluginError } from "../../../../RecoverablePluginError.js"; import { humanizeDuration } from "../../../../humanizeDuration.js"; import { logger } from "../../../../logger.js"; import { UnknownUser, UserNotificationMethod, asSingleLine, isDiscordAPIError, renderUsername, } from "../../../../utils.js"; import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; import { MuteResult } from "../../../Mutes/types.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; import { formatReasonWithAttachments, formatReasonWithMessageLinkForAttachments, } from "../../functions/formatReasonForAttachments.js"; import { ModActionsPluginType } from "../../types.js"; /** * The actual function run by both !mute and !forcemute. * The only difference between the two commands is in target member validation. */ export async function actualMuteCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, user: User | UnknownUser, attachments: Attachment[], mod: GuildMember, ppId?: string, time?: number, reason?: string | null, contactMethods?: UserNotificationMethod[], ) { if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { return; } const timeUntilUnmute = time && humanizeDuration(time); const formattedReason = reason || attachments.length > 0 ? await formatReasonWithMessageLinkForAttachments(pluginData, reason ?? "", context, attachments) : undefined; const formattedReasonWithAttachments = reason || attachments.length > 0 ? formatReasonWithAttachments(reason ?? "", attachments) : undefined; let muteResult: MuteResult; const mutesPlugin = pluginData.getPlugin(MutesPlugin); try { muteResult = await mutesPlugin.muteUser(user.id, time, formattedReason, formattedReasonWithAttachments, { contactMethods, caseArgs: { modId: mod.id, ppId, }, }); } catch (e) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { pluginData.state.common.sendErrorMessage(context, "Could not mute the user: no mute role set in config"); } else if (isDiscordAPIError(e) && e.code === 10007) { pluginData.state.common.sendErrorMessage(context, "Could not mute the user: unknown member"); } else { logger.error(`Failed to mute user ${user.id}: ${e.stack}`); if (user.id == null) { // FIXME: Debug // tslint:disable-next-line:no-console console.trace("[DEBUG] Null user.id for mute"); } pluginData.state.common.sendErrorMessage(context, "Could not mute the user"); } return; } // Confirm the action to the moderator let response: string; if (time) { if (muteResult.updatedExistingMute) { response = asSingleLine(` Updated **${renderUsername(user)}**'s mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number}) `); } else { response = asSingleLine(` Muted **${renderUsername(user)}** for ${timeUntilUnmute} (Case #${muteResult.case.case_number}) `); } } else { if (muteResult.updatedExistingMute) { response = asSingleLine(` Updated **${renderUsername(user)}**'s mute to indefinite (Case #${muteResult.case.case_number}) `); } else { response = asSingleLine(` Muted **${renderUsername(user)}** indefinitely (Case #${muteResult.case.case_number}) `); } } if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`; pluginData.state.common.sendSuccessMessage(context, response); } ================================================ FILE: backend/src/plugins/ModActions/commands/note/NoteMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { resolveUser } from "../../../../utils.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualNoteCmd } from "./actualNoteCmd.js"; export const NoteMsgCmd = modActionsMsgCmd({ trigger: "note", permission: "can_note", description: "Add a note to the specified user", signature: { user: ct.string(), note: ct.string({ required: false, catchAll: true }), }, async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user, "ModActions:NoteMsgCmd"); if (!user.id) { pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } if (!args.note && msg.attachments.size === 0) { pluginData.state.common.sendErrorMessage(msg, "Text or attachment required"); return; } actualNoteCmd(pluginData, msg, msg.author, [...msg.attachments.values()], user, args.note || ""); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/note/NoteSlashCmd.ts ================================================ import { slashOptions } from "vety"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualNoteCmd } from "./actualNoteCmd.js"; const opts = [ slashOptions.string({ name: "note", description: "The note to add to the user", required: false }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the note", }), ]; export const NoteSlashCmd = modActionsSlashCmd({ name: "note", configPermission: "can_note", description: "Add a note to the specified user", allowDms: false, signature: [slashOptions.user({ name: "user", description: "The user to add a note to", required: true }), ...opts], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); if ((!options.note || options.note.trim() === "") && attachments.length < 1) { pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); return; } actualNoteCmd(pluginData, interaction, interaction.user, attachments, options.user, options.note || ""); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/note/actualNoteCmd.ts ================================================ import { Attachment, ChatInputCommandInteraction, Message, User } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../../data/CaseTypes.js"; import { UnknownUser, renderUsername } from "../../../../utils.js"; import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; import { ModActionsPluginType } from "../../types.js"; export async function actualNoteCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, author: User, attachments: Array, user: User | UnknownUser, note: string, ) { if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, note)) { return; } const userName = renderUsername(user); const reason = await formatReasonWithMessageLinkForAttachments(pluginData, note, context, attachments); const casesPlugin = pluginData.getPlugin(CasesPlugin); const createdCase = await casesPlugin.createCase({ userId: user.id, modId: author.id, type: CaseTypes.Note, reason, }); pluginData.getPlugin(LogsPlugin).logMemberNote({ mod: author, user, caseNumber: createdCase.case_number, reason, }); pluginData.state.common.sendSuccessMessage( context, `Note added on **${userName}** (Case #${createdCase.case_number})`, undefined, undefined, true, ); pluginData.state.events.emit("note", user.id, reason); } ================================================ FILE: backend/src/plugins/ModActions/commands/unban/UnbanMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; import { resolveUser } from "../../../../utils.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualUnbanCmd } from "./actualUnbanCmd.js"; const opts = { mod: ct.member({ option: true }), }; export const UnbanMsgCmd = modActionsMsgCmd({ trigger: "unban", permission: "can_unban", description: "Unban the specified member", signature: [ { user: ct.string(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, ], async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user, "ModActions:UnbanMsgCmd"); if (!user.id) { pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } const authorMember = await resolveMessageMember(msg); // The moderator who did the action is the message author or, if used, the specified -mod let mod = authorMember; if (args.mod) { if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id }))) { pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); return; } mod = args.mod; } actualUnbanCmd(pluginData, msg, msg.author.id, user, args.reason, [...msg.attachments.values()], mod); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/unban/UnbanSlashCmd.ts ================================================ import { GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { hasPermission } from "../../../../pluginUtils.js"; import { resolveMember } from "../../../../utils.js"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualUnbanCmd } from "./actualUnbanCmd.js"; const opts = [ slashOptions.string({ name: "reason", description: "The reason", required: false }), slashOptions.user({ name: "mod", description: "The moderator to unban as", required: false }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const UnbanSlashCmd = modActionsSlashCmd({ name: "unban", configPermission: "can_unban", description: "Unban the specified member", allowDms: false, signature: [slashOptions.user({ name: "user", description: "The user to unban", required: true }), ...opts], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); let mod = interaction.member as GuildMember; const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { channel: interaction.channel, member: interaction.member, }); if (options.mod) { if (!canActAsOther) { pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); return; } mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; } actualUnbanCmd(pluginData, interaction, interaction.user.id, options.user, options.reason ?? "", attachments, mod); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/unban/actualUnbanCmd.ts ================================================ import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake, User } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../../data/CaseTypes.js"; import { LogType } from "../../../../data/LogType.js"; import { clearExpiringTempban } from "../../../../data/loops/expiringTempbansLoop.js"; import { UnknownUser } from "../../../../utils.js"; import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; import { ignoreEvent } from "../../functions/ignoreEvent.js"; import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; export async function actualUnbanCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, authorId: string, user: User | UnknownUser, reason: string, attachments: Array, mod: GuildMember, ) { if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { return; } pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, user.id); const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); try { ignoreEvent(pluginData, IgnoredEventType.Unban, user.id); await pluginData.guild.bans.remove(user.id as Snowflake, formattedReason ?? undefined); } catch { pluginData.state.common.sendErrorMessage(context, "Failed to unban member; are you sure they're banned?"); return; } // Create a case const casesPlugin = pluginData.getPlugin(CasesPlugin); const createdCase = await casesPlugin.createCase({ userId: user.id, modId: mod.id, type: CaseTypes.Unban, reason: formattedReason, ppId: mod.id !== authorId ? authorId : undefined, }); // Delete the tempban, if one exists const tempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); if (tempban) { clearExpiringTempban(tempban); await pluginData.state.tempbans.clear(user.id); } // Confirm the action pluginData.state.common.sendSuccessMessage(context, `Member unbanned (Case #${createdCase.case_number})`); // Log the action pluginData.getPlugin(LogsPlugin).logMemberUnban({ mod: mod.user, userId: user.id, caseNumber: createdCase.case_number, reason: formattedReason ?? "", }); pluginData.state.events.emit("unban", user.id); } ================================================ FILE: backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualUnhideCaseCmd } from "./actualUnhideCaseCmd.js"; export const UnhideCaseMsgCmd = modActionsMsgCmd({ trigger: ["unhide", "unhidecase", "unhide_case"], permission: "can_hidecase", description: "Un-hide the specified case, making it appear in !cases and !info again", signature: [ { caseNum: ct.number({ rest: true }), }, ], async run({ pluginData, message: msg, args }) { actualUnhideCaseCmd(pluginData, msg, args.caseNum); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseSlashCmd.ts ================================================ import { slashOptions } from "vety"; import { modActionsSlashCmd } from "../../types.js"; import { actualUnhideCaseCmd } from "./actualUnhideCaseCmd.js"; export const UnhideCaseSlashCmd = modActionsSlashCmd({ name: "unhidecase", configPermission: "can_hidecase", description: "Un-hide the specified case", allowDms: false, signature: [ slashOptions.string({ name: "case-number", description: "The number of the case to unhide", required: true }), ], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); actualUnhideCaseCmd(pluginData, interaction, options["case-number"].split(/\D+/).map(Number)); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/unhidecase/actualUnhideCaseCmd.ts ================================================ import { ChatInputCommandInteraction, Message } from "discord.js"; import { GuildPluginData } from "vety"; import { ModActionsPluginType } from "../../types.js"; export async function actualUnhideCaseCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, caseNumbers: number[], ) { const failed: number[] = []; for (const num of caseNumbers) { const theCase = await pluginData.state.cases.findByCaseNumber(num); if (!theCase) { failed.push(num); continue; } await pluginData.state.cases.setHidden(theCase.id, false); } if (failed.length === caseNumbers.length) { pluginData.state.common.sendErrorMessage(context, "None of the cases were found!"); return; } const failedAddendum = failed.length > 0 ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` : ""; const amt = caseNumbers.length - failed.length; pluginData.state.common.sendSuccessMessage( context, `${amt} case${amt === 1 ? " is" : "s are"} no longer hidden!${failedAddendum}`, ); } ================================================ FILE: backend/src/plugins/ModActions/commands/unmute/UnmuteMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { canActOn, hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; import { resolveMember, resolveUser } from "../../../../utils.js"; import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; import { isBanned } from "../../functions/isBanned.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualUnmuteCmd } from "./actualUnmuteCmd.js"; const opts = { mod: ct.member({ option: true }), }; export const UnmuteMsgCmd = modActionsMsgCmd({ trigger: "unmute", permission: "can_mute", description: "Unmute the specified member", signature: [ { user: ct.string(), time: ct.delay(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, { user: ct.string(), reason: ct.string({ required: false, catchAll: true }), ...opts, }, ], async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user, "ModActions:UnmuteMsgCmd"); if (!user.id) { pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } const authorMember = await resolveMessageMember(msg); const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id); const mutesPlugin = pluginData.getPlugin(MutesPlugin); const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute); // Check if they're muted in the first place if ( !(await pluginData.state.mutes.isMuted(user.id)) && !hasMuteRole && !memberToUnmute?.isCommunicationDisabled() ) { pluginData.state.common.sendErrorMessage(msg, "Cannot unmute: member is not muted"); return; } if (!memberToUnmute) { const banned = await isBanned(pluginData, user.id); const prefix = pluginData.fullConfig.prefix; if (banned) { pluginData.state.common.sendErrorMessage( msg, `User is banned. Use \`${prefix}forceunmute\` to unmute them anyway.`, ); return; } else { // Ask the mod if we should upgrade to a forceunmute as the user is not on the server const reply = await waitForButtonConfirm( msg, { content: "User not on server, forceunmute instead?" }, { confirmText: "Yes", cancelText: "No", restrictToId: authorMember.id }, ); if (!reply) { pluginData.state.common.sendErrorMessage(msg, "User not on server, unmute cancelled by moderator"); return; } } } // Make sure we're allowed to unmute this member if (memberToUnmute && !canActOn(pluginData, authorMember, memberToUnmute)) { pluginData.state.common.sendErrorMessage(msg, "Cannot unmute: insufficient permissions"); return; } // The moderator who did the action is the message author or, if used, the specified -mod let mod = authorMember; let ppId: string | undefined; if (args.mod) { if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); return; } mod = args.mod; ppId = msg.author.id; } actualUnmuteCmd( pluginData, msg, user, [...msg.attachments.values()], mod, ppId, "time" in args ? (args.time ?? undefined) : undefined, args.reason, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/unmute/UnmuteSlashCmd.ts ================================================ import { GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { canActOn, hasPermission } from "../../../../pluginUtils.js"; import { convertDelayStringToMS, resolveMember } from "../../../../utils.js"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; import { isBanned } from "../../functions/isBanned.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualUnmuteCmd } from "./actualUnmuteCmd.js"; const opts = [ slashOptions.string({ name: "time", description: "The duration of the unmute", required: false }), slashOptions.string({ name: "reason", description: "The reason", required: false }), slashOptions.user({ name: "mod", description: "The moderator to unmute as", required: false }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const UnmuteSlashCmd = modActionsSlashCmd({ name: "unmute", configPermission: "can_mute", description: "Unmute the specified member", allowDms: false, signature: [slashOptions.user({ name: "user", description: "The user to unmute", required: true }), ...opts], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); return; } const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, options.user.id); const mutesPlugin = pluginData.getPlugin(MutesPlugin); const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute); // Check if they're muted in the first place if ( !(await pluginData.state.mutes.isMuted(options.user.id)) && !hasMuteRole && !memberToUnmute?.isCommunicationDisabled() ) { pluginData.state.common.sendErrorMessage(interaction, "Cannot unmute: member is not muted"); return; } if (!memberToUnmute) { const banned = await isBanned(pluginData, options.user.id); const prefix = pluginData.fullConfig.prefix; if (banned) { pluginData.state.common.sendErrorMessage( interaction, `User is banned. Use \`${prefix}forceunmute\` to unmute them anyway.`, ); return; } else { // Ask the mod if we should upgrade to a forceunmute as the user is not on the server const reply = await waitForButtonConfirm( interaction, { content: "User not on server, forceunmute instead?" }, { confirmText: "Yes", cancelText: "No", restrictToId: interaction.user.id }, ); if (!reply) { pluginData.state.common.sendErrorMessage(interaction, "User not on server, unmute cancelled by moderator"); return; } } } // Make sure we're allowed to unmute this member if (memberToUnmute && !canActOn(pluginData, interaction.member as GuildMember, memberToUnmute)) { pluginData.state.common.sendErrorMessage(interaction, "Cannot unmute: insufficient permissions"); return; } let mod = interaction.member as GuildMember; let ppId: string | undefined; const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { channel: interaction.channel, member: interaction.member, }); if (options.mod) { if (!canActAsOther) { pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); return; } mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; ppId = interaction.user.id; } const convertedTime = options.time ? (convertDelayStringToMS(options.time) ?? undefined) : undefined; if (options.time && !convertedTime) { pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); return; } actualUnmuteCmd(pluginData, interaction, options.user, attachments, mod, ppId, convertedTime, options.reason); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/unmute/actualUnmuteCmd.ts ================================================ import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; import { GuildPluginData } from "vety"; import { humanizeDuration } from "../../../../humanizeDuration.js"; import { UnknownUser, asSingleLine, renderUsername } from "../../../../utils.js"; import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; import { ModActionsPluginType } from "../../types.js"; export async function actualUnmuteCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, user: User | UnknownUser, attachments: Array, mod: GuildMember, ppId?: string, time?: number, reason?: string | null, ) { if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { return; } const formattedReason = reason || attachments.length > 0 ? await formatReasonWithMessageLinkForAttachments(pluginData, reason ?? "", context, attachments) : undefined; const mutesPlugin = pluginData.getPlugin(MutesPlugin); const result = await mutesPlugin.unmuteUser(user.id, time, { modId: mod.id, ppId: ppId ?? undefined, reason: formattedReason, }); if (!result) { pluginData.state.common.sendErrorMessage(context, "User is not muted!"); return; } // Confirm the action to the moderator if (time) { const timeUntilUnmute = time && humanizeDuration(time); pluginData.state.common.sendSuccessMessage( context, asSingleLine(` Unmuting **${renderUsername(user)}** in ${timeUntilUnmute} (Case #${result.case.case_number}) `), ); } else { pluginData.state.common.sendSuccessMessage( context, asSingleLine(` Unmuted **${renderUsername(user)}** (Case #${result.case.case_number}) `), ); } } ================================================ FILE: backend/src/plugins/ModActions/commands/update/UpdateMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { updateCase } from "../../functions/updateCase.js"; import { modActionsMsgCmd } from "../../types.js"; export const UpdateMsgCmd = modActionsMsgCmd({ trigger: ["update", "reason"], permission: "can_note", description: "Update the specified case (or, if case number is omitted, your latest case) by adding more notes/details to it", signature: [ { caseNumber: ct.number(), note: ct.string({ required: false, catchAll: true }), }, { note: ct.string({ required: false, catchAll: true }), }, ], async run({ pluginData, message: msg, args }) { await updateCase(pluginData, msg, msg.author, args.caseNumber, args.note, [...msg.attachments.values()]); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/update/UpdateSlashCmd.ts ================================================ import { slashOptions } from "vety"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { updateCase } from "../../functions/updateCase.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_UPDATE } from "../constants.js"; const opts = [ slashOptions.string({ name: "case-number", description: "The number of the case to update", required: false }), slashOptions.string({ name: "reason", description: "The note to add to the case", required: false }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_UPDATE, { name: "attachment", description: "An attachment to add to the update", }), ]; export const UpdateSlashCmd = modActionsSlashCmd({ name: "update", configPermission: "can_note", description: "Update the specified case (or your latest case) by adding more notes to it", allowDms: false, signature: [...opts], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); await updateCase( pluginData, interaction, interaction.user, options["case-number"] ? Number(options["case-number"]) : null, options.reason ?? "", retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_UPDATE, options, "attachment"), ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/warn/WarnMsgCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; import { canActOn, hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; import { errorMessage, resolveMember, resolveUser } from "../../../../utils.js"; import { isBanned } from "../../functions/isBanned.js"; import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; import { modActionsMsgCmd } from "../../types.js"; import { actualWarnCmd } from "./actualWarnCmd.js"; export const WarnMsgCmd = modActionsMsgCmd({ trigger: "warn", permission: "can_warn", description: "Send a warning to the specified user", signature: { user: ct.string(), reason: ct.string({ catchAll: true }), mod: ct.member({ option: true }), notify: ct.string({ option: true }), "notify-channel": ct.textChannel({ option: true }), }, async run({ pluginData, message: msg, args }) { const user = await resolveUser(pluginData.client, args.user, "ModActions:WarnMsgCmd"); if (!user.id) { await pluginData.state.common.sendErrorMessage(msg, `User not found`); return; } const authorMember = await resolveMessageMember(msg); const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, user.id); if (!memberToWarn) { const _isBanned = await isBanned(pluginData, user.id); if (_isBanned) { await pluginData.state.common.sendErrorMessage(msg, `User is banned`); } else { await pluginData.state.common.sendErrorMessage(msg, `User not found on the server`); } return; } // Make sure we're allowed to warn this member if (!canActOn(pluginData, authorMember, memberToWarn)) { await pluginData.state.common.sendErrorMessage(msg, "Cannot warn: insufficient permissions"); return; } // The moderator who did the action is the message author or, if used, the specified -mod let mod = authorMember; if (args.mod) { if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { msg.channel.send(errorMessage("You don't have permission to use -mod")); return; } mod = args.mod; } let contactMethods; try { contactMethods = readContactMethodsFromArgs(args); } catch (e) { await pluginData.state.common.sendErrorMessage(msg, e.message); return; } actualWarnCmd( pluginData, msg, msg.author.id, mod, memberToWarn, args.reason, [...msg.attachments.values()], contactMethods, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/warn/WarnSlashCmd.ts ================================================ import { ChannelType, GuildMember } from "discord.js"; import { slashOptions } from "vety"; import { canActOn, hasPermission } from "../../../../pluginUtils.js"; import { UserNotificationMethod, resolveMember } from "../../../../utils.js"; import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; import { isBanned } from "../../functions/isBanned.js"; import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; import { modActionsSlashCmd } from "../../types.js"; import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; import { actualWarnCmd } from "./actualWarnCmd.js"; const opts = [ slashOptions.string({ name: "reason", description: "The reason", required: false }), slashOptions.user({ name: "mod", description: "The moderator to warn as", required: false }), slashOptions.string({ name: "notify", description: "How to notify", required: false, choices: [ { name: "DM", value: "dm" }, { name: "Channel", value: "channel" }, ], }), slashOptions.channel({ name: "notify-channel", description: "The channel to notify in", channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], required: false, }), ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { name: "attachment", description: "An attachment to add to the reason", }), ]; export const WarnSlashCmd = modActionsSlashCmd({ name: "warn", configPermission: "can_warn", description: "Send a warning to the specified user", allowDms: false, signature: [slashOptions.user({ name: "user", description: "The user to warn", required: true }), ...opts], async run({ interaction, options, pluginData }) { await interaction.deferReply({ ephemeral: true }); const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { await pluginData.state.common.sendErrorMessage( interaction, "Text or attachment required", undefined, undefined, true, ); return; } const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, options.user.id); if (!memberToWarn) { const _isBanned = await isBanned(pluginData, options.user.id); if (_isBanned) { await pluginData.state.common.sendErrorMessage(interaction, `User is banned`); } else { await pluginData.state.common.sendErrorMessage(interaction, `User not found on the server`); } return; } // Make sure we're allowed to warn this member if (!canActOn(pluginData, interaction.member as GuildMember, memberToWarn)) { await pluginData.state.common.sendErrorMessage(interaction, "Cannot warn: insufficient permissions"); return; } let mod = interaction.member as GuildMember; const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { channel: interaction.channel, member: interaction.member, }); if (options.mod) { if (!canActAsOther) { await pluginData.state.common.sendErrorMessage( interaction, "You don't have permission to act as another moderator", ); return; } mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; } let contactMethods: UserNotificationMethod[] | undefined; try { contactMethods = readContactMethodsFromArgs(options) ?? undefined; } catch (e) { await pluginData.state.common.sendErrorMessage(interaction, e.message); return; } actualWarnCmd( pluginData, interaction, interaction.user.id, mod, memberToWarn, options.reason ?? "", attachments, contactMethods, ); }, }); ================================================ FILE: backend/src/plugins/ModActions/commands/warn/actualWarnCmd.ts ================================================ import { Attachment, ChatInputCommandInteraction, GuildMember, Message } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../../data/CaseTypes.js"; import { UserNotificationMethod, renderUsername } from "../../../../utils.js"; import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; import { formatReasonWithAttachments, formatReasonWithMessageLinkForAttachments, } from "../../functions/formatReasonForAttachments.js"; import { warnMember } from "../../functions/warnMember.js"; import { ModActionsPluginType } from "../../types.js"; export async function actualWarnCmd( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, authorId: string, mod: GuildMember, memberToWarn: GuildMember, reason: string, attachments: Attachment[], contactMethods?: UserNotificationMethod[], ) { if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { return; } const config = pluginData.config.get(); const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments); const casesPlugin = pluginData.getPlugin(CasesPlugin); const priorWarnAmount = await casesPlugin.getCaseTypeAmountForUserId(memberToWarn.id, CaseTypes.Warn); if (config.warn_notify_enabled && priorWarnAmount >= config.warn_notify_threshold) { const reply = await waitForButtonConfirm( context, { content: config.warn_notify_message.replace("{priorWarnings}", `${priorWarnAmount}`) }, { confirmText: "Yes", cancelText: "No", restrictToId: authorId }, ); if (!reply) { await pluginData.state.common.sendErrorMessage(context, "Warn cancelled by moderator"); return; } } const warnResult = await warnMember(pluginData, memberToWarn, formattedReason, formattedReasonWithAttachments, { contactMethods, caseArgs: { modId: mod.id, ppId: mod.id !== authorId ? authorId : undefined, reason: formattedReason, }, retryPromptContext: context, }); if (warnResult.status === "failed") { const failReason = warnResult.error ? `: ${warnResult.error}` : ""; await pluginData.state.common.sendErrorMessage(context, `Failed to warn user${failReason}`); return; } const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : ""; await pluginData.state.common.sendSuccessMessage( context, `Warned **${renderUsername(memberToWarn.user)}** (Case #${warnResult.case.case_number})${messageResultText}`, ); } ================================================ FILE: backend/src/plugins/ModActions/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zModActionsConfig } from "./types.js"; export const modActionsPluginDocs: ZeppelinPluginDocs = { prettyName: "Mod actions", type: "stable", description: trimPluginDescription(` This plugin contains the 'typical' mod actions such as warning, muting, kicking, banning, etc. `), configSchema: zModActionsConfig, }; ================================================ FILE: backend/src/plugins/ModActions/events/AuditLogEvents.ts ================================================ import { AuditLogChange, AuditLogEvent } from "discord.js"; import moment from "moment-timezone"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { resolveUser } from "../../../utils.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { modActionsEvt } from "../types.js"; export const AuditLogEvents = modActionsEvt({ event: "guildAuditLogEntryCreate", async listener({ pluginData, args: { auditLogEntry } }) { // Ignore the bot's own audit log events if (auditLogEntry.executorId === pluginData.client.user?.id) { return; } const config = pluginData.config.get(); const casesPlugin = pluginData.getPlugin(CasesPlugin); // Create mute/unmute cases for manual timeouts if (auditLogEntry.action === AuditLogEvent.MemberUpdate && config.create_cases_for_manual_actions) { const target = await resolveUser(pluginData.client, auditLogEntry.targetId!, "ModActions:AuditLogEvents"); // Only act based on the last changes in this log let muteChange: AuditLogChange | null = null; let unmuteChange: AuditLogChange | null = null; for (const change of auditLogEntry.changes) { if (change.key === "communication_disabled_until") { if (change.new == null) { unmuteChange = change; } else { muteChange = change; unmuteChange = null; } } } if (muteChange) { const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(target.id); const existingCaseId = existingMute?.case_id; if (existingCaseId) { await casesPlugin.createCaseNote({ caseId: existingCaseId, modId: auditLogEntry.executor?.id || "0", body: auditLogEntry.reason || "", noteDetails: [ `Timeout set to expire on `, ], }); } else { await casesPlugin.createCase({ userId: target.id, modId: auditLogEntry.executor?.id || "0", type: CaseTypes.Mute, auditLogId: auditLogEntry.id, reason: auditLogEntry.reason || "", automatic: true, }); } } if (unmuteChange) { await casesPlugin.createCase({ userId: target.id, modId: auditLogEntry.executor?.id || "0", type: CaseTypes.Unmute, auditLogId: auditLogEntry.id, reason: auditLogEntry.reason || "", automatic: true, }); } } }, }); ================================================ FILE: backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts ================================================ import { AuditLogEvent, User } from "discord.js"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { Case } from "../../../data/entities/Case.js"; import { UnknownUser, resolveUser } from "../../../utils.js"; import { findMatchingAuditLogEntry } from "../../../utils/findMatchingAuditLogEntry.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { clearIgnoredEvents } from "../functions/clearIgnoredEvents.js"; import { isEventIgnored } from "../functions/isEventIgnored.js"; import { IgnoredEventType, modActionsEvt } from "../types.js"; /** * Create a BAN case automatically when a user is banned manually. * Attempts to find the ban's details in the audit log. */ export const CreateBanCaseOnManualBanEvt = modActionsEvt({ event: "guildBanAdd", async listener({ pluginData, args: { ban } }) { const user = ban.user; if (isEventIgnored(pluginData, IgnoredEventType.Ban, user.id)) { clearIgnoredEvents(pluginData, IgnoredEventType.Ban, user.id); return; } const relevantAuditLogEntry = await findMatchingAuditLogEntry( pluginData.guild, AuditLogEvent.MemberBanAdd, user.id, ); const casesPlugin = pluginData.getPlugin(CasesPlugin); let createdCase: Case | null = null; let mod: User | UnknownUser | null = null; let reason = ""; if (relevantAuditLogEntry) { const modId = relevantAuditLogEntry.executor!.id; const auditLogId = relevantAuditLogEntry.id; mod = await resolveUser(pluginData.client, modId, "ModActions:CreateBanCaseOnManualBanEvt"); const config = mod instanceof UnknownUser ? pluginData.config.get() : await pluginData.config.getForUser(mod); if (config.create_cases_for_manual_actions) { reason = relevantAuditLogEntry.reason ?? ""; createdCase = await casesPlugin.createCase({ userId: user.id, modId, type: CaseTypes.Ban, auditLogId, reason: reason || undefined, automatic: true, }); } } else { const config = pluginData.config.get(); if (config.create_cases_for_manual_actions) { createdCase = await casesPlugin.createCase({ userId: user.id, modId: "0", type: CaseTypes.Ban, }); } } pluginData.getPlugin(LogsPlugin).logMemberBan({ mod: mod ? userToTemplateSafeUser(mod) : null, user: userToTemplateSafeUser(user), caseNumber: createdCase?.case_number ?? 0, reason, }); pluginData.state.events.emit("ban", user.id, reason); }, }); ================================================ FILE: backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts ================================================ import { AuditLogEvent, User } from "discord.js"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { Case } from "../../../data/entities/Case.js"; import { logger } from "../../../logger.js"; import { UnknownUser, resolveUser } from "../../../utils.js"; import { findMatchingAuditLogEntry } from "../../../utils/findMatchingAuditLogEntry.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { clearIgnoredEvents } from "../functions/clearIgnoredEvents.js"; import { isEventIgnored } from "../functions/isEventIgnored.js"; import { IgnoredEventType, modActionsEvt } from "../types.js"; /** * Create a KICK case automatically when a user is kicked manually. * Attempts to find the kick's details in the audit log. */ export const CreateKickCaseOnManualKickEvt = modActionsEvt({ event: "guildMemberRemove", async listener({ pluginData, args: { member } }) { if (isEventIgnored(pluginData, IgnoredEventType.Kick, member.id)) { clearIgnoredEvents(pluginData, IgnoredEventType.Kick, member.id); return; } const kickAuditLogEntry = await findMatchingAuditLogEntry(pluginData.guild, AuditLogEvent.MemberKick, member.id); let mod: User | UnknownUser | null = null; let createdCase: Case | null = null; // Since a member leaving and a member being kicked are both the same gateway event, // we can only really interpret this event as a kick if there is a matching audit log entry. if (kickAuditLogEntry) { createdCase = (await pluginData.state.cases.findByAuditLogId(kickAuditLogEntry.id)) || null; if (createdCase) { logger.warn( `Tried to create duplicate case for audit log entry ${kickAuditLogEntry.id}, existing case id ${createdCase.id}`, ); } else { mod = await resolveUser(pluginData.client, kickAuditLogEntry.executor!.id, "ModActions:CreateKickCaseOnManualKickEvt"); const config = mod instanceof UnknownUser ? pluginData.config.get() : await pluginData.config.getForUser(mod); if (config.create_cases_for_manual_actions) { const casesPlugin = pluginData.getPlugin(CasesPlugin); createdCase = await casesPlugin.createCase({ userId: member.id, modId: mod.id, type: CaseTypes.Kick, auditLogId: kickAuditLogEntry.id, reason: kickAuditLogEntry.reason || undefined, automatic: true, }); } } pluginData.getPlugin(LogsPlugin).logMemberKick({ user: member.user!, mod, caseNumber: createdCase?.case_number ?? 0, reason: kickAuditLogEntry.reason || "", }); pluginData.state.events.emit("kick", member.id, kickAuditLogEntry.reason || undefined); } }, }); ================================================ FILE: backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts ================================================ import { AuditLogEvent, User } from "discord.js"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { Case } from "../../../data/entities/Case.js"; import { UnknownUser, resolveUser } from "../../../utils.js"; import { findMatchingAuditLogEntry } from "../../../utils/findMatchingAuditLogEntry.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { clearIgnoredEvents } from "../functions/clearIgnoredEvents.js"; import { isEventIgnored } from "../functions/isEventIgnored.js"; import { IgnoredEventType, modActionsEvt } from "../types.js"; /** * Create an UNBAN case automatically when a user is unbanned manually. * Attempts to find the unban's details in the audit log. */ export const CreateUnbanCaseOnManualUnbanEvt = modActionsEvt({ event: "guildBanRemove", async listener({ pluginData, args: { ban } }) { const user = ban.user; if (isEventIgnored(pluginData, IgnoredEventType.Unban, user.id)) { clearIgnoredEvents(pluginData, IgnoredEventType.Unban, user.id); return; } const relevantAuditLogEntry = await findMatchingAuditLogEntry( pluginData.guild, AuditLogEvent.MemberBanRemove, user.id, ); const casesPlugin = pluginData.getPlugin(CasesPlugin); let createdCase: Case | null = null; let mod: User | UnknownUser | null = null; if (relevantAuditLogEntry) { const modId = relevantAuditLogEntry.executor!.id; const auditLogId = relevantAuditLogEntry.id; mod = await resolveUser(pluginData.client, modId, "ModActions:CreateUnbanCaseOnManualUnbanEvt"); const config = mod instanceof UnknownUser ? pluginData.config.get() : await pluginData.config.getForUser(mod); if (config.create_cases_for_manual_actions) { createdCase = await casesPlugin.createCase({ userId: user.id, modId, type: CaseTypes.Unban, auditLogId, automatic: true, }); } } else { const config = pluginData.config.get(); if (config.create_cases_for_manual_actions) { createdCase = await casesPlugin.createCase({ userId: user.id, modId: "0", type: CaseTypes.Unban, automatic: true, }); } } pluginData.getPlugin(LogsPlugin).logMemberUnban({ mod, userId: user.id, caseNumber: createdCase?.case_number ?? 0, reason: "", }); pluginData.state.events.emit("unban", user.id); }, }); ================================================ FILE: backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts ================================================ import { PermissionsBitField, Snowflake, TextChannel } from "discord.js"; import { renderUsername, resolveMember } from "../../../utils.js"; import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { modActionsEvt } from "../types.js"; /** * Show an alert if a member with prior notes joins the server */ export const PostAlertOnMemberJoinEvt = modActionsEvt({ event: "guildMemberAdd", async listener({ pluginData, args: { member } }) { const config = pluginData.config.get(); if (!config.alert_on_rejoin) return; const alertChannelId = config.alert_channel; if (!alertChannelId) return; const actions = await pluginData.state.cases.getByUserId(member.id); const logs = pluginData.getPlugin(LogsPlugin); if (actions.length) { const alertChannel = pluginData.guild.channels.cache.get(alertChannelId as Snowflake); if (!alertChannel) { logs.logBotAlert({ body: `Unknown \`alert_channel\` configured for \`mod_actions\`: \`${alertChannelId}\``, }); return; } if (!(alertChannel instanceof TextChannel)) { logs.logBotAlert({ body: `Non-text channel configured as \`alert_channel\` in \`mod_actions\`: \`${alertChannelId}\``, }); return; } const botMember = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id); const botPerms = alertChannel.permissionsFor(botMember ?? pluginData.client.user!.id); if (!hasDiscordPermissions(botPerms, PermissionsBitField.Flags.SendMessages)) { logs.logBotAlert({ body: `Missing "Send Messages" permissions for the \`alert_channel\` configured in \`mod_actions\`: \`${alertChannelId}\``, }); return; } await alertChannel.send( `<@!${member.id}> (${renderUsername(member)} \`${member.id}\`) joined with ${actions.length} prior record(s)`, ); } }, }); ================================================ FILE: backend/src/plugins/ModActions/functions/attachmentLinkReaction.ts ================================================ import { ChatInputCommandInteraction, Message, SendableChannels } from "discord.js"; import { GuildPluginData } from "vety"; import { ModActionsPluginType } from "../types.js"; export function shouldReactToAttachmentLink(pluginData: GuildPluginData) { const config = pluginData.config.get(); return !config.attachment_link_reaction || config.attachment_link_reaction !== "none"; } export function attachmentLinkShouldRestrict(pluginData: GuildPluginData) { return pluginData.config.get().attachment_link_reaction === "restrict"; } export function detectAttachmentLink(reason: string | null | undefined) { return reason && /https:\/\/(cdn|media)\.discordapp\.(com|net)\/(ephemeral-)?attachments/gu.test(reason); } function sendAttachmentLinkDetectionErrorMessage( pluginData: GuildPluginData, context: SendableChannels | Message | ChatInputCommandInteraction, restricted = false, ) { const emoji = pluginData.state.common.getErrorEmoji(); pluginData.state.common.sendErrorMessage( context, "You manually added a Discord attachment link to the reason. This link will only work for a limited time.\n" + "You should instead **re-upload** the attachment with the command, in the same message.\n\n" + (restricted ? `${emoji} **Command canceled.** ${emoji}` : "").trim(), ); } export async function handleAttachmentLinkDetectionAndGetRestriction( pluginData: GuildPluginData, context: SendableChannels | Message | ChatInputCommandInteraction, reason: string | null | undefined, ) { if (!shouldReactToAttachmentLink(pluginData) || !detectAttachmentLink(reason)) { return false; } const restricted = attachmentLinkShouldRestrict(pluginData); sendAttachmentLinkDetectionErrorMessage(pluginData, context, restricted); return restricted; } ================================================ FILE: backend/src/plugins/ModActions/functions/banUserId.ts ================================================ import { DiscordAPIError, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { LogType } from "../../../data/LogType.js"; import { registerExpiringTempban } from "../../../data/loops/expiringTempbansLoop.js"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { logger } from "../../../logger.js"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { DAYS, SECONDS, UserNotificationResult, createUserNotificationError, notifyUser, resolveMember, resolveUser, ucfirst, } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { BanOptions, BanResult, IgnoredEventType, ModActionsPluginType } from "../types.js"; import { getDefaultContactMethods } from "./getDefaultContactMethods.js"; import { ignoreEvent } from "./ignoreEvent.js"; /** * Ban the specified user id, whether or not they're actually on the server at the time. Generates a case. */ export async function banUserId( pluginData: GuildPluginData, userId: string, reason?: string, reasonWithAttachments?: string, banOptions: BanOptions = {}, banTime?: number, ): Promise { const config = pluginData.config.get(); const user = await resolveUser(pluginData.client, userId, "ModActions:banUserId"); if (!user.id) { return { status: "failed", error: "Invalid user", }; } // Attempt to message the user *before* banning them, as doing it after may not be possible const member = await resolveMember(pluginData.client, pluginData.guild, userId); let notifyResult: UserNotificationResult = { method: null, success: true }; if (reasonWithAttachments && member) { const contactMethods = banOptions?.contactMethods ? banOptions.contactMethods : getDefaultContactMethods(pluginData, "ban"); if (contactMethods.length) { if (!banTime && config.ban_message) { let banMessage: string; try { banMessage = await renderTemplate( config.ban_message, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, reason: reasonWithAttachments, moderator: banOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId, "ModActions:banUserId")) : null, }), ); } catch (err) { if (err instanceof TemplateParseError) { return { status: "failed", error: `Invalid ban_message format: ${err.message}`, }; } throw err; } notifyResult = await notifyUser(member.user, banMessage, contactMethods); } else if (banTime && config.tempban_message) { let banMessage: string; try { banMessage = await renderTemplate( config.tempban_message, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, reason: reasonWithAttachments, moderator: banOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId, "ModActions:banUserId")) : null, banTime: humanizeDuration(banTime), }), ); } catch (err) { if (err instanceof TemplateParseError) { return { status: "failed", error: `Invalid tempban_message format: ${err.message}`, }; } throw err; } notifyResult = await notifyUser(member.user, banMessage, contactMethods); } else { notifyResult = createUserNotificationError("No ban/tempban message specified in config"); } } } // (Try to) ban the user pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId); ignoreEvent(pluginData, IgnoredEventType.Ban, userId); try { const deleteMessageDays = Math.min(7, Math.max(0, banOptions.deleteMessageDays ?? 1)); await pluginData.guild.bans.create(userId as Snowflake, { deleteMessageSeconds: (deleteMessageDays * DAYS) / SECONDS, reason: reason ?? undefined, }); } catch (e) { let errorMessage; if (e instanceof DiscordAPIError) { errorMessage = `API error ${e.code}: ${e.message}`; } else { logger.warn(`Error applying ban to ${userId}: ${e}`); errorMessage = "Unknown error"; } return { status: "failed", error: errorMessage, }; } const tempbanLock = await pluginData.locks.acquire(`tempban-${user.id}`); const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); if (banTime && banTime > 0) { const selfId = pluginData.client.user!.id; if (existingTempban) { await pluginData.state.tempbans.updateExpiryTime(user.id, banTime, banOptions.modId ?? selfId); } else { await pluginData.state.tempbans.addTempban(user.id, banTime, banOptions.modId ?? selfId); } const tempban = (await pluginData.state.tempbans.findExistingTempbanForUserId(user.id))!; registerExpiringTempban(tempban); } tempbanLock.unlock(); // Create a case for this action const modId = banOptions.caseArgs?.modId || pluginData.client.user!.id; const casesPlugin = pluginData.getPlugin(CasesPlugin); const noteDetails: string[] = []; const timeUntilUnban = banTime ? humanizeDuration(banTime) : "indefinite"; const timeDetails = `Banned ${banTime ? `for ${timeUntilUnban}` : "indefinitely"}`; if (notifyResult.text) noteDetails.push(ucfirst(notifyResult.text)); noteDetails.push(timeDetails); const createdCase = await casesPlugin.createCase({ ...(banOptions.caseArgs || {}), userId, modId, type: CaseTypes.Ban, reason, noteDetails, }); // Log the action const mod = await resolveUser(pluginData.client, modId, "ModActions:banUserId"); if (banTime) { pluginData.getPlugin(LogsPlugin).logMemberTimedBan({ mod, user, caseNumber: createdCase.case_number, reason: reason ?? "", banTime: humanizeDuration(banTime), }); } else { pluginData.getPlugin(LogsPlugin).logMemberBan({ mod, user, caseNumber: createdCase.case_number, reason: reason ?? "", }); } pluginData.state.events.emit("ban", user.id, reason, banOptions.isAutomodAction); return { status: "success", case: createdCase, notifyResult, }; } ================================================ FILE: backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts ================================================ import { GuildPluginData } from "vety"; import { IgnoredEventType, ModActionsPluginType } from "../types.js"; export function clearIgnoredEvents( pluginData: GuildPluginData, type: IgnoredEventType, userId: string, ) { pluginData.state.ignoredEvents.splice( pluginData.state.ignoredEvents.findIndex((info) => type === info.type && userId === info.userId), 1, ); } ================================================ FILE: backend/src/plugins/ModActions/functions/clearTempban.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { LogType } from "../../../data/LogType.js"; import { Tempban } from "../../../data/entities/Tempban.js"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { logger } from "../../../logger.js"; import { resolveUser } from "../../../utils.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { IgnoredEventType, ModActionsPluginType } from "../types.js"; import { ignoreEvent } from "./ignoreEvent.js"; import { isBanned } from "./isBanned.js"; export async function clearTempban(pluginData: GuildPluginData, tempban: Tempban) { if (!(await isBanned(pluginData, tempban.user_id))) { pluginData.state.tempbans.clear(tempban.user_id); return; } pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, tempban.user_id); const reason = `Tempban timed out. Tempbanned at: \`${tempban.created_at} UTC\``; try { ignoreEvent(pluginData, IgnoredEventType.Unban, tempban.user_id); await pluginData.guild.bans.remove(tempban.user_id as Snowflake, reason ?? undefined); } catch (e) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Encountered an error trying to automatically unban ${tempban.user_id} after tempban timeout`, }); logger.warn(`Error automatically unbanning ${tempban.user_id} (tempban timeout): ${e}`); return; } // Create case and delete tempban const casesPlugin = pluginData.getPlugin(CasesPlugin); const createdCase = await casesPlugin.createCase({ userId: tempban.user_id, modId: tempban.mod_id, type: CaseTypes.Unban, reason, ppId: undefined, }); pluginData.state.tempbans.clear(tempban.user_id); // Log the unban const banTime = moment(tempban.created_at).diff(moment(tempban.expires_at)); pluginData.getPlugin(LogsPlugin).logMemberTimedUnban({ mod: await resolveUser(pluginData.client, tempban.mod_id, "ModActions:clearTempban"), userId: tempban.user_id, caseNumber: createdCase.case_number, reason, banTime: humanizeDuration(banTime), }); } ================================================ FILE: backend/src/plugins/ModActions/functions/formatReasonForAttachments.ts ================================================ import { Attachment, ChatInputCommandInteraction, Message } from "discord.js"; import { GuildPluginData } from "vety"; import { isContextMessage } from "../../../pluginUtils.js"; import { ModActionsPluginType } from "../types.js"; export async function formatReasonWithMessageLinkForAttachments( pluginData: GuildPluginData, reason: string, context: Message | ChatInputCommandInteraction, attachments: Attachment[], ) { if (isContextMessage(context)) { const allAttachments = [...new Set([...context.attachments.values(), ...attachments])]; return allAttachments.length > 0 ? ((reason || "") + " " + context.url).trim() : reason; } if (attachments.length < 1) { return reason; } const attachmentsMessage = await pluginData.state.common.storeAttachmentsAsMessage(attachments, context.channel); return ((reason || "") + " " + attachmentsMessage.url).trim(); } export function formatReasonWithAttachments(reason: string, attachments: Attachment[]) { const attachmentUrls = attachments.map((a) => a.url); return ((reason || "") + " " + attachmentUrls.join(" ")).trim(); } ================================================ FILE: backend/src/plugins/ModActions/functions/getDefaultContactMethods.ts ================================================ import { Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { UserNotificationMethod } from "../../../utils.js"; import { ModActionsPluginType } from "../types.js"; export function getDefaultContactMethods( pluginData: GuildPluginData, type: "warn" | "kick" | "ban", ): UserNotificationMethod[] { const methods: UserNotificationMethod[] = []; const config = pluginData.config.get(); if (config[`dm_on_${type}`]) { methods.push({ type: "dm" }); } if (config[`message_on_${type}`] && config.message_channel) { const channel = pluginData.guild.channels.cache.get(config.message_channel as Snowflake); if (channel instanceof TextChannel) { methods.push({ type: "channel", channel, }); } } return methods; } ================================================ FILE: backend/src/plugins/ModActions/functions/hasModActionPerm.ts ================================================ import { GuildMember, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { ModActionsPluginType } from "../types.js"; export async function hasNotePermission( pluginData: GuildPluginData, member: GuildMember, channelId: Snowflake, ) { return (await pluginData.config.getMatchingConfig({ member, channelId })).can_note; } export async function hasWarnPermission( pluginData: GuildPluginData, member: GuildMember, channelId: Snowflake, ) { return (await pluginData.config.getMatchingConfig({ member, channelId })).can_warn; } export async function hasMutePermission( pluginData: GuildPluginData, member: GuildMember, channelId: Snowflake, ) { return (await pluginData.config.getMatchingConfig({ member, channelId })).can_mute; } export async function hasBanPermission( pluginData: GuildPluginData, member: GuildMember, channelId: Snowflake, ) { return (await pluginData.config.getMatchingConfig({ member, channelId })).can_ban; } ================================================ FILE: backend/src/plugins/ModActions/functions/ignoreEvent.ts ================================================ import { GuildPluginData } from "vety"; import { SECONDS } from "../../../utils.js"; import { IgnoredEventType, ModActionsPluginType } from "../types.js"; import { clearIgnoredEvents } from "./clearIgnoredEvents.js"; const DEFAULT_TIMEOUT = 15 * SECONDS; export function ignoreEvent( pluginData: GuildPluginData, type: IgnoredEventType, userId: string, timeout = DEFAULT_TIMEOUT, ) { pluginData.state.ignoredEvents.push({ type, userId }); // Clear after expiry (15sec by default) setTimeout(() => { clearIgnoredEvents(pluginData, type, userId); }, timeout); } ================================================ FILE: backend/src/plugins/ModActions/functions/isBanned.ts ================================================ import { PermissionsBitField, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { SECONDS, isDiscordAPIError, isDiscordHTTPError, sleep } from "../../../utils.js"; import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { ModActionsPluginType } from "../types.js"; export async function isBanned( pluginData: GuildPluginData, userId: string, timeout: number = 5 * SECONDS, ): Promise { const botMember = pluginData.guild.members.cache.get(pluginData.client.user!.id); if (botMember && !hasDiscordPermissions(botMember.permissions, PermissionsBitField.Flags.BanMembers)) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing "Ban Members" permission to check for existing bans`, }); return false; } try { const potentialBan = await Promise.race([ pluginData.guild.bans.fetch({ user: userId as Snowflake }).catch(() => null), sleep(timeout), ]); return potentialBan != null; } catch (e) { if (isDiscordAPIError(e) && e.code === 10026) { // [10026]: Unknown Ban return false; } if (isDiscordHTTPError(e) && e.code === 500) { // Internal server error, ignore return false; } if (isDiscordAPIError(e) && e.code === 50013) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing "Ban Members" permission to check for existing bans`, }); } throw e; } } ================================================ FILE: backend/src/plugins/ModActions/functions/isEventIgnored.ts ================================================ import { GuildPluginData } from "vety"; import { IgnoredEventType, ModActionsPluginType } from "../types.js"; export function isEventIgnored( pluginData: GuildPluginData, type: IgnoredEventType, userId: string, ) { return pluginData.state.ignoredEvents.some((info) => type === info.type && userId === info.userId); } ================================================ FILE: backend/src/plugins/ModActions/functions/kickMember.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { LogType } from "../../../data/LogType.js"; import { renderTemplate, TemplateParseError, TemplateSafeValueContainer } from "../../../templateFormatter.js"; import { createUserNotificationError, notifyUser, resolveUser, ucfirst, UserNotificationResult, } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { IgnoredEventType, KickOptions, KickResult, ModActionsPluginType } from "../types.js"; import { getDefaultContactMethods } from "./getDefaultContactMethods.js"; import { ignoreEvent } from "./ignoreEvent.js"; /** * Kick the specified server member. Generates a case. */ export async function kickMember( pluginData: GuildPluginData, member: GuildMember, reason?: string, reasonWithAttachments?: string, kickOptions: KickOptions = {}, ): Promise { const config = pluginData.config.get(); // Attempt to message the user *before* kicking them, as doing it after may not be possible let notifyResult: UserNotificationResult = { method: null, success: true }; if (reasonWithAttachments && member) { const contactMethods = kickOptions?.contactMethods ? kickOptions.contactMethods : getDefaultContactMethods(pluginData, "kick"); if (contactMethods.length) { if (config.kick_message) { let kickMessage: string; try { kickMessage = await renderTemplate( config.kick_message, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, reason: reasonWithAttachments, moderator: kickOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, kickOptions.caseArgs.modId, "ModActions:kickMember")) : null, }), ); } catch (err) { if (err instanceof TemplateParseError) { return { status: "failed", error: `Invalid kick_message format: ${err.message}`, }; } throw err; } notifyResult = await notifyUser(member.user, kickMessage, contactMethods); } else { notifyResult = createUserNotificationError("No kick message specified in the config"); } } } // Kick the user pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_KICK, member.id); ignoreEvent(pluginData, IgnoredEventType.Kick, member.id); try { await member.kick(reason ?? undefined); } catch (e) { return { status: "failed", error: e.message, }; } const modId = kickOptions.caseArgs?.modId || pluginData.client.user!.id; // Create a case for this action const casesPlugin = pluginData.getPlugin(CasesPlugin); const createdCase = await casesPlugin.createCase({ ...(kickOptions.caseArgs || {}), userId: member.id, modId, type: CaseTypes.Kick, reason, noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], }); // Log the action const mod = await resolveUser(pluginData.client, modId, "ModActions:kickMember"); pluginData.getPlugin(LogsPlugin).logMemberKick({ mod, user: member.user, caseNumber: createdCase.case_number, reason: reason ?? "", }); pluginData.state.events.emit("kick", member.id, reason, kickOptions.isAutomodAction); return { status: "success", case: createdCase, notifyResult, }; } ================================================ FILE: backend/src/plugins/ModActions/functions/offModActionsEvent.ts ================================================ import { GuildPluginData } from "vety"; import { ModActionsEvents, ModActionsPluginType } from "../types.js"; export function offModActionsEvent( pluginData: GuildPluginData, event: TEvent, listener: ModActionsEvents[TEvent], ) { return pluginData.state.events.off(event, listener); } ================================================ FILE: backend/src/plugins/ModActions/functions/onModActionsEvent.ts ================================================ import { GuildPluginData } from "vety"; import { ModActionsEvents, ModActionsPluginType } from "../types.js"; export function onModActionsEvent( pluginData: GuildPluginData, event: TEvent, listener: ModActionsEvents[TEvent], ) { return pluginData.state.events.on(event, listener); } ================================================ FILE: backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts ================================================ import { GuildTextBasedChannel } from "discord.js"; import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils.js"; export function readContactMethodsFromArgs(args: { notify?: string | null; "notify-channel"?: GuildTextBasedChannel | null; }): null | UserNotificationMethod[] { if (args.notify) { if (args.notify === "dm") { return [{ type: "dm" }]; } else if (args.notify === "channel") { if (!args["notify-channel"]) { throw new Error("No `-notify-channel` specified"); } return [{ type: "channel", channel: args["notify-channel"] }]; } else if (disableUserNotificationStrings.includes(args.notify)) { return []; } else { throw new Error("Unknown contact method"); } } return null; } ================================================ FILE: backend/src/plugins/ModActions/functions/updateCase.ts ================================================ import { Attachment, ChatInputCommandInteraction, Message, User } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { Case } from "../../../data/entities/Case.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { ModActionsPluginType } from "../types.js"; import { handleAttachmentLinkDetectionAndGetRestriction } from "./attachmentLinkReaction.js"; import { formatReasonWithMessageLinkForAttachments } from "./formatReasonForAttachments.js"; export async function updateCase( pluginData: GuildPluginData, context: Message | ChatInputCommandInteraction, author: User, caseNumber?: number | null, note = "", attachments: Attachment[] = [], ) { let theCase: Case | null; if (caseNumber != null) { theCase = await pluginData.state.cases.findByCaseNumber(caseNumber); } else { theCase = await pluginData.state.cases.findLatestByModId(author.id); } if (!theCase) { pluginData.state.common.sendErrorMessage(context, "Case not found"); return; } if (note.length === 0 && attachments.length === 0) { pluginData.state.common.sendErrorMessage(context, "Text or attachment required"); return; } if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, note)) { return; } const formattedNote = await formatReasonWithMessageLinkForAttachments(pluginData, note, context, attachments); const casesPlugin = pluginData.getPlugin(CasesPlugin); await casesPlugin.createCaseNote({ caseId: theCase.id, modId: author.id, body: formattedNote, }); pluginData.getPlugin(LogsPlugin).logCaseUpdate({ mod: author, caseNumber: theCase.case_number, caseType: CaseTypes[theCase.type], note: formattedNote, }); pluginData.state.common.sendSuccessMessage(context, `Case \`#${theCase.case_number}\` updated`); } ================================================ FILE: backend/src/plugins/ModActions/functions/warnMember.ts ================================================ import { GuildMember, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { UserNotificationResult, createUserNotificationError, notifyUser, resolveUser, ucfirst, } from "../../../utils.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { waitForButtonConfirm } from "../../../utils/waitForInteraction.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { ModActionsPluginType, WarnOptions, WarnResult } from "../types.js"; import { getDefaultContactMethods } from "./getDefaultContactMethods.js"; export async function warnMember( pluginData: GuildPluginData, member: GuildMember, reason: string, reasonWithAttachments: string, warnOptions: WarnOptions = {}, ): Promise { const config = pluginData.config.get(); let notifyResult: UserNotificationResult; if (config.warn_message) { let warnMessage: string; try { warnMessage = await renderTemplate( config.warn_message, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, reason: reasonWithAttachments, moderator: warnOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, warnOptions.caseArgs.modId, "ModActions:warnMember")) : null, }), ); } catch (err) { if (err instanceof TemplateParseError) { return { status: "failed", error: `Invalid warn_message format: ${err.message}`, }; } throw err; } const contactMethods = warnOptions?.contactMethods ? warnOptions.contactMethods : getDefaultContactMethods(pluginData, "warn"); notifyResult = await notifyUser(member.user, warnMessage, contactMethods); } else { notifyResult = createUserNotificationError("No warn message specified in config"); } if (!notifyResult.success) { if (!warnOptions.retryPromptContext) { return { status: "failed", error: "Failed to message user", }; } const reply = await waitForButtonConfirm( warnOptions.retryPromptContext, { content: "Failed to message the user. Log the warning anyway?" }, { confirmText: "Yes", cancelText: "No", restrictToId: warnOptions.caseArgs?.modId }, ); if (!reply) { return { status: "failed", error: "Failed to message user", }; } } const modId = warnOptions.caseArgs?.modId ?? pluginData.client.user!.id; const casesPlugin = pluginData.getPlugin(CasesPlugin); const createdCase = await casesPlugin.createCase({ ...(warnOptions.caseArgs || {}), userId: member.id, modId, type: CaseTypes.Warn, reason, noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], }); const mod = await pluginData.guild.members.fetch(modId as Snowflake); pluginData.getPlugin(LogsPlugin).logMemberWarn({ mod, member, caseNumber: createdCase.case_number, reason: reason ?? "", }); pluginData.state.events.emit("warn", member.id, reason, warnOptions.isAutomodAction); return { status: "success", case: createdCase, notifyResult, }; } ================================================ FILE: backend/src/plugins/ModActions/types.ts ================================================ import { ChatInputCommandInteraction, Message } from "discord.js"; import { EventEmitter } from "events"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, guildPluginSlashCommand, guildPluginSlashGroup, pluginUtils, } from "vety"; import { z } from "zod"; import { Queue } from "../../Queue.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildMutes } from "../../data/GuildMutes.js"; import { GuildTempbans } from "../../data/GuildTempbans.js"; import { Case } from "../../data/entities/Case.js"; import { UserNotificationMethod, UserNotificationResult } from "../../utils.js"; import { CaseArgs } from "../Cases/types.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export type AttachmentLinkReactionType = "none" | "warn" | "restrict" | null; export const zModActionsConfig = z.strictObject({ dm_on_warn: z.boolean().default(true), dm_on_kick: z.boolean().default(false), dm_on_ban: z.boolean().default(false), message_on_warn: z.boolean().default(false), message_on_kick: z.boolean().default(false), message_on_ban: z.boolean().default(false), message_channel: z.nullable(z.string()).default(null), warn_message: z.nullable(z.string()).default("You have received a warning on the {guildName} server: {reason}"), kick_message: z .nullable(z.string()) .default("You have been kicked from the {guildName} server. Reason given: {reason}"), ban_message: z .nullable(z.string()) .default("You have been banned from the {guildName} server. Reason given: {reason}"), tempban_message: z .nullable(z.string()) .default("You have been banned from the {guildName} server for {banTime}. Reason given: {reason}"), alert_on_rejoin: z.boolean().default(false), alert_channel: z.nullable(z.string()).default(null), warn_notify_enabled: z.boolean().default(false), warn_notify_threshold: z.number().default(5), warn_notify_message: z .string() .default( "The user already has **{priorWarnings}** warnings!\n Please check their prior cases and assess whether or not to warn anyways.\n Proceed with the warning?", ), ban_delete_message_days: z.number().default(1), attachment_link_reaction: z .nullable(z.union([z.literal("none"), z.literal("warn"), z.literal("restrict")])) .default("warn"), can_note: z.boolean().default(false), can_warn: z.boolean().default(false), can_mute: z.boolean().default(false), can_kick: z.boolean().default(false), can_ban: z.boolean().default(false), can_unban: z.boolean().default(false), can_view: z.boolean().default(false), can_addcase: z.boolean().default(false), can_massunban: z.boolean().default(false), can_massban: z.boolean().default(false), can_massmute: z.boolean().default(false), can_hidecase: z.boolean().default(false), can_deletecase: z.boolean().default(false), can_act_as_other: z.boolean().default(false), create_cases_for_manual_actions: z.boolean().default(true), }); export interface ModActionsEvents { note: (userId: string, reason?: string) => void; warn: (userId: string, reason?: string, isAutomodAction?: boolean) => void; kick: (userId: string, reason?: string, isAutomodAction?: boolean) => void; ban: (userId: string, reason?: string, isAutomodAction?: boolean) => void; unban: (userId: string, reason?: string) => void; // mute/unmute are in the Mutes plugin } export interface ModActionsEventEmitter extends EventEmitter { on(event: U, listener: ModActionsEvents[U]): this; emit(event: U, ...args: Parameters): boolean; } export interface ModActionsPluginType extends BasePluginType { configSchema: typeof zModActionsConfig; state: { mutes: GuildMutes; cases: GuildCases; tempbans: GuildTempbans; serverLogs: GuildLogs; unloaded: boolean; unregisterGuildEventListener: () => void; ignoredEvents: IIgnoredEvent[]; massbanQueue: Queue; events: ModActionsEventEmitter; common: pluginUtils.PluginPublicInterface; }; } export enum IgnoredEventType { Ban = 1, Unban, Kick, } export interface IIgnoredEvent { type: IgnoredEventType; userId: string; } export type WarnResult = | { status: "failed"; error: string; } | { status: "success"; case: Case; notifyResult: UserNotificationResult; }; export type KickResult = | { status: "failed"; error: string; } | { status: "success"; case: Case; notifyResult: UserNotificationResult; }; export type BanResult = | { status: "failed"; error: string; } | { status: "success"; case: Case; notifyResult: UserNotificationResult; }; export type WarnMemberNotifyRetryCallback = () => boolean | Promise; export interface WarnOptions { caseArgs?: Partial | null; contactMethods?: UserNotificationMethod[] | null; retryPromptContext?: Message | ChatInputCommandInteraction | null; isAutomodAction?: boolean; } export interface KickOptions { caseArgs?: Partial; contactMethods?: UserNotificationMethod[]; isAutomodAction?: boolean; } export interface BanOptions { caseArgs?: Partial; contactMethods?: UserNotificationMethod[]; deleteMessageDays?: number; modId?: string; isAutomodAction?: boolean; } export type ModActionType = "note" | "warn" | "mute" | "unmute" | "kick" | "ban" | "unban"; export const modActionsMsgCmd = guildPluginMessageCommand(); export const modActionsSlashGroup = guildPluginSlashGroup(); export const modActionsSlashCmd = guildPluginSlashCommand(); export const modActionsEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/Mutes/MutesPlugin.ts ================================================ import { GuildMember, Snowflake } from "discord.js"; import { EventEmitter } from "events"; import { guildPlugin } from "vety"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { onGuildEvent } from "../../data/GuildEvents.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildMutes } from "../../data/GuildMutes.js"; import { makePublicFn } from "../../pluginUtils.js"; import { CasesPlugin } from "../Cases/CasesPlugin.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin.js"; import { ClearBannedMutesCmd } from "./commands/ClearBannedMutesCmd.js"; import { ClearMutesCmd } from "./commands/ClearMutesCmd.js"; import { ClearMutesWithoutRoleCmd } from "./commands/ClearMutesWithoutRoleCmd.js"; import { MutesCmd } from "./commands/MutesCmd.js"; import { ClearActiveMuteOnMemberBanEvt } from "./events/ClearActiveMuteOnMemberBanEvt.js"; import { ReapplyActiveMuteOnJoinEvt } from "./events/ReapplyActiveMuteOnJoinEvt.js"; import { RegisterManualTimeoutsEvt } from "./events/RegisterManualTimeoutsEvt.js"; import { clearMute } from "./functions/clearMute.js"; import { muteUser } from "./functions/muteUser.js"; import { offMutesEvent } from "./functions/offMutesEvent.js"; import { onMutesEvent } from "./functions/onMutesEvent.js"; import { renewTimeoutMute } from "./functions/renewTimeoutMute.js"; import { unmuteUser } from "./functions/unmuteUser.js"; import { MutesPluginType, zMutesConfig } from "./types.js"; export const MutesPlugin = guildPlugin()({ name: "mutes", dependencies: () => [CasesPlugin, LogsPlugin, RoleManagerPlugin], configSchema: zMutesConfig, defaultOverrides: [ { level: ">=50", config: { can_view_list: true, }, }, { level: ">=100", config: { can_cleanup: true, }, }, ], // prettier-ignore messageCommands: [ MutesCmd, ClearBannedMutesCmd, ClearMutesWithoutRoleCmd, ClearMutesCmd, ], // prettier-ignore events: [ // ClearActiveMuteOnRoleRemovalEvt, // FIXME: Temporarily disabled for performance ClearActiveMuteOnMemberBanEvt, ReapplyActiveMuteOnJoinEvt, RegisterManualTimeoutsEvt, ], public(pluginData) { return { muteUser: makePublicFn(pluginData, muteUser), unmuteUser: makePublicFn(pluginData, unmuteUser), hasMutedRole: (member: GuildMember) => { const muteRole = pluginData.config.get().mute_role; return muteRole ? member.roles.cache.has(muteRole as Snowflake) : false; }, on: makePublicFn(pluginData, onMutesEvent), off: makePublicFn(pluginData, offMutesEvent), getEventEmitter: () => pluginData.state.events, }; }, beforeLoad(pluginData) { const { state, guild } = pluginData; state.mutes = GuildMutes.getGuildInstance(guild.id); state.cases = GuildCases.getGuildInstance(guild.id); state.serverLogs = new GuildLogs(guild.id); state.archives = GuildArchives.getGuildInstance(guild.id); state.events = new EventEmitter(); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, afterLoad(pluginData) { const { state, guild } = pluginData; state.unregisterExpiredRoleMuteListener = onGuildEvent(guild.id, "expiredMute", (mute) => clearMute(pluginData, mute), ); state.unregisterTimeoutMuteToRenewListener = onGuildEvent(guild.id, "timeoutMuteToRenew", (mute) => renewTimeoutMute(pluginData, mute), ); const muteRole = pluginData.config.get().mute_role; if (muteRole) { state.mutes.fillMissingMuteRole(muteRole); } }, beforeUnload(pluginData) { const { state } = pluginData; state.unregisterExpiredRoleMuteListener?.(); state.unregisterTimeoutMuteToRenewListener?.(); state.events.removeAllListeners(); }, }); ================================================ FILE: backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts ================================================ import { Snowflake } from "discord.js"; import { mutesCmd } from "../types.js"; export const ClearBannedMutesCmd = mutesCmd({ trigger: "clear_banned_mutes", permission: "can_cleanup", description: "Clear dangling mutes for members who have been banned", async run({ pluginData, message: msg }) { await msg.channel.send("Clearing mutes from banned users..."); const activeMutes = await pluginData.state.mutes.getActiveMutes(); const bans = await pluginData.guild.bans.fetch({ cache: true }); const bannedIds = bans.map((b) => b.user.id); await msg.channel.send(`Found ${activeMutes.length} mutes and ${bannedIds.length} bans, cross-referencing...`); let cleared = 0; for (const mute of activeMutes) { if (bannedIds.includes(mute.user_id as Snowflake)) { await pluginData.state.mutes.clear(mute.user_id); cleared++; } } void pluginData.state.common.sendSuccessMessage(msg, `Cleared ${cleared} mutes from banned users!`); }, }); ================================================ FILE: backend/src/plugins/Mutes/commands/ClearMutesCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { mutesCmd } from "../types.js"; export const ClearMutesCmd = mutesCmd({ trigger: "clear_mutes", permission: "can_cleanup", description: "Clear dangling mute records from the bot. Be careful not to clear valid mutes.", signature: { userIds: ct.string({ rest: true }), }, async run({ pluginData, message: msg, args }) { const failed: string[] = []; for (const id of args.userIds) { const mute = await pluginData.state.mutes.findExistingMuteForUserId(id); if (!mute) { failed.push(id); continue; } await pluginData.state.mutes.clear(id); } if (failed.length !== args.userIds.length) { void pluginData.state.common.sendSuccessMessage( msg, `**${args.userIds.length - failed.length} active mute(s) cleared**`, ); } if (failed.length) { void pluginData.state.common.sendErrorMessage( msg, `**${failed.length}/${args.userIds.length} IDs failed**, they are not muted: ${failed.join(" ")}`, ); } }, }); ================================================ FILE: backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts ================================================ import { Snowflake } from "discord.js"; import { resolveMember } from "../../../utils.js"; import { mutesCmd } from "../types.js"; export const ClearMutesWithoutRoleCmd = mutesCmd({ trigger: "clear_mutes_without_role", permission: "can_cleanup", description: "Clear dangling mutes for members whose mute role was removed by other means", async run({ pluginData, message: msg }) { const activeMutes = await pluginData.state.mutes.getActiveMutes(); const muteRole = pluginData.config.get().mute_role; if (!muteRole) return; await msg.channel.send("Clearing mutes from members that don't have the mute role..."); let cleared = 0; for (const mute of activeMutes) { const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id); if (!member) continue; if (!member.roles.cache.has(muteRole as Snowflake)) { await pluginData.state.mutes.clear(mute.user_id); cleared++; } } void pluginData.state.common.sendSuccessMessage( msg, `Cleared ${cleared} mutes from members that don't have the mute role`, ); }, }); ================================================ FILE: backend/src/plugins/Mutes/commands/MutesCmd.ts ================================================ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, GuildMember, MessageComponentInteraction, Snowflake, } from "discord.js"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { humanizeDurationShort } from "../../../humanizeDuration.js"; import { getBaseUrl } from "../../../pluginUtils.js"; import { DBDateFormat, MINUTES, renderUsername, resolveMember } from "../../../utils.js"; import { IMuteWithDetails, mutesCmd } from "../types.js"; export const MutesCmd = mutesCmd({ trigger: "mutes", permission: "can_view_list", signature: { age: ct.delay({ option: true, shortcut: "a", }), left: ct.switchOption({ def: false, shortcut: "l" }), manual: ct.switchOption({ def: false, shortcut: "m" }), export: ct.switchOption({ def: false, shortcut: "e" }), }, async run({ pluginData, message: msg, args }) { const listMessagePromise = msg.channel.send("Loading mutes..."); const mutesPerPage = 10; let totalMutes = 0; let hasFilters = false; let stopCollectionFn = () => { return; }; let stopCollectionTimeout: NodeJS.Timeout; const stopCollectionDebounce = 5 * MINUTES; const bumpCollectionTimeout = () => { clearTimeout(stopCollectionTimeout); stopCollectionTimeout = setTimeout(stopCollectionFn, stopCollectionDebounce); }; let lines: string[] = []; // Active, logged mutes const activeMutes = await pluginData.state.mutes.getActiveMutes(); activeMutes.sort((a, b) => { if (a.expires_at == null && b.expires_at != null) return 1; if (b.expires_at == null && a.expires_at != null) return -1; if (a.expires_at == null && b.expires_at == null) { return a.created_at > b.created_at ? -1 : 1; } return a.expires_at! > b.expires_at! ? 1 : -1; }); if (args.manual) { // Show only manual mutes (i.e. "Muted" role added without a logged mute) const muteUserIds = new Set(activeMutes.map((m) => m.user_id)); const manuallyMutedMembers: GuildMember[] = []; const muteRole = pluginData.config.get().mute_role; if (muteRole) { pluginData.guild.members.cache.forEach((member) => { if (muteUserIds.has(member.id)) return; if (member.roles.cache.has(muteRole as Snowflake)) manuallyMutedMembers.push(member); }); } totalMutes = manuallyMutedMembers.length; lines = manuallyMutedMembers.map((member) => { return `<@!${member.id}> (**${renderUsername(member)}**, \`${member.id}\`) 🔧 Manual mute`; }); } else { // Show filtered active mutes (but not manual mutes) let filteredMutes: IMuteWithDetails[] = activeMutes; let bannedIds: string[] | null = null; // Filter: mute age if (args.age) { const cutoff = moment.utc().subtract(args.age, "ms").format(DBDateFormat); filteredMutes = filteredMutes.filter((m) => m.created_at <= cutoff); hasFilters = true; } // Fetch some extra details for each mute: the muted member, and whether they've been banned for (const [index, mute] of filteredMutes.entries()) { const muteWithDetails = { ...mute }; const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id); if (!member) { if (!bannedIds) { const bans = await pluginData.guild.bans.fetch({ cache: true }); bannedIds = bans.map((u) => u.user.id); } muteWithDetails.banned = bannedIds.includes(mute.user_id); } else { muteWithDetails.member = member; } filteredMutes[index] = muteWithDetails; } // Filter: left the server if (args.left != null) { filteredMutes = filteredMutes.filter((m) => (args.left && !m.member) || (!args.left && m.member)); hasFilters = true; } totalMutes = filteredMutes.length; // Create a message line for each mute const caseIds = filteredMutes.map((m) => m.case_id).filter((v) => !!v); const muteCases = caseIds.length ? await pluginData.state.cases.get(caseIds) : []; const muteCasesById = muteCases.reduce((map, c) => map.set(c.id, c), new Map()); lines = filteredMutes.map((mute) => { const user = pluginData.client.users.resolve(mute.user_id as Snowflake); const username = user ? renderUsername(user) : "Unknown#0000"; const theCase = muteCasesById.get(mute.case_id); const caseName = theCase ? `Case #${theCase.case_number}` : "No case"; let line = `<@!${mute.user_id}> (**${username}**, \`${mute.user_id}\`) 📋 ${caseName}`; if (mute.expires_at) { const timeUntilExpiry = moment.utc().diff(moment.utc(mute.expires_at, DBDateFormat)); const humanizedTime = humanizeDurationShort(timeUntilExpiry, { largest: 2, round: true }); line += ` ⏰ Expires in ${humanizedTime}`; } else { line += ` ⏰ Indefinite`; } const timeFromMute = moment.utc(mute.created_at, DBDateFormat).diff(moment.utc()); const humanizedTimeFromMute = humanizeDurationShort(timeFromMute, { largest: 2, round: true }); line += ` 🕒 Muted ${humanizedTimeFromMute} ago`; if (mute.banned) { line += ` 🔨 Banned`; } else if (!mute.member) { line += ` ❌ Left server`; } return line; }); } const listMessage = await listMessagePromise; let currentPage = 1; const totalPages = Math.ceil(lines.length / mutesPerPage); const drawListPage = async (page) => { page = Math.max(1, Math.min(totalPages, page)); currentPage = page; const pageStart = (page - 1) * mutesPerPage; const pageLines = lines.slice(pageStart, pageStart + mutesPerPage); const pageRangeText = `${pageStart + 1}–${pageStart + pageLines.length} of ${totalMutes}`; let message; if (args.manual) { message = `Showing manual mutes ${pageRangeText}:`; } else if (hasFilters) { message = `Showing filtered active mutes ${pageRangeText}:`; } else { message = `Showing active mutes ${pageRangeText}:`; } message += "\n\n" + pageLines.join("\n"); listMessage.edit(message); bumpCollectionTimeout(); }; if (totalMutes === 0) { if (args.manual) { listMessage.edit("No manual mutes found!"); } else if (hasFilters) { listMessage.edit("No mutes found with the specified filters!"); } else { listMessage.edit("No active mutes!"); } } else if (args.export) { const archiveId = await pluginData.state.archives.create(lines.join("\n"), moment.utc().add(1, "hour")); const baseUrl = getBaseUrl(pluginData); const url = await pluginData.state.archives.getUrl(baseUrl, archiveId); await listMessage.edit(`Exported mutes: ${url}`); } else { drawListPage(1); if (totalPages > 1) { const idMod = `${listMessage.id}:muteList`; const buttons = [ new ButtonBuilder().setStyle(ButtonStyle.Secondary).setEmoji("⬅").setCustomId(`previousButton:${idMod}`), new ButtonBuilder().setStyle(ButtonStyle.Secondary).setEmoji("➡").setCustomId(`nextButton:${idMod}`), ] satisfies ButtonBuilder[]; const row = new ActionRowBuilder().addComponents(buttons); await listMessage.edit({ components: [row] }); const collector = listMessage.createMessageComponentCollector({ time: stopCollectionDebounce }); collector.on("collect", async (interaction: MessageComponentInteraction) => { if (msg.author.id !== interaction.user.id) { interaction .reply({ content: `You are not permitted to use these buttons.`, ephemeral: true }) // tslint:disable-next-line no-console .catch((err) => console.trace(err.message)); } else { collector.resetTimer(); await interaction.deferUpdate(); if (interaction.customId === `previousButton:${idMod}` && currentPage > 1) { await drawListPage(currentPage - 1); } else if (interaction.customId === `nextButton:${idMod}` && currentPage < totalPages) { await drawListPage(currentPage + 1); } } }); stopCollectionFn = async () => { collector.stop(); await listMessage.edit({ content: listMessage.content, components: [] }); }; bumpCollectionTimeout(); } } }, }); ================================================ FILE: backend/src/plugins/Mutes/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zMutesConfig } from "./types.js"; export const mutesPluginDocs: ZeppelinPluginDocs = { prettyName: "Mutes", type: "stable", configSchema: zMutesConfig, }; ================================================ FILE: backend/src/plugins/Mutes/events/ClearActiveMuteOnMemberBanEvt.ts ================================================ import { mutesEvt } from "../types.js"; /** * Clear active mute from the member if the member is banned */ export const ClearActiveMuteOnMemberBanEvt = mutesEvt({ event: "guildBanAdd", async listener({ pluginData, args: { ban } }) { const mute = await pluginData.state.mutes.findExistingMuteForUserId(ban.user.id); if (mute) { pluginData.state.mutes.clear(ban.user.id); } }, }); ================================================ FILE: backend/src/plugins/Mutes/events/ClearActiveMuteOnRoleRemovalEvt.ts ================================================ import { memberHasMutedRole } from "../functions/memberHasMutedRole.js"; import { mutesEvt } from "../types.js"; /** * Clear active mute if the mute role is removed manually */ export const ClearActiveMuteOnRoleRemovalEvt = mutesEvt({ event: "guildMemberUpdate", async listener({ pluginData, args: { newMember: member } }) { const muteRole = pluginData.config.get().mute_role; if (!muteRole) return; const mute = await pluginData.state.mutes.findExistingMuteForUserId(member.id); if (!mute) return; if (!memberHasMutedRole(pluginData, member)) { await pluginData.state.mutes.clear(muteRole); } }, }); ================================================ FILE: backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts ================================================ import moment from "moment-timezone"; import { MuteTypes } from "../../../data/MuteTypes.js"; import { noop } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; import { getTimeoutExpiryTime } from "../functions/getTimeoutExpiryTime.js"; import { mutesEvt } from "../types.js"; /** * Reapply active mutes on join */ export const ReapplyActiveMuteOnJoinEvt = mutesEvt({ event: "guildMemberAdd", async listener({ pluginData, args: { member } }) { const mute = await pluginData.state.mutes.findExistingMuteForUserId(member.id); const logs = pluginData.getPlugin(LogsPlugin); if (!mute) { return; } if (mute.type === MuteTypes.Role) { const muteRoleId = pluginData.config.get().mute_role; if (muteRoleId) { pluginData.getPlugin(RoleManagerPlugin).addPriorityRole(member.id, muteRoleId); } } else { if (!member.isCommunicationDisabled()) { const expiresAt = mute.expires_at ? moment.utc(mute.expires_at).valueOf() : null; const timeoutExpiresAt = getTimeoutExpiryTime(expiresAt); if (member.moderatable) { await member.disableCommunicationUntil(timeoutExpiresAt).catch(noop); } else { logs.logBotAlert({ body: `Cannot mute user, specified user is not moderatable`, }); } } } logs.logMemberMuteRejoin({ member, }); }, }); ================================================ FILE: backend/src/plugins/Mutes/events/RegisterManualTimeoutsEvt.ts ================================================ import { AuditLogChange, AuditLogEvent } from "discord.js"; import moment from "moment-timezone"; import { MuteTypes } from "../../../data/MuteTypes.js"; import { resolveUser } from "../../../utils.js"; import { mutesEvt } from "../types.js"; export const RegisterManualTimeoutsEvt = mutesEvt({ event: "guildAuditLogEntryCreate", async listener({ pluginData, args: { auditLogEntry } }) { // Ignore the bot's own audit log events if (auditLogEntry.executorId === pluginData.client.user?.id) { return; } if (auditLogEntry.action !== AuditLogEvent.MemberUpdate) { return; } const target = await resolveUser(pluginData.client, auditLogEntry.targetId!, "Mutes:RegisterManualTimeoutsEvt"); // Only act based on the last changes in this log let lastTimeoutChange: AuditLogChange | null = null; for (const change of auditLogEntry.changes) { if (change.key === "communication_disabled_until") { lastTimeoutChange = change; } } if (!lastTimeoutChange) { return; } const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(target.id); if (lastTimeoutChange.new == null && existingMute) { await pluginData.state.mutes.clear(target.id); return; } if (lastTimeoutChange.new != null) { const expiresAtTimestamp = moment.utc(lastTimeoutChange.new as string).valueOf(); if (existingMute) { await pluginData.state.mutes.updateExpiresAt(target.id, expiresAtTimestamp); } else { await pluginData.state.mutes.addMute({ userId: target.id, type: MuteTypes.Timeout, expiresAt: expiresAtTimestamp, timeoutExpiresAt: expiresAtTimestamp, }); } } }, }); ================================================ FILE: backend/src/plugins/Mutes/functions/clearMute.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { MuteTypes } from "../../../data/MuteTypes.js"; import { Mute } from "../../../data/entities/Mute.js"; import { clearExpiringMute } from "../../../data/loops/expiringMutesLoop.js"; import { resolveMember, verboseUserMention } from "../../../utils.js"; import { memberRolesLock } from "../../../utils/lockNameHelpers.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; import { MutesPluginType } from "../types.js"; export async function clearMute( pluginData: GuildPluginData, mute: Mute | null = null, member: GuildMember | null = null, ) { if (mute) { clearExpiringMute(mute); } if (!member && mute) { member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true); } if (member) { const lock = await pluginData.locks.acquire(memberRolesLock(member)); const roleManagerPlugin = pluginData.getPlugin(RoleManagerPlugin); try { const defaultMuteRole = pluginData.config.get().mute_role; if (mute) { const muteRoleId = mute.mute_role || defaultMuteRole; if (mute.type === MuteTypes.Role) { if (muteRoleId) { roleManagerPlugin.removePriorityRole(member.id, muteRoleId); } } else { await member.timeout(null); } if (mute.roles_to_restore) { const guildRoles = pluginData.guild.roles.cache; for (const roleIdToRestore of mute?.roles_to_restore ?? []) { if (guildRoles.has(roleIdToRestore) && roleIdToRestore !== muteRoleId) { roleManagerPlugin.addRole(member.id, roleIdToRestore); } } } } else { // Unmuting someone without an active mute -> remove timeouts and/or mute role const muteRole = defaultMuteRole; if (muteRole && member.roles.cache.has(muteRole)) { roleManagerPlugin.removePriorityRole(member.id, muteRole); } if (member.isCommunicationDisabled()) { await member.timeout(null); } } pluginData.getPlugin(LogsPlugin).logMemberMuteExpired({ member }); } catch { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to clear mute from ${verboseUserMention(member.user)}`, }); } finally { lock.unlock(); } } if (mute) { await pluginData.state.mutes.clear(mute.user_id); } } ================================================ FILE: backend/src/plugins/Mutes/functions/getDefaultMuteType.ts ================================================ import { GuildPluginData } from "vety"; import { MuteTypes } from "../../../data/MuteTypes.js"; import { MutesPluginType } from "../types.js"; export function getDefaultMuteType(pluginData: GuildPluginData): MuteTypes { const muteRole = pluginData.config.get().mute_role; return muteRole ? MuteTypes.Role : MuteTypes.Timeout; } ================================================ FILE: backend/src/plugins/Mutes/functions/getTimeoutExpiryTime.ts ================================================ import { MAX_TIMEOUT_DURATION } from "../../../data/Mutes.js"; /** * Since timeouts have a limited duration (max 28d) but we support mutes longer than that, * the timeouts are applied for a certain duration at first and then renewed as necessary. * This function returns the initial end time for a timeout. * @return - Timeout expiry timestamp */ export function getTimeoutExpiryTime(muteExpiresAt: number | null | undefined): number { if (muteExpiresAt && muteExpiresAt - Date.now() <= MAX_TIMEOUT_DURATION) { return muteExpiresAt; } return Date.now() + MAX_TIMEOUT_DURATION; } ================================================ FILE: backend/src/plugins/Mutes/functions/memberHasMutedRole.ts ================================================ import { GuildMember, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { MutesPluginType } from "../types.js"; export function memberHasMutedRole(pluginData: GuildPluginData, member: GuildMember): boolean { const muteRole = pluginData.config.get().mute_role; return muteRole ? member.roles.cache.has(muteRole as Snowflake) : false; } ================================================ FILE: backend/src/plugins/Mutes/functions/muteUser.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { AddMuteParams } from "../../../data/GuildMutes.js"; import { MuteTypes } from "../../../data/MuteTypes.js"; import { Case } from "../../../data/entities/Case.js"; import { Mute } from "../../../data/entities/Mute.js"; import { registerExpiringMute } from "../../../data/loops/expiringMutesLoop.js"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin.js"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { UserNotificationMethod, UserNotificationResult, noop, notifyUser, resolveMember, resolveUser, ucfirst, } from "../../../utils.js"; import { muteLock } from "../../../utils/lockNameHelpers.js"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; import { MuteOptions, MutesPluginType } from "../types.js"; import { getDefaultMuteType } from "./getDefaultMuteType.js"; import { getTimeoutExpiryTime } from "./getTimeoutExpiryTime.js"; /** * TODO: Clean up this function */ export async function muteUser( pluginData: GuildPluginData, userId: string, muteTime?: number, reason?: string, reasonWithAttachments?: string, muteOptions: MuteOptions = {}, removeRolesOnMuteOverride: boolean | string[] | null = null, restoreRolesOnMuteOverride: boolean | string[] | null = null, ) { const lock = await pluginData.locks.acquire(muteLock({ id: userId })); const muteRole = pluginData.config.get().mute_role; const muteType = getDefaultMuteType(pluginData); const muteExpiresAt = muteTime ? Date.now() + muteTime : null; const timeoutUntil = getTimeoutExpiryTime(muteExpiresAt); // No mod specified -> mark Zeppelin as the mod if (!muteOptions.caseArgs?.modId) { muteOptions.caseArgs = muteOptions.caseArgs ?? {}; muteOptions.caseArgs.modId = pluginData.client.user!.id; } const user = await resolveUser(pluginData.client, userId, "Mutes:muteUser"); if (!user.id) { lock.unlock(); throw new RecoverablePluginError(ERRORS.INVALID_USER); } const member = await resolveMember(pluginData.client, pluginData.guild, user.id, true); // Grab the fresh member so we don't have stale role info const config = await pluginData.config.getMatchingConfig({ member, userId }); const logs = pluginData.getPlugin(LogsPlugin); let rolesToRestore: string[] = []; if (member) { // remove and store any roles to be removed/restored const currentUserRoles = [...member.roles.cache.keys()]; let newRoles: string[] = currentUserRoles; const removeRoles = removeRolesOnMuteOverride ?? config.remove_roles_on_mute; const restoreRoles = restoreRolesOnMuteOverride ?? config.restore_roles_on_mute; // Remove roles if (!Array.isArray(removeRoles)) { if (removeRoles) { // exclude managed roles from being removed const managedRoles = pluginData.guild.roles.cache.filter((x) => x.managed).map((y) => y.id); newRoles = currentUserRoles.filter((r) => managedRoles.includes(r)); await member.roles.set(newRoles as Snowflake[]); } } else { newRoles = currentUserRoles.filter((x) => !(removeRoles).includes(x)); await member.roles.set(newRoles as Snowflake[]); } // Set roles to be restored if (!Array.isArray(restoreRoles)) { if (restoreRoles) { rolesToRestore = currentUserRoles; } } else { rolesToRestore = currentUserRoles.filter((x) => (restoreRoles).includes(x)); } if (muteType === MuteTypes.Role) { // Verify the configured mute role is valid const actualMuteRole = pluginData.guild.roles.cache.get(muteRole!); if (!actualMuteRole) { lock.unlock(); logs.logBotAlert({ body: `Cannot mute users, specified mute role Id is invalid`, }); throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID); } // Verify the mute role is not above Zep's roles const zep = await pluginData.guild.members.fetchMe(); const zepRoles = pluginData.guild.roles.cache.filter((x) => zep.roles.cache.has(x.id)); if (zepRoles.size === 0 || !zepRoles.some((zepRole) => zepRole.position > actualMuteRole.position)) { lock.unlock(); logs.logBotAlert({ body: `Cannot mute user, specified mute role is above Zeppelin in the role hierarchy`, }); throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP, pluginData.guild); } if (!currentUserRoles.includes(muteRole!)) { pluginData.getPlugin(RoleManagerPlugin).addPriorityRole(member.id, muteRole!); } } else { if (!member.manageable) { lock.unlock(); logs.logBotAlert({ body: `Cannot mute user, specified user is above Zeppelin in the role hierarchy`, }); throw new RecoverablePluginError(ERRORS.USER_ABOVE_ZEP, pluginData.guild); } if (!member.moderatable) { // redundant safety, since canActOn already checks this lock.unlock(); logs.logBotAlert({ body: `Cannot mute user, specified user is not moderatable`, }); throw new RecoverablePluginError(ERRORS.USER_NOT_MODERATABLE, pluginData.guild); } await member.disableCommunicationUntil(timeoutUntil).catch(noop); } // If enabled, move the user to the mute voice channel (e.g. afk - just to apply the voice perms from the mute role) const cfg = pluginData.config.get(); const moveToVoiceChannel = cfg.kick_from_voice_channel ? null : cfg.move_to_voice_channel; if (moveToVoiceChannel || cfg.kick_from_voice_channel) { // TODO: Add back the voiceState check once we figure out how to get voice state for guild members that are loaded on-demand try { await member.edit({ channel: moveToVoiceChannel as Snowflake }); } catch {} // eslint-disable-line no-empty } } // If the user is already muted, update the duration of their existing mute const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(user.id); let finalMute: Mute; let notifyResult: UserNotificationResult = { method: null, success: true }; if (existingMute) { if (existingMute.roles_to_restore?.length || rolesToRestore?.length) { rolesToRestore = Array.from(new Set([...existingMute.roles_to_restore, ...rolesToRestore])); } await pluginData.state.mutes.updateExpiryTime(user.id, muteTime, rolesToRestore); if (muteType === MuteTypes.Timeout) { await pluginData.state.mutes.updateTimeoutExpiresAt(user.id, timeoutUntil); } finalMute = (await pluginData.state.mutes.findExistingMuteForUserId(user.id))!; } else { const muteParams: AddMuteParams = { userId: user.id, type: muteType, expiresAt: muteExpiresAt, rolesToRestore, }; if (muteType === MuteTypes.Role) { muteParams.muteRole = muteRole; } else { muteParams.timeoutExpiresAt = timeoutUntil; } finalMute = await pluginData.state.mutes.addMute(muteParams); } registerExpiringMute(finalMute); const timeUntilUnmuteStr = muteTime ? humanizeDuration(muteTime) : "indefinite"; const template = existingMute ? config.update_mute_message : muteTime ? config.timed_mute_message : config.mute_message; let muteMessage: string | null = null; try { muteMessage = template && (await renderTemplate( template, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, reason: reasonWithAttachments || "None", time: timeUntilUnmuteStr, moderator: muteOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, muteOptions.caseArgs.modId, "Mutes:muteUser")) : null, }), )); } catch (err) { if (err instanceof TemplateParseError) { logs.logBotAlert({ body: `Invalid mute message format. The mute was still applied: ${err.message}`, }); } else { lock.unlock(); throw err; } } if (muteMessage && member) { let contactMethods: UserNotificationMethod[] = []; if (muteOptions?.contactMethods) { contactMethods = muteOptions.contactMethods; } else { const useDm = existingMute ? config.dm_on_update : config.dm_on_mute; if (useDm) { contactMethods.push({ type: "dm" }); } const useChannel = existingMute ? config.message_on_update : config.message_on_mute; const channel = config.message_channel ? pluginData.guild.channels.cache.get(config.message_channel as Snowflake) : null; if (useChannel && channel?.isTextBased()) { contactMethods.push({ type: "channel", channel }); } } notifyResult = await notifyUser(member.user, muteMessage, contactMethods); } // Create/update a case const casesPlugin = pluginData.getPlugin(CasesPlugin); let theCase: Case | null = existingMute && existingMute.case_id ? await pluginData.state.cases.find(existingMute.case_id) : null; if (theCase) { // Update old case const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmuteStr : "indefinite"}`]; const reasons = reason ? [reason] : [""]; // Empty string so that there is a case update even without reason if (muteOptions.caseArgs?.extraNotes) { reasons.push(...muteOptions.caseArgs.extraNotes); } for (const noteReason of reasons) { await casesPlugin.createCaseNote({ caseId: existingMute!.case_id, modId: muteOptions.caseArgs?.modId, body: noteReason, noteDetails, postInCaseLogOverride: muteOptions.caseArgs?.postInCaseLogOverride, }); } } else { // Create new case const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmuteStr}` : "indefinitely"}`]; if (notifyResult.text) { noteDetails.push(ucfirst(notifyResult.text)); } theCase = await casesPlugin.createCase({ ...(muteOptions.caseArgs || {}), userId, modId: muteOptions.caseArgs?.modId, type: CaseTypes.Mute, reason, noteDetails, }); await pluginData.state.mutes.setCaseId(user.id, theCase.id); } // Log the action const mod = await resolveUser(pluginData.client, muteOptions.caseArgs?.modId, "Mutes:muteUser"); if (muteTime) { pluginData.getPlugin(LogsPlugin).logMemberTimedMute({ mod, user, time: timeUntilUnmuteStr, caseNumber: theCase.case_number, reason: reason ?? "", }); } else { pluginData.getPlugin(LogsPlugin).logMemberMute({ mod, user, caseNumber: theCase.case_number, reason: reason ?? "", }); } lock.unlock(); pluginData.state.events.emit("mute", user.id, reason, muteOptions.isAutomodAction); return { case: theCase, notifyResult, updatedExistingMute: !!existingMute, }; } ================================================ FILE: backend/src/plugins/Mutes/functions/offMutesEvent.ts ================================================ import { GuildPluginData } from "vety"; import { MutesEvents, MutesPluginType } from "../types.js"; export function offMutesEvent( pluginData: GuildPluginData, event: TEvent, listener: MutesEvents[TEvent], ) { return pluginData.state.events.off(event, listener); } ================================================ FILE: backend/src/plugins/Mutes/functions/onMutesEvent.ts ================================================ import { GuildPluginData } from "vety"; import { MutesEvents, MutesPluginType } from "../types.js"; export function onMutesEvent( pluginData: GuildPluginData, event: TEvent, listener: MutesEvents[TEvent], ) { return pluginData.state.events.on(event, listener); } ================================================ FILE: backend/src/plugins/Mutes/functions/renewTimeoutMute.ts ================================================ import { PermissionFlagsBits } from "discord.js"; import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { MAX_TIMEOUT_DURATION } from "../../../data/Mutes.js"; import { Mute } from "../../../data/entities/Mute.js"; import { DBDateFormat, noop, resolveMember } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { MutesPluginType } from "../types.js"; export async function renewTimeoutMute(pluginData: GuildPluginData, mute: Mute) { const me = pluginData.client.user && (await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user.id)); if (!me || !me.permissions.has(PermissionFlagsBits.ModerateMembers)) { return; } const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true); if (!member) { return; } let newExpiryTime = moment.utc().add(MAX_TIMEOUT_DURATION).format(DBDateFormat); if (mute.expires_at && newExpiryTime > mute.expires_at) { newExpiryTime = mute.expires_at; } const expiryTimestamp = moment.utc(newExpiryTime).valueOf(); if (!member.moderatable) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Cannot renew user's timeout, specified user is not moderatable`, }); return; } await member.disableCommunicationUntil(expiryTimestamp).catch(noop); await pluginData.state.mutes.updateTimeoutExpiresAt(mute.user_id, expiryTimestamp); } ================================================ FILE: backend/src/plugins/Mutes/functions/unmuteUser.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { AddMuteParams } from "../../../data/GuildMutes.js"; import { MuteTypes } from "../../../data/MuteTypes.js"; import { Mute } from "../../../data/entities/Mute.js"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { noop, resolveMember, resolveUser } from "../../../utils.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { CaseArgs } from "../../Cases/types.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { MutesPluginType, UnmuteResult } from "../types.js"; import { clearMute } from "./clearMute.js"; import { getDefaultMuteType } from "./getDefaultMuteType.js"; import { getTimeoutExpiryTime } from "./getTimeoutExpiryTime.js"; import { memberHasMutedRole } from "./memberHasMutedRole.js"; export async function unmuteUser( pluginData: GuildPluginData, userId: string, unmuteTime?: number, caseArgs: Partial = {}, ): Promise { const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(userId); const user = await resolveUser(pluginData.client, userId, "Mutes:unmuteUser"); const member = await resolveMember(pluginData.client, pluginData.guild, userId, true); // Grab the fresh member so we don't have stale role info const modId = caseArgs.modId || pluginData.client.user!.id; if (!existingMute && member && !memberHasMutedRole(pluginData, member) && !member?.isCommunicationDisabled()) { return null; } if (unmuteTime) { // Schedule timed unmute (= just update the mute's duration) const muteExpiresAt = Date.now() + unmuteTime; const timeoutExpiresAt = getTimeoutExpiryTime(muteExpiresAt); let createdMute: Mute | null = null; if (!existingMute) { const defaultMuteType = getDefaultMuteType(pluginData); const muteParams: AddMuteParams = { userId, type: defaultMuteType, expiresAt: muteExpiresAt, }; if (defaultMuteType === MuteTypes.Role) { muteParams.muteRole = pluginData.config.get().mute_role; } else { muteParams.timeoutExpiresAt = timeoutExpiresAt; } createdMute = await pluginData.state.mutes.addMute(muteParams); } else { await pluginData.state.mutes.updateExpiryTime(userId, unmuteTime); } // Update timeout if (member && (existingMute?.type === MuteTypes.Timeout || createdMute?.type === MuteTypes.Timeout)) { if (!member.moderatable) return null; await member.disableCommunicationUntil(timeoutExpiresAt).catch(noop); await pluginData.state.mutes.updateTimeoutExpiresAt(userId, timeoutExpiresAt); } } else { // Unmute immediately clearMute(pluginData, existingMute); } const timeUntilUnmute = unmuteTime && humanizeDuration(unmuteTime); // Create a case const noteDetails: string[] = []; if (unmuteTime) { noteDetails.push(`Scheduled unmute in ${timeUntilUnmute}`); } else { noteDetails.push(`Unmuted immediately`); } if (!existingMute) { noteDetails.push(`Removed external mute`); } const casesPlugin = pluginData.getPlugin(CasesPlugin); const createdCase = await casesPlugin.createCase({ ...caseArgs, userId, modId, type: CaseTypes.Unmute, noteDetails, }); // Log the action const mod = await pluginData.client.users.fetch(modId as Snowflake); if (unmuteTime) { pluginData.getPlugin(LogsPlugin).logMemberTimedUnmute({ mod, user, caseNumber: createdCase.case_number, time: timeUntilUnmute, reason: caseArgs.reason ?? "", }); } else { pluginData.getPlugin(LogsPlugin).logMemberUnmute({ mod, user, caseNumber: createdCase.case_number, reason: caseArgs.reason ?? "", }); } if (!unmuteTime) { // If the member was unmuted, not just scheduled to be unmuted, fire the unmute event as well // Scheduled unmutes have their event fired in clearExpiredMutes() pluginData.state.events.emit("unmute", user.id, caseArgs.reason); } return { case: createdCase, }; } ================================================ FILE: backend/src/plugins/Mutes/types.ts ================================================ import { GuildMember } from "discord.js"; import { EventEmitter } from "events"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildMutes } from "../../data/GuildMutes.js"; import { Case } from "../../data/entities/Case.js"; import { Mute } from "../../data/entities/Mute.js"; import { UserNotificationMethod, UserNotificationResult, zSnowflake } from "../../utils.js"; import { CaseArgs } from "../Cases/types.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zMutesConfig = z.strictObject({ mute_role: zSnowflake.nullable().default(null), move_to_voice_channel: zSnowflake.nullable().default(null), kick_from_voice_channel: z.boolean().default(false), dm_on_mute: z.boolean().default(false), dm_on_update: z.boolean().default(false), message_on_mute: z.boolean().default(false), message_on_update: z.boolean().default(false), message_channel: z.string().nullable().default(null), mute_message: z.string().nullable().default("You have been muted on the {guildName} server. Reason given: {reason}"), timed_mute_message: z .string() .nullable() .default("You have been muted on the {guildName} server for {time}. Reason given: {reason}"), update_mute_message: z.string().nullable().default("Your mute on the {guildName} server has been updated to {time}."), remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false), restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false), can_view_list: z.boolean().default(false), can_cleanup: z.boolean().default(false), }); export interface MutesEvents { mute: (userId: string, reason?: string, isAutomodAction?: boolean) => void; unmute: (userId: string, reason?: string) => void; } export interface MutesEventEmitter extends EventEmitter { on(event: U, listener: MutesEvents[U]): this; emit(event: U, ...args: Parameters): boolean; } export interface MutesPluginType extends BasePluginType { configSchema: typeof zMutesConfig; state: { mutes: GuildMutes; cases: GuildCases; serverLogs: GuildLogs; archives: GuildArchives; unregisterExpiredRoleMuteListener: () => void; unregisterTimeoutMuteToRenewListener: () => void; events: MutesEventEmitter; common: pluginUtils.PluginPublicInterface; }; } export interface IMuteWithDetails extends Mute { member?: GuildMember; banned?: boolean; } export type MuteResult = { case: Case; notifyResult: UserNotificationResult; updatedExistingMute: boolean; }; export type UnmuteResult = { case: Case; }; export interface MuteOptions { caseArgs?: Partial; contactMethods?: UserNotificationMethod[]; isAutomodAction?: boolean; } export const mutesCmd = guildPluginMessageCommand(); export const mutesEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/NameHistory/NameHistoryPlugin.ts ================================================ import { guildPlugin } from "vety"; import { Queue } from "../../Queue.js"; import { GuildNicknameHistory } from "../../data/GuildNicknameHistory.js"; import { UsernameHistory } from "../../data/UsernameHistory.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { NamesCmd } from "./commands/NamesCmd.js"; import { NameHistoryPluginType, zNameHistoryConfig } from "./types.js"; export const NameHistoryPlugin = guildPlugin()({ name: "name_history", configSchema: zNameHistoryConfig, defaultOverrides: [ { level: ">=50", config: { can_view: true, }, }, ], // prettier-ignore messageCommands: [ NamesCmd, ], // prettier-ignore events: [ // FIXME: Temporary // ChannelJoinEvt, // MessageCreateEvt, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.nicknameHistory = GuildNicknameHistory.getGuildInstance(guild.id); state.usernameHistory = new UsernameHistory(); state.updateQueue = new Queue(); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, }); ================================================ FILE: backend/src/plugins/NameHistory/commands/NamesCmd.ts ================================================ import { Snowflake } from "discord.js"; import { createChunkedMessage, disableCodeBlocks } from "vety/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { MAX_NICKNAME_ENTRIES_PER_USER } from "../../../data/GuildNicknameHistory.js"; import { MAX_USERNAME_ENTRIES_PER_USER } from "../../../data/UsernameHistory.js"; import { NICKNAME_RETENTION_PERIOD } from "../../../data/cleanup/nicknames.js"; import { DAYS, renderUsername } from "../../../utils.js"; import { nameHistoryCmd } from "../types.js"; export const NamesCmd = nameHistoryCmd({ trigger: "names", permission: "can_view", signature: { userId: ct.userId(), }, async run({ message: msg, args, pluginData }) { const nicknames = await pluginData.state.nicknameHistory.getByUserId(args.userId); const usernames = await pluginData.state.usernameHistory.getByUserId(args.userId); if (nicknames.length === 0 && usernames.length === 0) { void pluginData.state.common.sendErrorMessage(msg, "No name history found"); return; } const nicknameRows = nicknames.map( (r) => `\`[${r.timestamp}]\` ${r.nickname ? `**${disableCodeBlocks(r.nickname)}**` : "*None*"}`, ); const usernameRows = usernames.map((r) => `\`[${r.timestamp}]\` **${disableCodeBlocks(r.username)}**`); const user = await pluginData.client.users.fetch(args.userId as Snowflake).catch(() => null); const currentUsername = user ? renderUsername(user) : args.userId; const nicknameDays = Math.round(NICKNAME_RETENTION_PERIOD / DAYS); const usernameDays = Math.round(NICKNAME_RETENTION_PERIOD / DAYS); let message = `Name history for **${currentUsername}**:`; if (nicknameRows.length) { message += `\n\n__Last ${MAX_NICKNAME_ENTRIES_PER_USER} nicknames within ${nicknameDays} days:__\n${nicknameRows.join( "\n", )}`; } if (usernameRows.length) { message += `\n\n__Last ${MAX_USERNAME_ENTRIES_PER_USER} usernames within ${usernameDays} days:__\n${usernameRows.join( "\n", )}`; } createChunkedMessage(msg.channel, message); }, }); ================================================ FILE: backend/src/plugins/NameHistory/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zNameHistoryConfig } from "./types.js"; export const nameHistoryPluginDocs: ZeppelinPluginDocs = { prettyName: "Name history", type: "internal", configSchema: zNameHistoryConfig, }; ================================================ FILE: backend/src/plugins/NameHistory/events/UpdateNameEvts.ts ================================================ import { nameHistoryEvt } from "../types.js"; import { updateNickname } from "../updateNickname.js"; export const ChannelJoinEvt = nameHistoryEvt({ event: "voiceStateUpdate", async listener(meta) { meta.pluginData.state.updateQueue.add(() => updateNickname(meta.pluginData, meta.args.newState.member ?? meta.args.oldState.member!), ); }, }); export const MessageCreateEvt = nameHistoryEvt({ event: "messageCreate", async listener(meta) { meta.pluginData.state.updateQueue.add(() => updateNickname(meta.pluginData, meta.args.message.member!)); }, }); ================================================ FILE: backend/src/plugins/NameHistory/types.ts ================================================ import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { Queue } from "../../Queue.js"; import { GuildNicknameHistory } from "../../data/GuildNicknameHistory.js"; import { UsernameHistory } from "../../data/UsernameHistory.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zNameHistoryConfig = z.strictObject({ can_view: z.boolean().default(false), }); export interface NameHistoryPluginType extends BasePluginType { configSchema: typeof zNameHistoryConfig; state: { nicknameHistory: GuildNicknameHistory; usernameHistory: UsernameHistory; updateQueue: Queue; common: pluginUtils.PluginPublicInterface; }; } export const nameHistoryCmd = guildPluginMessageCommand(); export const nameHistoryEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/NameHistory/updateNickname.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { NameHistoryPluginType } from "./types.js"; export async function updateNickname(pluginData: GuildPluginData, member: GuildMember) { if (!member) return; const latestEntry = await pluginData.state.nicknameHistory.getLastEntry(member.id); if (!latestEntry || latestEntry.nickname !== member.nickname) { if (!latestEntry && member.nickname == null) return; // No need to save "no nickname" if there's no previous data await pluginData.state.nicknameHistory.addEntry(member.id, member.nickname); } } ================================================ FILE: backend/src/plugins/Persist/PersistPlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildPersistedData } from "../../data/GuildPersistedData.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin.js"; import { LoadDataEvt } from "./events/LoadDataEvt.js"; import { StoreDataEvt } from "./events/StoreDataEvt.js"; import { PersistPluginType, zPersistConfig } from "./types.js"; export const PersistPlugin = guildPlugin()({ name: "persist", dependencies: () => [LogsPlugin, RoleManagerPlugin], configSchema: zPersistConfig, // prettier-ignore events: [ StoreDataEvt, LoadDataEvt, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.persistedData = GuildPersistedData.getGuildInstance(guild.id); state.logs = new GuildLogs(guild.id); }, }); ================================================ FILE: backend/src/plugins/Persist/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zPersistConfig } from "./types.js"; export const persistPluginDocs: ZeppelinPluginDocs = { prettyName: "Persist", description: trimPluginDescription(` Re-apply roles or nicknames for users when they rejoin the server. Mute roles are re-applied automatically, this plugin is not required for that. `), configSchema: zPersistConfig, type: "stable", }; ================================================ FILE: backend/src/plugins/Persist/events/LoadDataEvt.ts ================================================ import { GuildMember, PermissionFlagsBits } from "discord.js"; import { GuildPluginData } from "vety"; import { intersection } from "lodash-es"; import { PersistedData } from "../../../data/entities/PersistedData.js"; import { SECONDS } from "../../../utils.js"; import { canAssignRole } from "../../../utils/canAssignRole.js"; import { getMissingPermissions } from "../../../utils/getMissingPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; import { PersistPluginType, persistEvt } from "../types.js"; const p = PermissionFlagsBits; async function applyPersistedData( pluginData: GuildPluginData, persistedData: PersistedData, member: GuildMember, ): Promise { const config = await pluginData.config.getForMember(member); const guildRoles = Array.from(pluginData.guild.roles.cache.keys()); const restoredData: string[] = []; const persistedRoles = config.persisted_roles; if (persistedRoles.length) { const roleManager = pluginData.getPlugin(RoleManagerPlugin); const rolesToRestore = intersection(persistedRoles, persistedData.roles, guildRoles).filter( (roleId) => !member.roles.cache.has(roleId), ); if (rolesToRestore.length) { restoredData.push("roles"); for (const roleId of rolesToRestore) { roleManager.addRole(member.id, roleId); } } } if (config.persist_nicknames && persistedData.nickname && member.nickname !== persistedData.nickname) { restoredData.push("nickname"); await member.edit({ nick: persistedData.nickname, }); } return restoredData; } export const LoadDataEvt = persistEvt({ event: "guildMemberAdd", async listener(meta) { const member = meta.args.member; const pluginData = meta.pluginData; const persistedData = await pluginData.state.persistedData.find(member.id); if (!persistedData) { return; } await pluginData.state.persistedData.clear(member.id); const config = await pluginData.config.getForMember(member); // Check permissions const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; let requiredPermissions = 0n; if (config.persist_nicknames) requiredPermissions |= p.ManageNicknames; if (config.persisted_roles) requiredPermissions |= p.ManageRoles; const missingPermissions = getMissingPermissions(me.permissions, requiredPermissions); if (missingPermissions) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions for persist plugin: ${missingPermissionError(missingPermissions)}`, }); return; } const guildRoles = Array.from(pluginData.guild.roles.cache.keys()); // Check specific role permissions if (config.persisted_roles) { for (const roleId of config.persisted_roles) { if (!canAssignRole(pluginData.guild, me, roleId) && guildRoles.includes(roleId)) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions to assign role \`${roleId}\` in persist plugin`, }); return; } } } const restoredData = await applyPersistedData(pluginData, persistedData, member); setTimeout(() => { // Reapply persisted data after a while for better interop with other bots that restore roles void applyPersistedData(pluginData, persistedData, member); }, 5 * SECONDS); if (restoredData.length) { pluginData.getPlugin(LogsPlugin).logMemberRestore({ member, restoredData: restoredData.join(", "), }); } }, }); ================================================ FILE: backend/src/plugins/Persist/events/StoreDataEvt.ts ================================================ import { PersistedData } from "../../../data/entities/PersistedData.js"; import { persistEvt } from "../types.js"; export const StoreDataEvt = persistEvt({ event: "guildMemberRemove", async listener({ pluginData, args: { member } }) { const config = await pluginData.config.getForUser(member.user); const persistData: Partial = {}; // FIXME: New caching thing, or fix deadlocks with this plugin if (member.partial) { return; // Djs hasn't cached member data => use db cache /* const data = await pluginData.getPlugin(GuildMemberCachePlugin).getCachedMemberData(member.id); if (!data) { return; } const rolesToPersist = config.persisted_roles.filter((roleId) => data.roles.includes(roleId)); if (rolesToPersist.length) { persistData.roles = rolesToPersist; } if (config.persist_nicknames && data.nickname) { persistData.nickname = data.nickname; }*/ } else { // Djs has cached member data => use that const memberRoles = Array.from(member.roles.cache.keys()); const rolesToPersist = config.persisted_roles.filter((roleId) => memberRoles.includes(roleId)); if (rolesToPersist.length) { persistData.roles = rolesToPersist; } if (config.persist_nicknames && member.nickname) { persistData.nickname = member.nickname as any; } } if (Object.keys(persistData).length) { pluginData.state.persistedData.set(member.id, persistData); } }, }); ================================================ FILE: backend/src/plugins/Persist/types.ts ================================================ import { BasePluginType, guildPluginEventListener } from "vety"; import { z } from "zod"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildPersistedData } from "../../data/GuildPersistedData.js"; import { zSnowflake } from "../../utils.js"; export const zPersistConfig = z.strictObject({ persisted_roles: z.array(zSnowflake).default([]), persist_nicknames: z.boolean().default(false), persist_voice_mutes: z.boolean().default(false), }); export interface PersistPluginType extends BasePluginType { configSchema: typeof zPersistConfig; state: { persistedData: GuildPersistedData; logs: GuildLogs; }; } export const persistEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/Phisherman/PhishermanPlugin.ts ================================================ import { guildPlugin } from "vety"; import { PhishermanPluginType, zPhishermanConfig } from "./types.js"; export const PhishermanPlugin = guildPlugin()({ name: "phisherman", configSchema: zPhishermanConfig, }); ================================================ FILE: backend/src/plugins/Phisherman/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zPhishermanConfig } from "./types.js"; export const phishermanPluginDocs: ZeppelinPluginDocs = { prettyName: "Phisherman", type: "legacy", description: trimPluginDescription(` Match malicious links using Phisherman `), configurationGuide: trimPluginDescription(` This plugin has been deprecated. Please use the \`include_malicious\` option for automod \`match_links\` trigger instead. `), configSchema: zPhishermanConfig, }; ================================================ FILE: backend/src/plugins/Phisherman/types.ts ================================================ import { BasePluginType } from "vety"; import { z } from "zod"; export const zPhishermanConfig = z.strictObject({ api_key: z.string().max(255).nullable().default(null), }); export interface PhishermanPluginType extends BasePluginType { configSchema: typeof zPhishermanConfig; // eslint-disable-next-line @typescript-eslint/ban-types state: {}; } ================================================ FILE: backend/src/plugins/PingableRoles/PingableRolesPlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildPingableRoles } from "../../data/GuildPingableRoles.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { PingableRoleDisableCmd } from "./commands/PingableRoleDisableCmd.js"; import { PingableRoleEnableCmd } from "./commands/PingableRoleEnableCmd.js"; import { PingableRolesPluginType, zPingableRolesConfig } from "./types.js"; export const PingableRolesPlugin = guildPlugin()({ name: "pingable_roles", configSchema: zPingableRolesConfig, defaultOverrides: [ { level: ">=100", config: { can_manage: true, }, }, ], // prettier-ignore messageCommands: [ PingableRoleEnableCmd, PingableRoleDisableCmd, ], // prettier-ignore events: [ // FIXME: Temporarily disabled for performance. This is very buggy anyway, so consider removing in the future. // TypingEnablePingableEvt, // MessageCreateDisablePingableEvt, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.pingableRoles = GuildPingableRoles.getGuildInstance(guild.id); state.cache = new Map(); state.timeouts = new Map(); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, }); ================================================ FILE: backend/src/plugins/PingableRoles/commands/PingableRoleDisableCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { pingableRolesCmd } from "../types.js"; export const PingableRoleDisableCmd = pingableRolesCmd({ trigger: ["pingable_role disable", "pingable_role d"], permission: "can_manage", signature: { channelId: ct.channelId(), role: ct.role(), }, async run({ message: msg, args, pluginData }) { const pingableRole = await pluginData.state.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id); if (!pingableRole) { void pluginData.state.common.sendErrorMessage( msg, `**${args.role.name}** is not set as pingable in <#${args.channelId}>`, ); return; } await pluginData.state.pingableRoles.delete(args.channelId, args.role.id); pluginData.state.cache.delete(args.channelId); void pluginData.state.common.sendSuccessMessage( msg, `**${args.role.name}** is no longer set as pingable in <#${args.channelId}>`, ); }, }); ================================================ FILE: backend/src/plugins/PingableRoles/commands/PingableRoleEnableCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { pingableRolesCmd } from "../types.js"; export const PingableRoleEnableCmd = pingableRolesCmd({ trigger: "pingable_role", permission: "can_manage", signature: { channelId: ct.channelId(), role: ct.role(), }, async run({ message: msg, args, pluginData }) { const existingPingableRole = await pluginData.state.pingableRoles.getByChannelAndRoleId( args.channelId, args.role.id, ); if (existingPingableRole) { void pluginData.state.common.sendErrorMessage( msg, `**${args.role.name}** is already set as pingable in <#${args.channelId}>`, ); return; } await pluginData.state.pingableRoles.add(args.channelId, args.role.id); pluginData.state.cache.delete(args.channelId); void pluginData.state.common.sendSuccessMessage( msg, `**${args.role.name}** has been set as pingable in <#${args.channelId}>`, ); }, }); ================================================ FILE: backend/src/plugins/PingableRoles/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zPingableRolesConfig } from "./types.js"; export const pingableRolesPluginDocs: ZeppelinPluginDocs = { prettyName: "Pingable roles", configSchema: zPingableRolesConfig, type: "stable", }; ================================================ FILE: backend/src/plugins/PingableRoles/events/ChangePingableEvts.ts ================================================ import { pingableRolesEvt } from "../types.js"; import { disablePingableRoles } from "../utils/disablePingableRoles.js"; import { enablePingableRoles } from "../utils/enablePingableRoles.js"; import { getPingableRolesForChannel } from "../utils/getPingableRolesForChannel.js"; const TIMEOUT = 10 * 1000; export const TypingEnablePingableEvt = pingableRolesEvt({ event: "typingStart", async listener(meta) { const pluginData = meta.pluginData; const channel = meta.args.typing.channel; const pingableRoles = await getPingableRolesForChannel(pluginData, channel.id); if (pingableRoles.length === 0) return; if (pluginData.state.timeouts.has(channel.id)) { clearTimeout(pluginData.state.timeouts.get(channel.id)); } enablePingableRoles(pluginData, pingableRoles); const timeout = setTimeout(() => { disablePingableRoles(pluginData, pingableRoles); }, TIMEOUT); pluginData.state.timeouts.set(channel.id, timeout); }, }); export const MessageCreateDisablePingableEvt = pingableRolesEvt({ event: "messageCreate", async listener(meta) { const pluginData = meta.pluginData; const msg = meta.args.message; const pingableRoles = await getPingableRolesForChannel(pluginData, msg.channel.id); if (pingableRoles.length === 0) return; if (pluginData.state.timeouts.has(msg.channel.id)) { clearTimeout(pluginData.state.timeouts.get(msg.channel.id)); } disablePingableRoles(pluginData, pingableRoles); }, }); ================================================ FILE: backend/src/plugins/PingableRoles/types.ts ================================================ import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { GuildPingableRoles } from "../../data/GuildPingableRoles.js"; import { PingableRole } from "../../data/entities/PingableRole.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zPingableRolesConfig = z.strictObject({ can_manage: z.boolean().default(false), }); export interface PingableRolesPluginType extends BasePluginType { configSchema: typeof zPingableRolesConfig; state: { pingableRoles: GuildPingableRoles; cache: Map; timeouts: Map; common: pluginUtils.PluginPublicInterface; }; } export const pingableRolesCmd = guildPluginMessageCommand(); export const pingableRolesEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/PingableRoles/utils/disablePingableRoles.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { PingableRole } from "../../../data/entities/PingableRole.js"; import { PingableRolesPluginType } from "../types.js"; export function disablePingableRoles( pluginData: GuildPluginData, pingableRoles: PingableRole[], ) { for (const pingableRole of pingableRoles) { const role = pluginData.guild.roles.cache.get(pingableRole.role_id as Snowflake); if (!role) continue; role.setMentionable(false, "Disable pingable role"); } } ================================================ FILE: backend/src/plugins/PingableRoles/utils/enablePingableRoles.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { PingableRole } from "../../../data/entities/PingableRole.js"; import { PingableRolesPluginType } from "../types.js"; export function enablePingableRoles( pluginData: GuildPluginData, pingableRoles: PingableRole[], ) { for (const pingableRole of pingableRoles) { const role = pluginData.guild.roles.cache.get(pingableRole.role_id as Snowflake); if (!role) continue; role.setMentionable(true, "Enable pingable role"); } } ================================================ FILE: backend/src/plugins/PingableRoles/utils/getPingableRolesForChannel.ts ================================================ import { GuildPluginData } from "vety"; import { PingableRole } from "../../../data/entities/PingableRole.js"; import { PingableRolesPluginType } from "../types.js"; export async function getPingableRolesForChannel( pluginData: GuildPluginData, channelId: string, ): Promise { if (!pluginData.state.cache.has(channelId)) { pluginData.state.cache.set(channelId, await pluginData.state.pingableRoles.getForChannel(channelId)); } return pluginData.state.cache.get(channelId)!; } ================================================ FILE: backend/src/plugins/Post/PostPlugin.ts ================================================ import { guildPlugin } from "vety"; import { onGuildEvent } from "../../data/GuildEvents.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildScheduledPosts } from "../../data/GuildScheduledPosts.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { EditCmd } from "./commands/EditCmd.js"; import { EditEmbedCmd } from "./commands/EditEmbedCmd.js"; import { PostCmd } from "./commands/PostCmd.js"; import { PostEmbedCmd } from "./commands/PostEmbedCmd.js"; import { ScheduledPostsDeleteCmd } from "./commands/ScheduledPostsDeleteCmd.js"; import { ScheduledPostsListCmd } from "./commands/ScheduledPostsListCmd.js"; import { ScheduledPostsShowCmd } from "./commands/ScheduledPostsShowCmd.js"; import { PostPluginType, zPostConfig } from "./types.js"; import { postScheduledPost } from "./util/postScheduledPost.js"; export const PostPlugin = guildPlugin()({ name: "post", dependencies: () => [TimeAndDatePlugin, LogsPlugin], configSchema: zPostConfig, defaultOverrides: [ { level: ">=100", config: { can_post: true, }, }, ], // prettier-ignore messageCommands: [ PostCmd, PostEmbedCmd, EditCmd, EditEmbedCmd, ScheduledPostsShowCmd, ScheduledPostsListCmd, ScheduledPostsDeleteCmd, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); state.scheduledPosts = GuildScheduledPosts.getGuildInstance(guild.id); state.logs = new GuildLogs(guild.id); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, afterLoad(pluginData) { const { state, guild } = pluginData; state.unregisterGuildEventListener = onGuildEvent(guild.id, "scheduledPost", (post) => postScheduledPost(pluginData, post), ); }, beforeUnload(pluginData) { const { state } = pluginData; state.unregisterGuildEventListener?.(); }, }); ================================================ FILE: backend/src/plugins/Post/commands/EditCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { postCmd } from "../types.js"; import { formatContent } from "../util/formatContent.js"; export const EditCmd = postCmd({ trigger: "edit", permission: "can_post", signature: { message: ct.messageTarget(), content: ct.string({ catchAll: true }), }, async run({ message: msg, args, pluginData }) { const targetMessage = await args.message.channel.messages.fetch(args.message.messageId); if (!targetMessage) { void pluginData.state.common.sendErrorMessage(msg, "Unknown message"); return; } if (targetMessage.author.id !== pluginData.client.user!.id) { void pluginData.state.common.sendErrorMessage(msg, "Message wasn't posted by me"); return; } targetMessage.channel.messages.edit(targetMessage.id, { content: formatContent(args.content), }); void pluginData.state.common.sendSuccessMessage(msg, "Message edited"); }, }); ================================================ FILE: backend/src/plugins/Post/commands/EditEmbedCmd.ts ================================================ import { APIEmbed } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isValidEmbed, trimLines } from "../../../utils.js"; import { parseColor } from "../../../utils/parseColor.js"; import { rgbToInt } from "../../../utils/rgbToInt.js"; import { postCmd } from "../types.js"; import { formatContent } from "../util/formatContent.js"; export const EditEmbedCmd = postCmd({ trigger: "edit_embed", permission: "can_post", signature: { message: ct.messageTarget(), maincontent: ct.string({ catchAll: true }), title: ct.string({ option: true }), content: ct.string({ option: true }), color: ct.string({ option: true }), raw: ct.bool({ option: true, isSwitch: true, shortcut: "r" }), }, async run({ message: msg, args, pluginData }) { const content = args.content || args.maincontent; let color: number | null = null; if (args.color) { const colorRgb = parseColor(args.color); if (colorRgb) { color = rgbToInt(colorRgb); } else { void pluginData.state.common.sendErrorMessage(msg, "Invalid color specified"); return; } } const targetMessage = await args.message.channel.messages.fetch(args.message.messageId); if (!targetMessage) { void pluginData.state.common.sendErrorMessage(msg, "Unknown message"); return; } let embed: APIEmbed = targetMessage.embeds![0]?.toJSON() ?? { fields: [] }; if (args.title) embed.title = args.title; if (color) embed.color = color; if (content) { if (args.raw) { let parsed; try { parsed = JSON.parse(content); } catch (e) { void pluginData.state.common.sendErrorMessage(msg, `Syntax error in embed JSON: ${e.message}`); return; } if (!isValidEmbed(parsed)) { void pluginData.state.common.sendErrorMessage(msg, "Embed is not valid"); return; } embed = Object.assign({}, embed, parsed); } else { embed.description = formatContent(content); } } args.message.channel.messages.edit(targetMessage.id, { embeds: [embed], }); await pluginData.state.common.sendSuccessMessage(msg, "Embed edited"); if (args.content) { const prefix = pluginData.fullConfig.prefix || "!"; msg.channel.send( trimLines(` <@!${msg.author.id}> You can now specify an embed's content directly at the end of the command: \`${prefix}edit_embed -title "Some title" content goes here\` The \`-content\` option will soon be removed in favor of this. `), ); } }, }); ================================================ FILE: backend/src/plugins/Post/commands/PostCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { postCmd } from "../types.js"; import { actualPostCmd } from "../util/actualPostCmd.js"; export const PostCmd = postCmd({ trigger: "post", permission: "can_post", signature: { channel: ct.textChannel(), content: ct.string({ catchAll: true }), "enable-mentions": ct.bool({ option: true, isSwitch: true }), schedule: ct.string({ option: true }), repeat: ct.delay({ option: true }), "repeat-until": ct.string({ option: true }), "repeat-times": ct.number({ option: true }), }, async run({ message: msg, args, pluginData }) { actualPostCmd(pluginData, msg, args.channel, { content: args.content }, args); }, }); ================================================ FILE: backend/src/plugins/Post/commands/PostEmbedCmd.ts ================================================ import { APIEmbed } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isValidEmbed, trimLines } from "../../../utils.js"; import { parseColor } from "../../../utils/parseColor.js"; import { rgbToInt } from "../../../utils/rgbToInt.js"; import { postCmd } from "../types.js"; import { actualPostCmd } from "../util/actualPostCmd.js"; import { formatContent } from "../util/formatContent.js"; export const PostEmbedCmd = postCmd({ trigger: "post_embed", permission: "can_post", signature: { channel: ct.textChannel(), maincontent: ct.string({ catchAll: true }), title: ct.string({ option: true }), content: ct.string({ option: true }), color: ct.string({ option: true }), raw: ct.bool({ option: true, isSwitch: true, shortcut: "r" }), schedule: ct.string({ option: true }), repeat: ct.delay({ option: true }), "repeat-until": ct.string({ option: true }), "repeat-times": ct.number({ option: true }), }, async run({ message: msg, args, pluginData }) { const content = args.content || args.maincontent; if (!args.title && !content) { void pluginData.state.common.sendErrorMessage(msg, "Title or content required"); return; } let color: number | null = null; if (args.color) { const colorRgb = parseColor(args.color); if (colorRgb) { color = rgbToInt(colorRgb); } else { void pluginData.state.common.sendErrorMessage(msg, "Invalid color specified"); return; } } let embed: APIEmbed = {}; if (args.title) embed.title = args.title; if (color) embed.color = color; if (content) { if (args.raw) { let parsed; try { parsed = JSON.parse(content); } catch (e) { void pluginData.state.common.sendErrorMessage(msg, `Syntax error in embed JSON: ${e.message}`); return; } if (!isValidEmbed(parsed)) { void pluginData.state.common.sendErrorMessage(msg, "Embed is not valid"); return; } embed = Object.assign({}, embed, parsed); } else { embed.description = formatContent(content); } } if (args.content) { const prefix = pluginData.fullConfig.prefix || "!"; msg.channel.send( trimLines(` <@!${msg.author.id}> You can now specify an embed's content directly at the end of the command: \`${prefix}edit_embed -title "Some title" content goes here\` The \`-content\` option will soon be removed in favor of this. `), ); } actualPostCmd(pluginData, msg, args.channel, { embeds: [embed] }, args); }, }); ================================================ FILE: backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { clearUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop.js"; import { sorter } from "../../../utils.js"; import { postCmd } from "../types.js"; export const ScheduledPostsDeleteCmd = postCmd({ trigger: ["scheduled_posts delete", "scheduled_posts d"], permission: "can_post", signature: { num: ct.number(), }, async run({ message: msg, args, pluginData }) { const scheduledPosts = await pluginData.state.scheduledPosts.all(); scheduledPosts.sort(sorter("post_at")); const post = scheduledPosts[args.num - 1]; if (!post) { void pluginData.state.common.sendErrorMessage(msg, "Scheduled post not found"); return; } clearUpcomingScheduledPost(post); await pluginData.state.scheduledPosts.delete(post.id); void pluginData.state.common.sendSuccessMessage(msg, "Scheduled post deleted!"); }, }); ================================================ FILE: backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts ================================================ import { escapeCodeBlock } from "discord.js"; import moment from "moment-timezone"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { createChunkedMessage, DBDateFormat, deactivateMentions, sorter, trimLines } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { postCmd } from "../types.js"; const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50; export const ScheduledPostsListCmd = postCmd({ trigger: ["scheduled_posts", "scheduled_posts list"], permission: "can_post", async run({ message: msg, pluginData }) { const scheduledPosts = await pluginData.state.scheduledPosts.all(); if (scheduledPosts.length === 0) { msg.channel.send("No scheduled posts"); return; } scheduledPosts.sort(sorter("post_at")); let i = 1; const postLines = scheduledPosts.map((p) => { let previewText = p.content.content || p.content.embeds?.[0]?.description || p.content.embeds?.[0]?.title || ""; const isTruncated = previewText.length > SCHEDULED_POST_PREVIEW_TEXT_LENGTH; previewText = escapeCodeBlock(deactivateMentions(previewText)) .replace(/\s+/g, " ") .slice(0, SCHEDULED_POST_PREVIEW_TEXT_LENGTH); const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); const prettyPostAt = timeAndDate .inGuildTz(moment.utc(p.post_at!, DBDateFormat)) .format(timeAndDate.getDateFormat("pretty_datetime")); const parts = [`\`#${i++}\` \`[${prettyPostAt}]\` ${previewText}${isTruncated ? "..." : ""}`]; if (p.attachments.length) parts.push("*(with attachment)*"); if (p.content.embeds?.length) parts.push("*(embed)*"); if (p.repeat_until) { parts.push(`*(repeated every ${humanizeDuration(p.repeat_interval)} until ${p.repeat_until})*`); } if (p.repeat_times) { parts.push( `*(repeated every ${humanizeDuration(p.repeat_interval)}, ${p.repeat_times} more ${ p.repeat_times === 1 ? "time" : "times" })*`, ); } parts.push(`*(${p.author_name})*`); return parts.join(" "); }); const finalMessage = trimLines(` ${postLines.join("\n")} Use \`scheduled_posts \` to view a scheduled post in full Use \`scheduled_posts delete \` to delete a scheduled post `); createChunkedMessage(msg.channel, finalMessage); }, }); ================================================ FILE: backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { sorter } from "../../../utils.js"; import { postCmd } from "../types.js"; import { postMessage } from "../util/postMessage.js"; export const ScheduledPostsShowCmd = postCmd({ trigger: ["scheduled_posts", "scheduled_posts show"], permission: "can_post", signature: { num: ct.number(), }, async run({ message: msg, args, pluginData }) { const scheduledPosts = await pluginData.state.scheduledPosts.all(); scheduledPosts.sort(sorter("post_at")); const post = scheduledPosts[args.num - 1]; if (!post) { void pluginData.state.common.sendErrorMessage(msg, "Scheduled post not found"); return; } postMessage(pluginData, msg.channel, post.content, post.attachments, post.enable_mentions); }, }); ================================================ FILE: backend/src/plugins/Post/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zPostConfig } from "./types.js"; export const postPluginDocs: ZeppelinPluginDocs = { prettyName: "Post", configSchema: zPostConfig, type: "stable", }; ================================================ FILE: backend/src/plugins/Post/types.ts ================================================ import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildScheduledPosts } from "../../data/GuildScheduledPosts.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zPostConfig = z.strictObject({ can_post: z.boolean().default(false), }); export interface PostPluginType extends BasePluginType { configSchema: typeof zPostConfig; state: { savedMessages: GuildSavedMessages; scheduledPosts: GuildScheduledPosts; logs: GuildLogs; common: pluginUtils.PluginPublicInterface; unregisterGuildEventListener: () => void; }; } export const postCmd = guildPluginMessageCommand(); ================================================ FILE: backend/src/plugins/Post/util/actualPostCmd.ts ================================================ import { GuildTextBasedChannel, Message } from "discord.js"; import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { registerUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop.js"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { DBDateFormat, MINUTES, StrictMessageContent, errorMessage, renderUsername } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { PostPluginType } from "../types.js"; import { parseScheduleTime } from "./parseScheduleTime.js"; import { postMessage } from "./postMessage.js"; const MIN_REPEAT_TIME = 5 * MINUTES; const MAX_REPEAT_TIME = Math.pow(2, 32); const MAX_REPEAT_UNTIL = moment.utc().add(100, "years"); export async function actualPostCmd( pluginData: GuildPluginData, msg: Message, targetChannel: GuildTextBasedChannel, content: StrictMessageContent, opts: { "enable-mentions"?: boolean; schedule?: string; repeat?: number; "repeat-until"?: string; "repeat-times"?: number; } = {}, ) { if (!targetChannel.isSendable()) { msg.reply(errorMessage("Specified channel is not a sendable channel")); return; } if (content == null && msg.attachments.size === 0) { msg.reply(errorMessage("Message content or attachment required")); return; } if (opts.repeat) { if (opts.repeat < MIN_REPEAT_TIME) { void pluginData.state.common.sendErrorMessage( msg, `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`, ); return; } if (opts.repeat > MAX_REPEAT_TIME) { void pluginData.state.common.sendErrorMessage( msg, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`, ); return; } } // If this is a scheduled or repeated post, figure out the next post date let postAt; if (opts.schedule) { // Schedule the post to be posted later postAt = await parseScheduleTime(pluginData, msg.author.id, opts.schedule); if (!postAt) { void pluginData.state.common.sendErrorMessage(msg, "Invalid schedule time"); return; } } else if (opts.repeat) { postAt = moment.utc().add(opts.repeat, "ms"); } // For repeated posts, make sure repeat-until or repeat-times is specified let repeatUntil: moment.Moment | null = null; let repeatTimes: number | null = null; let repeatDetailsStr: string | null = null; if (opts["repeat-until"]) { repeatUntil = await parseScheduleTime(pluginData, msg.author.id, opts["repeat-until"]); // Invalid time if (!repeatUntil) { void pluginData.state.common.sendErrorMessage(msg, "Invalid time specified for -repeat-until"); return; } if (repeatUntil.isBefore(moment.utc())) { void pluginData.state.common.sendErrorMessage(msg, "You can't set -repeat-until in the past"); return; } if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) { void pluginData.state.common.sendErrorMessage( msg, "Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?", ); return; } } else if (opts["repeat-times"]) { repeatTimes = opts["repeat-times"]; if (repeatTimes <= 0) { void pluginData.state.common.sendErrorMessage(msg, "-repeat-times must be 1 or more"); return; } } if (repeatUntil && repeatTimes) { void pluginData.state.common.sendErrorMessage( msg, "You can only use one of -repeat-until or -repeat-times at once", ); return; } if (opts.repeat && !repeatUntil && !repeatTimes) { void pluginData.state.common.sendErrorMessage( msg, "You must specify -repeat-until or -repeat-times for repeated messages", ); return; } if (opts.repeat) { repeatDetailsStr = repeatUntil ? `every ${humanizeDuration(opts.repeat)} until ${repeatUntil.format(DBDateFormat)}` : `every ${humanizeDuration(opts.repeat)}, ${repeatTimes} times in total`; } const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); // Save schedule/repeat information in DB if (postAt) { if (postAt < moment.utc()) { void pluginData.state.common.sendErrorMessage(msg, "Post can't be scheduled to be posted in the past"); return; } const post = await pluginData.state.scheduledPosts.create({ author_id: msg.author.id, author_name: renderUsername(msg.author), channel_id: targetChannel.id, content, attachments: [...msg.attachments.values()], post_at: postAt.clone().tz("Etc/UTC").format(DBDateFormat), enable_mentions: opts["enable-mentions"], repeat_interval: opts.repeat, repeat_until: repeatUntil ? repeatUntil.clone().tz("Etc/UTC").format(DBDateFormat) : null, repeat_times: repeatTimes ?? null, }); registerUpcomingScheduledPost(post); if (opts.repeat) { pluginData.getPlugin(LogsPlugin).logScheduledRepeatedMessage({ author: msg.author, channel: targetChannel, datetime: postAt.format(timeAndDate.getDateFormat("pretty_datetime")), date: postAt.format(timeAndDate.getDateFormat("date")), time: postAt.format(timeAndDate.getDateFormat("time")), repeatInterval: humanizeDuration(opts.repeat), repeatDetails: repeatDetailsStr!, }); } else { pluginData.getPlugin(LogsPlugin).logScheduledMessage({ author: msg.author, channel: targetChannel, datetime: postAt.format(timeAndDate.getDateFormat("pretty_datetime")), date: postAt.format(timeAndDate.getDateFormat("date")), time: postAt.format(timeAndDate.getDateFormat("time")), }); } } // When the message isn't scheduled for later, post it immediately if (!opts.schedule) { await postMessage(pluginData, targetChannel, content, [...msg.attachments.values()], opts["enable-mentions"]); } if (opts.repeat) { pluginData.getPlugin(LogsPlugin).logRepeatedMessage({ author: msg.author, channel: targetChannel, datetime: postAt.format(timeAndDate.getDateFormat("pretty_datetime")), date: postAt.format(timeAndDate.getDateFormat("date")), time: postAt.format(timeAndDate.getDateFormat("time")), repeatInterval: humanizeDuration(opts.repeat), repeatDetails: repeatDetailsStr ?? "", }); } // Bot reply schenanigans let successMessage = opts.schedule ? `Message scheduled to be posted in <#${targetChannel.id}> on ${postAt.format( timeAndDate.getDateFormat("pretty_datetime"), )}` : `Message posted in <#${targetChannel.id}>`; if (opts.repeat) { successMessage += `. Message will be automatically reposted every ${humanizeDuration(opts.repeat)}`; if (repeatUntil) { successMessage += ` until ${repeatUntil.format(timeAndDate.getDateFormat("pretty_datetime"))}`; } else if (repeatTimes) { successMessage += `, ${repeatTimes} times in total`; } successMessage += "."; } if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) { void pluginData.state.common.sendSuccessMessage(msg, successMessage); } } ================================================ FILE: backend/src/plugins/Post/util/formatContent.ts ================================================ export function formatContent(str: string) { return str.replace(/\\n/g, "\n"); } ================================================ FILE: backend/src/plugins/Post/util/parseScheduleTime.ts ================================================ import { GuildPluginData } from "vety"; import moment, { Moment } from "moment-timezone"; import { convertDelayStringToMS } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; // TODO: Extract out of the Post plugin, use everywhere with a date input export async function parseScheduleTime( pluginData: GuildPluginData, memberId: string, str: string, ): Promise { const tz = await pluginData.getPlugin(TimeAndDatePlugin).getMemberTz(memberId); const dt1 = moment.tz(str, "YYYY-MM-DD HH:mm:ss", tz); if (dt1 && dt1.isValid()) return dt1; const dt2 = moment.tz(str, "YYYY-MM-DD HH:mm", tz); if (dt2 && dt2.isValid()) return dt2; const date = moment.tz(str, "YYYY-MM-DD", tz); if (date && date.isValid()) return date; const t1 = moment.tz(str, "HH:mm:ss", tz); if (t1 && t1.isValid()) { if (t1.isBefore(moment.utc())) t1.add(1, "day"); return t1; } const t2 = moment.tz(str, "HH:mm", tz); if (t2 && t2.isValid()) { if (t2.isBefore(moment.utc())) t2.add(1, "day"); return t2; } const delayStringMS = convertDelayStringToMS(str, "m"); if (delayStringMS) { return moment.tz(tz).add(delayStringMS, "ms"); } return null; } ================================================ FILE: backend/src/plugins/Post/util/postMessage.ts ================================================ import { Attachment, GuildTextBasedChannel, Message, MessageCreateOptions } from "discord.js"; import fs from "fs"; import { GuildPluginData } from "vety"; import { downloadFile } from "../../../utils.js"; import { PostPluginType } from "../types.js"; import { formatContent } from "./formatContent.js"; const fsp = fs.promises; export async function postMessage( pluginData: GuildPluginData, channel: GuildTextBasedChannel, content: MessageCreateOptions, attachments: Attachment[] = [], enableMentions = false, ): Promise { if (typeof content === "string") { content = { content }; } if (content && content.content) { content.content = formatContent(content.content); } let downloadedAttachment; let file; if (attachments.length) { downloadedAttachment = await downloadFile(attachments[0].url); file = { name: attachments[0].name, file: await fsp.readFile(downloadedAttachment.path), }; content.files = [file.file]; } if (enableMentions) { content.allowedMentions = { parse: ["everyone", "roles", "users"], }; } const createdMsg = await channel.send(content); pluginData.state.savedMessages.setPermanent(createdMsg.id); if (downloadedAttachment) { downloadedAttachment.deleteFn(); } return createdMsg; } ================================================ FILE: backend/src/plugins/Post/util/postScheduledPost.ts ================================================ import { Snowflake, User } from "discord.js"; import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { ScheduledPost } from "../../../data/entities/ScheduledPost.js"; import { registerUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop.js"; import { logger } from "../../../logger.js"; import { DBDateFormat, verboseChannelMention, verboseUserMention } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { PostPluginType } from "../types.js"; import { postMessage } from "./postMessage.js"; export async function postScheduledPost(pluginData: GuildPluginData, post: ScheduledPost) { // First, update the scheduled post or delete it from the database *before* we try posting it. // This ensures strange errors don't cause reposts. let shouldClear = true; if (post.repeat_interval) { const nextPostAt = moment.utc().add(post.repeat_interval, "ms"); if (post.repeat_until) { const repeatUntil = moment.utc(post.repeat_until, DBDateFormat); if (nextPostAt.isSameOrBefore(repeatUntil)) { await pluginData.state.scheduledPosts.update(post.id, { post_at: nextPostAt.format(DBDateFormat), }); shouldClear = false; } } else if (post.repeat_times) { if (post.repeat_times > 1) { await pluginData.state.scheduledPosts.update(post.id, { post_at: nextPostAt.format(DBDateFormat), repeat_times: post.repeat_times - 1, }); shouldClear = false; } } } if (shouldClear) { await pluginData.state.scheduledPosts.delete(post.id); } else { const upToDatePost = (await pluginData.state.scheduledPosts.find(post.id))!; registerUpcomingScheduledPost(upToDatePost); } // Post the message const channel = pluginData.guild.channels.cache.get(post.channel_id as Snowflake); if (channel?.isTextBased() || channel?.isThread()) { const [username, discriminator] = post.author_name.split("#"); const author: User = (await pluginData.client.users.fetch(post.author_id as Snowflake)) || { id: post.author_id, username, discriminator, }; try { const postedMessage = await postMessage( pluginData, channel, post.content, post.attachments, post.enable_mentions, ); pluginData.getPlugin(LogsPlugin).logPostedScheduledMessage({ author, channel, messageId: postedMessage.id, }); } catch { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to post scheduled message by ${verboseUserMention(author)} to ${verboseChannelMention(channel)}`, }); logger.warn( `Failed to post scheduled message to #${channel.name} (${channel.id}) on ${pluginData.guild.name} (${pluginData.guild.id})`, ); } } } ================================================ FILE: backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts ================================================ import { guildPlugin } from "vety"; import { Queue } from "../../Queue.js"; import { GuildReactionRoles } from "../../data/GuildReactionRoles.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd.js"; import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd.js"; import { RefreshReactionRolesCmd } from "./commands/RefreshReactionRolesCmd.js"; import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt.js"; import { MessageDeletedEvt } from "./events/MessageDeletedEvt.js"; import { ReactionRolesPluginType, zReactionRolesConfig } from "./types.js"; export const ReactionRolesPlugin = guildPlugin()({ name: "reaction_roles", dependencies: () => [LogsPlugin], configSchema: zReactionRolesConfig, defaultOverrides: [ { level: ">=100", config: { can_manage: true, }, }, ], // prettier-ignore messageCommands: [ RefreshReactionRolesCmd, ClearReactionRolesCmd, InitReactionRolesCmd, ], // prettier-ignore events: [ AddReactionRoleEvt, MessageDeletedEvt, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.reactionRoles = GuildReactionRoles.getGuildInstance(guild.id); state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); state.reactionRemoveQueue = new Queue(); state.roleChangeQueue = new Queue(); state.pendingRoleChanges = new Map(); state.pendingRefreshes = new Set(); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, afterLoad(pluginData) { const config = pluginData.config.get(); if (config.button_groups) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: "The 'button_groups' option of the 'reaction_roles' plugin is deprecated and non-functional. Consider using the new 'role_buttons' plugin instead!", }); } }, beforeUnload(pluginData) { const { state } = pluginData; if (state.autoRefreshTimeout) { clearTimeout(state.autoRefreshTimeout); } }, }); ================================================ FILE: backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts ================================================ import { Message } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { isDiscordAPIError } from "../../../utils.js"; import { reactionRolesCmd } from "../types.js"; export const ClearReactionRolesCmd = reactionRolesCmd({ trigger: "reaction_roles clear", permission: "can_manage", signature: { message: ct.messageTarget(), }, async run({ message: msg, args, pluginData }) { const existingReactionRoles = pluginData.state.reactionRoles.getForMessage(args.message.messageId); if (!existingReactionRoles) { void pluginData.state.common.sendErrorMessage(msg, "Message doesn't have reaction roles on it"); return; } pluginData.state.reactionRoles.removeFromMessage(args.message.messageId); let targetMessage: Message; try { targetMessage = await args.message.channel.messages.fetch(args.message.messageId); } catch (err) { if (isDiscordAPIError(err) && err.code === 50001) { void pluginData.state.common.sendErrorMessage(msg, "Missing access to the specified message"); return; } throw err; } await targetMessage.reactions.removeAll(); void pluginData.state.common.sendSuccessMessage(msg, "Reaction roles cleared"); }, }); ================================================ FILE: backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts ================================================ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { resolveMessageMember } from "../../../pluginUtils.js"; import { canUseEmoji, isDiscordAPIError, isValidEmoji, noop, trimPluginDescription } from "../../../utils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { TReactionRolePair, reactionRolesCmd } from "../types.js"; import { applyReactionRoleReactionsToMessage } from "../util/applyReactionRoleReactionsToMessage.js"; const CLEAR_ROLES_EMOJI = "❌"; export const InitReactionRolesCmd = reactionRolesCmd({ trigger: "reaction_roles", permission: "can_manage", description: trimPluginDescription(` This command allows you to add reaction roles to a given message. The basic usage is as follows: !reaction_roles 800865377520582687 👍 = 556110793058287637 👎 = 558037973581430785 A reactionRolePair is any emoji the bot can use, an equal sign and the role id it should correspond to. Every pair needs to be in its own line for the command to work properly. If the message you specify is not found, use \`!save_messages_to_db \` to manually add it to the stored messages database permanently. `), signature: { message: ct.messageTarget(), reactionRolePairs: ct.string({ catchAll: true }), exclusive: ct.bool({ option: true, isSwitch: true, shortcut: "e" }), }, async run({ message: msg, args, pluginData }) { const member = await resolveMessageMember(msg); if (!canReadChannel(args.message.channel, member)) { void pluginData.state.common.sendErrorMessage( msg, "You can't add reaction roles to channels you can't see yourself", ); return; } let targetMessage; try { targetMessage = await args.message.channel.messages.fetch(args.message.messageId); } catch (e) { if (isDiscordAPIError(e)) { void pluginData.state.common.sendErrorMessage(msg, `Error ${e.code} while getting message: ${e.message}`); return; } throw e; } // Clear old reaction roles for the message from the DB await pluginData.state.reactionRoles.removeFromMessage(targetMessage.id); // Turn "emoji = role" pairs into an array of tuples of the form [emoji, roleId] // Emoji is either a unicode emoji or the snowflake of a custom emoji const emojiRolePairs: TReactionRolePair[] = args.reactionRolePairs .trim() .split("\n") .map((v) => v.split(/[\s=,]+/).map((v) => v.trim())) // tslint:disable-line .map((pair): TReactionRolePair => { const customEmojiMatch = pair[0].match(/^$/); if (customEmojiMatch) { return [customEmojiMatch[2], pair[1], customEmojiMatch[1]]; } else { return pair as TReactionRolePair; } }); // Verify the specified emojis and roles are valid and usable for (const pair of emojiRolePairs) { if (pair[0] === CLEAR_ROLES_EMOJI) { void pluginData.state.common.sendErrorMessage( msg, `The emoji for clearing roles (${CLEAR_ROLES_EMOJI}) is reserved and cannot be used`, ); return; } if (!isValidEmoji(pair[0])) { void pluginData.state.common.sendErrorMessage(msg, `Invalid emoji: ${pair[0]}`); return; } if (!canUseEmoji(pluginData.client, pair[0])) { void pluginData.state.common.sendErrorMessage( msg, "I can only use regular emojis and custom emojis from servers I'm on", ); return; } if (!pluginData.guild.roles.cache.has(pair[1] as Snowflake)) { void pluginData.state.common.sendErrorMessage(msg, `Unknown role ${pair[1]}`); return; } } const progressMessage = msg.channel.send("Adding reaction roles..."); // Save the new reaction roles to the database let pos = 0; for (const pair of emojiRolePairs) { await pluginData.state.reactionRoles.add( args.message.channel.id, targetMessage.id, pair[0], pair[1], args.exclusive, pos, ); pos++; } // Apply the reactions themselves const reactionRoles = await pluginData.state.reactionRoles.getForMessage(targetMessage.id); const errors = await applyReactionRoleReactionsToMessage( pluginData, targetMessage.channel.id, targetMessage.id, reactionRoles, ); if (errors?.length) { void pluginData.state.common.sendErrorMessage(msg, `Errors while adding reaction roles:\n${errors.join("\n")}`); } else { void pluginData.state.common.sendSuccessMessage(msg, "Reaction roles added"); } (await progressMessage).delete().catch(noop); }, }); ================================================ FILE: backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { reactionRolesCmd } from "../types.js"; import { refreshReactionRoles } from "../util/refreshReactionRoles.js"; export const RefreshReactionRolesCmd = reactionRolesCmd({ trigger: "reaction_roles refresh", permission: "can_manage", signature: { message: ct.messageTarget(), }, async run({ message: msg, args, pluginData }) { if (pluginData.state.pendingRefreshes.has(`${args.message.channel.id}-${args.message.messageId}`)) { void pluginData.state.common.sendErrorMessage(msg, "Another refresh in progress"); return; } await refreshReactionRoles(pluginData, args.message.channel.id, args.message.messageId); void pluginData.state.common.sendSuccessMessage(msg, "Reaction roles refreshed"); }, }); ================================================ FILE: backend/src/plugins/ReactionRoles/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zReactionRolesConfig } from "./types.js"; export const reactionRolesPluginDocs: ZeppelinPluginDocs = { prettyName: "Reaction roles", description: "Consider using the [Role buttons](https://zeppelin.gg/docs/plugins/role_buttons) plugin instead.", type: "legacy", configSchema: zReactionRolesConfig, }; ================================================ FILE: backend/src/plugins/ReactionRoles/events/AddReactionRoleEvt.ts ================================================ import { Message } from "discord.js"; import { noop, resolveMember, sleep } from "../../../utils.js"; import { reactionRolesEvt } from "../types.js"; import { addMemberPendingRoleChange } from "../util/addMemberPendingRoleChange.js"; const CLEAR_ROLES_EMOJI = "❌"; export const AddReactionRoleEvt = reactionRolesEvt({ event: "messageReactionAdd", async listener(meta) { const pluginData = meta.pluginData; const msg = meta.args.reaction.message as Message; const emoji = meta.args.reaction.emoji; const userId = meta.args.user.id; if (userId === pluginData.client.user!.id) { // Don't act on own reactions // FIXME: This may not be needed? Vety currently requires the *member* to be found for the user to be resolved as well. Need to look into it more. return; } // Make sure this message has reaction roles on it const reactionRoles = await pluginData.state.reactionRoles.getForMessage(msg.id); if (reactionRoles.length === 0) return; const member = await resolveMember(pluginData.client, pluginData.guild, userId); if (!member) return; if (emoji.name === CLEAR_ROLES_EMOJI) { // User reacted with "clear roles" emoji -> clear their roles const reactionRoleRoleIds = reactionRoles.map((rr) => rr.role_id); for (const roleId of reactionRoleRoleIds) { addMemberPendingRoleChange(pluginData, userId, "-", roleId); } } else { // User reacted with a reaction role emoji -> add the role const matchingReactionRole = await pluginData.state.reactionRoles.getByMessageAndEmoji( msg.id, emoji.id || emoji.name!, ); if (!matchingReactionRole) return; // If the reaction role is exclusive, remove any other roles in the message first if (matchingReactionRole.is_exclusive) { const messageReactionRoles = await pluginData.state.reactionRoles.getForMessage(msg.id); for (const reactionRole of messageReactionRoles) { addMemberPendingRoleChange(pluginData, userId, "-", reactionRole.role_id); } } addMemberPendingRoleChange(pluginData, userId, "+", matchingReactionRole.role_id); } // Remove the reaction after a small delay const config = await pluginData.config.getForMember(member); if (config.remove_user_reactions) { setTimeout(() => { pluginData.state.reactionRemoveQueue.add(async () => { const wait = sleep(1500); await meta.args.reaction.users.remove(userId).catch(noop); await wait; }); }, 1500); } }, }); ================================================ FILE: backend/src/plugins/ReactionRoles/events/MessageDeletedEvt.ts ================================================ import { reactionRolesEvt } from "../types.js"; export const MessageDeletedEvt = reactionRolesEvt({ event: "messageDelete", allowBots: true, allowSelf: true, async listener(meta) { const pluginData = meta.pluginData; await pluginData.state.reactionRoles.removeFromMessage(meta.args.message.id); }, }); ================================================ FILE: backend/src/plugins/ReactionRoles/types.ts ================================================ import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { Queue } from "../../Queue.js"; import { GuildReactionRoles } from "../../data/GuildReactionRoles.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API export const zReactionRolesConfig = z.strictObject({ auto_refresh_interval: z.number().min(MIN_AUTO_REFRESH).default(MIN_AUTO_REFRESH), remove_user_reactions: z.boolean().default(true), can_manage: z.boolean().default(false), button_groups: z.null().default(null), }); export type RoleChangeMode = "+" | "-"; export type PendingMemberRoleChanges = { timeout: NodeJS.Timeout | null; applyFn: () => void; changes: Array<{ mode: RoleChangeMode; roleId: string; }>; }; const zReactionRolePair = z.union([z.tuple([z.string(), z.string(), z.string()]), z.tuple([z.string(), z.string()])]); export type TReactionRolePair = z.infer; export interface ReactionRolesPluginType extends BasePluginType { configSchema: typeof zReactionRolesConfig; state: { reactionRoles: GuildReactionRoles; savedMessages: GuildSavedMessages; reactionRemoveQueue: Queue; roleChangeQueue: Queue; pendingRoleChanges: Map; pendingRefreshes: Set; autoRefreshTimeout: NodeJS.Timeout; common: pluginUtils.PluginPublicInterface; }; } export const reactionRolesCmd = guildPluginMessageCommand(); export const reactionRolesEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { logger } from "../../../logger.js"; import { renderUsername, resolveMember } from "../../../utils.js"; import { memberRolesLock } from "../../../utils/lockNameHelpers.js"; import { PendingMemberRoleChanges, ReactionRolesPluginType, RoleChangeMode } from "../types.js"; const ROLE_CHANGE_BATCH_DEBOUNCE_TIME = 1500; export async function addMemberPendingRoleChange( pluginData: GuildPluginData, memberId: string, mode: RoleChangeMode, roleId: string, ) { if (!pluginData.state.pendingRoleChanges.has(memberId)) { const newPendingRoleChangeObj: PendingMemberRoleChanges = { timeout: null, changes: [], applyFn: async () => { pluginData.state.pendingRoleChanges.delete(memberId); const lock = await pluginData.locks.acquire(memberRolesLock({ id: memberId })); const member = await resolveMember(pluginData.client, pluginData.guild, memberId); if (member) { const newRoleIds = new Set(member.roles.cache.keys()); for (const change of newPendingRoleChangeObj.changes) { if (change.mode === "+") newRoleIds.add(change.roleId as Snowflake); else newRoleIds.delete(change.roleId as Snowflake); } try { await member.roles.set(Array.from(newRoleIds.values()), "Reaction roles"); } catch (e) { logger.warn(`Failed to apply role changes to ${renderUsername(member)} (${member.id}): ${e.message}`); } } lock.unlock(); }, }; pluginData.state.pendingRoleChanges.set(memberId, newPendingRoleChangeObj); } const pendingRoleChangeObj = pluginData.state.pendingRoleChanges.get(memberId)!; pendingRoleChangeObj.changes.push({ mode, roleId }); if (pendingRoleChangeObj.timeout) clearTimeout(pendingRoleChangeObj.timeout); pendingRoleChangeObj.timeout = setTimeout( () => pluginData.state.roleChangeQueue.add(pendingRoleChangeObj.applyFn), ROLE_CHANGE_BATCH_DEBOUNCE_TIME, ); } ================================================ FILE: backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { ReactionRole } from "../../../data/entities/ReactionRole.js"; import { isDiscordAPIError, isDiscordJsTypeError, sleep } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { ReactionRolesPluginType } from "../types.js"; const CLEAR_ROLES_EMOJI = "❌"; /** * @return Errors encountered while applying reaction roles, if any */ export async function applyReactionRoleReactionsToMessage( pluginData: GuildPluginData, channelId: string, messageId: string, reactionRoles: ReactionRole[], ): Promise { const channel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!channel?.isTextBased()) return; const errors: string[] = []; const logs = pluginData.getPlugin(LogsPlugin); let targetMessage; try { targetMessage = await channel.messages.fetch({ message: messageId, force: true }); } catch (e) { if (isDiscordAPIError(e)) { if (e.code === 10008) { // Unknown message, remove reaction roles from the message logs.logBotAlert({ body: `Removed reaction roles from unknown message ${channelId}/${messageId} (${pluginData.guild.id})`, }); await pluginData.state.reactionRoles.removeFromMessage(messageId); } else { logs.logBotAlert({ body: `Error ${e.code} when applying reaction roles to message ${channelId}/${messageId}: ${e.message}`, }); } errors.push(`Error ${e.code} while fetching reaction role message: ${e.message}`); return errors; } else { throw e; } } // Remove old reactions, if any try { await targetMessage.reactions.removeAll(); } catch (e) { if (isDiscordAPIError(e)) { errors.push(`Error ${e.code} while removing old reactions: ${e.message}`); logs.logBotAlert({ body: `Error ${e.code} while removing old reaction role reactions from message ${channelId}/${messageId}: ${e.message}`, }); return errors; } throw e; } await sleep(1500); // Add reaction role reactions const emojisToAdd = reactionRoles.map((rr) => rr.emoji); emojisToAdd.push(CLEAR_ROLES_EMOJI); for (const rawEmoji of emojisToAdd) { try { await targetMessage.react(rawEmoji); await sleep(750); // Make sure we don't hit rate limits } catch (e) { if (isDiscordJsTypeError(e)) { errors.push(e.message); logs.logBotAlert({ body: `Error ${e.code} while applying reaction role reactions to ${channelId}/${messageId}: ${e.message}.`, }); } else if (isDiscordAPIError(e)) { if (e.code === 10014) { pluginData.state.reactionRoles.removeFromMessage(messageId, rawEmoji); errors.push(`Unknown emoji: ${rawEmoji}`); logs.logBotAlert({ body: `Could not add unknown reaction role emoji ${rawEmoji} to message ${channelId}/${messageId}`, }); continue; } else if (e.code === 50013) { errors.push(`Missing permissions to apply reactions`); logs.logBotAlert({ body: `Error ${e.code} while applying reaction role reactions to ${channelId}/${messageId}: ${e.message}`, }); break; } else if (e.code === 30010) { errors.push(`Maximum number of reactions reached (20)`); logs.logBotAlert({ body: `Error ${e.code} while applying reaction role reactions to ${channelId}/${messageId}: ${e.message}`, }); break; } } throw e; } } return errors; } ================================================ FILE: backend/src/plugins/ReactionRoles/util/autoRefreshLoop.ts ================================================ import { GuildPluginData } from "vety"; import { ReactionRolesPluginType } from "../types.js"; import { runAutoRefresh } from "./runAutoRefresh.js"; export async function autoRefreshLoop(pluginData: GuildPluginData, interval: number) { pluginData.state.autoRefreshTimeout = setTimeout(async () => { await runAutoRefresh(pluginData); autoRefreshLoop(pluginData, interval); }, interval); } ================================================ FILE: backend/src/plugins/ReactionRoles/util/refreshReactionRoles.ts ================================================ import { GuildPluginData } from "vety"; import { ReactionRolesPluginType } from "../types.js"; import { applyReactionRoleReactionsToMessage } from "./applyReactionRoleReactionsToMessage.js"; export async function refreshReactionRoles( pluginData: GuildPluginData, channelId: string, messageId: string, ) { const pendingKey = `${channelId}-${messageId}`; if (pluginData.state.pendingRefreshes.has(pendingKey)) return; pluginData.state.pendingRefreshes.add(pendingKey); try { const reactionRoles = await pluginData.state.reactionRoles.getForMessage(messageId); await applyReactionRoleReactionsToMessage(pluginData, channelId, messageId, reactionRoles); } finally { pluginData.state.pendingRefreshes.delete(pendingKey); } } ================================================ FILE: backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts ================================================ import { GuildPluginData } from "vety"; import { ReactionRolesPluginType } from "../types.js"; import { refreshReactionRoles } from "./refreshReactionRoles.js"; export async function runAutoRefresh(pluginData: GuildPluginData) { // Refresh reaction roles on all reaction role messages const reactionRoles = await pluginData.state.reactionRoles.all(); const idPairs = new Set(reactionRoles.map((r) => `${r.channel_id}-${r.message_id}`)); for (const pair of idPairs) { const [channelId, messageId] = pair.split("-"); await refreshReactionRoles(pluginData, channelId, messageId); } } ================================================ FILE: backend/src/plugins/Reminders/RemindersPlugin.ts ================================================ import { guildPlugin } from "vety"; import { onGuildEvent } from "../../data/GuildEvents.js"; import { GuildReminders } from "../../data/GuildReminders.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { RemindCmd } from "./commands/RemindCmd.js"; import { RemindersCmd } from "./commands/RemindersCmd.js"; import { RemindersDeleteCmd } from "./commands/RemindersDeleteCmd.js"; import { postReminder } from "./functions/postReminder.js"; import { RemindersPluginType, zRemindersConfig } from "./types.js"; export const RemindersPlugin = guildPlugin()({ name: "reminders", dependencies: () => [TimeAndDatePlugin], configSchema: zRemindersConfig, defaultOverrides: [ { level: ">=50", config: { can_use: true, }, }, ], // prettier-ignore messageCommands: [ RemindCmd, RemindersCmd, RemindersDeleteCmd, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.reminders = GuildReminders.getGuildInstance(guild.id); state.tries = new Map(); state.unloaded = false; }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, afterLoad(pluginData) { const { state, guild } = pluginData; state.unregisterGuildEventListener = onGuildEvent(guild.id, "reminder", (reminder) => postReminder(pluginData, reminder), ); }, beforeUnload(pluginData) { const { state } = pluginData; state.unregisterGuildEventListener?.(); state.unloaded = true; }, }); ================================================ FILE: backend/src/plugins/Reminders/commands/RemindCmd.ts ================================================ import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { registerUpcomingReminder } from "../../../data/loops/upcomingRemindersLoop.js"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { convertDelayStringToMS, messageLink } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { remindersCmd } from "../types.js"; export const RemindCmd = remindersCmd({ trigger: ["remind", "remindme", "reminder"], usage: "!remind 3h Remind me of this in 3 hours please", permission: "can_use", signature: { time: ct.string(), reminder: ct.string({ required: false, catchAll: true }), }, async run({ message: msg, args, pluginData }) { const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); const now = moment.utc(); const tz = await timeAndDate.getMemberTz(msg.author.id); let reminderTime: moment.Moment; if (args.time.match(/^\d{4}-\d{1,2}-\d{1,2}$/)) { // Date in YYYY-MM-DD format, remind at current time on that date reminderTime = moment.tz(args.time, "YYYY-M-D", tz).set({ hour: now.hour(), minute: now.minute(), second: now.second(), }); } else if (args.time.match(/^\d{4}-\d{1,2}-\d{1,2}T\d{2}:\d{2}$/)) { // Date and time in YYYY-MM-DD[T]HH:mm format reminderTime = moment.tz(args.time, "YYYY-M-D[T]HH:mm", tz).second(0); } else { // "Delay string" i.e. e.g. "2h30m" const ms = convertDelayStringToMS(args.time); if (ms === null) { void pluginData.state.common.sendErrorMessage(msg, "Invalid reminder time"); return; } reminderTime = moment.utc().add(ms, "millisecond"); } if (!reminderTime.isValid() || reminderTime.isBefore(now)) { void pluginData.state.common.sendErrorMessage(msg, "Invalid reminder time"); return; } const reminderBody = args.reminder || messageLink(pluginData.guild.id, msg.channel.id, msg.id); const reminder = await pluginData.state.reminders.add( msg.author.id, msg.channel.id, reminderTime.clone().tz("Etc/UTC").format("YYYY-MM-DD HH:mm:ss"), reminderBody, moment.utc().format("YYYY-MM-DD HH:mm:ss"), ); registerUpcomingReminder(reminder); const msUntilReminder = reminderTime.diff(now); const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true }); const prettyReminderTime = (await timeAndDate.inMemberTz(msg.author.id, reminderTime)).format( pluginData.getPlugin(TimeAndDatePlugin).getDateFormat("pretty_datetime"), ); void pluginData.state.common.sendSuccessMessage( msg, `I will remind you in **${timeUntilReminder}** at **${prettyReminderTime}**`, ); }, }); ================================================ FILE: backend/src/plugins/Reminders/commands/RemindersCmd.ts ================================================ import moment from "moment-timezone"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { createChunkedMessage, DBDateFormat, sorter } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { remindersCmd } from "../types.js"; export const RemindersCmd = remindersCmd({ trigger: "reminders", permission: "can_use", async run({ message: msg, pluginData }) { const reminders = await pluginData.state.reminders.getRemindersByUserId(msg.author.id); if (reminders.length === 0) { void pluginData.state.common.sendErrorMessage(msg, "No reminders"); return; } const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); reminders.sort(sorter("remind_at")); const longestNum = (reminders.length + 1).toString().length; const lines = Array.from(reminders.entries()).map(([i, reminder]) => { const num = i + 1; const paddedNum = num.toString().padStart(longestNum, " "); const target = moment.utc(reminder.remind_at, "YYYY-MM-DD HH:mm:ss"); const diff = target.diff(moment.utc()); const result = humanizeDuration(diff, { largest: 2, round: true }); const prettyRemindAt = timeAndDate .inGuildTz(moment.utc(reminder.remind_at, DBDateFormat)) .format(timeAndDate.getDateFormat("pretty_datetime")); return `\`${paddedNum}.\` \`${prettyRemindAt} (${result})\` ${reminder.body}`; }); createChunkedMessage(msg.channel, lines.join("\n")); }, }); ================================================ FILE: backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { clearUpcomingReminder } from "../../../data/loops/upcomingRemindersLoop.js"; import { sorter } from "../../../utils.js"; import { remindersCmd } from "../types.js"; export const RemindersDeleteCmd = remindersCmd({ trigger: ["reminders delete", "reminders d"], permission: "can_use", signature: { num: ct.number(), }, async run({ message: msg, args, pluginData }) { const reminders = await pluginData.state.reminders.getRemindersByUserId(msg.author.id); reminders.sort(sorter("remind_at")); if (args.num > reminders.length || args.num <= 0) { void pluginData.state.common.sendErrorMessage(msg, "Unknown reminder"); return; } const toDelete = reminders[args.num - 1]; clearUpcomingReminder(toDelete); await pluginData.state.reminders.delete(toDelete.id); void pluginData.state.common.sendSuccessMessage(msg, "Reminder deleted"); }, }); ================================================ FILE: backend/src/plugins/Reminders/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zRemindersConfig } from "./types.js"; export const remindersPluginDocs: ZeppelinPluginDocs = { prettyName: "Reminders", configSchema: zRemindersConfig, type: "stable", }; ================================================ FILE: backend/src/plugins/Reminders/functions/postReminder.ts ================================================ import { HTTPError, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { disableLinkPreviews } from "vety/helpers"; import moment from "moment-timezone"; import { Reminder } from "../../../data/entities/Reminder.js"; import { DBDateFormat } from "../../../utils.js"; import { RemindersPluginType } from "../types.js"; export async function postReminder(pluginData: GuildPluginData, reminder: Reminder) { const channel = pluginData.guild.channels.cache.get(reminder.channel_id as Snowflake); if (channel && (channel.isTextBased() || channel.isThread())) { try { // Only show created at date if one exists if (moment.utc(reminder.created_at).isValid()) { const createdAtTS = Math.floor(moment.utc(reminder.created_at, DBDateFormat).valueOf() / 1000); await channel.send({ content: disableLinkPreviews( `Reminder for <@!${reminder.user_id}>: ${reminder.body} \nSet `, ), allowedMentions: { users: [reminder.user_id as Snowflake], }, }); } else { await channel.send({ content: disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`), allowedMentions: { users: [reminder.user_id as Snowflake], }, }); } } catch (err) { // tslint:disable-next-line:no-console console.warn(`Error when posting reminder for ${reminder.user_id} in guild ${reminder.guild_id}: ${String(err)}`); if (err instanceof HTTPError && err.status >= 500) { // If we get a server error, try again later return; } } } await pluginData.state.reminders.delete(reminder.id); } ================================================ FILE: backend/src/plugins/Reminders/types.ts ================================================ import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { GuildReminders } from "../../data/GuildReminders.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zRemindersConfig = z.strictObject({ can_use: z.boolean().default(false), }); export interface RemindersPluginType extends BasePluginType { configSchema: typeof zRemindersConfig; state: { reminders: GuildReminders; tries: Map; common: pluginUtils.PluginPublicInterface; unregisterGuildEventListener: () => void; unloaded: boolean; }; } export const remindersCmd = guildPluginMessageCommand(); ================================================ FILE: backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildRoleButtons } from "../../data/GuildRoleButtons.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin.js"; import { resetButtonsCmd } from "./commands/resetButtons.js"; import { onButtonInteraction } from "./events/buttonInteraction.js"; import { applyAllRoleButtons } from "./functions/applyAllRoleButtons.js"; import { RoleButtonsPluginType, zRoleButtonsConfig } from "./types.js"; export const RoleButtonsPlugin = guildPlugin()({ name: "role_buttons", configSchema: zRoleButtonsConfig, defaultOverrides: [ { level: ">=100", config: { can_reset: true, }, }, ], dependencies: () => [LogsPlugin, RoleManagerPlugin], events: [onButtonInteraction], messageCommands: [resetButtonsCmd], beforeLoad(pluginData) { pluginData.state.roleButtons = GuildRoleButtons.getGuildInstance(pluginData.guild.id); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, async afterLoad(pluginData) { await applyAllRoleButtons(pluginData); }, }); ================================================ FILE: backend/src/plugins/RoleButtons/commands/resetButtons.ts ================================================ import { guildPluginMessageCommand } from "vety"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { applyAllRoleButtons } from "../functions/applyAllRoleButtons.js"; import { RoleButtonsPluginType } from "../types.js"; export const resetButtonsCmd = guildPluginMessageCommand()({ trigger: "role_buttons reset", description: "In case of issues, you can run this command to have Zeppelin 'forget' about specific role buttons and re-apply them. This will also repost the message, if not targeting an existing message.", usage: "!role_buttons reset my_roles", permission: "can_reset", signature: { name: ct.string(), }, async run({ pluginData, args, message }) { const config = pluginData.config.get(); if (!config.buttons[args.name]) { void pluginData.state.common.sendErrorMessage(message, `Can't find role buttons with the name "${args.name}"`); return; } await pluginData.state.roleButtons.deleteRoleButtonItem(args.name); await applyAllRoleButtons(pluginData); void pluginData.state.common.sendSuccessMessage(message, "Done!"); }, }); ================================================ FILE: backend/src/plugins/RoleButtons/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zRoleButtonsConfig } from "./types.js"; export const roleButtonsPluginDocs: ZeppelinPluginDocs = { type: "stable", prettyName: "Role buttons", description: trimPluginDescription(` Allow users to pick roles by clicking on buttons `), configurationGuide: trimPluginDescription(` Button roles are entirely config-based; this is in contrast to the old reaction roles. They can either be added to an existing message posted by Zeppelin or posted as a new message. ## Basic role buttons ~~~yml role_buttons: config: buttons: my_roles: # You can use any name you want here, but make sure not to change it afterwards message: channel_id: "967407495544983552" content: "Click the reactions below to get roles! Click again to remove the role." options: - role_id: "878339100015489044" label: "Role 1" - role_id: "967410091571703808" emoji: "😁" # Default emoji as a unicode emoji label: "Role 2" - role_id: "967410091571703234" emoji: "967412591683047445" # Custom emoji ID - role_id: "967410091571703567" label: "Role 4" style: DANGER # Button style (in all caps), see https://discord.com/developers/docs/interactions/message-components#button-object-button-styles ~~~ ### Or with an embed: ~~~yml role_buttons: config: buttons: my_roles: message: channel_id: "967407495544983552" content: embeds: - title: "Pick your role below!" color: 0x0088FF description: "You can pick any role you want by clicking the buttons below." options: ... # See above for examples for options ~~~ ## Role buttons for an existing message This message must be posted by Zeppelin. ~~~yml role_buttons: config: buttons: my_roles: message: channel_id: "967407495544983552" message_id: "967407554412040193" options: ... # See above for examples for options ~~~ ## Limiting to one role ("exclusive" roles) When the \`exclusive\` option is enabled, only one role can be selected at a time. ~~~yml role_buttons: config: buttons: my_roles: message: channel_id: "967407495544983552" message_id: "967407554412040193" exclusive: true # With this option set, only one role can be selected at a time options: ... # See above for examples for options ~~~ `), configSchema: zRoleButtonsConfig, }; ================================================ FILE: backend/src/plugins/RoleButtons/events/buttonInteraction.ts ================================================ import { GuildMember } from "discord.js"; import { guildPluginEventListener } from "vety"; import { SECONDS } from "../../../utils.js"; import { renderRecursively } from "../../../utils.js"; import { parseCustomId } from "../../../utils/parseCustomId.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; import { getAllRolesInButtons } from "../functions/getAllRolesInButtons.js"; import { RoleButtonsPluginType, TRoleButtonOption } from "../types.js"; import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember, roleToTemplateSafeRole, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; const ROLE_BUTTON_CD = 5 * SECONDS; export const onButtonInteraction = guildPluginEventListener()({ event: "interactionCreate", async listener({ pluginData, args }) { if (!args.interaction.isButton()) { return; } const { namespace, data } = parseCustomId(args.interaction.customId); if (namespace !== "roleButtons") { return; } const config = pluginData.config.get(); const { name, index: optionIndex } = data; // For some reason TS's type inference fails here so using a type annotation const buttons = config.buttons[name]; const option: TRoleButtonOption | undefined = buttons?.options[optionIndex]; if (!buttons || !option) { args.interaction .reply({ ephemeral: true, content: "Invalid option selected", }) .catch((err) => console.trace(err.message)); return; } const cdIdentifier = `${args.interaction.user.id}-${optionIndex}`; if (pluginData.cooldowns.isOnCooldown(cdIdentifier)) { args.interaction.reply({ ephemeral: true, content: "Please wait before clicking the button again", }); return; } pluginData.cooldowns.setCooldown(cdIdentifier, ROLE_BUTTON_CD); const member = args.interaction.member as GuildMember; const role = pluginData.guild.roles.cache.get(option.role_id); const roleName = role?.name || option.role_id; const rolesToRemove: string[] = []; const rolesToAdd: string[] = []; const renderTemplateText = async (str: string) => renderTemplate( str, new TemplateSafeValueContainer({ user: member ? memberToTemplateSafeMember(member) : userToTemplateSafeUser(args.interaction.user), role: role ? roleToTemplateSafeRole(role) : new TemplateSafeValueContainer({ name: roleName, id: option.role_id }), }), ); if (member.roles.cache.has(option.role_id)) { rolesToRemove.push(option.role_id); const messageTemplate = config.buttons[name].remove_message || `The role **${roleName}** will be removed shortly!`; const formatted = typeof messageTemplate === "string" ? await renderTemplateText(messageTemplate) : await renderRecursively(messageTemplate, renderTemplateText); args.interaction .reply({ ephemeral: true, ...(typeof formatted === "string" ? { content: formatted } : formatted) }) .catch((err) => console.trace(err.message)); } else { rolesToAdd.push(option.role_id); if (buttons.exclusive) { for (const roleId of getAllRolesInButtons(buttons)) { if (member.roles.cache.has(roleId)) { rolesToRemove.push(roleId); } } } const messageTemplate = config.buttons[name].add_message || `You will receive the **${roleName}** role shortly!`; const formatted = typeof messageTemplate === "string" ? await renderTemplateText(messageTemplate) : await renderRecursively(messageTemplate, renderTemplateText); args.interaction .reply({ ephemeral: true, ...(typeof formatted === "string" ? { content: formatted } : formatted) }) .catch((err) => console.trace(err.message)); } for (const roleId of rolesToAdd) { pluginData.getPlugin(RoleManagerPlugin).addRole(member.user.id, roleId); } for (const roleId of rolesToRemove) { pluginData.getPlugin(RoleManagerPlugin).removeRole(member.user.id, roleId); } }, }); ================================================ FILE: backend/src/plugins/RoleButtons/functions/TooManyComponentsError.ts ================================================ export class TooManyComponentsError extends Error {} ================================================ FILE: backend/src/plugins/RoleButtons/functions/applyAllRoleButtons.ts ================================================ import { createHash } from "crypto"; import { GuildPluginData } from "vety"; import { RoleButtonsPluginType } from "../types.js"; import { applyRoleButtons } from "./applyRoleButtons.js"; export async function applyAllRoleButtons(pluginData: GuildPluginData) { const savedRoleButtons = await pluginData.state.roleButtons.getSavedRoleButtons(); const config = pluginData.config.get(); for (const [configName, configItem] of Object.entries(config.buttons)) { // Use the hash of the config to quickly check if we need to update buttons const configItemToHash = { ...configItem, name: configName }; // Add name property for backwards compatibility const hash = createHash("md5").update(JSON.stringify(configItemToHash)).digest("hex"); const savedButtonsItem = savedRoleButtons.find((bt) => bt.name === configName); if (savedButtonsItem?.hash === hash) { // No changes continue; } if (savedButtonsItem) { await pluginData.state.roleButtons.deleteRoleButtonItem(configName); } const applyResult = await applyRoleButtons(pluginData, configItem, configName, savedButtonsItem ?? null); if (!applyResult) { return; } await pluginData.state.roleButtons.saveRoleButtonItem( configName, applyResult.channel_id, applyResult.message_id, hash, ); } // Remove saved role buttons from the DB that are no longer in the config const savedRoleButtonsToDelete = savedRoleButtons .filter((savedRoleButton) => !config.buttons[savedRoleButton.name]) .map((savedRoleButton) => savedRoleButton.name); for (const name of savedRoleButtonsToDelete) { await pluginData.state.roleButtons.deleteRoleButtonItem(name); } } ================================================ FILE: backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts ================================================ import { Message, MessageCreateOptions, MessageEditOptions } from "discord.js"; import { GuildPluginData } from "vety"; import { RoleButtonsItem } from "../../../data/entities/RoleButtonsItem.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleButtonsPluginType, TRoleButtonsConfigItem } from "../types.js"; import { createButtonComponents } from "./createButtonComponents.js"; export async function applyRoleButtons( pluginData: GuildPluginData, configItem: TRoleButtonsConfigItem, configName: string, existingSavedButtons: RoleButtonsItem | null, ): Promise<{ channel_id: string; message_id: string } | null> { let message: Message; // Remove existing role buttons, if any if (existingSavedButtons?.channel_id) { const existingChannel = await pluginData.guild.channels.fetch(configItem.message.channel_id).catch(() => null); const existingMessage = await (existingChannel?.isTextBased() && existingChannel.messages.fetch(existingSavedButtons.message_id).catch(() => null)); if (existingMessage && existingMessage.components.length) { await existingMessage.edit({ components: [], }); } } // Find or create message for role buttons if ("message_id" in configItem.message) { // channel id + message id: apply role buttons to existing message const channel = await pluginData.guild.channels.fetch(configItem.message.channel_id).catch(() => null); const messageCandidate = await (channel?.isTextBased() && channel.messages.fetch(configItem.message.message_id).catch(() => null)); if (!messageCandidate) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Message not found for role_buttons/${configName}`, }); return null; } message = messageCandidate; } else { // channel id + message content: post new message to apply role buttons to const contentIsValid = typeof configItem.message.content === "string" ? configItem.message.content.trim() !== "" : Boolean(configItem.message.content.content?.trim()) || configItem.message.content.embeds?.length; if (!contentIsValid) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Invalid message content for role_buttons/${configName}`, }); return null; } const channel = await pluginData.guild.channels.fetch(configItem.message.channel_id).catch(() => null); if (channel && (!channel.isTextBased || typeof channel.isTextBased !== "function")) { // FIXME: Probably not relevant anymore? // tslint:disable-next-line no-console console.log("wtf", pluginData.guild?.id, configItem.message.channel_id); } if (!channel || !channel?.isTextBased()) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Text channel not found for role_buttons/${configName}`, }); return null; } let candidateMessage: Message | null = null; if (existingSavedButtons?.channel_id === configItem.message.channel_id && existingSavedButtons.message_id) { try { candidateMessage = await channel.messages.fetch(existingSavedButtons.message_id); // Make sure message contents are up-to-date const editContent = typeof configItem.message.content === "string" ? { content: configItem.message.content } : { ...configItem.message.content }; if (!editContent.content) { // Editing with empty content doesn't go through at all for whatever reason, even if there's differences in e.g. the embeds, // so send a space as the content instead. This still functions as if there's no content at all. editContent.content = " "; } await candidateMessage.edit(editContent as MessageEditOptions); } catch (err) { // Message was deleted or is inaccessible. Proceed with reposting it. } } if (!candidateMessage) { try { candidateMessage = await channel.send(configItem.message.content as string | MessageCreateOptions); } catch (err) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Error while posting message for role_buttons/${configName}: ${String(err)}`, }); return null; } } message = candidateMessage; } if (message.author.id !== pluginData.client.user?.id) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Error applying role buttons for role_buttons/${configName}: target message must be posted by Zeppelin`, }); return null; } // Apply role buttons const components = createButtonComponents(configItem, configName); await message.edit({ components }).catch((err) => { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Error applying role buttons for role_buttons/${configName}: ${String(err)}`, }); return null; }); return { channel_id: message.channelId, message_id: message.id, }; } ================================================ FILE: backend/src/plugins/RoleButtons/functions/convertButtonStyleStringToEnum.ts ================================================ import { ButtonStyle } from "discord.js"; import { TRoleButtonOption } from "../types.js"; export function convertButtonStyleStringToEnum(input: TRoleButtonOption["style"]): ButtonStyle | null | undefined { switch (input) { case "PRIMARY": return ButtonStyle.Primary; case "SECONDARY": return ButtonStyle.Secondary; case "SUCCESS": return ButtonStyle.Success; case "DANGER": return ButtonStyle.Danger; default: return input; } } ================================================ FILE: backend/src/plugins/RoleButtons/functions/createButtonComponents.ts ================================================ import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; import { buildCustomId } from "../../../utils/buildCustomId.js"; import { TRoleButtonsConfigItem } from "../types.js"; import { TooManyComponentsError } from "./TooManyComponentsError.js"; import { convertButtonStyleStringToEnum } from "./convertButtonStyleStringToEnum.js"; export function createButtonComponents( configItem: TRoleButtonsConfigItem, configName: string, ): Array> { const rows: Array> = []; let currentRow = new ActionRowBuilder(); for (const [index, option] of configItem.options.entries()) { if (currentRow.components.length === 5 || (currentRow.components.length > 0 && option.start_new_row)) { rows.push(currentRow); currentRow = new ActionRowBuilder(); } const button = new ButtonBuilder() .setLabel(option.label ?? "") .setStyle(convertButtonStyleStringToEnum(option.style) ?? ButtonStyle.Primary) .setCustomId(buildCustomId("roleButtons", { name: configName, index })); if (option.emoji) { button.setEmoji(option.emoji); } currentRow.components.push(button); } if (currentRow.components.length > 0) { rows.push(currentRow); } if (rows.length > 5) { throw new TooManyComponentsError(); } return rows; } ================================================ FILE: backend/src/plugins/RoleButtons/functions/getAllRolesInButtons.ts ================================================ import { TRoleButtonsConfigItem } from "../types.js"; // This function will be more complex in the future when the plugin supports select menus + sub-menus export function getAllRolesInButtons(buttons: TRoleButtonsConfigItem): string[] { const roles = new Set(); for (const option of buttons.options) { roles.add(option.role_id); } return Array.from(roles); } ================================================ FILE: backend/src/plugins/RoleButtons/types.ts ================================================ import { ButtonStyle } from "discord.js"; import { BasePluginType, pluginUtils } from "vety"; import { z } from "zod"; import { GuildRoleButtons } from "../../data/GuildRoleButtons.js"; import { zBoundedCharacters, zBoundedRecord, zMessageContent, zSnowflake } from "../../utils.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { TooManyComponentsError } from "./functions/TooManyComponentsError.js"; import { createButtonComponents } from "./functions/createButtonComponents.js"; const zRoleButtonOption = z.strictObject({ role_id: zSnowflake, label: z.string().nullable().default(null), emoji: z.string().nullable().default(null), // https://discord.js.org/#/docs/discord.js/v13/typedef/MessageButtonStyle style: z .union([ z.literal(ButtonStyle.Primary), z.literal(ButtonStyle.Secondary), z.literal(ButtonStyle.Success), z.literal(ButtonStyle.Danger), // The following are deprecated z.literal("PRIMARY"), z.literal("SECONDARY"), z.literal("SUCCESS"), z.literal("DANGER"), // z.literal("LINK"), // Role buttons don't use link buttons, but adding this here so it's documented why it's not available ]) .nullable() .default(null), start_new_row: z.boolean().default(false), }); export type TRoleButtonOption = z.infer; const zRoleButtonsConfigItem = z .strictObject({ message: z.union([ z.strictObject({ channel_id: zSnowflake, message_id: zSnowflake, }), z.strictObject({ channel_id: zSnowflake, content: zMessageContent, }), ]), add_message: zMessageContent.optional(), remove_message: zMessageContent.optional(), options: z.array(zRoleButtonOption).max(25), exclusive: z.boolean().default(false), }) .refine( (parsed) => { try { createButtonComponents(parsed, "test"); // We can use any configName here } catch (err) { if (err instanceof TooManyComponentsError) { return false; } throw err; } return true; }, { message: "Too many options; can only have max 5 buttons per row on max 5 rows.", }, ); export type TRoleButtonsConfigItem = z.infer; export const zRoleButtonsConfig = z .strictObject({ buttons: zBoundedRecord(z.record(zBoundedCharacters(1, 16), zRoleButtonsConfigItem), 0, 100).default({}), can_reset: z.boolean().default(false), }) .refine( (parsed) => { const seenMessages = new Set(); for (const button of Object.values(parsed.buttons)) { if (button.message) { if ("message_id" in button.message) { if (seenMessages.has(button.message.message_id)) { return false; } seenMessages.add(button.message.message_id); } } } return true; }, { message: "Can't target the same message with two sets of role buttons", }, ); export interface RoleButtonsPluginType extends BasePluginType { configSchema: typeof zRoleButtonsConfig; state: { roleButtons: GuildRoleButtons; common: pluginUtils.PluginPublicInterface; }; } ================================================ FILE: backend/src/plugins/RoleManager/RoleManagerPlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildRoleQueue } from "../../data/GuildRoleQueue.js"; import { makePublicFn } from "../../pluginUtils.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { addPriorityRole } from "./functions/addPriorityRole.js"; import { addRole } from "./functions/addRole.js"; import { removePriorityRole } from "./functions/removePriorityRole.js"; import { removeRole } from "./functions/removeRole.js"; import { runRoleAssignmentLoop } from "./functions/runRoleAssignmentLoop.js"; import { RoleManagerPluginType, zRoleManagerConfig } from "./types.js"; export const RoleManagerPlugin = guildPlugin()({ name: "role_manager", dependencies: () => [LogsPlugin], configSchema: zRoleManagerConfig, public(pluginData) { return { addRole: makePublicFn(pluginData, addRole), removeRole: makePublicFn(pluginData, removeRole), addPriorityRole: makePublicFn(pluginData, addPriorityRole), removePriorityRole: makePublicFn(pluginData, removePriorityRole), }; }, beforeLoad(pluginData) { const { state, guild } = pluginData; state.roleQueue = GuildRoleQueue.getGuildInstance(guild.id); state.pendingRoleAssignmentPromise = Promise.resolve(); }, afterLoad(pluginData) { runRoleAssignmentLoop(pluginData); }, async afterUnload(pluginData) { const { state } = pluginData; state.abortRoleAssignmentLoop = true; await state.pendingRoleAssignmentPromise; }, }); ================================================ FILE: backend/src/plugins/RoleManager/constants.ts ================================================ export const PRIORITY_ROLE_PRIORITY = 10; ================================================ FILE: backend/src/plugins/RoleManager/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zRoleManagerConfig } from "./types.js"; export const roleManagerPluginDocs: ZeppelinPluginDocs = { prettyName: "Role manager", type: "internal", configSchema: zRoleManagerConfig, }; ================================================ FILE: backend/src/plugins/RoleManager/functions/addPriorityRole.ts ================================================ import { GuildPluginData } from "vety"; import { PRIORITY_ROLE_PRIORITY } from "../constants.js"; import { RoleManagerPluginType } from "../types.js"; import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop.js"; export async function addPriorityRole( pluginData: GuildPluginData, userId: string, roleId: string, ) { await pluginData.state.roleQueue.addQueueItem(userId, roleId, true, PRIORITY_ROLE_PRIORITY); runRoleAssignmentLoop(pluginData); } ================================================ FILE: backend/src/plugins/RoleManager/functions/addRole.ts ================================================ import { GuildPluginData } from "vety"; import { RoleManagerPluginType } from "../types.js"; import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop.js"; export async function addRole(pluginData: GuildPluginData, userId: string, roleId: string) { await pluginData.state.roleQueue.addQueueItem(userId, roleId, true); runRoleAssignmentLoop(pluginData); } ================================================ FILE: backend/src/plugins/RoleManager/functions/removePriorityRole.ts ================================================ import { GuildPluginData } from "vety"; import { PRIORITY_ROLE_PRIORITY } from "../constants.js"; import { RoleManagerPluginType } from "../types.js"; import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop.js"; export async function removePriorityRole( pluginData: GuildPluginData, userId: string, roleId: string, ) { await pluginData.state.roleQueue.addQueueItem(userId, roleId, false, PRIORITY_ROLE_PRIORITY); runRoleAssignmentLoop(pluginData); } ================================================ FILE: backend/src/plugins/RoleManager/functions/removeRole.ts ================================================ import { GuildPluginData } from "vety"; import { RoleManagerPluginType } from "../types.js"; import { runRoleAssignmentLoop } from "./runRoleAssignmentLoop.js"; export async function removeRole(pluginData: GuildPluginData, userId: string, roleId: string) { await pluginData.state.roleQueue.addQueueItem(userId, roleId, false); runRoleAssignmentLoop(pluginData); } ================================================ FILE: backend/src/plugins/RoleManager/functions/runRoleAssignmentLoop.ts ================================================ import { GuildPluginData } from "vety"; import { logger } from "../../../logger.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPluginType } from "../types.js"; const ROLE_ASSIGNMENTS_PER_BATCH = 10; export async function runRoleAssignmentLoop(pluginData: GuildPluginData) { if (pluginData.state.roleAssignmentLoopRunning || pluginData.state.abortRoleAssignmentLoop) { return; } pluginData.state.roleAssignmentLoopRunning = true; while (true) { // Abort on unload if (pluginData.state.abortRoleAssignmentLoop) { break; } if (!pluginData.state.roleAssignmentLoopRunning) { break; } await (pluginData.state.pendingRoleAssignmentPromise = (async () => { // Process assignments in batches, stopping once the queue's exhausted const nextAssignments = await pluginData.state.roleQueue.consumeNextRoleAssignments(ROLE_ASSIGNMENTS_PER_BATCH); if (nextAssignments.length === 0) { pluginData.state.roleAssignmentLoopRunning = false; return; } for (const assignment of nextAssignments) { const member = await pluginData.guild.members.fetch(assignment.user_id).catch(() => null); if (!member) { return; } const operation = assignment.should_add ? member.roles.add(assignment.role_id) : member.roles.remove(assignment.role_id); await operation.catch((err) => { logger.warn(err); pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Could not ${assignment.should_add ? "assign" : "remove"} role <@&${assignment.role_id}> (\`${ assignment.role_id }\`) ${assignment.should_add ? "to" : "from"} <@!${assignment.user_id}> (\`${assignment.user_id}\`)`, }); }); } })()); } } ================================================ FILE: backend/src/plugins/RoleManager/types.ts ================================================ import { BasePluginType } from "vety"; import { z } from "zod"; import { GuildRoleQueue } from "../../data/GuildRoleQueue.js"; export const zRoleManagerConfig = z.strictObject({}); export interface RoleManagerPluginType extends BasePluginType { configSchema: typeof zRoleManagerConfig; state: { roleQueue: GuildRoleQueue; roleAssignmentLoopRunning: boolean; abortRoleAssignmentLoop: boolean; pendingRoleAssignmentPromise: Promise; }; } ================================================ FILE: backend/src/plugins/Roles/RolesPlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildLogs } from "../../data/GuildLogs.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin.js"; import { AddRoleCmd } from "./commands/AddRoleCmd.js"; import { MassAddRoleCmd } from "./commands/MassAddRoleCmd.js"; import { MassRemoveRoleCmd } from "./commands/MassRemoveRoleCmd.js"; import { RemoveRoleCmd } from "./commands/RemoveRoleCmd.js"; import { RolesPluginType, zRolesConfig } from "./types.js"; export const RolesPlugin = guildPlugin()({ name: "roles", dependencies: () => [LogsPlugin, RoleManagerPlugin], configSchema: zRolesConfig, defaultOverrides: [ { level: ">=50", config: { can_assign: true, }, }, { level: ">=100", config: { can_mass_assign: true, }, }, ], // prettier-ignore messageCommands: [ AddRoleCmd, RemoveRoleCmd, MassAddRoleCmd, MassRemoveRoleCmd, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.logs = new GuildLogs(guild.id); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, }); ================================================ FILE: backend/src/plugins/Roles/commands/AddRoleCmd.ts ================================================ import { GuildChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { resolveRoleId, verboseUserMention } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; import { rolesCmd } from "../types.js"; export const AddRoleCmd = rolesCmd({ trigger: "addrole", permission: "can_assign", description: "Add a role to the specified member", signature: { member: ct.resolvedMember(), role: ct.string({ catchAll: true }), }, async run({ message: msg, args, pluginData }) { const member = await resolveMessageMember(msg); if (!canActOn(pluginData, member, args.member, true)) { void pluginData.state.common.sendErrorMessage(msg, "Cannot add roles to this user: insufficient permissions"); return; } const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { void pluginData.state.common.sendErrorMessage(msg, "Invalid role id"); return; } const config = await pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { void pluginData.state.common.sendErrorMessage(msg, "You cannot assign that role"); return; } // Sanity check: make sure the role is configured properly const role = (msg.channel as GuildChannel).guild.roles.cache.get(roleId); if (!role) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); void pluginData.state.common.sendErrorMessage(msg, "You cannot assign that role"); return; } if (args.member.roles.cache.has(roleId)) { void pluginData.state.common.sendErrorMessage(msg, "Member already has that role"); return; } pluginData.getPlugin(RoleManagerPlugin).addRole(args.member.id, roleId); pluginData.getPlugin(LogsPlugin).logMemberRoleAdd({ mod: msg.author, member: args.member, roles: [role], }); void pluginData.state.common.sendSuccessMessage( msg, `Added role **${role.name}** to ${verboseUserMention(args.member.user)}!`, ); }, }); ================================================ FILE: backend/src/plugins/Roles/commands/MassAddRoleCmd.ts ================================================ import { GuildMember } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { logger } from "../../../logger.js"; import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { resolveMember, resolveRoleId, successMessage } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; import { rolesCmd } from "../types.js"; export const MassAddRoleCmd = rolesCmd({ trigger: "massaddrole", permission: "can_mass_assign", signature: { role: ct.string(), members: ct.string({ rest: true }), }, async run({ message: msg, args, pluginData }) { msg.channel.send(`Resolving members...`); const authorMember = await resolveMessageMember(msg); const members: GuildMember[] = []; const unknownMembers: string[] = []; for (const memberId of args.members) { const member = await resolveMember(pluginData.client, pluginData.guild, memberId); if (member) members.push(member); else unknownMembers.push(memberId); } for (const member of members) { if (!canActOn(pluginData, authorMember, member, true)) { void pluginData.state.common.sendErrorMessage( msg, "Cannot add roles to 1 or more specified members: insufficient permissions", ); return; } } const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { void pluginData.state.common.sendErrorMessage(msg, "Invalid role id"); return; } const config = await pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { void pluginData.state.common.sendErrorMessage(msg, "You cannot assign that role"); return; } const role = pluginData.guild.roles.cache.get(roleId); if (!role) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); void pluginData.state.common.sendErrorMessage(msg, "You cannot assign that role"); return; } const membersWithoutTheRole = members.filter((m) => !m.roles.cache.has(roleId)); let assigned = 0; const failed: string[] = []; const alreadyHadRole = members.length - membersWithoutTheRole.length; msg.channel.send( `Adding role **${role.name}** to ${membersWithoutTheRole.length} ${ membersWithoutTheRole.length === 1 ? "member" : "members" }...`, ); for (const member of membersWithoutTheRole) { try { pluginData.getPlugin(RoleManagerPlugin).addRole(member.id, roleId); pluginData.getPlugin(LogsPlugin).logMemberRoleAdd({ member, roles: [role], mod: msg.author, }); assigned++; } catch (e) { logger.warn(`Error when adding role via !massaddrole: ${e.message}`); failed.push(member.id); } } let resultMessage = `Added role **${role.name}** to ${assigned} ${assigned === 1 ? "member" : "members"}!`; if (alreadyHadRole) { resultMessage += ` ${alreadyHadRole} ${alreadyHadRole === 1 ? "member" : "members"} already had the role.`; } if (failed.length) { resultMessage += `\nFailed to add the role to the following members: ${failed.join(", ")}`; } if (unknownMembers.length) { resultMessage += `\nUnknown members: ${unknownMembers.join(", ")}`; } msg.channel.send(successMessage(resultMessage)); }, }); ================================================ FILE: backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts ================================================ import { GuildMember } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { resolveMember, resolveRoleId, successMessage } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; import { rolesCmd } from "../types.js"; export const MassRemoveRoleCmd = rolesCmd({ trigger: "massremoverole", permission: "can_mass_assign", signature: { role: ct.string(), members: ct.string({ rest: true }), }, async run({ message: msg, args, pluginData }) { msg.channel.send(`Resolving members...`); const authorMember = await resolveMessageMember(msg); const members: GuildMember[] = []; const unknownMembers: string[] = []; for (const memberId of args.members) { const member = await resolveMember(pluginData.client, pluginData.guild, memberId); if (member) members.push(member); else unknownMembers.push(memberId); } for (const member of members) { if (!canActOn(pluginData, authorMember, member, true)) { void pluginData.state.common.sendErrorMessage( msg, "Cannot add roles to 1 or more specified members: insufficient permissions", ); return; } } const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { void pluginData.state.common.sendErrorMessage(msg, "Invalid role id"); return; } const config = await pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { void pluginData.state.common.sendErrorMessage(msg, "You cannot remove that role"); return; } const role = pluginData.guild.roles.cache.get(roleId); if (!role) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); void pluginData.state.common.sendErrorMessage(msg, "You cannot remove that role"); return; } const membersWithTheRole = members.filter((m) => m.roles.cache.has(roleId)); let assigned = 0; const failed: string[] = []; const didNotHaveRole = members.length - membersWithTheRole.length; msg.channel.send( `Removing role **${role.name}** from ${membersWithTheRole.length} ${ membersWithTheRole.length === 1 ? "member" : "members" }...`, ); for (const member of membersWithTheRole) { pluginData.getPlugin(RoleManagerPlugin).removeRole(member.id, roleId); pluginData.getPlugin(LogsPlugin).logMemberRoleRemove({ member, roles: [role], mod: msg.author, }); assigned++; } let resultMessage = `Removed role **${role.name}** from ${assigned} ${assigned === 1 ? "member" : "members"}!`; if (didNotHaveRole) { resultMessage += ` ${didNotHaveRole} ${didNotHaveRole === 1 ? "member" : "members"} didn't have the role.`; } if (failed.length) { resultMessage += `\nFailed to remove the role from the following members: ${failed.join(", ")}`; } if (unknownMembers.length) { resultMessage += `\nUnknown members: ${unknownMembers.join(", ")}`; } msg.channel.send(successMessage(resultMessage)); }, }); ================================================ FILE: backend/src/plugins/Roles/commands/RemoveRoleCmd.ts ================================================ import { GuildChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { resolveRoleId, verboseUserMention } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; import { rolesCmd } from "../types.js"; export const RemoveRoleCmd = rolesCmd({ trigger: "removerole", permission: "can_assign", description: "Remove a role from the specified member", signature: { member: ct.resolvedMember(), role: ct.string({ catchAll: true }), }, async run({ message: msg, args, pluginData }) { const authorMember = await resolveMessageMember(msg); if (!canActOn(pluginData, authorMember, args.member, true)) { void pluginData.state.common.sendErrorMessage( msg, "Cannot remove roles from this user: insufficient permissions", ); return; } const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { void pluginData.state.common.sendErrorMessage(msg, "Invalid role id"); return; } const config = await pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { void pluginData.state.common.sendErrorMessage(msg, "You cannot remove that role"); return; } // Sanity check: make sure the role is configured properly const role = (msg.channel as GuildChannel).guild.roles.cache.get(roleId); if (!role) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); void pluginData.state.common.sendErrorMessage(msg, "You cannot remove that role"); return; } if (!args.member.roles.cache.has(roleId)) { void pluginData.state.common.sendErrorMessage(msg, "Member doesn't have that role"); return; } pluginData.getPlugin(RoleManagerPlugin).removeRole(args.member.id, roleId); pluginData.getPlugin(LogsPlugin).logMemberRoleRemove({ mod: msg.author, member: args.member, roles: [role], }); void pluginData.state.common.sendSuccessMessage( msg, `Removed role **${role.name}** from ${verboseUserMention(args.member.user)}!`, ); }, }); ================================================ FILE: backend/src/plugins/Roles/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zRolesConfig } from "./types.js"; export const rolesPluginDocs: ZeppelinPluginDocs = { type: "stable", prettyName: "Roles", description: trimPluginDescription(` Enables authorised users to add and remove whitelisted roles with a command. `), configSchema: zRolesConfig, }; ================================================ FILE: backend/src/plugins/Roles/types.ts ================================================ import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { GuildLogs } from "../../data/GuildLogs.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zRolesConfig = z.strictObject({ can_assign: z.boolean().default(false), can_mass_assign: z.boolean().default(false), assignable_roles: z.array(z.string()).max(100).default([]), }); export interface RolesPluginType extends BasePluginType { configSchema: typeof zRolesConfig; state: { logs: GuildLogs; common: pluginUtils.PluginPublicInterface; }; } export const rolesCmd = guildPluginMessageCommand(); ================================================ FILE: backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts ================================================ import { CooldownManager, guildPlugin } from "vety"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { RoleAddCmd } from "./commands/RoleAddCmd.js"; import { RoleHelpCmd } from "./commands/RoleHelpCmd.js"; import { RoleRemoveCmd } from "./commands/RoleRemoveCmd.js"; import { SelfGrantableRolesPluginType, zSelfGrantableRolesConfig } from "./types.js"; export const SelfGrantableRolesPlugin = guildPlugin()({ name: "self_grantable_roles", configSchema: zSelfGrantableRolesConfig, // prettier-ignore messageCommands: [ RoleHelpCmd, RoleRemoveCmd, RoleAddCmd, ], beforeLoad(pluginData) { pluginData.state.cooldowns = new CooldownManager(); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, }); ================================================ FILE: backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts ================================================ import { Role, Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { resolveMessageMember } from "../../../pluginUtils.js"; import { memberRolesLock } from "../../../utils/lockNameHelpers.js"; import { selfGrantableRolesCmd } from "../types.js"; import { findMatchingRoles } from "../util/findMatchingRoles.js"; import { getApplyingEntries } from "../util/getApplyingEntries.js"; import { normalizeRoleNames } from "../util/normalizeRoleNames.js"; import { splitRoleNames } from "../util/splitRoleNames.js"; export const RoleAddCmd = selfGrantableRolesCmd({ trigger: ["role", "role add"], permission: null, signature: { roleNames: ct.string({ rest: true }), }, async run({ message: msg, args, pluginData }) { const lock = await pluginData.locks.acquire(memberRolesLock(msg.author)); const applyingEntries = await getApplyingEntries(pluginData, msg); if (applyingEntries.length === 0) { lock.unlock(); return; } const roleNames = normalizeRoleNames(splitRoleNames(args.roleNames)); const matchedRoleIds = findMatchingRoles(roleNames, applyingEntries); const hasUnknownRoles = matchedRoleIds.length !== roleNames.length; const rolesToAdd: Map = Array.from(matchedRoleIds.values()) .map((id) => pluginData.guild.roles.cache.get(id as Snowflake)!) .filter(Boolean) .reduce((map, role) => { map.set(role.id, role); return map; }, new Map()); if (!rolesToAdd.size) { void pluginData.state.common.sendErrorMessage( msg, `<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`, { users: [msg.author.id], }, ); lock.unlock(); return; } const authorMember = await resolveMessageMember(msg); // Grant the roles const newRoleIds = new Set([...rolesToAdd.keys(), ...authorMember.roles.cache.keys()]); // Remove extra roles (max_roles) for each entry const skipped: Set = new Set(); const removed: Set = new Set(); for (const entry of applyingEntries) { if (entry.max_roles === 0) continue; let foundRoles = 0; for (const roleId of newRoleIds) { if (entry.roles[roleId]) { if (foundRoles < entry.max_roles) { foundRoles++; } else { newRoleIds.delete(roleId); rolesToAdd.delete(roleId); if (authorMember.roles.cache.has(roleId as Snowflake)) { removed.add(pluginData.guild.roles.cache.get(roleId as Snowflake)!); } else { skipped.add(pluginData.guild.roles.cache.get(roleId as Snowflake)!); } } } } } try { await authorMember.edit({ roles: Array.from(newRoleIds) as Snowflake[], }); } catch { void pluginData.state.common.sendErrorMessage( msg, `<@!${msg.author.id}> Got an error while trying to grant you the roles`, { users: [msg.author.id], }, ); return; } const mentionRoles = pluginData.config.get().mention_roles; const addedRolesStr = Array.from(rolesToAdd.values()).map((r) => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`)); const addedRolesWord = rolesToAdd.size === 1 ? "role" : "roles"; const messageParts: string[] = []; messageParts.push(`Granted you the ${addedRolesStr.join(", ")} ${addedRolesWord}`); if (skipped.size || removed.size) { const skippedRolesStr = skipped.size ? "skipped " + Array.from(skipped.values()) .map((r) => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`)) .join(",") : null; const removedRolesStr = removed.size ? "removed " + Array.from(removed.values()).map((r) => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`)) : null; const skippedRemovedStr = [skippedRolesStr, removedRolesStr].filter(Boolean).join(" and "); messageParts.push(`${skippedRemovedStr} due to role limits`); } if (hasUnknownRoles) { messageParts.push("couldn't recognize some of the roles"); } void pluginData.state.common.sendSuccessMessage(msg, `<@!${msg.author.id}> ${messageParts.join("; ")}`, { users: [msg.author.id], }); lock.unlock(); }, }); ================================================ FILE: backend/src/plugins/SelfGrantableRoles/commands/RoleHelpCmd.ts ================================================ import { asSingleLine, trimLines } from "../../../utils.js"; import { selfGrantableRolesCmd } from "../types.js"; import { getApplyingEntries } from "../util/getApplyingEntries.js"; export const RoleHelpCmd = selfGrantableRolesCmd({ trigger: ["role help", "role"], permission: null, async run({ message: msg, pluginData }) { const applyingEntries = await getApplyingEntries(pluginData, msg); if (applyingEntries.length === 0) return; const allPrimaryAliases: string[] = []; for (const entry of applyingEntries) { for (const aliases of Object.values(entry.roles)) { if (aliases[0]) { allPrimaryAliases.push(aliases[0]); } } } const prefix = pluginData.fullConfig.prefix; const [firstRole, secondRole] = allPrimaryAliases; const help1 = asSingleLine(` To give yourself a role, type e.g. \`${prefix}role ${firstRole}\` where **${firstRole}** is the role you want. ${secondRole ? `You can also add multiple roles at once, e.g. \`${prefix}role ${firstRole} ${secondRole}\`` : ""} `); const help2 = asSingleLine(` To remove a role, type \`${prefix}role remove ${firstRole}\`, again replacing **${firstRole}** with the role you want to remove. `); const helpMessage = trimLines(` ${help1} ${help2} **Roles available to you:** ${allPrimaryAliases.join(", ")} `); const helpEmbed = { title: "How to get roles", description: helpMessage, color: parseInt("42bff4", 16), }; msg.channel.send({ embeds: [helpEmbed] }); }, }); ================================================ FILE: backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts ================================================ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { resolveMessageMember } from "../../../pluginUtils.js"; import { memberRolesLock } from "../../../utils/lockNameHelpers.js"; import { selfGrantableRolesCmd } from "../types.js"; import { findMatchingRoles } from "../util/findMatchingRoles.js"; import { getApplyingEntries } from "../util/getApplyingEntries.js"; import { normalizeRoleNames } from "../util/normalizeRoleNames.js"; import { splitRoleNames } from "../util/splitRoleNames.js"; export const RoleRemoveCmd = selfGrantableRolesCmd({ trigger: "role remove", permission: null, signature: { roleNames: ct.string({ rest: true }), }, async run({ message: msg, args, pluginData }) { const lock = await pluginData.locks.acquire(memberRolesLock(msg.author)); const applyingEntries = await getApplyingEntries(pluginData, msg); if (applyingEntries.length === 0) { lock.unlock(); return; } const roleNames = normalizeRoleNames(splitRoleNames(args.roleNames)); const matchedRoleIds = findMatchingRoles(roleNames, applyingEntries); const rolesToRemove = Array.from(matchedRoleIds.values()).map( (id) => pluginData.guild.roles.cache.get(id as Snowflake)!, ); const roleIdsToRemove = rolesToRemove.map((r) => r.id); const authorMember = await resolveMessageMember(msg); // Remove the roles if (rolesToRemove.length) { const newRoleIds = authorMember.roles.cache.filter((role) => !roleIdsToRemove.includes(role.id)); try { await authorMember.edit({ roles: newRoleIds, }); const removedRolesStr = rolesToRemove.map((r) => `**${r.name}**`); const removedRolesWord = rolesToRemove.length === 1 ? "role" : "roles"; if (rolesToRemove.length !== roleNames.length) { void pluginData.state.common.sendSuccessMessage( msg, `<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord};` + ` couldn't recognize the other roles you mentioned`, { users: [msg.author.id] }, ); } else { void pluginData.state.common.sendSuccessMessage( msg, `<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord}`, { users: [msg.author.id], }, ); } } catch { void pluginData.state.common.sendSuccessMessage( msg, `<@!${msg.author.id}> Got an error while trying to remove the roles`, { users: [msg.author.id], }, ); } } else { void pluginData.state.common.sendErrorMessage( msg, `<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`, { users: [msg.author.id], }, ); } lock.unlock(); }, }); ================================================ FILE: backend/src/plugins/SelfGrantableRoles/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zSelfGrantableRolesConfig } from "./types.js"; export const selfGrantableRolesPluginDocs: ZeppelinPluginDocs = { type: "stable", prettyName: "Self-grantable roles", description: trimPluginDescription(` Allows users to grant themselves roles via a command `), configurationGuide: trimPluginDescription(` ### Basic configuration In this example, users can add themselves platform roles on the channel 473087035574321152 by using the \`!role\` command. For example, \`!role pc ps4\` to add both the "pc" and "ps4" roles as specified below. ~~~yml self_grantable_roles: config: entries: basic: roles: "543184300250759188": ["pc", "computer"] "534710505915547658": ["ps4", "ps", "playstation"] "473085927053590538": ["xbox", "xb1", "xb"] overrides: - channel: "473087035574321152" config: entries: basic: can_use: true ~~~ ### Maximum number of roles This is identical to the basic example above, but users can only choose 1 role. ~~~yml self_grantable_roles: config: entries: basic: roles: "543184300250759188": ["pc", "computer"] "534710505915547658": ["ps4", "ps", "playstation"] "473085927053590538": ["xbox", "xb1", "xb"] max_roles: 1 overrides: - channel: "473087035574321152" config: entries: basic: can_use: true ~~~ `), configSchema: zSelfGrantableRolesConfig, }; ================================================ FILE: backend/src/plugins/SelfGrantableRoles/types.ts ================================================ import { BasePluginType, CooldownManager, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { zBoundedCharacters, zBoundedRecord } from "../../utils.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; const zRoleMap = z.record( zBoundedCharacters(1, 100), z .array(zBoundedCharacters(1, 2000)) .max(100) .transform((parsed) => parsed.map((v) => v.toLowerCase())), ); const zSelfGrantableRoleEntry = z.strictObject({ roles: zBoundedRecord(zRoleMap, 0, 100), can_use: z.boolean().default(false), can_ignore_cooldown: z.boolean().default(false), max_roles: z.number().default(0), }); export type TSelfGrantableRoleEntry = z.infer; export const zSelfGrantableRolesConfig = z.strictObject({ entries: zBoundedRecord(z.record(zBoundedCharacters(0, 255), zSelfGrantableRoleEntry), 0, 100).default({}), mention_roles: z.boolean().default(false), }); export interface SelfGrantableRolesPluginType extends BasePluginType { configSchema: typeof zSelfGrantableRolesConfig; state: { cooldowns: CooldownManager; common: pluginUtils.PluginPublicInterface; }; } export const selfGrantableRolesCmd = guildPluginMessageCommand(); ================================================ FILE: backend/src/plugins/SelfGrantableRoles/util/findMatchingRoles.ts ================================================ import { TSelfGrantableRoleEntry } from "../types.js"; export function findMatchingRoles(roleNames: string[], entries: TSelfGrantableRoleEntry[]): string[] { const aliasToRoleId = entries.reduce((map, entry) => { for (const [roleId, aliases] of Object.entries(entry.roles)) { for (const alias of aliases) { map.set(alias, roleId); } } return map; }, new Map()); return roleNames.map((roleName) => aliasToRoleId.get(roleName)).filter(Boolean); } ================================================ FILE: backend/src/plugins/SelfGrantableRoles/util/getApplyingEntries.ts ================================================ import { GuildPluginData } from "vety"; import { SelfGrantableRolesPluginType, TSelfGrantableRoleEntry } from "../types.js"; export async function getApplyingEntries( pluginData: GuildPluginData, msg, ): Promise { const config = await pluginData.config.getForMessage(msg); return Object.entries(config.entries) .filter( ([k, e]) => e.can_use && !(!e.can_ignore_cooldown && pluginData.state.cooldowns.isOnCooldown(`${k}:${msg.author.id}`)), ) .map((pair) => pair[1]); } ================================================ FILE: backend/src/plugins/SelfGrantableRoles/util/normalizeRoleNames.ts ================================================ export function normalizeRoleNames(roleNames: string[]) { return roleNames.map((v) => v.toLowerCase()); } ================================================ FILE: backend/src/plugins/SelfGrantableRoles/util/splitRoleNames.ts ================================================ export function splitRoleNames(roleNames: string[]) { return roleNames .map((v) => v.split(/[\s,]+/)) .flat() .filter(Boolean); } ================================================ FILE: backend/src/plugins/Slowmode/SlowmodePlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildSlowmodes } from "../../data/GuildSlowmodes.js"; import { SECONDS } from "../../utils.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { SlowmodeClearCmd } from "./commands/SlowmodeClearCmd.js"; import { SlowmodeDisableCmd } from "./commands/SlowmodeDisableCmd.js"; import { SlowmodeGetCmd } from "./commands/SlowmodeGetCmd.js"; import { SlowmodeListCmd } from "./commands/SlowmodeListCmd.js"; import { SlowmodeSetCmd } from "./commands/SlowmodeSetCmd.js"; import { SlowmodePluginType, zSlowmodeConfig } from "./types.js"; import { clearExpiredSlowmodes } from "./util/clearExpiredSlowmodes.js"; import { onMessageCreate } from "./util/onMessageCreate.js"; const BOT_SLOWMODE_CLEAR_INTERVAL = 60 * SECONDS; export const SlowmodePlugin = guildPlugin()({ name: "slowmode", // prettier-ignore dependencies: () => [ LogsPlugin, ], configSchema: zSlowmodeConfig, defaultOverrides: [ { level: ">=50", config: { can_manage: true, is_affected: false, }, }, ], // prettier-ignore messageCommands: [ SlowmodeDisableCmd, SlowmodeClearCmd, SlowmodeListCmd, SlowmodeGetCmd, SlowmodeSetCmd, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.slowmodes = GuildSlowmodes.getGuildInstance(guild.id); state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); state.logs = new GuildLogs(guild.id); state.channelSlowmodeCache = new Map(); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, afterLoad(pluginData) { const { state } = pluginData; state.serverLogs = new GuildLogs(pluginData.guild.id); state.clearInterval = setInterval(() => clearExpiredSlowmodes(pluginData), BOT_SLOWMODE_CLEAR_INTERVAL); state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg); state.savedMessages.events.on("create", state.onMessageCreateFn); }, beforeUnload(pluginData) { const { state } = pluginData; state.savedMessages.events.off("create", state.onMessageCreateFn); clearInterval(state.clearInterval); }, }); ================================================ FILE: backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts ================================================ import { ChannelType, escapeInlineCode } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { asSingleLine, renderUsername } from "../../../utils.js"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; import { BOT_SLOWMODE_CLEAR_PERMISSIONS } from "../requiredPermissions.js"; import { slowmodeCmd } from "../types.js"; import { clearBotSlowmodeFromUserId } from "../util/clearBotSlowmodeFromUserId.js"; export const SlowmodeClearCmd = slowmodeCmd({ trigger: ["slowmode clear", "slowmode c"], permission: "can_manage", signature: { channel: ct.textChannel(), user: ct.resolvedUserLoose(), force: ct.bool({ option: true, isSwitch: true }), }, async run({ message: msg, args, pluginData }) { const channelSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(args.channel.id); if (!channelSlowmode) { void pluginData.state.common.sendErrorMessage(msg, "Channel doesn't have slowmode!"); return; } const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const missingPermissions = getMissingChannelPermissions(me, args.channel, BOT_SLOWMODE_CLEAR_PERMISSIONS); if (missingPermissions) { void pluginData.state.common.sendErrorMessage( msg, `Unable to clear slowmode. ${missingPermissionError(missingPermissions)}`, ); return; } try { if (args.channel.type === ChannelType.GuildText) { await clearBotSlowmodeFromUserId(pluginData, args.channel, args.user.id, args.force); } else { void pluginData.state.common.sendErrorMessage( msg, asSingleLine(` Failed to clear slowmode from **${renderUsername(args.user)}** in <#${args.channel.id}>: Threads cannot have Bot Slowmode `), ); return; } } catch (e) { void pluginData.state.common.sendErrorMessage( msg, asSingleLine(` Failed to clear slowmode from **${renderUsername(args.user)}** in <#${args.channel.id}>: \`${escapeInlineCode(e.message)}\` `), ); return; } void pluginData.state.common.sendSuccessMessage( msg, `Slowmode cleared from **${renderUsername(args.user)}** in <#${args.channel.id}>`, ); }, }); ================================================ FILE: backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { slowmodeCmd } from "../types.js"; import { actualDisableSlowmodeCmd } from "../util/actualDisableSlowmodeCmd.js"; export const SlowmodeDisableCmd = slowmodeCmd({ trigger: ["slowmode disable", "slowmode d"], permission: "can_manage", signature: { channel: ct.textChannel(), }, async run({ message: msg, args, pluginData }) { // Workaround until you can call this cmd from SlowmodeSetChannelCmd actualDisableSlowmodeCmd(msg, args, pluginData); }, }); ================================================ FILE: backend/src/plugins/Slowmode/commands/SlowmodeGetCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { slowmodeCmd } from "../types.js"; export const SlowmodeGetCmd = slowmodeCmd({ trigger: "slowmode", permission: "can_manage", source: "guild", signature: { channel: ct.textChannel({ option: true }), }, async run({ message: msg, args, pluginData }) { const channel = args.channel || msg.channel; let currentSlowmode = channel.rateLimitPerUser; let isNative = true; if (!currentSlowmode) { const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id); if (botSlowmode) { currentSlowmode = botSlowmode.slowmode_seconds; isNative = false; } } if (currentSlowmode) { const humanized = humanizeDuration(currentSlowmode * 1000); const slowmodeType = isNative ? "native" : "bot-maintained"; msg.channel.send(`The current slowmode of <#${channel.id}> is **${humanized}** (${slowmodeType})`); } else { msg.channel.send("Channel is not on slowmode"); } }, }); ================================================ FILE: backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts ================================================ import { GuildChannel, TextChannel } from "discord.js"; import { createChunkedMessage } from "vety/helpers"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { errorMessage } from "../../../utils.js"; import { slowmodeCmd } from "../types.js"; export const SlowmodeListCmd = slowmodeCmd({ trigger: ["slowmode list", "slowmode l", "slowmodes"], permission: "can_manage", async run({ message: msg, pluginData }) { const channels = pluginData.guild.channels; const slowmodes: Array<{ channel: GuildChannel; seconds: number; native: boolean }> = []; for (const channel of channels.cache.values()) { if (!(channel instanceof TextChannel)) continue; // Bot slowmode const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id); if (botSlowmode) { slowmodes.push({ channel, seconds: botSlowmode.slowmode_seconds, native: false }); continue; } // Native slowmode if (channel.rateLimitPerUser) { slowmodes.push({ channel, seconds: channel.rateLimitPerUser, native: true }); continue; } } if (slowmodes.length) { const lines = slowmodes.map((slowmode) => { const humanized = humanizeDuration(slowmode.seconds * 1000); const type = slowmode.native ? "native slowmode" : "bot slowmode"; return `<#${slowmode.channel.id}> **${humanized}** ${type}`; }); createChunkedMessage(msg.channel, lines.join("\n")); } else { msg.channel.send(errorMessage("No active slowmodes!")); } }, }); ================================================ FILE: backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts ================================================ import { escapeInlineCode, PermissionsBitField } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { asSingleLine, DAYS, HOURS, MINUTES } from "../../../utils.js"; import { getMissingPermissions } from "../../../utils/getMissingPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; import { BOT_SLOWMODE_PERMISSIONS, NATIVE_SLOWMODE_PERMISSIONS } from "../requiredPermissions.js"; import { slowmodeCmd } from "../types.js"; import { actualDisableSlowmodeCmd } from "../util/actualDisableSlowmodeCmd.js"; import { disableBotSlowmodeForChannel } from "../util/disableBotSlowmodeForChannel.js"; const MAX_NATIVE_SLOWMODE = 6 * HOURS; // 6 hours const MAX_BOT_SLOWMODE = DAYS * 365 * 100; // 100 years const MIN_BOT_SLOWMODE = 15 * MINUTES; const validModes = ["bot", "native"]; type TMode = "bot" | "native"; export const SlowmodeSetCmd = slowmodeCmd({ trigger: "slowmode", permission: "can_manage", source: "guild", signature: [ { time: ct.delay(), mode: ct.string({ option: true, shortcut: "m" }), }, { channel: ct.textChannel(), time: ct.delay(), mode: ct.string({ option: true, shortcut: "m" }), }, ], async run({ message: msg, args, pluginData }) { const channel = args.channel || msg.channel; if (!channel.isTextBased() || channel.isThread()) { void pluginData.state.common.sendErrorMessage(msg, "Slowmode can only be set on non-thread text-based channels"); return; } if (args.time === 0) { // Workaround until we can call SlowmodeDisableCmd from here return actualDisableSlowmodeCmd(msg, { channel }, pluginData); } const defaultMode: TMode = (await pluginData.config.getForChannel(channel)).use_native_slowmode && args.time <= MAX_NATIVE_SLOWMODE ? "native" : "bot"; const mode = (args.mode as TMode) || defaultMode; if (!validModes.includes(mode)) { void pluginData.state.common.sendErrorMessage(msg, "--mode must be 'bot' or 'native'"); return; } // Validate durations if (mode === "native" && args.time > MAX_NATIVE_SLOWMODE) { void pluginData.state.common.sendErrorMessage(msg, "Native slowmode can only be set to 6h or less"); return; } if (mode === "bot" && args.time > MAX_BOT_SLOWMODE) { void pluginData.state.common.sendErrorMessage( msg, `Sorry, bot managed slowmodes can be at most 100 years long. Maybe 99 would be enough?`, ); return; } if (mode === "bot" && args.time < MIN_BOT_SLOWMODE) { void pluginData.state.common.sendErrorMessage( msg, asSingleLine(` Bot managed slowmode must be 15min or more. Use \`--mode native\` to use native slowmodes for short slowmodes instead. `), ); return; } // Verify permissions const channelPermissions = channel.permissionsFor(pluginData.client.user!.id); if (mode === "native") { const missingPermissions = getMissingPermissions( channelPermissions ?? new PermissionsBitField(), NATIVE_SLOWMODE_PERMISSIONS, ); if (missingPermissions) { void pluginData.state.common.sendErrorMessage( msg, `Unable to set native slowmode. ${missingPermissionError(missingPermissions)}`, ); return; } } if (mode === "bot") { const missingPermissions = getMissingPermissions( channelPermissions ?? new PermissionsBitField(), BOT_SLOWMODE_PERMISSIONS, ); if (missingPermissions) { void pluginData.state.common.sendErrorMessage( msg, `Unable to set bot managed slowmode. ${missingPermissionError(missingPermissions)}`, ); return; } } // Apply the slowmode! const rateLimitSeconds = Math.ceil(args.time / 1000); if (mode === "native") { // If there is an existing bot-maintained slowmode, disable that first const existingBotSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id); if (existingBotSlowmode && channel.isTextBased()) { await disableBotSlowmodeForChannel(pluginData, channel); } // Set native slowmode try { await channel.setRateLimitPerUser(rateLimitSeconds); } catch (e) { void pluginData.state.common.sendErrorMessage( msg, `Failed to set native slowmode: ${escapeInlineCode(e.message)}`, ); return; } } else { // If there is an existing native slowmode, disable that first if (channel.rateLimitPerUser) { await channel.setRateLimitPerUser(0); } // Set bot-maintained slowmode await pluginData.state.slowmodes.setChannelSlowmode(channel.id, rateLimitSeconds); // Update cache const slowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id); pluginData.state.channelSlowmodeCache.set(channel.id, slowmode ?? null); } const humanizedSlowmodeTime = humanizeDuration(args.time); const slowmodeType = mode === "native" ? "native slowmode" : "bot-maintained slowmode"; void pluginData.state.common.sendSuccessMessage( msg, `Set ${humanizedSlowmodeTime} slowmode for <#${channel.id}> (${slowmodeType})`, ); }, }); ================================================ FILE: backend/src/plugins/Slowmode/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zSlowmodeConfig } from "./types.js"; export const slowmodePluginDocs: ZeppelinPluginDocs = { type: "stable", prettyName: "Slowmode", configSchema: zSlowmodeConfig, }; ================================================ FILE: backend/src/plugins/Slowmode/requiredPermissions.ts ================================================ import { PermissionsBitField } from "discord.js"; const p = PermissionsBitField.Flags; export const NATIVE_SLOWMODE_PERMISSIONS = p.ViewChannel | p.ManageChannels; export const BOT_SLOWMODE_PERMISSIONS = p.ViewChannel | p.ManageRoles | p.ManageMessages; export const BOT_SLOWMODE_CLEAR_PERMISSIONS = p.ViewChannel | p.ManageRoles; export const BOT_SLOWMODE_DISABLE_PERMISSIONS = p.ViewChannel | p.ManageRoles; ================================================ FILE: backend/src/plugins/Slowmode/types.ts ================================================ import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildSlowmodes } from "../../data/GuildSlowmodes.js"; import { SlowmodeChannel } from "../../data/entities/SlowmodeChannel.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zSlowmodeConfig = z.strictObject({ use_native_slowmode: z.boolean().default(true), can_manage: z.boolean().default(false), is_affected: z.boolean().default(true), }); export interface SlowmodePluginType extends BasePluginType { configSchema: typeof zSlowmodeConfig; state: { slowmodes: GuildSlowmodes; savedMessages: GuildSavedMessages; logs: GuildLogs; clearInterval: NodeJS.Timeout; serverLogs: GuildLogs; channelSlowmodeCache: Map; common: pluginUtils.PluginPublicInterface; onMessageCreateFn; }; } export const slowmodeCmd = guildPluginMessageCommand(); export const slowmodeEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts ================================================ import { Message } from "discord.js"; import { noop } from "../../../utils.js"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; import { BOT_SLOWMODE_DISABLE_PERMISSIONS } from "../requiredPermissions.js"; import { disableBotSlowmodeForChannel } from "./disableBotSlowmodeForChannel.js"; export async function actualDisableSlowmodeCmd(msg: Message, args, pluginData) { const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(args.channel.id); const hasNativeSlowmode = args.channel.rateLimitPerUser; if (!botSlowmode && hasNativeSlowmode === 0) { void pluginData.state.common.sendErrorMessage(msg, "Channel is not on slowmode!"); return; } const me = pluginData.guild.members.cache.get(pluginData.client.user!.id); const missingPermissions = getMissingChannelPermissions(me, args.channel, BOT_SLOWMODE_DISABLE_PERMISSIONS); if (missingPermissions) { void pluginData.state.common.sendErrorMessage( msg, `Unable to disable slowmode. ${missingPermissionError(missingPermissions)}`, ); return; } const initMsg = await msg.reply("Disabling slowmode..."); // Disable bot-maintained slowmode let failedUsers: string[] = []; if (botSlowmode) { const result = await disableBotSlowmodeForChannel(pluginData, args.channel); failedUsers = result.failedUsers; } // Disable native slowmode if (hasNativeSlowmode) { await args.channel.edit({ rateLimitPerUser: 0 }); } if (failedUsers.length) { void pluginData.state.common.sendSuccessMessage( msg, `Slowmode disabled! Failed to clear slowmode from the following users:\n\n<@!${failedUsers.join(">\n<@!")}>`, ); } else { void pluginData.state.common.sendSuccessMessage(msg, "Slowmode disabled!"); initMsg.delete().catch(noop); } } ================================================ FILE: backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts ================================================ import { GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { logger } from "../../../logger.js"; import { UnknownUser, isDiscordAPIError, verboseChannelMention, verboseUserMention } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { SlowmodePluginType } from "../types.js"; export async function applyBotSlowmodeToUserId( pluginData: GuildPluginData, channel: GuildTextBasedChannel, userId: string, ) { // FIXME: Is there a better way to do this? if (channel.isThread()) return; // Deny sendMessage permission from the user. If there are existing permission overwrites, take those into account. const existingOverride = channel.permissionOverwrites?.resolve(userId as Snowflake); try { pluginData.state.serverLogs.ignoreLog(LogType.CHANNEL_UPDATE, channel.id, 5 * 1000); if (existingOverride) { await existingOverride.edit({ SendMessages: false }); } else { await channel.permissionOverwrites?.create(userId as Snowflake, { SendMessages: false }, { type: 1 }); } } catch (e) { const user = await pluginData.client.users.fetch(userId as Snowflake).catch(() => new UnknownUser({ id: userId })); if (isDiscordAPIError(e) && e.code === 50013) { logger.warn( `Missing permissions to apply bot slowmode to user ${userId} on channel ${channel.name} (${channel.id}) on server ${pluginData.guild.name} (${pluginData.guild.id})`, ); pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions to apply bot slowmode to ${verboseUserMention(user)} in ${verboseChannelMention( channel, )}`, }); } else { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to apply bot slowmode to ${verboseUserMention(user)} in ${verboseChannelMention(channel)}`, }); throw e; } } await pluginData.state.slowmodes.addSlowmodeUser(channel.id, userId); } ================================================ FILE: backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts ================================================ import { AnyThreadChannel, GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { LogType } from "../../../data/LogType.js"; import { SlowmodePluginType } from "../types.js"; export async function clearBotSlowmodeFromUserId( pluginData: GuildPluginData, channel: Exclude, userId: string, force = false, ) { try { // Remove permission overrides from the channel for this user // Previously we diffed the overrides so we could clear the "send messages" override without touching other // overrides. Unfortunately, it seems that was a bit buggy - we didn't always receive the event for the changed // overrides and then we also couldn't diff against them. For consistency's sake, we just delete the override now. pluginData.state.serverLogs.ignoreLog(LogType.CHANNEL_UPDATE, channel.id, 3 * 1000); await channel.permissionOverwrites.resolve(userId as Snowflake)?.delete(); } catch (e) { if (!force) { throw e; } } await pluginData.state.slowmodes.clearSlowmodeUser(channel.id, userId); } ================================================ FILE: backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts ================================================ import { GuildChannel, Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { logger } from "../../../logger.js"; import { UnknownUser, verboseChannelMention, verboseUserMention } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { SlowmodePluginType } from "../types.js"; import { clearBotSlowmodeFromUserId } from "./clearBotSlowmodeFromUserId.js"; export async function clearExpiredSlowmodes(pluginData: GuildPluginData) { const expiredSlowmodeUsers = await pluginData.state.slowmodes.getExpiredSlowmodeUsers(); for (const user of expiredSlowmodeUsers) { const channel = pluginData.guild.channels.cache.get(user.channel_id as Snowflake); if (!channel) { await pluginData.state.slowmodes.clearSlowmodeUser(user.channel_id, user.user_id); continue; } try { await clearBotSlowmodeFromUserId(pluginData, channel as GuildChannel & TextChannel, user.user_id); } catch (e) { logger.error(e); const realUser = await pluginData.client .users!.fetch(user.user_id as Snowflake) .catch(() => new UnknownUser({ id: user.user_id })); pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to clear slowmode permissions from ${verboseUserMention( await realUser, )} in ${verboseChannelMention(channel)}`, }); } } } ================================================ FILE: backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts ================================================ import { AnyThreadChannel, GuildTextBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { SlowmodePluginType } from "../types.js"; import { clearBotSlowmodeFromUserId } from "./clearBotSlowmodeFromUserId.js"; export async function disableBotSlowmodeForChannel( pluginData: GuildPluginData, channel: Exclude, ) { // Disable channel slowmode await pluginData.state.slowmodes.deleteChannelSlowmode(channel.id); // Remove currently applied slowmodes const users = await pluginData.state.slowmodes.getChannelSlowmodeUsers(channel.id); const failedUsers: string[] = []; for (const slowmodeUser of users) { try { await clearBotSlowmodeFromUserId(pluginData, channel, slowmodeUser.user_id); } catch { // Removing the slowmode failed. Record this so the permissions can be changed manually, and remove the database entry. failedUsers.push(slowmodeUser.user_id); await pluginData.state.slowmodes.clearSlowmodeUser(channel.id, slowmodeUser.user_id); } } // Clear cache pluginData.state.channelSlowmodeCache.set(channel.id, null); return { failedUsers }; } ================================================ FILE: backend/src/plugins/Slowmode/util/onMessageCreate.ts ================================================ import { ChannelType, GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { SlowmodeChannel } from "../../../data/entities/SlowmodeChannel.js"; import { hasPermission } from "../../../pluginUtils.js"; import { resolveMember } from "../../../utils.js"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js"; import { messageLock } from "../../../utils/lockNameHelpers.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { BOT_SLOWMODE_PERMISSIONS } from "../requiredPermissions.js"; import { SlowmodePluginType } from "../types.js"; import { applyBotSlowmodeToUserId } from "./applyBotSlowmodeToUserId.js"; export async function onMessageCreate(pluginData: GuildPluginData, msg: SavedMessage) { if (msg.is_bot) return; const channel = pluginData.guild.channels.cache.get(msg.channel_id as Snowflake) as GuildTextBasedChannel; if (!channel?.isTextBased() || channel.type === ChannelType.GuildStageVoice) return; // Don't apply slowmode if the lock was interrupted earlier (e.g. the message was caught by word filters) const thisMsgLock = await pluginData.locks.acquire(messageLock(msg)); if (thisMsgLock.interrupted) return; // Check if this channel even *has* a bot-maintained slowmode let channelSlowmode: SlowmodeChannel | null; if (pluginData.state.channelSlowmodeCache.has(channel.id)) { channelSlowmode = pluginData.state.channelSlowmodeCache.get(channel.id) ?? null; } else { channelSlowmode = (await pluginData.state.slowmodes.getChannelSlowmode(channel.id)) ?? null; pluginData.state.channelSlowmodeCache.set(channel.id, channelSlowmode); } if (!channelSlowmode) { return thisMsgLock.unlock(); } // Make sure this user is affected by the slowmode const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id); const isAffected = await hasPermission(pluginData, "is_affected", { channelId: channel.id, userId: msg.user_id, member, }); if (!isAffected) { return thisMsgLock.unlock(); } // Make sure we have the appropriate permissions to manage this slowmode const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const missingPermissions = getMissingChannelPermissions(me, channel, BOT_SLOWMODE_PERMISSIONS); if (missingPermissions) { const logs = pluginData.getPlugin(LogsPlugin); logs.logBotAlert({ body: `Unable to manage bot slowmode in <#${channel.id}>. ${missingPermissionError(missingPermissions)}`, }); return; } // Delete any extra messages sent after a slowmode was already applied const userHasSlowmode = await pluginData.state.slowmodes.userHasSlowmode(channel.id, msg.user_id); if (userHasSlowmode) { try { // FIXME: Debug // tslint:disable-next-line:no-console console.log( `[DEBUG] [SLOWMODE] Deleting message ${msg.id} from channel ${channel.id} in guild ${pluginData.guild.id}`, ); await channel.messages.delete(msg.id); thisMsgLock.interrupt(); } catch (err) { thisMsgLock.unlock(); } return; } await applyBotSlowmodeToUserId(pluginData, channel, msg.user_id); thisMsgLock.unlock(); } ================================================ FILE: backend/src/plugins/Spam/SpamPlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildMutes } from "../../data/GuildMutes.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { SpamVoiceStateUpdateEvt } from "./events/SpamVoiceEvt.js"; import { SpamPluginType, zSpamConfig } from "./types.js"; import { clearOldRecentActions } from "./util/clearOldRecentActions.js"; import { onMessageCreate } from "./util/onMessageCreate.js"; export const SpamPlugin = guildPlugin()({ name: "spam", dependencies: () => [LogsPlugin], configSchema: zSpamConfig, defaultOverrides: [ { level: ">=50", config: { max_messages: null, max_mentions: null, max_links: null, max_attachments: null, max_emojis: null, max_newlines: null, max_duplicates: null, max_characters: null, max_voice_moves: null, }, }, ], // prettier-ignore events: [ SpamVoiceStateUpdateEvt, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.logs = new GuildLogs(guild.id); state.archives = GuildArchives.getGuildInstance(guild.id); state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); state.mutes = GuildMutes.getGuildInstance(guild.id); state.recentActions = []; state.lastHandledMsgIds = new Map(); state.spamDetectionQueue = Promise.resolve(); }, afterLoad(pluginData) { const { state } = pluginData; state.expiryInterval = setInterval(() => clearOldRecentActions(pluginData), 1000 * 60); state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg); state.savedMessages.events.on("create", state.onMessageCreateFn); }, beforeUnload(pluginData) { const { state } = pluginData; state.savedMessages.events.off("create", state.onMessageCreateFn); clearInterval(state.expiryInterval); }, }); ================================================ FILE: backend/src/plugins/Spam/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zSpamConfig } from "./types.js"; export const spamPluginDocs: ZeppelinPluginDocs = { type: "legacy", prettyName: "Spam protection", description: trimPluginDescription(` Basic spam detection and auto-muting. For more advanced spam filtering, check out the Automod plugin! `), configSchema: zSpamConfig, }; ================================================ FILE: backend/src/plugins/Spam/events/SpamVoiceEvt.ts ================================================ import { RecentActionType, spamEvt } from "../types.js"; import { logAndDetectOtherSpam } from "../util/logAndDetectOtherSpam.js"; export const SpamVoiceStateUpdateEvt = spamEvt({ event: "voiceStateUpdate", async listener(meta) { const member = meta.args.newState.member; if (!member) return; const channel = meta.args.newState.channel; if (!channel) return; if (channel.id === meta.args.oldState?.channelId) return; const config = await meta.pluginData.config.getMatchingConfig({ member, channelId: channel.id }); const maxVoiceMoves = config.max_voice_moves; if (maxVoiceMoves) { logAndDetectOtherSpam( meta.pluginData, RecentActionType.VoiceChannelMove, maxVoiceMoves, member.id, 1, "0", Date.now(), null, "too many voice channel moves", ); } }, }); ================================================ FILE: backend/src/plugins/Spam/types.ts ================================================ import { BasePluginType, guildPluginEventListener } from "vety"; import { z } from "zod"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildMutes } from "../../data/GuildMutes.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { zSnowflake } from "../../utils.js"; const zBaseSingleSpamConfig = z.strictObject({ interval: z.number(), count: z.number(), mute: z.boolean().default(false), mute_time: z.number().nullable().default(null), remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false), restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false), clean: z.boolean().default(false), }); export type TBaseSingleSpamConfig = z.infer; export const zSpamConfig = z.strictObject({ max_censor: zBaseSingleSpamConfig.nullable().default(null), max_messages: zBaseSingleSpamConfig.nullable().default(null), max_mentions: zBaseSingleSpamConfig.nullable().default(null), max_links: zBaseSingleSpamConfig.nullable().default(null), max_attachments: zBaseSingleSpamConfig.nullable().default(null), max_emojis: zBaseSingleSpamConfig.nullable().default(null), max_newlines: zBaseSingleSpamConfig.nullable().default(null), max_duplicates: zBaseSingleSpamConfig.nullable().default(null), max_characters: zBaseSingleSpamConfig.nullable().default(null), max_voice_moves: zBaseSingleSpamConfig.nullable().default(null), }); export enum RecentActionType { Message = 1, Mention, Link, Attachment, Emoji, Newline, Censor, Character, VoiceChannelMove, } export interface IRecentAction { type: RecentActionType; userId: string; actionGroupId: string; extraData: T; timestamp: number; count: number; } export interface SpamPluginType extends BasePluginType { configSchema: typeof zSpamConfig; state: { logs: GuildLogs; archives: GuildArchives; savedMessages: GuildSavedMessages; mutes: GuildMutes; onMessageCreateFn; // Handle spam detection with a queue so we don't have overlapping detections on the same user spamDetectionQueue: Promise; // List of recent potentially-spammy actions recentActions: Array>; // A map of userId => channelId => msgId // Keeps track of the last handled (= spam detected and acted on) message ID per user, per channel // TODO: Prevent this from growing infinitely somehow lastHandledMsgIds: Map>; expiryInterval; }; } export const spamEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/Spam/util/addRecentAction.ts ================================================ import { GuildPluginData } from "vety"; import { RecentActionType, SpamPluginType } from "../types.js"; export function addRecentAction( pluginData: GuildPluginData, type: RecentActionType, userId: string, actionGroupId: string, extraData: any, timestamp: number, count = 1, ) { pluginData.state.recentActions.push({ type, userId, actionGroupId, extraData, timestamp, count }); } ================================================ FILE: backend/src/plugins/Spam/util/clearOldRecentActions.ts ================================================ import { GuildPluginData } from "vety"; import { SpamPluginType } from "../types.js"; const MAX_INTERVAL = 300; export function clearOldRecentActions(pluginData: GuildPluginData) { // TODO: Figure out expiry time from longest interval in the config? const expiryTimestamp = Date.now() - 1000 * MAX_INTERVAL; pluginData.state.recentActions = pluginData.state.recentActions.filter( (action) => action.timestamp >= expiryTimestamp, ); } ================================================ FILE: backend/src/plugins/Spam/util/clearRecentUserActions.ts ================================================ import { GuildPluginData } from "vety"; import { RecentActionType, SpamPluginType } from "../types.js"; export function clearRecentUserActions( pluginData: GuildPluginData, type: RecentActionType, userId: string, actionGroupId: string, ) { pluginData.state.recentActions = pluginData.state.recentActions.filter((action) => { return action.type !== type || action.userId !== userId || action.actionGroupId !== actionGroupId; }); } ================================================ FILE: backend/src/plugins/Spam/util/getRecentActionCount.ts ================================================ import { GuildPluginData } from "vety"; import { RecentActionType, SpamPluginType } from "../types.js"; export function getRecentActionCount( pluginData: GuildPluginData, type: RecentActionType, userId: string, actionGroupId: string, since: number, ): number { return pluginData.state.recentActions.reduce((count, action) => { if (action.timestamp < since) return count; if (action.type !== type) return count; if (action.actionGroupId !== actionGroupId) return count; if (action.userId !== userId) return count; return count + action.count; }, 0); } ================================================ FILE: backend/src/plugins/Spam/util/getRecentActions.ts ================================================ import { GuildPluginData } from "vety"; import { RecentActionType, SpamPluginType } from "../types.js"; export function getRecentActions( pluginData: GuildPluginData, type: RecentActionType, userId: string, actionGroupId: string, since: number, ) { return pluginData.state.recentActions.filter((action) => { if (action.timestamp < since) return false; if (action.type !== type) return false; if (action.actionGroupId !== actionGroupId) return false; if (action.userId !== userId) return false; return true; }); } ================================================ FILE: backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts ================================================ import { GuildTextBasedChannel, Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { LogType } from "../../../data/LogType.js"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { logger } from "../../../logger.js"; import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin.js"; import { MuteResult } from "../../../plugins/Mutes/types.js"; import { DBDateFormat, convertDelayStringToMS, noop, resolveMember, trimLines } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RecentActionType, SpamPluginType, TBaseSingleSpamConfig } from "../types.js"; import { addRecentAction } from "./addRecentAction.js"; import { clearRecentUserActions } from "./clearRecentUserActions.js"; import { getRecentActionCount } from "./getRecentActionCount.js"; import { getRecentActions } from "./getRecentActions.js"; import { saveSpamArchives } from "./saveSpamArchives.js"; export async function logAndDetectMessageSpam( pluginData: GuildPluginData, savedMessage: SavedMessage, type: RecentActionType, spamConfig: TBaseSingleSpamConfig, actionCount: number, description: string, ) { if (actionCount === 0) return; // Make sure we're not handling some messages twice if (pluginData.state.lastHandledMsgIds.has(savedMessage.user_id)) { const channelMap = pluginData.state.lastHandledMsgIds.get(savedMessage.user_id)!; if (channelMap.has(savedMessage.channel_id)) { const lastHandledMsgId = channelMap.get(savedMessage.channel_id)!; if (lastHandledMsgId >= savedMessage.id) return; } } pluginData.state.spamDetectionQueue = pluginData.state.spamDetectionQueue.then( async () => { const timestamp = moment.utc(savedMessage.posted_at, DBDateFormat).valueOf(); const member = await resolveMember(pluginData.client, pluginData.guild, savedMessage.user_id); const logs = pluginData.getPlugin(LogsPlugin); // Log this action... addRecentAction( pluginData, type, savedMessage.user_id, savedMessage.channel_id, savedMessage, timestamp, actionCount, ); // ...and then check if it trips the spam filters const since = timestamp - 1000 * spamConfig.interval; const recentActionsCount = getRecentActionCount( pluginData, type, savedMessage.user_id, savedMessage.channel_id, since, ); // If the user tripped the spam filter... if (recentActionsCount > spamConfig.count) { const recentActions = getRecentActions(pluginData, type, savedMessage.user_id, savedMessage.channel_id, since); // Start by muting them, if enabled let muteResult: MuteResult | null = null; if (spamConfig.mute && member) { const mutesPlugin = pluginData.getPlugin(MutesPlugin); const muteTime = (spamConfig.mute_time && convertDelayStringToMS(spamConfig.mute_time.toString())) ?? 120 * 1000; try { const reason = "Automatic spam detection"; muteResult = await mutesPlugin.muteUser( member.id, muteTime, reason, reason, { caseArgs: { modId: pluginData.client.user!.id, postInCaseLogOverride: false, }, }, spamConfig.remove_roles_on_mute, spamConfig.restore_roles_on_mute, ); } catch (e) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { logs.logBotAlert({ body: `Failed to mute <@!${member.id}> in \`spam\` plugin because a mute role has not been specified in server config`, }); } else { throw e; } } } // Get the offending message IDs // We also get the IDs of any messages after the last offending message, to account for lag before detection const savedMessages = recentActions.map((a) => a.extraData as SavedMessage); const msgIds = savedMessages.map((m) => m.id); const lastDetectedMsgId = msgIds[msgIds.length - 1]; const additionalMessages = await pluginData.state.savedMessages.getUserMessagesByChannelAfterId( savedMessage.user_id, savedMessage.channel_id, lastDetectedMsgId, ); additionalMessages.forEach((m) => msgIds.push(m.id)); // Then, if enabled, remove the spam messages if (spamConfig.clean !== false) { msgIds.forEach((id) => pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id)); (pluginData.guild.channels.cache.get(savedMessage.channel_id as Snowflake)! as TextChannel | undefined) ?.bulkDelete(msgIds as Snowflake[]) .catch(noop); } // Store the ID of the last handled message const uniqueMessages = Array.from(new Set([...savedMessages, ...additionalMessages])); uniqueMessages.sort((a, b) => (a.id > b.id ? 1 : -1)); const lastHandledMsgId = uniqueMessages .map((m) => m.id) .reduce((last, id): string => { return id > last ? id : last; }); if (!pluginData.state.lastHandledMsgIds.has(savedMessage.user_id)) { pluginData.state.lastHandledMsgIds.set(savedMessage.user_id, new Map()); } const channelMap = pluginData.state.lastHandledMsgIds.get(savedMessage.user_id)!; channelMap.set(savedMessage.channel_id, lastHandledMsgId); // Clear the handled actions from recentActions clearRecentUserActions(pluginData, type, savedMessage.user_id, savedMessage.channel_id); // Generate a log from the detected messages const channel = pluginData.guild.channels.cache.get( savedMessage.channel_id as Snowflake, ) as GuildTextBasedChannel; const archiveUrl = await saveSpamArchives(pluginData, uniqueMessages); // Create a case const casesPlugin = pluginData.getPlugin(CasesPlugin); if (muteResult) { // If the user was muted, the mute already generated a case - in that case, just update the case with extra details // This will also post the case in the case log channel, which we didn't do with the mute initially to avoid // posting the case on the channel twice: once with the initial reason, and then again with the note from here const updateText = trimLines(` Details: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s) ${archiveUrl} `); casesPlugin.createCaseNote({ caseId: muteResult.case.id, modId: muteResult.case.mod_id || "0", body: updateText, automatic: true, }); } else { // If the user was not muted, create a note case of the detected spam instead const caseText = trimLines(` Automatic spam detection: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s) ${archiveUrl} `); casesPlugin.createCase({ userId: savedMessage.user_id, modId: pluginData.client.user!.id, type: CaseTypes.Note, reason: caseText, automatic: true, }); } // Create a log entry logs.logMessageSpamDetected({ member: member!, channel: channel!, description, limit: spamConfig.count, interval: spamConfig.interval, archiveUrl, }); } }, (err) => { logger.error(`Error while detecting spam:\n${err}`); }, ); } ================================================ FILE: backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts ================================================ import { GuildPluginData } from "vety"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin.js"; import { convertDelayStringToMS, resolveMember } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RecentActionType, SpamPluginType } from "../types.js"; import { addRecentAction } from "./addRecentAction.js"; import { clearRecentUserActions } from "./clearRecentUserActions.js"; import { getRecentActionCount } from "./getRecentActionCount.js"; export async function logAndDetectOtherSpam( pluginData: GuildPluginData, type: RecentActionType, spamConfig: any, userId: string, actionCount: number, actionGroupId: string, timestamp: number, extraData = null, description: string, ) { pluginData.state.spamDetectionQueue = pluginData.state.spamDetectionQueue.then(async () => { // Log this action... addRecentAction(pluginData, type, userId, actionGroupId, extraData, timestamp, actionCount); // ...and then check if it trips the spam filters const since = timestamp - 1000 * spamConfig.interval; const recentActionsCount = getRecentActionCount(pluginData, type, userId, actionGroupId, since); if (recentActionsCount > spamConfig.count) { const member = await resolveMember(pluginData.client, pluginData.guild, userId); const details = `${description} (over ${spamConfig.count} in ${spamConfig.interval}s)`; const logs = pluginData.getPlugin(LogsPlugin); if (spamConfig.mute && member) { const mutesPlugin = pluginData.getPlugin(MutesPlugin); const muteTime = (spamConfig.mute_time && convertDelayStringToMS(spamConfig.mute_time.toString())) ?? 120 * 1000; try { const reason = "Automatic spam detection"; await mutesPlugin.muteUser( member.id, muteTime, reason, reason, { caseArgs: { modId: pluginData.client.user!.id, extraNotes: [`Details: ${details}`], }, }, spamConfig.remove_roles_on_mute, spamConfig.restore_roles_on_mute, ); } catch (e) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { logs.logBotAlert({ body: `Failed to mute <@!${member.id}> in \`spam\` plugin because a mute role has not been specified in server config`, }); } else { throw e; } } } else { // If we're not muting the user, just add a note on them const casesPlugin = pluginData.getPlugin(CasesPlugin); await casesPlugin.createCase({ userId, modId: pluginData.client.user!.id, type: CaseTypes.Note, reason: `Automatic spam detection: ${details}`, }); } // Clear recent cases clearRecentUserActions(pluginData, RecentActionType.VoiceChannelMove, userId, actionGroupId); logs.logOtherSpamDetected({ member: member!, description, limit: spamConfig.count, interval: spamConfig.interval, }); } }); } ================================================ FILE: backend/src/plugins/Spam/util/logCensor.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { RecentActionType, SpamPluginType } from "../types.js"; import { logAndDetectMessageSpam } from "./logAndDetectMessageSpam.js"; export async function logCensor(pluginData: GuildPluginData, savedMessage: SavedMessage) { const member = pluginData.guild.members.cache.get(savedMessage.user_id as Snowflake); const config = await pluginData.config.getMatchingConfig({ userId: savedMessage.user_id, channelId: savedMessage.channel_id, member, }); const spamConfig = config.max_censor; if (spamConfig) { logAndDetectMessageSpam( pluginData, savedMessage, RecentActionType.Censor, spamConfig, 1, "too many censored messages", ); } } ================================================ FILE: backend/src/plugins/Spam/util/onMessageCreate.ts ================================================ import { Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { getEmojiInString, getRoleMentions, getUrlsInString, getUserMentions } from "../../../utils.js"; import { RecentActionType, SpamPluginType } from "../types.js"; import { logAndDetectMessageSpam } from "./logAndDetectMessageSpam.js"; export async function onMessageCreate(pluginData: GuildPluginData, savedMessage: SavedMessage) { if (savedMessage.is_bot) return; const member = pluginData.guild.members.cache.get(savedMessage.user_id as Snowflake); const config = await pluginData.config.getMatchingConfig({ userId: savedMessage.user_id, channelId: savedMessage.channel_id, member, }); const maxMessages = config.max_messages; if (maxMessages) { logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Message, maxMessages, 1, "too many messages"); } const maxMentions = config.max_mentions; const mentions = savedMessage.data.content ? [...getUserMentions(savedMessage.data.content), ...getRoleMentions(savedMessage.data.content)] : []; if (maxMentions && mentions.length) { logAndDetectMessageSpam( pluginData, savedMessage, RecentActionType.Mention, maxMentions, mentions.length, "too many mentions", ); } const maxLinks = config.max_links; if (maxLinks && savedMessage.data.content && typeof savedMessage.data.content === "string") { const links = getUrlsInString(savedMessage.data.content); logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Link, maxLinks, links.length, "too many links"); } const maxAttachments = config.max_attachments; if (maxAttachments && savedMessage.data.attachments) { logAndDetectMessageSpam( pluginData, savedMessage, RecentActionType.Attachment, maxAttachments, savedMessage.data.attachments.length, "too many attachments", ); } const maxEmojis = config.max_emojis; if (maxEmojis && savedMessage.data.content) { const emojiCount = getEmojiInString(savedMessage.data.content).length; logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Emoji, maxEmojis, emojiCount, "too many emoji"); } const maxNewlines = config.max_newlines; if (maxNewlines && savedMessage.data.content) { const newlineCount = (savedMessage.data.content.match(/\n/g) || []).length; logAndDetectMessageSpam( pluginData, savedMessage, RecentActionType.Newline, maxNewlines, newlineCount, "too many newlines", ); } const maxCharacters = config.max_characters; if (maxCharacters && savedMessage.data.content) { const characterCount = [...savedMessage.data.content.trim()].length; logAndDetectMessageSpam( pluginData, savedMessage, RecentActionType.Character, maxCharacters, characterCount, "too many characters", ); } // TODO: Max duplicates check } ================================================ FILE: backend/src/plugins/Spam/util/saveSpamArchives.ts ================================================ import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { getBaseUrl } from "../../../pluginUtils.js"; import { SpamPluginType } from "../types.js"; const SPAM_ARCHIVE_EXPIRY_DAYS = 90; export async function saveSpamArchives(pluginData: GuildPluginData, savedMessages: SavedMessage[]) { const expiresAt = moment.utc().add(SPAM_ARCHIVE_EXPIRY_DAYS, "days"); const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild, expiresAt); return pluginData.state.archives.getUrl(getBaseUrl(pluginData), archiveId); } ================================================ FILE: backend/src/plugins/Starboard/StarboardPlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildStarboardMessages } from "../../data/GuildStarboardMessages.js"; import { GuildStarboardReactions } from "../../data/GuildStarboardReactions.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { MigratePinsCmd } from "./commands/MigratePinsCmd.js"; import { StarboardReactionAddEvt } from "./events/StarboardReactionAddEvt.js"; import { StarboardReactionRemoveAllEvt, StarboardReactionRemoveEvt } from "./events/StarboardReactionRemoveEvts.js"; import { StarboardPluginType, zStarboardConfig } from "./types.js"; import { onMessageDelete } from "./util/onMessageDelete.js"; export const StarboardPlugin = guildPlugin()({ name: "starboard", configSchema: zStarboardConfig, defaultOverrides: [ { level: ">=100", config: { can_migrate: true, }, }, ], // prettier-ignore messageCommands: [ MigratePinsCmd, ], // prettier-ignore events: [ StarboardReactionAddEvt, StarboardReactionRemoveEvt, StarboardReactionRemoveAllEvt, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); state.starboardMessages = GuildStarboardMessages.getGuildInstance(guild.id); state.starboardReactions = GuildStarboardReactions.getGuildInstance(guild.id); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, afterLoad(pluginData) { const { state } = pluginData; state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg); state.savedMessages.events.on("delete", state.onMessageDeleteFn); }, beforeUnload(pluginData) { const { state } = pluginData; state.savedMessages.events.off("delete", state.onMessageDeleteFn); }, }); ================================================ FILE: backend/src/plugins/Starboard/commands/MigratePinsCmd.ts ================================================ import { Snowflake, TextChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { starboardCmd } from "../types.js"; import { saveMessageToStarboard } from "../util/saveMessageToStarboard.js"; export const MigratePinsCmd = starboardCmd({ trigger: "starboard migrate_pins", permission: "can_migrate", description: "Posts all pins from a channel to the specified starboard. The pins are NOT unpinned automatically.", signature: { pinChannel: ct.textChannel(), starboardName: ct.string(), }, async run({ message: msg, args, pluginData }) { const config = pluginData.config.get(); const starboard = config.boards[args.starboardName]; if (!starboard) { void pluginData.state.common.sendErrorMessage(msg, "Unknown starboard specified"); return; } const starboardChannel = pluginData.guild.channels.cache.get(starboard.channel_id as Snowflake); if (!starboardChannel || !(starboardChannel instanceof TextChannel)) { void pluginData.state.common.sendErrorMessage(msg, "Starboard has an unknown/invalid channel id"); return; } msg.channel.send(`Migrating pins from <#${args.pinChannel.id}> to <#${starboardChannel.id}>...`); const pins = [...(await args.pinChannel.messages.fetchPinned().catch(() => [])).values()]; pins.reverse(); // Migrate pins starting from the oldest message for (const pin of pins) { const existingStarboardMessage = await pluginData.state.starboardMessages.getMatchingStarboardMessages( starboardChannel.id, pin.id, ); if (existingStarboardMessage.length > 0) continue; await saveMessageToStarboard(pluginData, pin, starboard); } void pluginData.state.common.sendSuccessMessage( msg, `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`, ); }, }); ================================================ FILE: backend/src/plugins/Starboard/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zStarboardConfig } from "./types.js"; export const starboardPluginDocs: ZeppelinPluginDocs = { type: "stable", prettyName: "Starboard", description: trimPluginDescription(` This plugin allows you to set up starboards on your server. Starboards are like user voted pins where messages with enough reactions get immortalized on a "starboard" channel. `), configurationGuide: trimPluginDescription(` ### Note on emojis To specify emoji in the config, you need to use the emoji's "raw form". To obtain this, post the emoji with a backslash in front of it. - Example with a default emoji: ":star:" => "⭐" - Example with a custom emoji: ":mrvnSmile:" => "<:mrvnSmile:543000534102310933>" ### Basic starboard Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226). ~~~yml starboard: config: boards: basic: channel_id: "604342689038729226" stars_required: 5 ~~~ ### Basic starboard with custom color Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226), with the given color (0x87CEEB). ~~~yml starboard: config: boards: basic: channel_id: "604342689038729226" stars_required: 5 color: 0x87CEEB ~~~ ### Custom star emoji This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji ~~~yml starboard: config: boards: basic: channel_id: "604342689038729226" star_emoji: ["⭐", "<:mrvnSmile:543000534102310933>"] stars_required: 5 ~~~ ### Limit starboard to a specific channel This is identical to the basic starboard above, but only works from a specific channel (473087035574321152). ~~~yml starboard: config: boards: basic: enabled: false # The starboard starts disabled and is then enabled in a channel override below channel_id: "604342689038729226" stars_required: 5 overrides: - channel: "473087035574321152" config: boards: basic: enabled: true ~~~ ### Limit starboard to a specific level (and above) This is identical to the basic starboard above, but only works for a specific level (>=50). ~~~yml starboard: config: boards: levelonly: enabled: false # The starboard starts disabled and is then enabled in a level override below channel_id: "604342689038729226" stars_required: 1 overrides: - level: ">=50" config: boards: levelonly: enabled: true ~~~ `), configSchema: zStarboardConfig, }; ================================================ FILE: backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts ================================================ import { Message, Snowflake, TextChannel } from "discord.js"; import { noop, resolveMember } from "../../../utils.js"; import { allStarboardsLock } from "../../../utils/lockNameHelpers.js"; import { starboardEvt } from "../types.js"; import { saveMessageToStarboard } from "../util/saveMessageToStarboard.js"; import { updateStarboardMessageStarCount } from "../util/updateStarboardMessageStarCount.js"; export const StarboardReactionAddEvt = starboardEvt({ event: "messageReactionAdd", async listener(meta) { const pluginData = meta.pluginData; let msg = meta.args.reaction.message as Message; const userId = meta.args.user.id; const emoji = meta.args.reaction.emoji; if (!msg.author) { // Message is not cached, fetch it try { msg = await msg.channel.messages.fetch(msg.id); } catch { // Sometimes we get this event for messages we can't fetch with getMessage; ignore silently return; } } const member = await resolveMember(pluginData.client, pluginData.guild, userId); if (!member || member!.user.bot) return; const config = await pluginData.config.getMatchingConfig({ userId, member, message: msg, }); const boardLock = await pluginData.locks.acquire(allStarboardsLock()); const applicableStarboards = Object.values(config.boards) .filter((board) => board.enabled) // Can't star messages in the starboard channel itself .filter((board) => board.channel_id !== msg.channel.id) // Matching emoji .filter((board) => { return board.star_emoji!.some((boardEmoji: string) => { if (emoji.id) { // Custom emoji const customEmojiMatch = boardEmoji.match(/^?$/); if (customEmojiMatch) { return customEmojiMatch[1] === emoji.id; } return boardEmoji === emoji.id; } else { // Unicode emoji return emoji.name === boardEmoji; } }); }); const selfStar = msg.author.id === userId; for (const starboard of applicableStarboards) { if (selfStar && !starboard.allow_selfstars) continue; // Save reaction into the database await pluginData.state.starboardReactions.createStarboardReaction(msg.id, userId).catch(noop); const reactions = await pluginData.state.starboardReactions.getAllReactionsForMessageId(msg.id); const reactionsCount = reactions.length; const starboardMessages = await pluginData.state.starboardMessages.getMatchingStarboardMessages( starboard.channel_id, msg.id, ); if (starboardMessages.length > 0) { // If the message has already been posted to this starboard, update star counts if (starboard.show_star_count) { for (const starboardMessage of starboardMessages) { const channel = pluginData.guild.channels.cache.get( starboardMessage.starboard_channel_id as Snowflake, ) as TextChannel; const realStarboardMessage = await channel.messages.fetch(starboardMessage.starboard_message_id); await updateStarboardMessageStarCount( starboard, msg, realStarboardMessage, starboard.star_emoji![0]!, reactionsCount, ); } } } else if (reactionsCount >= starboard.stars_required) { // Otherwise, if the star count exceeds the required star count, save the message to the starboard await saveMessageToStarboard(pluginData, msg, starboard); } } boardLock.unlock(); }, }); ================================================ FILE: backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts ================================================ import { allStarboardsLock } from "../../../utils/lockNameHelpers.js"; import { starboardEvt } from "../types.js"; export const StarboardReactionRemoveEvt = starboardEvt({ event: "messageReactionRemove", async listener(meta) { const boardLock = await meta.pluginData.locks.acquire(allStarboardsLock()); await meta.pluginData.state.starboardReactions.deleteStarboardReaction( meta.args.reaction.message.id, meta.args.user.id, ); boardLock.unlock(); }, }); export const StarboardReactionRemoveAllEvt = starboardEvt({ event: "messageReactionRemoveAll", async listener(meta) { const boardLock = await meta.pluginData.locks.acquire(allStarboardsLock()); await meta.pluginData.state.starboardReactions.deleteAllStarboardReactionsForMessageId(meta.args.message.id); boardLock.unlock(); }, }); ================================================ FILE: backend/src/plugins/Starboard/types.ts ================================================ import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildStarboardMessages } from "../../data/GuildStarboardMessages.js"; import { GuildStarboardReactions } from "../../data/GuildStarboardReactions.js"; import { zBoundedRecord, zSnowflake } from "../../utils.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; const zStarboardOpts = z.strictObject({ channel_id: zSnowflake, stars_required: z.number(), star_emoji: z.array(z.string()).default(["⭐"]), allow_selfstars: z.boolean().default(false), copy_full_embed: z.boolean().default(false), enabled: z.boolean().default(true), show_star_count: z.boolean().default(true), color: z.number().nullable().default(null), }); export type TStarboardOpts = z.infer; export const zStarboardConfig = z.strictObject({ boards: zBoundedRecord(z.record(z.string(), zStarboardOpts), 0, 100).default({}), can_migrate: z.boolean().default(false), }); export interface StarboardPluginType extends BasePluginType { configSchema: typeof zStarboardConfig; state: { savedMessages: GuildSavedMessages; starboardMessages: GuildStarboardMessages; starboardReactions: GuildStarboardReactions; common: pluginUtils.PluginPublicInterface; onMessageDeleteFn; }; } export const starboardCmd = guildPluginMessageCommand(); export const starboardEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts ================================================ import { GuildChannel, Message } from "discord.js"; import path from "path"; import { EMPTY_CHAR, EmbedWith, renderUsername } from "../../../utils.js"; const imageAttachmentExtensions = ["jpeg", "jpg", "png", "gif", "webp"]; const audioAttachmentExtensions = ["wav", "mp3", "m4a"]; const videoAttachmentExtensions = ["mp4", "mkv", "mov"]; type StarboardEmbed = EmbedWith<"footer" | "author" | "fields" | "timestamp">; export function createStarboardEmbedFromMessage( msg: Message, copyFullEmbed: boolean, color?: number | null, ): StarboardEmbed { const embed: StarboardEmbed = { footer: { text: `#${(msg.channel as GuildChannel).name}`, }, author: { name: renderUsername(msg.author), }, fields: [], timestamp: msg.createdAt.toISOString(), }; if (color != null) { embed.color = color; } embed.author.icon_url = (msg.member ?? msg.author).displayAvatarURL(); // The second condition here checks for messages with only an image link that is then embedded. // The message content in that case is hidden by the Discord client, so we hide it here too. if (msg.content && msg.embeds[0]?.thumbnail?.url !== msg.content) { embed.description = msg.content; } // Merge media and - if copy_full_embed is enabled - fields and title from the first embed in the original message if (msg.embeds.length > 0) { if (msg.embeds[0].image) { embed.image = msg.embeds[0].image; } else if (msg.embeds[0].thumbnail) { embed.image = { url: msg.embeds[0].thumbnail.url }; } if (copyFullEmbed) { if (msg.embeds[0].title) { const titleText = msg.embeds[0].url ? `[${msg.embeds[0].title}](${msg.embeds[0].url})` : msg.embeds[0].title; embed.fields.push({ name: EMPTY_CHAR, value: titleText }); } if (msg.embeds[0].fields) { embed.fields.push(...msg.embeds[0].fields); } } } // If there are no embeds, add the first image attachment explicitly else if (msg.attachments.size) { for (const attachment of msg.attachments) { const ext = path.extname(attachment[1].name!).slice(1).toLowerCase(); if (imageAttachmentExtensions.includes(ext)) { embed.image = { url: attachment[1].url }; break; } if (audioAttachmentExtensions.includes(ext)) { embed.fields.push({ name: EMPTY_CHAR, value: `*Message contains an audio clip*` }); break; } if (videoAttachmentExtensions.includes(ext)) { embed.fields.push({ name: EMPTY_CHAR, value: `*Message contains a video*` }); break; } } } return embed; } ================================================ FILE: backend/src/plugins/Starboard/util/createStarboardPseudoFooterForMessage.ts ================================================ import { EmbedField, Message } from "discord.js"; import { EMPTY_CHAR, messageLink } from "../../../utils.js"; import { TStarboardOpts } from "../types.js"; export function createStarboardPseudoFooterForMessage( starboard: TStarboardOpts, msg: Message, starEmoji: string, starCount: number, ): EmbedField { const jumpLink = `[Jump to message](${messageLink(msg)})`; let content; if (starboard.show_star_count) { content = starCount > 1 ? `${starEmoji} **${starCount}** \u200B \u200B \u200B ${jumpLink}` : `${starEmoji} \u200B ${jumpLink}`; } else { content = jumpLink; } return { name: EMPTY_CHAR, value: content, inline: false, }; } ================================================ FILE: backend/src/plugins/Starboard/util/onMessageDelete.ts ================================================ import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { StarboardPluginType } from "../types.js"; import { removeMessageFromStarboard } from "./removeMessageFromStarboard.js"; import { removeMessageFromStarboardMessages } from "./removeMessageFromStarboardMessages.js"; export async function onMessageDelete(pluginData: GuildPluginData, msg: SavedMessage) { // Deleted source message const starboardMessages = await pluginData.state.starboardMessages.getStarboardMessagesForMessageId(msg.id); for (const starboardMessage of starboardMessages) { removeMessageFromStarboard(pluginData, starboardMessage); } // Deleted message from the starboard const deletedStarboardMessages = await pluginData.state.starboardMessages.getStarboardMessagesForStarboardMessageId( msg.id, ); if (deletedStarboardMessages.length === 0) return; for (const starboardMessage of deletedStarboardMessages) { removeMessageFromStarboardMessages( pluginData, starboardMessage.starboard_message_id, starboardMessage.starboard_channel_id, ); } } ================================================ FILE: backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts ================================================ import { ChannelType } from "discord.js"; import { GuildPluginData } from "vety"; import { StarboardMessage } from "../../../data/entities/StarboardMessage.js"; import { noop } from "../../../utils.js"; import { StarboardPluginType } from "../types.js"; export async function removeMessageFromStarboard( pluginData: GuildPluginData, msg: StarboardMessage, ) { // fixes stuck entries on starboard_reactions table after messages being deleted, probably should add a cleanup script for this as well, i.e. DELETE FROM starboard_reactions WHERE message_id NOT IN (SELECT id FROM starboard_messages) await pluginData.state.starboardReactions.deleteAllStarboardReactionsForMessageId(msg.message_id).catch(noop); // this code is now Almeida-certified and no longer ugly :ok_hand: :cake: const channel = pluginData.client.channels.cache.find((c) => c.id === msg.starboard_channel_id); if (channel?.type !== ChannelType.GuildText) return; const message = await channel.messages.fetch(msg.starboard_message_id).catch(noop); if (!message?.deletable) return; await message.delete().catch(noop); } ================================================ FILE: backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts ================================================ import { GuildPluginData } from "vety"; import { StarboardPluginType } from "../types.js"; export async function removeMessageFromStarboardMessages( pluginData: GuildPluginData, starboard_message_id: string, channel_id: string, ) { await pluginData.state.starboardMessages.deleteStarboardMessage(starboard_message_id, channel_id); } ================================================ FILE: backend/src/plugins/Starboard/util/saveMessageToStarboard.ts ================================================ import { APIEmbed, Message, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { StarboardPluginType, TStarboardOpts } from "../types.js"; import { createStarboardEmbedFromMessage } from "./createStarboardEmbedFromMessage.js"; import { createStarboardPseudoFooterForMessage } from "./createStarboardPseudoFooterForMessage.js"; export async function saveMessageToStarboard( pluginData: GuildPluginData, msg: Message, starboard: TStarboardOpts, ) { const channel = pluginData.guild.channels.cache.get(starboard.channel_id as Snowflake); if (!channel?.isTextBased()) return; const starCount = (await pluginData.state.starboardReactions.getAllReactionsForMessageId(msg.id)).length; const embed = createStarboardEmbedFromMessage(msg, Boolean(starboard.copy_full_embed), starboard.color); embed.fields!.push(createStarboardPseudoFooterForMessage(starboard, msg, starboard.star_emoji![0], starCount)); const starboardMessage = await channel.send({ embeds: [embed as APIEmbed] }); await pluginData.state.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id); } ================================================ FILE: backend/src/plugins/Starboard/util/updateStarboardMessageStarCount.ts ================================================ import { Message } from "discord.js"; import { TStarboardOpts } from "../types.js"; import { createStarboardPseudoFooterForMessage } from "./createStarboardPseudoFooterForMessage.js"; import Timeout = NodeJS.Timeout; const DEBOUNCE_DELAY = 1000; const debouncedUpdates: Record = {}; export async function updateStarboardMessageStarCount( starboard: TStarboardOpts, originalMessage: Message, starboardMessage: Message, starEmoji: string, starCount: number, ) { const key = `${originalMessage.id}-${starboardMessage.id}`; if (debouncedUpdates[key]) { clearTimeout(debouncedUpdates[key]); } debouncedUpdates[key] = setTimeout(() => { delete debouncedUpdates[key]; const embed = starboardMessage.embeds[0]!; embed.fields!.pop(); // Remove pseudo footer embed.fields!.push(createStarboardPseudoFooterForMessage(starboard, originalMessage, starEmoji, starCount)); // Create new pseudo footer starboardMessage.edit({ embeds: [embed] }); }, DEBOUNCE_DELAY); } ================================================ FILE: backend/src/plugins/Tags/TagsPlugin.ts ================================================ import { Snowflake } from "discord.js"; import { guildPlugin } from "vety"; import moment from "moment-timezone"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildTags } from "../../data/GuildTags.js"; import { humanizeDuration } from "../../humanizeDuration.js"; import { makePublicFn } from "../../pluginUtils.js"; import { convertDelayStringToMS } from "../../utils.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { TagCreateCmd } from "./commands/TagCreateCmd.js"; import { TagDeleteCmd } from "./commands/TagDeleteCmd.js"; import { TagEvalCmd } from "./commands/TagEvalCmd.js"; import { TagListCmd } from "./commands/TagListCmd.js"; import { TagSourceCmd } from "./commands/TagSourceCmd.js"; import { TagsPluginType, zTagsConfig } from "./types.js"; import { findTagByName } from "./util/findTagByName.js"; import { onMessageCreate } from "./util/onMessageCreate.js"; import { onMessageDelete } from "./util/onMessageDelete.js"; import { renderTagBody } from "./util/renderTagBody.js"; export const TagsPlugin = guildPlugin()({ name: "tags", dependencies: () => [LogsPlugin], configSchema: zTagsConfig, defaultOverrides: [ { level: ">=50", config: { can_use: true, can_create: true, can_list: true, }, }, ], // prettier-ignore messageCommands: [ TagEvalCmd, TagDeleteCmd, TagListCmd, TagSourceCmd, TagCreateCmd, ], // prettier-ignore events: [ onMessageDelete, ], public(pluginData) { return { renderTagBody: makePublicFn(pluginData, renderTagBody), findTagByName: makePublicFn(pluginData, findTagByName), }; }, beforeLoad(pluginData) { const { state, guild } = pluginData; state.archives = GuildArchives.getGuildInstance(guild.id); state.tags = GuildTags.getGuildInstance(guild.id); state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); state.logs = new GuildLogs(guild.id); state.tagFunctions = {}; }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, afterLoad(pluginData) { const { state } = pluginData; state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg); state.savedMessages.events.on("create", state.onMessageCreateFn); const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); const tz = timeAndDate.getGuildTz(); state.tagFunctions = { parseDateTime(str) { if (typeof str === "number") { return str; // Unix timestamp } if (typeof str !== "string") { return Date.now(); } if (!Number.isNaN(Number(str))) { return Number(str); // Unix timestamp as a string } return moment.tz(str, "YYYY-MM-DD HH:mm:ss", tz).valueOf(); }, countdown(toDate) { const target = moment.utc(this.parseDateTime(toDate), "x"); const now = moment.utc(); if (!target.isValid()) return ""; const diff = target.diff(now); const result = humanizeDuration(diff, { largest: 2, round: true }); return diff >= 0 ? result : `${result} ago`; }, now() { return Date.now(); }, timeAdd(...args) { if (args.length === 0) return; let reference; let delay; for (const [i, arg] of args.entries()) { if (typeof arg === "number") { args[i] = String(arg); } else if (typeof arg !== "string") { args[i] = ""; } } if (args.length >= 2) { // (time, delay) reference = this.parseDateTime(args[0]); delay = args[1]; } else { // (delay), implicit "now" as time reference = Date.now(); delay = args[0]; } const delayMS = convertDelayStringToMS(delay) ?? 0; return moment.utc(reference, "x").add(delayMS).valueOf(); }, timeSub(...args) { if (args.length === 0) return; let reference; let delay; for (const [i, arg] of args.entries()) { if (typeof arg === "number") { args[i] = String(arg); } else if (typeof arg !== "string") { args[i] = ""; } } if (args.length >= 2) { // (time, delay) reference = this.parseDateTime(args[0]); delay = args[1]; } else { // (delay), implicit "now" as time reference = Date.now(); delay = args[0]; } const delayMS = convertDelayStringToMS(delay) ?? 0; return moment.utc(reference, "x").subtract(delayMS).valueOf(); }, timeAgo(delay) { return this.timeSub(delay); }, formatTime(time, format) { const parsed = this.parseDateTime(time); return timeAndDate.inGuildTz(parsed).format(format); }, discordDateFormat(time) { const parsed = time ? this.parseDateTime(time) : Date.now(); return timeAndDate.inGuildTz(parsed).format("YYYY-MM-DD"); }, mention: (input) => { if (typeof input !== "string") { return ""; } if (input.match(/^<(?:@[!&]?|#)\d+>$/)) { return input; } if ( pluginData.guild.members.cache.has(input as Snowflake) || pluginData.client.users.resolve(input as Snowflake) ) { return `<@!${input}>`; } if (pluginData.guild.channels.cache.has(input as Snowflake)) { return `<#${input}>`; } return ""; }, isMention: (input) => { if (typeof input !== "string") { return false; } return /^<(?:@[!&]?|#)\d+>$/.test(input); }, }; for (const [name, fn] of Object.entries(state.tagFunctions)) { state.tagFunctions[name] = (fn as any).bind(state.tagFunctions); } }, beforeUnload(pluginData) { const { state } = pluginData; state.savedMessages.events.off("create", state.onMessageCreateFn); }, }); ================================================ FILE: backend/src/plugins/Tags/commands/TagCreateCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { TemplateParseError, parseTemplate } from "../../../templateFormatter.js"; import { tagsCmd } from "../types.js"; export const TagCreateCmd = tagsCmd({ trigger: "tag", permission: "can_create", signature: { tag: ct.string(), body: ct.string({ catchAll: true }), }, async run({ message: msg, args, pluginData }) { try { parseTemplate(args.body); } catch (e) { if (e instanceof TemplateParseError) { void pluginData.state.common.sendErrorMessage(msg, `Invalid tag syntax: ${e.message}`); return; } else { throw e; } } await pluginData.state.tags.createOrUpdate(args.tag, args.body, msg.author.id); const prefix = pluginData.config.get().prefix; void pluginData.state.common.sendSuccessMessage(msg, `Tag set! Use it with: \`${prefix}${args.tag}\``); }, }); ================================================ FILE: backend/src/plugins/Tags/commands/TagDeleteCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { tagsCmd } from "../types.js"; export const TagDeleteCmd = tagsCmd({ trigger: "tag delete", permission: "can_create", signature: { tag: ct.string(), }, async run({ message: msg, args, pluginData }) { const tag = await pluginData.state.tags.find(args.tag); if (!tag) { void pluginData.state.common.sendErrorMessage(msg, "No tag with that name"); return; } await pluginData.state.tags.delete(args.tag); void pluginData.state.common.sendSuccessMessage(msg, "Tag deleted!"); }, }); ================================================ FILE: backend/src/plugins/Tags/commands/TagEvalCmd.ts ================================================ import { MessageCreateOptions } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { logger } from "../../../logger.js"; import { resolveMessageMember } from "../../../pluginUtils.js"; import { TemplateParseError } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { tagsCmd } from "../types.js"; import { renderTagBody } from "../util/renderTagBody.js"; export const TagEvalCmd = tagsCmd({ trigger: "tag eval", permission: "can_create", signature: { body: ct.string({ catchAll: true }), }, async run({ message: msg, args, pluginData }) { const authorMember = await resolveMessageMember(msg); try { const rendered = (await renderTagBody( pluginData, args.body, [], { member: memberToTemplateSafeMember(authorMember), user: userToTemplateSafeUser(msg.author), }, { member: msg.member }, )) as MessageCreateOptions; if (!rendered.content && !rendered.embeds?.length) { void pluginData.state.common.sendErrorMessage(msg, "Evaluation resulted in an empty text"); return; } msg.channel.send(rendered); } catch (e) { const errorMessage = e instanceof TemplateParseError ? e.message : "Internal error"; void pluginData.state.common.sendErrorMessage(msg, `Failed to render tag: ${errorMessage}`); if (!(e instanceof TemplateParseError)) { logger.warn(`Internal error evaluating tag in ${pluginData.guild.id}: ${e}`); } return; } }, }); ================================================ FILE: backend/src/plugins/Tags/commands/TagListCmd.ts ================================================ import escapeStringRegexp from "escape-string-regexp"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { createChunkedMessage } from "../../../utils.js"; import { tagsCmd } from "../types.js"; export const TagListCmd = tagsCmd({ trigger: ["tag list", "tags", "taglist"], permission: "can_list", signature: { search: ct.string({ required: false }), }, async run({ message: msg, args, pluginData }) { const tags = await pluginData.state.tags.all(); if (tags.length === 0) { msg.channel.send(`No tags created yet! Use \`tag create\` command to create one.`); return; } const prefix = (await pluginData.config.getForMessage(msg)).prefix; const tagNames = tags.map((tag) => tag.tag).sort(); const searchRegex = args.search ? new RegExp([...args.search].map((s) => escapeStringRegexp(s)).join(".*"), "i") : null; const filteredTags = args.search ? tagNames.filter((tag) => searchRegex!.test(tag)) : tagNames; if (filteredTags.length === 0) { msg.channel.send("No tags matched the filter"); return; } const tagGroups = filteredTags.reduce((obj, tag) => { const tagUpper = tag.toUpperCase(); const key = /[A-Z]/.test(tagUpper[0]) ? tagUpper[0] : "#"; if (!(key in obj)) { obj[key] = []; } obj[key].push(tag); return obj; }, {}); const tagList = Object.keys(tagGroups) .sort() .map((key) => `[${key}] ${tagGroups[key].join(", ")}`) .join("\n"); createChunkedMessage(msg.channel, `Available tags (use with ${prefix}tag): \`\`\`${tagList}\`\`\``); }, }); ================================================ FILE: backend/src/plugins/Tags/commands/TagSourceCmd.ts ================================================ import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { getBaseUrl } from "../../../pluginUtils.js"; import { tagsCmd } from "../types.js"; export const TagSourceCmd = tagsCmd({ trigger: "tag", permission: "can_create", signature: { tag: ct.string(), delete: ct.bool({ option: true, shortcut: "d", isSwitch: true }), }, async run({ message: msg, args, pluginData }) { if (args.delete) { const actualTag = await pluginData.state.tags.find(args.tag); if (!actualTag) { void pluginData.state.common.sendErrorMessage(msg, "No tag with that name"); return; } await pluginData.state.tags.delete(args.tag); void pluginData.state.common.sendSuccessMessage(msg, "Tag deleted!"); return; } const tag = await pluginData.state.tags.find(args.tag); if (!tag) { void pluginData.state.common.sendErrorMessage(msg, "No tag with that name"); return; } const archiveId = await pluginData.state.archives.create(tag.body, moment.utc().add(10, "minutes")); const url = pluginData.state.archives.getUrl(getBaseUrl(pluginData), archiveId); msg.channel.send(`Tag source:\n${url}`); }, }); ================================================ FILE: backend/src/plugins/Tags/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { TemplateFunctions } from "./templateFunctions.js"; import { TemplateFunction, zTagsConfig } from "./types.js"; export const tagsPluginDocs: ZeppelinPluginDocs = { type: "stable", prettyName: "Tags", description: "Tags are a way to store and reuse information.", configurationGuide: trimPluginDescription(` ### Template Functions You can use template functions in your tags. These functions are called when the tag is rendered. You can use these functions to render dynamic content, or to access information from the message and/or user calling the tag. You use them by adding a \`{}\` on your tag. Here are the functions you can use in your tags: ${generateTemplateMarkdown(TemplateFunctions)} `), configSchema: zTagsConfig, }; function generateTemplateMarkdown(definitions: TemplateFunction[]): string { return definitions .map((def) => { const usage = def.signature ?? `(${def.arguments.join(", ")})`; const examples = def.examples?.map((ex) => `> \`{${ex}}\``).join("\n") ?? null; return trimPluginDescription(` ## ${def.name} **${def.description}**\n __Usage__: \`{${def.name}${usage}}\`\n ${examples ? `__Examples__:\n${examples}` : ""}\n\n `); }) .join("\n\n"); } ================================================ FILE: backend/src/plugins/Tags/templateFunctions.ts ================================================ import { TemplateFunction } from "./types.js"; // TODO: Generate this dynamically, lmao export const TemplateFunctions: TemplateFunction[] = [ { name: "if", description: "Checks if a condition is true or false and returns the corresponding ifTrue or ifFalse", returnValue: "boolean", arguments: ["condition", "ifTrue", "ifFalse"], examples: ['if(user.bot, "User is a bot", "User is not a bot")'], }, { name: "and", description: "Checks if all provided conditions are true", returnValue: "boolean", arguments: ["condition1", "condition2", "..."], examples: ["and(user.bot, user.verified)"], }, { name: "or", description: "Checks if atleast one of the provided conditions is true", returnValue: "boolean", arguments: ["condition1", "condition2", "..."], examples: ["or(user.bot, user.verified)"], }, { name: "not", description: "Checks if the provided condition is false", returnValue: "boolean", arguments: ["condition"], examples: ["not(user.bot)"], }, { name: "concat", description: "Concatenates several arguments into a string", returnValue: "string", arguments: ["argument1", "argument2", "..."], examples: ['concat("Hello ", user.username, "!")'], }, { name: "concatArr", description: "Joins a array with the provided separator", returnValue: "string", arguments: ["array", "separator"], examples: ['concatArr(["Hello", "World"], " ")'], }, { name: "eq", description: "Checks if all provided arguments are equal to each other", returnValue: "boolean", arguments: ["argument1", "argument2", "..."], examples: ['eq(user.id, "106391128718245888")'], }, { name: "gt", description: "Checks if the first argument is greater than the second", returnValue: "boolean", arguments: ["argument1", "argument2"], examples: ["gt(5, 2)"], }, { name: "gte", description: "Checks if the first argument is greater or equal to the second", returnValue: "boolean", arguments: ["argument1", "argument2"], examples: ["gte(2, 2)"], }, { name: "lt", description: "Checks if the first argument is smaller than the second", returnValue: "boolean", arguments: ["argument1", "argument2"], examples: ["lt(2, 5)"], }, { name: "lte", description: "Checks if the first argument is smaller or equal to the second", returnValue: "boolean", arguments: ["argument1", "argument2"], examples: ["lte(2, 2)"], }, { name: "slice", description: "Slices a string argument at start and end", returnValue: "string", arguments: ["string", "start", "end"], examples: ['slice("Hello World", 0, 5)'], }, { name: "lower", description: "Converts a string argument to lowercase", returnValue: "string", arguments: ["string"], examples: ['lower("Hello World")'], }, { name: "upper", description: "Converts a string argument to uppercase", returnValue: "string", arguments: ["string"], examples: ['upper("Hello World")'], }, { name: "upperFirst", description: "Converts the first character of a string argument to uppercase", returnValue: "string", arguments: ["string"], examples: ['upperFirst("hello World")'], }, { name: "rand", description: "Returns a random number between from and to, optionally using seed", returnValue: "number", arguments: ["from", "to", "seed"], examples: ["rand(1, 10)"], }, { name: "round", description: "Rounds a number to the given decimal places", returnValue: "number", arguments: ["number", "decimalPlaces"], examples: ["round(1.2345, 2)"], }, { name: "add", description: "Adds two or more numbers", returnValue: "number", arguments: ["number1", "number2", "..."], examples: ["add(1, 2)"], }, { name: "sub", description: "Subtracts two or more numbers", returnValue: "number", arguments: ["number1", "number2", "..."], examples: ["sub(3, 1)"], }, { name: "mul", description: "Multiplies two or more numbers", returnValue: "number", arguments: ["number1", "number2", "..."], examples: ["mul(2, 3)"], }, { name: "div", description: "Divides two or more numbers", returnValue: "number", arguments: ["number1", "number2", "..."], examples: ["div(6, 2)"], }, { name: "cases", description: "Returns the argument at position", returnValue: "any", arguments: ["position", "argument1", "argument2", "..."], examples: ['cases(1, "Hello", "World")'], }, { name: "choose", description: "Returns a random argument", returnValue: "any", arguments: ["argument1", "argument2", "..."], examples: ['choose("Hello", "World", "!")'], }, ]; ================================================ FILE: backend/src/plugins/Tags/types.ts ================================================ import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildTags } from "../../data/GuildTags.js"; import { zBoundedCharacters, zStrictMessageContent } from "../../utils.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zTag = z.union([zBoundedCharacters(0, 4000), zStrictMessageContent]); export type TTag = z.infer; export const zTagCategory = z .strictObject({ prefix: z.string().nullable().default(null), delete_with_command: z.boolean().default(false), user_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag user_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag category global_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per tag allow_mentions: z.boolean().nullable().default(null), global_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per category auto_delete_command: z.boolean().nullable().default(null), tags: z.record(z.string(), zTag), can_use: z.boolean().nullable().default(null), }) .refine((parsed) => !(parsed.auto_delete_command && parsed.delete_with_command), { message: "Cannot have both (category specific) delete_with_command and auto_delete_command enabled", }); export type TTagCategory = z.infer; export const zTagsConfig = z .strictObject({ prefix: z.string().default("!!"), delete_with_command: z.boolean().default(true), user_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag global_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per tag user_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user allow_mentions: z.boolean().default(false), // Per user global_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any tag use auto_delete_command: z.boolean().default(false), // Any tag categories: z.record(z.string(), zTagCategory).default({}), can_create: z.boolean().default(false), can_use: z.boolean().default(false), can_list: z.boolean().default(false), }) .refine((parsed) => !(parsed.auto_delete_command && parsed.delete_with_command), { message: "Cannot have both (category specific) delete_with_command and auto_delete_command enabled", }); export interface TagsPluginType extends BasePluginType { configSchema: typeof zTagsConfig; state: { archives: GuildArchives; tags: GuildTags; savedMessages: GuildSavedMessages; logs: GuildLogs; common: pluginUtils.PluginPublicInterface; onMessageCreateFn; tagFunctions: any; }; } export interface TemplateFunction { name: string; description: string; arguments: string[]; returnValue: string; signature?: string; examples?: string[]; } export const tagsCmd = guildPluginMessageCommand(); export const tagsEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/Tags/util/findTagByName.ts ================================================ import { ExtendedMatchParams, GuildPluginData } from "vety"; import { TTag, TagsPluginType } from "../types.js"; export async function findTagByName( pluginData: GuildPluginData, name: string, matchParams: ExtendedMatchParams = {}, ): Promise { const config = await pluginData.config.getMatchingConfig(matchParams); // Tag from a hardcoded category // Format: "category.tag" const categorySeparatorIndex = name.indexOf("."); if (categorySeparatorIndex > 0) { const categoryName = name.slice(0, categorySeparatorIndex); if (!Object.hasOwn(config.categories, categoryName)) { return null; } const category = config.categories[categoryName]; const tagName = name.slice(categorySeparatorIndex + 1); if (!Object.hasOwn(category.tags, tagName)) { return null; } return category.tags[tagName]; } // Dynamic tag // Format: "tag" const dynamicTag = await pluginData.state.tags.find(name); return dynamicTag?.body ?? null; } ================================================ FILE: backend/src/plugins/Tags/util/matchAndRenderTagFromString.ts ================================================ import { GuildMember } from "discord.js"; import escapeStringRegexp from "escape-string-regexp"; import { ExtendedMatchParams, GuildPluginData } from "vety"; import { StrictMessageContent } from "../../../utils.js"; import { TTagCategory, TagsPluginType } from "../types.js"; import { renderTagFromString } from "./renderTagFromString.js"; interface BaseResult { renderedContent: StrictMessageContent; tagName: string; } type ResultWithCategory = BaseResult & { categoryName: string; category: TTagCategory; }; type ResultWithoutCategory = BaseResult & { categoryName: null; category: null; }; type Result = ResultWithCategory | ResultWithoutCategory; export async function matchAndRenderTagFromString( pluginData: GuildPluginData, str: string, member: GuildMember, extraMatchParams: ExtendedMatchParams = {}, ): Promise { const config = await pluginData.config.getMatchingConfig({ ...extraMatchParams, member, }); // Hard-coded tags in categories for (const [name, category] of Object.entries(config.categories)) { const canUse = category.can_use != null ? category.can_use : config.can_use; if (canUse !== true) continue; const prefix = category.prefix != null ? category.prefix : config.prefix; if (prefix !== "" && !str.startsWith(prefix)) continue; const withoutPrefix = str.slice(prefix.length); for (const [tagName, tagBody] of Object.entries(category.tags)) { const regex = new RegExp(`^${escapeStringRegexp(tagName)}(?:\\s|$)`); if (regex.test(withoutPrefix)) { const renderedContent = await renderTagFromString(pluginData, str, prefix, tagName, tagBody, member); if (renderedContent == null) { return null; } return { renderedContent, tagName, categoryName: name, category, }; } } } // Dynamic tags if (config.can_use !== true) { return null; } const dynamicTagPrefix = config.prefix; if (!str.startsWith(dynamicTagPrefix)) { return null; } const dynamicTagNameMatch = str.slice(dynamicTagPrefix.length).match(/^\S+/); if (dynamicTagNameMatch === null) { return null; } const dynamicTagName = dynamicTagNameMatch[0]; const dynamicTag = await pluginData.state.tags.find(dynamicTagName); if (!dynamicTag) { return null; } const renderedDynamicTagContent = await renderTagFromString( pluginData, str, dynamicTagPrefix, dynamicTagName, dynamicTag.body, member, ); if (renderedDynamicTagContent == null) { return null; } return { renderedContent: renderedDynamicTagContent, tagName: dynamicTagName, categoryName: null, category: null, }; } ================================================ FILE: backend/src/plugins/Tags/util/onMessageCreate.ts ================================================ import { Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { convertDelayStringToMS, resolveMember, zStrictMessageContent } from "../../../utils.js"; import { erisAllowedMentionsToDjsMentionOptions } from "../../../utils/erisAllowedMentionsToDjsMentionOptions.js"; import { messageIsEmpty } from "../../../utils/messageIsEmpty.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { TagsPluginType } from "../types.js"; import { matchAndRenderTagFromString } from "./matchAndRenderTagFromString.js"; export async function onMessageCreate(pluginData: GuildPluginData, msg: SavedMessage) { if (msg.is_bot) return; if (!msg.data.content) return; const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id); if (!member) return; const channel = pluginData.guild.channels.cache.get(msg.channel_id as Snowflake); if (!channel?.isTextBased()) return; const config = await pluginData.config.getMatchingConfig({ member, channelId: msg.channel_id, categoryId: channel.parentId, }); const tagResult = await matchAndRenderTagFromString(pluginData, msg.data.content, member, { channelId: msg.channel_id, categoryId: channel.parentId, }); if (!tagResult) { return; } // Check for cooldowns const cooldowns: any[] = []; if (tagResult.category) { // Category-specific cooldowns if (tagResult.category.user_tag_cooldown) { const delay = convertDelayStringToMS(String(tagResult.category.user_tag_cooldown), "s"); cooldowns.push([`tags-category-${tagResult.categoryName}-user-${msg.user_id}-tag-${tagResult.tagName}`, delay]); } if (tagResult.category.global_tag_cooldown) { const delay = convertDelayStringToMS(String(tagResult.category.global_tag_cooldown), "s"); cooldowns.push([`tags-category-${tagResult.categoryName}-tag-${tagResult.tagName}`, delay]); } if (tagResult.category.user_category_cooldown) { const delay = convertDelayStringToMS(String(tagResult.category.user_category_cooldown), "s"); cooldowns.push([`tags-category-${tagResult.categoryName}-user--${msg.user_id}`, delay]); } if (tagResult.category.global_category_cooldown) { const delay = convertDelayStringToMS(String(tagResult.category.global_category_cooldown), "s"); cooldowns.push([`tags-category-${tagResult.categoryName}`, delay]); } } else { // Dynamic tag cooldowns if (config.user_tag_cooldown) { const delay = convertDelayStringToMS(String(config.user_tag_cooldown), "s"); cooldowns.push([`tags-user-${msg.user_id}-tag-${tagResult.tagName}`, delay]); } if (config.global_tag_cooldown) { const delay = convertDelayStringToMS(String(config.global_tag_cooldown), "s"); cooldowns.push([`tags-tag-${tagResult.tagName}`, delay]); } if (config.user_cooldown) { const delay = convertDelayStringToMS(String(config.user_cooldown), "s"); cooldowns.push([`tags-user-${msg.user_id}`, delay]); } if (config.global_cooldown) { const delay = convertDelayStringToMS(String(config.global_cooldown), "s"); cooldowns.push([`tags`, delay]); } } const isOnCooldown = cooldowns.some((cd) => pluginData.cooldowns.isOnCooldown(cd[0])); if (isOnCooldown) return; for (const cd of cooldowns) { pluginData.cooldowns.setCooldown(cd[0], cd[1]); } const validated = zStrictMessageContent.safeParse(tagResult.renderedContent); if (!validated.success) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Rendering tag ${tagResult.tagName} resulted in an invalid message: ${validated.error.message}`, }); return; } if (messageIsEmpty(tagResult.renderedContent)) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Tag \`${tagResult.tagName}\` resulted in an empty message, so it couldn't be sent`, }); return; } const allowMentions = tagResult.category?.allow_mentions ?? config.allow_mentions; const responseMsg = await channel.send({ ...tagResult.renderedContent, allowedMentions: erisAllowedMentionsToDjsMentionOptions({ roles: allowMentions, users: allowMentions }), }); // Save the command-response message pair once the message is in our database const deleteWithCommand = tagResult.category?.delete_with_command ?? config.delete_with_command; if (deleteWithCommand) { await pluginData.state.tags.addResponse(msg.id, responseMsg.id); } const deleteInvoke = tagResult.category?.auto_delete_command ?? config.auto_delete_command; if (!deleteWithCommand && deleteInvoke) { // Try deleting the invoking message, ignore errors silently (pluginData.guild.channels.resolve(msg.channel_id as Snowflake) as TextChannel).messages.delete( msg.id as Snowflake, ); } } ================================================ FILE: backend/src/plugins/Tags/util/onMessageDelete.ts ================================================ import { guildPluginEventListener } from "vety"; import { noop } from "../../../utils.js"; export const onMessageDelete = guildPluginEventListener({ event: "messageDelete", async listener({ pluginData, args: { message: msg } }) { const channel = pluginData.guild.channels.cache.get(msg.channelId); if (!channel?.isTextBased()) return; // Command message was deleted -> delete the response as well const commandMsgResponse = await pluginData.state.tags.findResponseByCommandMessageId(msg.id); if (commandMsgResponse) { await pluginData.state.tags.deleteResponseByCommandMessageId(msg.id); await channel.messages.delete(commandMsgResponse.response_message_id).catch(noop); return; } // Response was deleted -> delete the command message as well const responseMsgResponse = await pluginData.state.tags.findResponseByResponseMessageId(msg.id); if (responseMsgResponse) { await pluginData.state.tags.deleteResponseByResponseMessageId(msg.id); await channel.messages.delete(responseMsgResponse.command_message_id).catch(noop); return; } }, }); ================================================ FILE: backend/src/plugins/Tags/util/renderTagBody.ts ================================================ import { ExtendedMatchParams, GuildPluginData } from "vety"; import { TemplateSafeValue, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { StrictMessageContent, renderRecursively } from "../../../utils.js"; import { TTag, TagsPluginType } from "../types.js"; import { findTagByName } from "./findTagByName.js"; const MAX_TAG_FN_CALLS = 25; // This is used to disallow setting/getting default object properties (such as __proto__) in dynamicVars const emptyObject = {}; export async function renderTagBody( pluginData: GuildPluginData, body: TTag, args: TemplateSafeValue[] = [], extraData = {}, subTagPermissionMatchParams?: ExtendedMatchParams, tagFnCallsObj = { calls: 0 }, ): Promise { const dynamicVars = {}; const data = new TemplateSafeValueContainer({ args, ...extraData, ...pluginData.state.tagFunctions, set(name, val) { if (typeof name !== "string") return; if (emptyObject[name]) return; dynamicVars[name] = val; }, setr(name, val) { if (typeof name !== "string") return ""; if (emptyObject[name]) return; dynamicVars[name] = val; return val; }, get(name) { if (typeof name !== "string") return ""; if (emptyObject[name]) return; return !Object.hasOwn(dynamicVars, name) || dynamicVars[name] == null ? "" : dynamicVars[name]; }, tag: async (name, ...subTagArgs) => { if (++tagFnCallsObj.calls > MAX_TAG_FN_CALLS) return ""; if (typeof name !== "string") return ""; if (name === "") return ""; const subTagBody = await findTagByName(pluginData, name, subTagPermissionMatchParams); if (!subTagBody) { return ""; } if (typeof subTagBody !== "string") { return ""; } const rendered = await renderTagBody( pluginData, subTagBody, subTagArgs, extraData, subTagPermissionMatchParams, tagFnCallsObj, ); return rendered.content!; }, }); if (typeof body === "string") { // Plain text tag return { content: await renderTemplate(body, data) }; } else { // Embed return renderRecursively(body, (str) => renderTemplate(str, data)); } } ================================================ FILE: backend/src/plugins/Tags/util/renderTagFromString.ts ================================================ import { GuildMember } from "discord.js"; import { GuildPluginData } from "vety"; import { parseArguments } from "knub-command-manager"; import { logger } from "../../../logger.js"; import { TemplateParseError } from "../../../templateFormatter.js"; import { StrictMessageContent, validateAndParseMessageContent } from "../../../utils.js"; import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { TTag, TagsPluginType } from "../types.js"; import { renderTagBody } from "./renderTagBody.js"; export async function renderTagFromString( pluginData: GuildPluginData, str: string, prefix: string, tagName: string, tagBody: TTag, member: GuildMember, ): Promise { const variableStr = str.slice(prefix.length + tagName.length).trim(); const tagArgs = parseArguments(variableStr).map((v) => v.value); // Format the string try { const rendered = await renderTagBody( pluginData, tagBody, tagArgs, { member: memberToTemplateSafeMember(member), user: userToTemplateSafeUser(member.user), }, { member }, ); return validateAndParseMessageContent(rendered); } catch (e) { const logs = pluginData.getPlugin(LogsPlugin); const errorMessage = e instanceof TemplateParseError ? e.message : "Internal error"; logs.logBotAlert({ body: `Failed to render tag \`${prefix}${tagName}\`: ${errorMessage}`, }); if (!(e instanceof TemplateParseError)) { logger.warn(`Internal error rendering tag ${tagName} in ${pluginData.guild.id}: ${e}`); } return null; } } ================================================ FILE: backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildMemberTimezones } from "../../data/GuildMemberTimezones.js"; import { makePublicFn } from "../../pluginUtils.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { ResetTimezoneCmd } from "./commands/ResetTimezoneCmd.js"; import { SetTimezoneCmd } from "./commands/SetTimezoneCmd.js"; import { ViewTimezoneCmd } from "./commands/ViewTimezoneCmd.js"; import { getDateFormat } from "./functions/getDateFormat.js"; import { getGuildTz } from "./functions/getGuildTz.js"; import { getMemberTz } from "./functions/getMemberTz.js"; import { inGuildTz } from "./functions/inGuildTz.js"; import { inMemberTz } from "./functions/inMemberTz.js"; import { TimeAndDatePluginType, zTimeAndDateConfig } from "./types.js"; export const TimeAndDatePlugin = guildPlugin()({ name: "time_and_date", configSchema: zTimeAndDateConfig, defaultOverrides: [ { level: ">=50", config: { can_set_timezone: true, }, }, ], // prettier-ignore messageCommands: [ ResetTimezoneCmd, SetTimezoneCmd, ViewTimezoneCmd, ], public(pluginData) { return { getGuildTz: makePublicFn(pluginData, getGuildTz), inGuildTz: makePublicFn(pluginData, inGuildTz), getMemberTz: makePublicFn(pluginData, getMemberTz), inMemberTz: makePublicFn(pluginData, inMemberTz), getDateFormat: makePublicFn(pluginData, getDateFormat), }; }, beforeLoad(pluginData) { const { state, guild } = pluginData; state.memberTimezones = GuildMemberTimezones.getGuildInstance(guild.id); }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, }); ================================================ FILE: backend/src/plugins/TimeAndDate/commands/ResetTimezoneCmd.ts ================================================ import { getGuildTz } from "../functions/getGuildTz.js"; import { timeAndDateCmd } from "../types.js"; export const ResetTimezoneCmd = timeAndDateCmd({ trigger: "timezone reset", permission: "can_set_timezone", signature: {}, async run({ pluginData, message }) { await pluginData.state.memberTimezones.reset(message.author.id); const serverTimezone = getGuildTz(pluginData); void pluginData.state.common.sendSuccessMessage( message, `Your timezone has been reset to server default, **${serverTimezone}**`, ); }, }); ================================================ FILE: backend/src/plugins/TimeAndDate/commands/SetTimezoneCmd.ts ================================================ import { escapeInlineCode } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { trimLines } from "../../../utils.js"; import { parseFuzzyTimezone } from "../../../utils/parseFuzzyTimezone.js"; import { timeAndDateCmd } from "../types.js"; export const SetTimezoneCmd = timeAndDateCmd({ trigger: "timezone", permission: "can_set_timezone", signature: { timezone: ct.string(), }, async run({ pluginData, message, args }) { const parsedTz = parseFuzzyTimezone(args.timezone); if (!parsedTz) { void pluginData.state.common.sendErrorMessage( message, trimLines(` Invalid timezone: \`${escapeInlineCode(args.timezone)}\` Zeppelin uses timezone locations rather than specific timezone names. See the **TZ database name** column at for a list of valid options. `), ); return; } await pluginData.state.memberTimezones.set(message.author.id, parsedTz); void pluginData.state.common.sendSuccessMessage(message, `Your timezone is now set to **${parsedTz}**`); }, }); ================================================ FILE: backend/src/plugins/TimeAndDate/commands/ViewTimezoneCmd.ts ================================================ import { getGuildTz } from "../functions/getGuildTz.js"; import { timeAndDateCmd } from "../types.js"; export const ViewTimezoneCmd = timeAndDateCmd({ trigger: "timezone", permission: "can_set_timezone", signature: {}, async run({ pluginData, message }) { const memberTimezone = await pluginData.state.memberTimezones.get(message.author.id); if (memberTimezone) { message.channel.send(`Your timezone is currently set to **${memberTimezone.timezone}**`); return; } const serverTimezone = getGuildTz(pluginData); message.channel.send(`Your timezone is currently set to **${serverTimezone}** (server default)`); }, }); ================================================ FILE: backend/src/plugins/TimeAndDate/defaultDateFormats.ts ================================================ export const defaultDateFormats = { date: "MMM D, YYYY", time: "H:mm", pretty_datetime: "MMM D, YYYY [at] H:mm z", }; ================================================ FILE: backend/src/plugins/TimeAndDate/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zTimeAndDateConfig } from "./types.js"; export const timeAndDatePluginDocs: ZeppelinPluginDocs = { type: "stable", prettyName: "Time and date", description: trimPluginDescription(` Allows controlling the displayed time/date formats and timezones `), configSchema: zTimeAndDateConfig, }; ================================================ FILE: backend/src/plugins/TimeAndDate/functions/getDateFormat.ts ================================================ import { GuildPluginData } from "vety"; import { defaultDateFormats } from "../defaultDateFormats.js"; import { TimeAndDatePluginType } from "../types.js"; export function getDateFormat( pluginData: GuildPluginData, formatName: keyof typeof defaultDateFormats, ) { return pluginData.config.get().date_formats?.[formatName] || defaultDateFormats[formatName]; } ================================================ FILE: backend/src/plugins/TimeAndDate/functions/getGuildTz.ts ================================================ import { GuildPluginData } from "vety"; import { TimeAndDatePluginType } from "../types.js"; export function getGuildTz(pluginData: GuildPluginData) { return pluginData.config.get().timezone; } ================================================ FILE: backend/src/plugins/TimeAndDate/functions/getMemberTz.ts ================================================ import { GuildPluginData } from "vety"; import { TimeAndDatePluginType } from "../types.js"; import { getGuildTz } from "./getGuildTz.js"; export async function getMemberTz(pluginData: GuildPluginData, memberId: string) { const memberTz = await pluginData.state.memberTimezones.get(memberId); return memberTz?.timezone || getGuildTz(pluginData); } ================================================ FILE: backend/src/plugins/TimeAndDate/functions/inGuildTz.ts ================================================ import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { TimeAndDatePluginType } from "../types.js"; import { getGuildTz } from "./getGuildTz.js"; export function inGuildTz(pluginData: GuildPluginData, input?: moment.Moment | number) { let momentObj: moment.Moment; if (typeof input === "number") { momentObj = moment.utc(input, "x"); } else if (moment.isMoment(input)) { momentObj = input.clone(); } else { momentObj = moment.utc(); } return momentObj.tz(getGuildTz(pluginData)); } ================================================ FILE: backend/src/plugins/TimeAndDate/functions/inMemberTz.ts ================================================ import { GuildPluginData } from "vety"; import moment from "moment-timezone"; import { TimeAndDatePluginType } from "../types.js"; import { getMemberTz } from "./getMemberTz.js"; export async function inMemberTz( pluginData: GuildPluginData, memberId: string, input?: moment.Moment | number, ) { let momentObj: moment.Moment; if (typeof input === "number") { momentObj = moment.utc(input, "x"); } else if (moment.isMoment(input)) { momentObj = input.clone(); } else { momentObj = moment.utc(); } return momentObj.tz(await getMemberTz(pluginData, memberId)); } ================================================ FILE: backend/src/plugins/TimeAndDate/types.ts ================================================ import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { GuildMemberTimezones } from "../../data/GuildMemberTimezones.js"; import { keys } from "../../utils.js"; import { zValidTimezone } from "../../utils/zValidTimezone.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { defaultDateFormats } from "./defaultDateFormats.js"; const dateFormatTypeMap = keys(defaultDateFormats).reduce( (map, key) => { map[key] = z.string().default(defaultDateFormats[key]); return map; }, {} as Record>, ); export const zTimeAndDateConfig = z.strictObject({ timezone: zValidTimezone(z.string()).default("Etc/UTC"), date_formats: z.strictObject(dateFormatTypeMap).default(defaultDateFormats), can_set_timezone: z.boolean().default(false), }); export interface TimeAndDatePluginType extends BasePluginType { configSchema: typeof zTimeAndDateConfig; state: { memberTimezones: GuildMemberTimezones; common: pluginUtils.PluginPublicInterface; }; } export const timeAndDateCmd = guildPluginMessageCommand(); ================================================ FILE: backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts ================================================ import { guildPlugin } from "vety"; import { Queue } from "../../Queue.js"; import { UsernameHistory } from "../../data/UsernameHistory.js"; import { MessageCreateUpdateUsernameEvt, VoiceChannelJoinUpdateUsernameEvt } from "./events/UpdateUsernameEvts.js"; import { UsernameSaverPluginType, zUsernameSaverConfig } from "./types.js"; export const UsernameSaverPlugin = guildPlugin()({ name: "username_saver", configSchema: zUsernameSaverConfig, // prettier-ignore events: [ MessageCreateUpdateUsernameEvt, VoiceChannelJoinUpdateUsernameEvt, ], beforeLoad(pluginData) { const { state } = pluginData; state.usernameHistory = new UsernameHistory(); state.updateQueue = new Queue(); }, }); ================================================ FILE: backend/src/plugins/UsernameSaver/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zUsernameSaverConfig } from "./types.js"; export const usernameSaverPluginDocs: ZeppelinPluginDocs = { type: "internal", prettyName: "Username saver", configSchema: zUsernameSaverConfig, }; ================================================ FILE: backend/src/plugins/UsernameSaver/events/UpdateUsernameEvts.ts ================================================ import { usernameSaverEvt } from "../types.js"; import { updateUsername } from "../updateUsername.js"; export const MessageCreateUpdateUsernameEvt = usernameSaverEvt({ event: "messageCreate", async listener(meta) { if (meta.args.message.author.bot) return; meta.pluginData.state.updateQueue.add(() => updateUsername(meta.pluginData, meta.args.message.author)); }, }); export const VoiceChannelJoinUpdateUsernameEvt = usernameSaverEvt({ event: "voiceStateUpdate", async listener(meta) { if (meta.args.newState.member?.user.bot) return; meta.pluginData.state.updateQueue.add(() => updateUsername(meta.pluginData, meta.args.newState.member!.user)); }, }); ================================================ FILE: backend/src/plugins/UsernameSaver/types.ts ================================================ import { BasePluginType, guildPluginEventListener } from "vety"; import { z } from "zod"; import { Queue } from "../../Queue.js"; import { UsernameHistory } from "../../data/UsernameHistory.js"; export const zUsernameSaverConfig = z.strictObject({}); export interface UsernameSaverPluginType extends BasePluginType { configSchema: typeof zUsernameSaverConfig; state: { usernameHistory: UsernameHistory; updateQueue: Queue; }; } export const usernameSaverEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/UsernameSaver/updateUsername.ts ================================================ import { User } from "discord.js"; import { GuildPluginData } from "vety"; import { renderUsername } from "../../utils.js"; import { UsernameSaverPluginType } from "./types.js"; export async function updateUsername(pluginData: GuildPluginData, user: User) { if (!user) return; const newUsername = renderUsername(user); const latestEntry = await pluginData.state.usernameHistory.getLastEntry(user.id); if (!latestEntry || newUsername !== latestEntry.username) { await pluginData.state.usernameHistory.addEntry(user.id, newUsername); } } ================================================ FILE: backend/src/plugins/Utility/UtilityPlugin.ts ================================================ import { Snowflake } from "discord.js"; import { guildPlugin } from "vety"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { Supporters } from "../../data/Supporters.js"; import { makePublicFn } from "../../pluginUtils.js"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { ModActionsPlugin } from "../ModActions/ModActionsPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { AboutCmd } from "./commands/AboutCmd.js"; import { AvatarCmd } from "./commands/AvatarCmd.js"; import { BanSearchCmd } from "./commands/BanSearchCmd.js"; import { ChannelInfoCmd } from "./commands/ChannelInfoCmd.js"; import { CleanCmd } from "./commands/CleanCmd.js"; import { ContextCmd } from "./commands/ContextCmd.js"; import { EmojiInfoCmd } from "./commands/EmojiInfoCmd.js"; import { HelpCmd } from "./commands/HelpCmd.js"; import { InfoCmd } from "./commands/InfoCmd.js"; import { InviteInfoCmd } from "./commands/InviteInfoCmd.js"; import { JumboCmd } from "./commands/JumboCmd.js"; import { LevelCmd } from "./commands/LevelCmd.js"; import { MessageInfoCmd } from "./commands/MessageInfoCmd.js"; import { NicknameCmd } from "./commands/NicknameCmd.js"; import { NicknameResetCmd } from "./commands/NicknameResetCmd.js"; import { PingCmd } from "./commands/PingCmd.js"; import { ReloadGuildCmd } from "./commands/ReloadGuildCmd.js"; import { RoleInfoCmd } from "./commands/RoleInfoCmd.js"; import { RolesCmd } from "./commands/RolesCmd.js"; import { SearchCmd } from "./commands/SearchCmd.js"; import { ServerInfoCmd } from "./commands/ServerInfoCmd.js"; import { SnowflakeInfoCmd } from "./commands/SnowflakeInfoCmd.js"; import { SourceCmd } from "./commands/SourceCmd.js"; import { UserInfoCmd } from "./commands/UserInfoCmd.js"; import { VcdisconnectCmd } from "./commands/VcdisconnectCmd.js"; import { VcmoveAllCmd, VcmoveCmd } from "./commands/VcmoveCmd.js"; import { AutoJoinThreadEvt, AutoJoinThreadSyncEvt } from "./events/AutoJoinThreadEvt.js"; import { cleanMessages } from "./functions/cleanMessages.js"; import { fetchChannelMessagesToClean } from "./functions/fetchChannelMessagesToClean.js"; import { getUserInfoEmbed } from "./functions/getUserInfoEmbed.js"; import { hasPermission } from "./functions/hasPermission.js"; import { activeReloads } from "./guildReloads.js"; import { refreshMembersIfNeeded } from "./refreshMembers.js"; import { UtilityPluginType, zUtilityConfig } from "./types.js"; export const UtilityPlugin = guildPlugin()({ name: "utility", dependencies: () => [TimeAndDatePlugin, ModActionsPlugin, LogsPlugin], configSchema: zUtilityConfig, defaultOverrides: [ { level: ">=50", config: { can_roles: true, can_level: true, can_search: true, can_clean: true, can_info: true, can_server: true, can_inviteinfo: true, can_channelinfo: true, can_messageinfo: true, can_userinfo: true, can_roleinfo: true, can_emojiinfo: true, can_snowflake: true, can_nickname: true, can_vcmove: true, can_vckick: true, can_help: true, can_context: true, can_jumbo: true, can_avatar: true, can_source: true, }, }, { level: ">=100", config: { can_reload_guild: true, can_ping: true, can_about: true, }, }, ], // prettier-ignore messageCommands: [ SearchCmd, BanSearchCmd, UserInfoCmd, LevelCmd, RolesCmd, ServerInfoCmd, NicknameResetCmd, NicknameCmd, PingCmd, SourceCmd, ContextCmd, VcmoveCmd, VcdisconnectCmd, VcmoveAllCmd, HelpCmd, AboutCmd, ReloadGuildCmd, JumboCmd, AvatarCmd, CleanCmd, InviteInfoCmd, ChannelInfoCmd, MessageInfoCmd, InfoCmd, SnowflakeInfoCmd, RoleInfoCmd, EmojiInfoCmd, ], // prettier-ignore events: [ AutoJoinThreadEvt, AutoJoinThreadSyncEvt, ], public(pluginData) { return { fetchChannelMessagesToClean: makePublicFn(pluginData, fetchChannelMessagesToClean), cleanMessages: makePublicFn(pluginData, cleanMessages), userInfo: (userId: Snowflake) => getUserInfoEmbed(pluginData, userId, false), hasPermission: makePublicFn(pluginData, hasPermission), }; }, beforeLoad(pluginData) { const { state, guild } = pluginData; state.logs = new GuildLogs(guild.id); state.cases = GuildCases.getGuildInstance(guild.id); state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); state.archives = GuildArchives.getGuildInstance(guild.id); state.supporters = new Supporters(); state.regexRunner = getRegExpRunner(`guild-${pluginData.guild.id}`); state.lastReload = Date.now(); // FIXME: Temp fix for role change detection for specific servers, load all guild members in the background on bot start const roleChangeDetectionFixServers = [ "786212572285763605", "653681924384096287", "493351982887862283", "513338222810497041", "523043978178723840", "718076393295970376", "803251072877199400", "750492934343753798", ]; if (roleChangeDetectionFixServers.includes(pluginData.guild.id)) { refreshMembersIfNeeded(pluginData.guild); } }, beforeStart(pluginData) { pluginData.state.common = pluginData.getPlugin(CommonPlugin); }, afterLoad(pluginData) { const { guild } = pluginData; if (activeReloads.has(guild.id)) { pluginData.state.common.sendSuccessMessage(activeReloads.get(guild.id)!, "Reloaded!"); activeReloads.delete(guild.id); } }, beforeUnload(pluginData) { discardRegExpRunner(`guild-${pluginData.guild.id}`); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/AboutCmd.ts ================================================ import { APIEmbed, GuildChannel } from "discord.js"; import { shuffle } from "lodash-es"; import moment from "moment-timezone"; import { accessSync, readFileSync } from "node:fs"; import { rootDir } from "../../../paths.js"; import { getBotStartTime } from "../../../uptime.js"; import { resolveMember, sorter } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { utilityCmd } from "../types.js"; let commitHash: string | null = null; try { accessSync(`${rootDir}/.commit-hash`); commitHash = readFileSync(`${rootDir}/.commit-hash`, "utf-8").trim(); } catch {} let buildTime: string | null = null; try { accessSync(`${rootDir}/.build-time`); buildTime = readFileSync(`${rootDir}/.build-time`, "utf-8").trim(); } catch {} export const AboutCmd = utilityCmd({ trigger: "about", description: "Show information about Zeppelin's status on the server", permission: "can_about", async run({ message: msg, pluginData }) { const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); const botStartTime = getBotStartTime(); const buildTimeMoment = buildTime ? moment.utc(buildTime, "YYYY-MM-DDTHH:mm:ss[Z]") : null; const basicInfoRows = [ ["Bot start time", ``], ["Last config reload", ``], ["Last bot update", buildTimeMoment ? `` : "Unknown"], ["Version", commitHash?.slice(0, 7) || "Unknown"], ["API latency", `${pluginData.client.ws.ping}ms`], ["Server timezone", timeAndDate.getGuildTz()], ]; const loadedPlugins = Array.from( pluginData.getVetyInstance().getLoadedGuild(pluginData.guild.id)!.loadedPlugins.keys(), ); loadedPlugins.sort(); const aboutEmbed: APIEmbed = { title: `About ${pluginData.client.user!.username}`, fields: [ { name: "Status", value: basicInfoRows.map(([label, value]) => `${label}: **${value}**`).join("\n"), }, { name: `Loaded plugins on this server (${loadedPlugins.length})`, value: loadedPlugins.join(", "), }, ], }; const supporters = await pluginData.state.supporters.getAll(); const shuffledSupporters = shuffle(supporters); if (supporters.length) { const formattedSupporters = shuffledSupporters // Bold every other supporter to make them easy to recognize from each other .map((s, i) => (i % 2 === 0 ? `**${s.name}**` : `__${s.name}__`)) .join(" "); aboutEmbed.fields!.push({ name: "Zeppelin supporters 🎉", value: "These amazing people have supported Zeppelin development:\n\n" + formattedSupporters, inline: false, }); } // For the embed color, find the highest colored role the bot has - this is their color on the server as well const botMember = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id); let botRoles = botMember?.roles.cache.map((r) => (msg.channel as GuildChannel).guild.roles.cache.get(r.id)!) || []; botRoles = botRoles.filter((r) => !!r); // Drop any unknown roles botRoles = botRoles.filter((r) => r.color); // Filter to those with a color botRoles.sort(sorter("position", "DESC")); // Sort by position (highest first) if (botRoles.length) { aboutEmbed.color = botRoles[0].color; } // Use the bot avatar as the embed image if (pluginData.client.user!.displayAvatarURL()) { aboutEmbed.thumbnail = { url: pluginData.client.user!.displayAvatarURL()! }; } msg.channel.send({ embeds: [aboutEmbed] }); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/AvatarCmd.ts ================================================ import { APIEmbed, ImageFormat } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { UnknownUser, renderUsername } from "../../../utils.js"; import { utilityCmd } from "../types.js"; export const AvatarCmd = utilityCmd({ trigger: ["avatar", "av"], description: "Retrieves a user's profile picture", permission: "can_avatar", signature: { user: ct.resolvedMember({ required: false }) || ct.resolvedUserLoose({ required: false }), }, async run({ message: msg, args, pluginData }) { const user = args.user ?? msg.member ?? msg.author; if (!(user instanceof UnknownUser)) { const embed: APIEmbed = { image: { url: user.displayAvatarURL({ extension: ImageFormat.PNG, size: 2048 }), }, title: `Avatar of ${renderUsername(user)}:`, }; msg.channel.send({ embeds: [embed] }); } else { void pluginData.state.common.sendErrorMessage(msg, "Invalid user ID"); } }, }); ================================================ FILE: backend/src/plugins/Utility/commands/BanSearchCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { archiveSearch, displaySearch, SearchType } from "../search.js"; import { utilityCmd } from "../types.js"; // Separate from BanSearchCmd to avoid a circular reference from ./search.ts export const banSearchSignature = { query: ct.string({ catchAll: true }), page: ct.number({ option: true, shortcut: "p" }), sort: ct.string({ option: true }), "case-sensitive": ct.switchOption({ def: false, shortcut: "cs" }), export: ct.switchOption({ def: false, shortcut: "e" }), ids: ct.switchOption(), regex: ct.switchOption({ def: false, shortcut: "re" }), }; export const BanSearchCmd = utilityCmd({ trigger: ["bansearch", "bs"], description: "Search banned users", usage: "!bansearch dragory", permission: "can_search", signature: banSearchSignature, run({ pluginData, message, args }) { if (args.export) { return archiveSearch(pluginData, args, SearchType.BanSearch, message); } else { return displaySearch(pluginData, args, SearchType.BanSearch, message); } }, }); ================================================ FILE: backend/src/plugins/Utility/commands/ChannelInfoCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { getChannelInfoEmbed } from "../functions/getChannelInfoEmbed.js"; import { utilityCmd } from "../types.js"; export const ChannelInfoCmd = utilityCmd({ trigger: ["channel", "channelinfo"], description: "Show information about a channel", usage: "!channel 534722016549404673", permission: "can_channelinfo", signature: { channel: ct.channelId({ required: false }), }, async run({ message, args, pluginData }) { const embed = await getChannelInfoEmbed(pluginData, args.channel); if (!embed) { void pluginData.state.common.sendErrorMessage(message, "Unknown channel"); return; } message.channel.send({ embeds: [embed] }); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/CleanCmd.ts ================================================ import { Message, Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { ContextResponse, deleteContextResponse } from "../../../pluginUtils.js"; import { ModActionsPlugin } from "../../../plugins/ModActions/ModActionsPlugin.js"; import { SECONDS, noop } from "../../../utils.js"; import { cleanMessages } from "../functions/cleanMessages.js"; import { fetchChannelMessagesToClean } from "../functions/fetchChannelMessagesToClean.js"; import { utilityCmd } from "../types.js"; const CLEAN_COMMAND_DELETE_DELAY = 10 * SECONDS; const opts = { user: ct.userId({ option: true, shortcut: "u" }), channel: ct.channelId({ option: true, shortcut: "c" }), bots: ct.switchOption({ def: false, shortcut: "b" }), "delete-pins": ct.switchOption({ def: false, shortcut: "p" }), "has-invites": ct.switchOption({ def: false, shortcut: "i" }), match: ct.regex({ option: true, shortcut: "m" }), "to-id": ct.anyId({ option: true, shortcut: "id" }), }; export const CleanCmd = utilityCmd({ trigger: ["clean", "clear"], description: "Remove a number of recent messages", usage: "!clean 20", permission: "can_clean", signature: [ { count: ct.number(), update: ct.number({ option: true, shortcut: "up" }), ...opts, }, { count: ct.number(), update: ct.switchOption({ def: false, shortcut: "up" }), ...opts, }, ], async run({ message: msg, args, pluginData }) { const targetChannel = args.channel ? pluginData.guild.channels.cache.get(args.channel as Snowflake) : msg.channel; if (!targetChannel?.isTextBased()) { void pluginData.state.common.sendErrorMessage( msg, `Invalid channel specified`, undefined, args["response-interaction"], ); return; } if (targetChannel.id !== msg.channel.id) { const configForTargetChannel = await pluginData.config.getMatchingConfig({ userId: msg.author.id, member: msg.member, channelId: targetChannel.id, categoryId: targetChannel.parentId, }); if (configForTargetChannel.can_clean !== true) { void pluginData.state.common.sendErrorMessage( msg, `Missing permissions to use clean on that channel`, undefined, args["response-interaction"], ); return; } } let cleaningMessage: Message | undefined = undefined; if (!args["response-interaction"]) { cleaningMessage = await msg.channel.send("Cleaning..."); } const fetchMessagesResult = await fetchChannelMessagesToClean(pluginData, targetChannel, { beforeId: msg.id, count: args.count, authorId: args.user, includePins: args["delete-pins"], onlyBotMessages: args.bots, onlyWithInvites: args["has-invites"], upToId: args["to-id"], matchContent: args.match, }); if ("error" in fetchMessagesResult) { void pluginData.state.common.sendErrorMessage(msg, fetchMessagesResult.error); return; } const { messages: messagesToClean, note } = fetchMessagesResult; let responseMsg: ContextResponse | null = null; if (messagesToClean.length > 0) { const cleanResult = await cleanMessages(pluginData, targetChannel, messagesToClean, msg.author); let responseText = `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`; if (note) { responseText += ` (${note})`; } if (targetChannel.id !== msg.channel.id) { responseText += ` in <#${targetChannel.id}>: ${cleanResult.archiveUrl}`; } if (args.update) { const modActions = pluginData.getPlugin(ModActionsPlugin); const channelId = targetChannel.id !== msg.channel.id ? targetChannel.id : msg.channel.id; const updateMessage = `Cleaned ${messagesToClean.length} ${ messagesToClean.length === 1 ? "message" : "messages" } in <#${channelId}>: ${cleanResult.archiveUrl}`; if (typeof args.update === "number") { modActions.updateCase(msg, args.update, updateMessage); } else { modActions.updateCase(msg, null, updateMessage); } } responseMsg = await pluginData.state.common.sendSuccessMessage( msg, responseText, undefined, args["response-interaction"], ); } else { const responseText = `Found no messages to clean${note ? ` (${note})` : ""}!`; responseMsg = await pluginData.state.common.sendErrorMessage( msg, responseText, undefined, args["response-interaction"], ); } cleaningMessage?.delete(); if (targetChannel.id === msg.channel.id) { // Delete the !clean command and the bot response if a different channel wasn't specified // (so as not to spam the cleaned channel with the command itself) msg.delete().catch(noop); setTimeout(() => { deleteContextResponse(responseMsg).catch(noop); responseMsg?.delete().catch(noop); }, CLEAN_COMMAND_DELETE_DELAY); } }, }); ================================================ FILE: backend/src/plugins/Utility/commands/ContextCmd.ts ================================================ import { Snowflake, TextChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { resolveMessageMember } from "../../../pluginUtils.js"; import { messageLink } from "../../../utils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { utilityCmd } from "../types.js"; export const ContextCmd = utilityCmd({ trigger: "context", description: "Get a link to the context of the specified message", usage: "!context 94882524378968064 650391267720822785", permission: "can_context", signature: [ { message: ct.messageTarget(), }, { channel: ct.channel(), messageId: ct.string(), }, ], async run({ message: msg, args, pluginData }) { if (args.channel && !(args.channel instanceof TextChannel)) { void pluginData.state.common.sendErrorMessage(msg, "Channel must be a text channel"); return; } const channel = args.channel ?? args.message.channel; const messageId = args.messageId ?? args.message.messageId; const authorMember = await resolveMessageMember(msg); if (!canReadChannel(channel, authorMember)) { void pluginData.state.common.sendErrorMessage(msg, "Message context not found"); return; } const previousMessage = ( await (pluginData.guild.channels.cache.get(channel.id) as TextChannel).messages.fetch({ limit: 1, before: messageId as Snowflake, }) )[0]; if (!previousMessage) { void pluginData.state.common.sendErrorMessage(msg, "Message context not found"); return; } msg.channel.send(messageLink(pluginData.guild.id, previousMessage.channel.id, previousMessage.id)); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/EmojiInfoCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { getCustomEmojiId } from "../functions/getCustomEmojiId.js"; import { getEmojiInfoEmbed } from "../functions/getEmojiInfoEmbed.js"; import { utilityCmd } from "../types.js"; export const EmojiInfoCmd = utilityCmd({ trigger: ["emoji", "emojiinfo"], description: "Show information about an emoji", usage: "!emoji 106391128718245888", permission: "can_emojiinfo", signature: { emoji: ct.string({ required: true }), }, async run({ message, args, pluginData }) { const emojiId = getCustomEmojiId(args.emoji); if (!emojiId) { void pluginData.state.common.sendErrorMessage(message, "Emoji not found"); return; } const embed = await getEmojiInfoEmbed(pluginData, emojiId); if (!embed) { void pluginData.state.common.sendErrorMessage(message, "Emoji not found"); return; } message.channel.send({ embeds: [embed] }); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/HelpCmd.ts ================================================ import { LoadedGuildPlugin, PluginCommandDefinition } from "vety"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { env } from "../../../env.js"; import { createChunkedMessage } from "../../../utils.js"; import { utilityCmd } from "../types.js"; export const HelpCmd = utilityCmd({ trigger: "help", description: "Show a quick reference for the specified command's usage", usage: "!help clean", permission: "can_help", signature: { command: ct.string({ catchAll: true }), }, async run({ message: msg, args, pluginData }) { const searchStr = args.command.toLowerCase(); const matchingCommands: Array<{ plugin: LoadedGuildPlugin; command: PluginCommandDefinition; }> = []; const guildData = pluginData.getVetyInstance().getLoadedGuild(pluginData.guild.id)!; for (const plugin of guildData.loadedPlugins.values()) { const registeredCommands = plugin.pluginData.messageCommands.getAll(); for (const registeredCommand of registeredCommands) { for (const trigger of registeredCommand.originalTriggers) { const strTrigger = typeof trigger === "string" ? trigger : trigger.source; if (strTrigger.startsWith(searchStr)) { matchingCommands.push({ plugin, command: registeredCommand, }); break; } } } } const totalResults = matchingCommands.length; const limitedResults = matchingCommands.slice(0, 3); const commandSnippets = limitedResults.map(({ plugin, command }) => { const prefix: string = command.originalPrefix ? typeof command.originalPrefix === "string" ? command.originalPrefix : command.originalPrefix.source : ""; const originalTrigger = command.originalTriggers[0]; const trigger: string = originalTrigger ? typeof originalTrigger === "string" ? originalTrigger : originalTrigger.source : ""; const description = command.config!.extra!.blueprint.description; const usage = command.config!.extra!.blueprint.usage; const commandSlug = trigger.trim().toLowerCase().replace(/\s/g, "-"); let snippet = `**${prefix}${trigger}**`; if (description) snippet += `\n${description}`; if (usage) snippet += `\nBasic usage: \`${usage}\``; snippet += `\n<${env.DASHBOARD_URL}/docs/plugins/${plugin.blueprint.name}/usage#command-${commandSlug}>`; return snippet; }); if (totalResults === 0) { msg.channel.send("No matching commands found!"); return; } let message = totalResults !== limitedResults.length ? `Results (${totalResults} total, showing first ${limitedResults.length}):\n\n` : ""; message += commandSnippets.join("\n\n"); createChunkedMessage(msg.channel, message); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/InfoCmd.ts ================================================ import { Snowflake } from "discord.js"; import { getChannelId, getRoleId } from "vety/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { resolveMessageMember } from "../../../pluginUtils.js"; import { isValidSnowflake, noop, parseInviteCodeInput, resolveInvite, resolveUser } from "../../../utils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { resolveMessageTarget } from "../../../utils/resolveMessageTarget.js"; import { getChannelInfoEmbed } from "../functions/getChannelInfoEmbed.js"; import { getCustomEmojiId } from "../functions/getCustomEmojiId.js"; import { getEmojiInfoEmbed } from "../functions/getEmojiInfoEmbed.js"; import { getGuildPreview } from "../functions/getGuildPreview.js"; import { getInviteInfoEmbed } from "../functions/getInviteInfoEmbed.js"; import { getMessageInfoEmbed } from "../functions/getMessageInfoEmbed.js"; import { getRoleInfoEmbed } from "../functions/getRoleInfoEmbed.js"; import { getServerInfoEmbed } from "../functions/getServerInfoEmbed.js"; import { getSnowflakeInfoEmbed } from "../functions/getSnowflakeInfoEmbed.js"; import { getUserInfoEmbed } from "../functions/getUserInfoEmbed.js"; import { utilityCmd } from "../types.js"; export const InfoCmd = utilityCmd({ trigger: "info", description: "Show information about the specified thing", usage: "!info", permission: "can_info", signature: { value: ct.string({ required: false }), compact: ct.switchOption({ def: false, shortcut: "c" }), }, async run({ message, args, pluginData }) { const value = args.value || message.author.id; const userCfg = await pluginData.config.getMatchingConfig({ member: message.member, channelId: message.channel.id, message, }); // 1. Channel if (userCfg.can_channelinfo) { const channelId = getChannelId(value); const channel = channelId && pluginData.guild.channels.cache.get(channelId as Snowflake); if (channel) { const embed = await getChannelInfoEmbed(pluginData, channelId!); if (embed) { message.channel.send({ embeds: [embed] }); return; } } } // 2. Server if (userCfg.can_server) { const guild = await pluginData.client.guilds.fetch(value as Snowflake).catch(noop); if (guild) { const embed = await getServerInfoEmbed(pluginData, value); if (embed) { message.channel.send({ embeds: [embed] }); return; } } } // 3. User if (userCfg.can_userinfo) { const user = await resolveUser(pluginData.client, value, "Utility:InfoCmd"); if (user && userCfg.can_userinfo) { const embed = await getUserInfoEmbed(pluginData, user.id, Boolean(args.compact)); if (embed) { message.channel.send({ embeds: [embed] }); return; } } } // 4. Message if (userCfg.can_messageinfo) { const messageTarget = await resolveMessageTarget(pluginData, value); if (messageTarget) { const authorMember = await resolveMessageMember(message); if (canReadChannel(messageTarget.channel, authorMember)) { const embed = await getMessageInfoEmbed(pluginData, messageTarget.channel.id, messageTarget.messageId); if (embed) { message.channel.send({ embeds: [embed] }); return; } } } } // 5. Invite if (userCfg.can_inviteinfo) { const inviteCode = parseInviteCodeInput(value) ?? value; if (inviteCode) { const invite = await resolveInvite(pluginData.client, inviteCode, true); if (invite) { const embed = await getInviteInfoEmbed(pluginData, inviteCode); if (embed) { message.channel.send({ embeds: [embed] }); return; } } } } // 6. Server again (fallback for discovery servers) if (userCfg.can_server) { const serverPreview = await getGuildPreview(pluginData.client, value).catch(() => null); if (serverPreview) { const embed = await getServerInfoEmbed(pluginData, value); if (embed) { message.channel.send({ embeds: [embed] }); return; } } } // 7. Role if (userCfg.can_roleinfo) { const roleId = getRoleId(value); const role = roleId && pluginData.guild.roles.cache.get(roleId as Snowflake); if (role) { const embed = await getRoleInfoEmbed(pluginData, role); message.channel.send({ embeds: [embed] }); return; } } // 8. Emoji if (userCfg.can_emojiinfo) { const emojiId = getCustomEmojiId(value); if (emojiId) { const embed = await getEmojiInfoEmbed(pluginData, emojiId); if (embed) { message.channel.send({ embeds: [embed] }); return; } } } // 9. Arbitrary ID if (isValidSnowflake(value) && userCfg.can_snowflake) { const embed = await getSnowflakeInfoEmbed(value, true); message.channel.send({ embeds: [embed] }); return; } // 10. No can do void pluginData.state.common.sendErrorMessage( message, "Could not find anything with that value or you are lacking permission for the snowflake type", ); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/InviteInfoCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { parseInviteCodeInput } from "../../../utils.js"; import { getInviteInfoEmbed } from "../functions/getInviteInfoEmbed.js"; import { utilityCmd } from "../types.js"; export const InviteInfoCmd = utilityCmd({ trigger: ["invite", "inviteinfo"], description: "Show information about an invite", usage: "!invite overwatch", permission: "can_inviteinfo", signature: { inviteCode: ct.string(), }, async run({ message, args, pluginData }) { const inviteCode = parseInviteCodeInput(args.inviteCode); const embed = await getInviteInfoEmbed(pluginData, inviteCode); if (!embed) { void pluginData.state.common.sendErrorMessage(message, "Unknown invite"); return; } message.channel.send({ embeds: [embed] }); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/JumboCmd.ts ================================================ import photon from "@silvia-odwyer/photon-node"; import { AttachmentBuilder } from "discord.js"; import fs from "fs"; import twemoji from "twemoji"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { downloadFile, isEmoji, SECONDS } from "../../../utils.js"; import { utilityCmd } from "../types.js"; const fsp = fs.promises; async function getBufferFromUrl(url: string): Promise { const downloadedEmoji = await downloadFile(url); return fsp.readFile(downloadedEmoji.path); } function bufferToPhotonImage(input: Buffer): photon.PhotonImage { const base64 = input.toString("base64").replace(/^data:image\/\w+;base64,/, ""); return photon.PhotonImage.new_from_base64(base64); } function photonImageToBuffer(image: photon.PhotonImage): Buffer { const base64 = image.get_base64().replace(/^data:image\/\w+;base64,/, ""); return Buffer.from(base64, "base64"); } function resizeBuffer(input: Buffer, width: number, height: number): Buffer { const photonImage = bufferToPhotonImage(input); photon.resize(photonImage, width, height, photon.SamplingFilter.Lanczos3); return photonImageToBuffer(photonImage); } export const JumboCmd = utilityCmd({ trigger: "jumbo", description: "Makes an emoji jumbo", permission: "can_jumbo", cooldown: 5 * SECONDS, signature: { emoji: ct.string(), }, async run({ message: msg, args, pluginData }) { // Get emoji url const config = pluginData.config.get(); const size = config.jumbo_size > 2048 ? 2048 : config.jumbo_size; const emojiRegex = new RegExp(`(<.*:).*:(\\d+)`); const results = emojiRegex.exec(args.emoji); let extension = ".png"; let file: AttachmentBuilder | undefined; if (!isEmoji(args.emoji)) { void pluginData.state.common.sendErrorMessage(msg, "Invalid emoji"); return; } if (results) { let url = "https://cdn.discordapp.com/emojis/"; if (results[1] === " does not have a nickname`); } else { msg.channel.send(`The nickname of <@!${args.member.id}> is **${escapeBold(args.member.nickname)}**`); } return; } const authorMember = await resolveMessageMember(msg); if (msg.author.id !== args.member.id && !canActOn(pluginData, authorMember, args.member)) { msg.channel.send(errorMessage("Cannot change nickname: insufficient permissions")); return; } const nicknameLength = [...args.nickname].length; if (nicknameLength < 2 || nicknameLength > 32) { msg.channel.send(errorMessage("Nickname must be between 2 and 32 characters long")); return; } const oldNickname = args.member.nickname || ""; try { await args.member.setNickname(args.nickname ?? null); } catch { msg.channel.send(errorMessage("Failed to change nickname")); return; } void pluginData.state.common.sendSuccessMessage( msg, `Changed nickname of <@!${args.member.id}> from **${oldNickname}** to **${args.nickname}**`, ); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/NicknameResetCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { errorMessage } from "../../../utils.js"; import { utilityCmd } from "../types.js"; export const NicknameResetCmd = utilityCmd({ trigger: ["nickname reset", "nick reset"], description: "Reset a member's nickname to their username", usage: "!nickname reset 106391128718245888", permission: "can_nickname", signature: { member: ct.resolvedMember(), }, async run({ message: msg, args, pluginData }) { const authorMember = await resolveMessageMember(msg); if (msg.author.id !== args.member.id && !canActOn(pluginData, authorMember, args.member)) { msg.channel.send(errorMessage("Cannot reset nickname: insufficient permissions")); return; } if (!args.member.nickname) { msg.channel.send(errorMessage("User does not have a nickname")); return; } try { await args.member.setNickname(null); } catch { msg.channel.send(errorMessage("Failed to reset nickname")); return; } void pluginData.state.common.sendSuccessMessage(msg, `The nickname of <@!${args.member.id}> has been reset`); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/PingCmd.ts ================================================ import { Message } from "discord.js"; import { performance } from "perf_hooks"; import { noop, trimLines } from "../../../utils.js"; import { utilityCmd } from "../types.js"; export const PingCmd = utilityCmd({ trigger: ["ping", "pong"], description: "Test the bot's ping to the Discord API", permission: "can_ping", async run({ message: msg, pluginData }) { const times: number[] = []; const messages: Message[] = []; let msgToMsgDelay: number | undefined; for (let i = 0; i < 4; i++) { const start = performance.now(); const message = await msg.channel.send(`Calculating ping... ${i + 1}`); times.push(performance.now() - start); messages.push(message); if (msgToMsgDelay === undefined) { msgToMsgDelay = message.createdTimestamp - msg.createdTimestamp; } } const highest = Math.round(Math.max(...times)); const lowest = Math.round(Math.min(...times)); const mean = Math.round(times.reduce((total, ms) => total + ms, 0) / times.length); msg.channel.send( trimLines(` **Ping:** Lowest: **${lowest}ms** Highest: **${highest}ms** Mean: **${mean}ms** Time between ping command and first reply: **${msgToMsgDelay!}ms** Shard latency: **${pluginData.client.ws.ping}ms** `), ); // Clean up test messages msg.channel.bulkDelete(messages).catch(noop); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/ReloadGuildCmd.ts ================================================ import { TextChannel } from "discord.js"; import { activeReloads } from "../guildReloads.js"; import { utilityCmd } from "../types.js"; export const ReloadGuildCmd = utilityCmd({ trigger: "reload_guild", description: "Reload the Zeppelin configuration and all plugins for the server. This can sometimes fix issues.", permission: "can_reload_guild", async run({ message: msg, pluginData }) { if (activeReloads.has(pluginData.guild.id)) return; activeReloads.set(pluginData.guild.id, msg.channel as TextChannel); msg.channel.send("Reloading..."); pluginData.getVetyInstance().reloadGuild(pluginData.guild.id); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/RoleInfoCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { getRoleInfoEmbed } from "../functions/getRoleInfoEmbed.js"; import { utilityCmd } from "../types.js"; export const RoleInfoCmd = utilityCmd({ trigger: ["roleinfo"], description: "Show information about a role", usage: "!role 106391128718245888", permission: "can_roleinfo", signature: { role: ct.role({ required: true }), }, async run({ message, args, pluginData }) { const embed = await getRoleInfoEmbed(pluginData, args.role); message.channel.send({ embeds: [embed] }); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/RolesCmd.ts ================================================ import { Role } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { chunkArray, sorter, trimLines } from "../../../utils.js"; import { refreshMembersIfNeeded } from "../refreshMembers.js"; import { utilityCmd } from "../types.js"; export const RolesCmd = utilityCmd({ trigger: "roles", description: "List all roles or roles matching a search", usage: "!roles mod", permission: "can_roles", signature: { search: ct.string({ required: false, catchAll: true }), counts: ct.switchOption(), sort: ct.string({ option: true }), }, async run({ message: msg, args, pluginData }) { const { guild } = pluginData; let roles: Role[] = Array.from(guild.roles.cache.values()); let sort = args.sort; if (args.search) { const searchStr = args.search.toLowerCase(); roles = roles.filter((r) => r.name.toLowerCase().includes(searchStr) || r.id === searchStr); } let roleCounts: Map | null = null; if (args.counts) { await refreshMembersIfNeeded(guild); roleCounts = new Map(guild.roles.cache.map((r) => [r.id, 0])); for (const member of guild.members.cache.values()) { for (const id of member.roles.cache.keys()) { roleCounts.set(id, (roleCounts.get(id) ?? 0) + 1); } } // The "@everyone" role always has all members in it roleCounts.set(guild.id, guild.memberCount); if (!sort) sort = "-memberCount"; } if (!sort) sort = "name"; let sortDir: "ASC" | "DESC" = "ASC"; if (sort[0] === "-") { sort = sort.slice(1); sortDir = "DESC"; } if (sort === "position" || sort === "order") { roles.sort(sorter("position", sortDir)); } else if (sort === "memberCount" && args.counts) { roles.sort((first, second) => roleCounts!.get(second.id)! - roleCounts!.get(first.id)!); } else if (sort === "name") { roles.sort(sorter((r) => r.name.toLowerCase(), sortDir)); } else { void pluginData.state.common.sendErrorMessage(msg, "Unknown sorting method"); return; } const longestId = roles.reduce((longest, role) => Math.max(longest, role.id.length), 0); const chunks = chunkArray(roles, 20); for (const [i, chunk] of chunks.entries()) { const roleLines = chunk.map((role) => { const paddedId = role.id.padEnd(longestId, " "); let line = `${paddedId} ${role.name}`; const memberCount = roleCounts?.get(role.id); if (memberCount !== undefined) { line += ` (${memberCount} ${memberCount === 1 ? "member" : "members"})`; } return line; }); const codeBlock = "```py\n" + roleLines.join("\n") + "```"; if (i === 0) { msg.channel.send( trimLines(` ${args.search ? "Total roles found" : "Total roles"}: ${roles.length} ${codeBlock} `), ); } else { msg.channel.send(codeBlock); } } }, }); ================================================ FILE: backend/src/plugins/Utility/commands/SearchCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { archiveSearch, displaySearch, SearchType } from "../search.js"; import { utilityCmd } from "../types.js"; // Separate from SearchCmd to avoid a circular reference from ./search.ts export const searchCmdSignature = { query: ct.string({ catchAll: true, required: false }), page: ct.number({ option: true, shortcut: "p" }), role: ct.string({ option: true, shortcut: "r" }), voice: ct.switchOption({ def: false, shortcut: "v" }), bot: ct.switchOption({ def: false, shortcut: "b" }), sort: ct.string({ option: true }), "case-sensitive": ct.switchOption({ def: false, shortcut: "cs" }), export: ct.switchOption({ def: false, shortcut: "e" }), ids: ct.switchOption(), regex: ct.switchOption({ def: false, shortcut: "re" }), // "status-search": ct.switchOption({ def: false, shortcut: "ss" }), }; export const SearchCmd = utilityCmd({ trigger: ["search", "s"], description: "Search server members", usage: "!search dragory", permission: "can_search", signature: searchCmdSignature, run({ pluginData, message, args }) { if (args.export) { return archiveSearch(pluginData, args, SearchType.MemberSearch, message); } else { return displaySearch(pluginData, args, SearchType.MemberSearch, message); } }, }); ================================================ FILE: backend/src/plugins/Utility/commands/ServerInfoCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { getServerInfoEmbed } from "../functions/getServerInfoEmbed.js"; import { utilityCmd } from "../types.js"; export const ServerInfoCmd = utilityCmd({ trigger: ["server", "serverinfo"], description: "Show server information", usage: "!server", permission: "can_server", signature: { serverId: ct.string({ required: false }), }, async run({ message, pluginData, args }) { const serverId = args.serverId || pluginData.guild.id; const serverInfoEmbed = await getServerInfoEmbed(pluginData, serverId); if (!serverInfoEmbed) { void pluginData.state.common.sendErrorMessage(message, "Could not find information for that server"); return; } message.channel.send({ embeds: [serverInfoEmbed] }); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/SnowflakeInfoCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { getSnowflakeInfoEmbed } from "../functions/getSnowflakeInfoEmbed.js"; import { utilityCmd } from "../types.js"; export const SnowflakeInfoCmd = utilityCmd({ trigger: ["snowflake", "snowflakeinfo"], description: "Show information about a snowflake ID", usage: "!snowflake 534722016549404673", permission: "can_snowflake", signature: { id: ct.anyId(), }, async run({ message, args }) { const embed = await getSnowflakeInfoEmbed(args.id, false); message.channel.send({ embeds: [embed] }); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/SourceCmd.ts ================================================ import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { getBaseUrl, resolveMessageMember } from "../../../pluginUtils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { utilityCmd } from "../types.js"; export const SourceCmd = utilityCmd({ trigger: "source", description: "View the message source of the specified message id", usage: "!source 534722219696455701", permission: "can_source", signature: { message: ct.messageTarget(), }, async run({ message: cmdMessage, args, pluginData }) { const cmdAuthorMember = await resolveMessageMember(cmdMessage); if (!canReadChannel(args.message.channel, cmdAuthorMember)) { void pluginData.state.common.sendErrorMessage(cmdMessage, "Unknown message"); return; } const message = await args.message.channel.messages.fetch(args.message.messageId); if (!message) { void pluginData.state.common.sendErrorMessage(cmdMessage, "Unknown message"); return; } const textSource = message.content || ""; const fullSource = JSON.stringify({ id: message.id, content: message.content, attachments: message.attachments, embeds: message.embeds, stickers: message.stickers, }); const source = `${textSource}\n\nSource:\n\n${fullSource}`; const archiveId = await pluginData.state.archives.create(source, moment.utc().add(1, "hour")); const baseUrl = getBaseUrl(pluginData); const url = pluginData.state.archives.getUrl(baseUrl, archiveId); cmdMessage.channel.send(`Message source: ${url}`); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/UserInfoCmd.ts ================================================ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { getUserInfoEmbed } from "../functions/getUserInfoEmbed.js"; import { utilityCmd } from "../types.js"; export const UserInfoCmd = utilityCmd({ trigger: ["user", "userinfo", "whois"], description: "Show information about a user", usage: "!user 106391128718245888", permission: "can_userinfo", signature: { user: ct.resolvedUserLoose({ required: false }), compact: ct.switchOption({ def: false, shortcut: "c" }), }, async run({ message, args, pluginData }) { const userId = args.user?.id || message.author.id; const embed = await getUserInfoEmbed(pluginData, userId, args.compact); if (!embed) { void pluginData.state.common.sendErrorMessage(message, "User not found"); return; } message.channel.send({ embeds: [embed] }); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/VcdisconnectCmd.ts ================================================ import { VoiceChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { renderUsername } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { utilityCmd } from "../types.js"; export const VcdisconnectCmd = utilityCmd({ trigger: ["vcdisconnect", "vcdisc", "vcdc", "vckick", "vck"], description: "Disconnect a member from their voice channel", usage: "!vcdc @Dark", permission: "can_vckick", signature: { member: ct.resolvedMember(), }, async run({ message: msg, args, pluginData }) { const authorMember = await resolveMessageMember(msg); if (!canActOn(pluginData, authorMember, args.member)) { void pluginData.state.common.sendErrorMessage(msg, "Cannot move: insufficient permissions"); return; } if (!args.member.voice?.channelId) { void pluginData.state.common.sendErrorMessage(msg, "Member is not in a voice channel"); return; } const channel = pluginData.guild.channels.cache.get(args.member.voice.channelId) as VoiceChannel; try { await args.member.voice.disconnect(); } catch { void pluginData.state.common.sendErrorMessage(msg, "Failed to disconnect member"); return; } pluginData.getPlugin(LogsPlugin).logVoiceChannelForceDisconnect({ mod: msg.author, member: args.member, oldChannel: channel, }); pluginData.state.common.sendSuccessMessage( msg, `**${renderUsername(args.member)}** disconnected from **${channel.name}**`, ); }, }); ================================================ FILE: backend/src/plugins/Utility/commands/VcmoveCmd.ts ================================================ import { ChannelType, Snowflake, VoiceChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { channelMentionRegex, isSnowflake, renderUsername, simpleClosestStringMatch } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { utilityCmd } from "../types.js"; export const VcmoveCmd = utilityCmd({ trigger: "vcmove", description: "Move a member to another voice channel", usage: "!vcmove @Dragory 473223047822704651", permission: "can_vcmove", signature: { member: ct.resolvedMember(), channel: ct.string({ catchAll: true }), }, async run({ message: msg, args, pluginData }) { let channel: VoiceChannel; if (isSnowflake(args.channel)) { // Snowflake -> resolve channel directly const potentialChannel = pluginData.guild.channels.cache.get(args.channel as Snowflake); if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { void pluginData.state.common.sendErrorMessage(msg, "Unknown or non-voice channel"); return; } channel = potentialChannel; } else if (channelMentionRegex.test(args.channel)) { // Channel mention -> parse channel id and resolve channel from that const channelId = args.channel.match(channelMentionRegex)![1]; const potentialChannel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { void pluginData.state.common.sendErrorMessage(msg, "Unknown or non-voice channel"); return; } channel = potentialChannel; } else { // Search string -> find closest matching voice channel name const voiceChannels = [...pluginData.guild.channels.cache.values()].filter( (c): c is VoiceChannel => c.type === ChannelType.GuildVoice, ); const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, (ch) => ch.name); if (!closestMatch) { void pluginData.state.common.sendErrorMessage(msg, "No matching voice channels"); return; } channel = closestMatch; } if (!args.member.voice?.channelId) { void pluginData.state.common.sendErrorMessage(msg, "Member is not in a voice channel"); return; } if (args.member.voice.channelId === channel.id) { void pluginData.state.common.sendErrorMessage(msg, "Member is already on that channel!"); return; } const oldVoiceChannel = pluginData.guild.channels.cache.get(args.member.voice.channelId) as VoiceChannel; try { await args.member.edit({ channel: channel.id, }); } catch { void pluginData.state.common.sendErrorMessage(msg, "Failed to move member"); return; } pluginData.getPlugin(LogsPlugin).logVoiceChannelForceMove({ mod: msg.author, member: args.member, oldChannel: oldVoiceChannel, newChannel: channel, }); void pluginData.state.common.sendSuccessMessage( msg, `**${renderUsername(args.member)}** moved to **${channel.name}**`, ); }, }); export const VcmoveAllCmd = utilityCmd({ trigger: "vcmoveall", description: "Move all members of a voice channel to another voice channel", usage: "!vcmoveall 551767166395875334 767497573560352798", permission: "can_vcmove", signature: { oldChannel: ct.voiceChannel(), channel: ct.string({ catchAll: true }), }, async run({ message: msg, args, pluginData }) { let channel: VoiceChannel; if (isSnowflake(args.channel)) { // Snowflake -> resolve channel directly const potentialChannel = pluginData.guild.channels.cache.get(args.channel as Snowflake); if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { void pluginData.state.common.sendErrorMessage(msg, "Unknown or non-voice channel"); return; } channel = potentialChannel; } else if (channelMentionRegex.test(args.channel)) { // Channel mention -> parse channel id and resolve channel from that const channelId = args.channel.match(channelMentionRegex)![1]; const potentialChannel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { void pluginData.state.common.sendErrorMessage(msg, "Unknown or non-voice channel"); return; } channel = potentialChannel; } else { // Search string -> find closest matching voice channel name const voiceChannels = [...pluginData.guild.channels.cache.values()].filter( (c): c is VoiceChannel => c.type === ChannelType.GuildVoice, ); const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, (ch) => ch.name); if (!closestMatch) { void pluginData.state.common.sendErrorMessage(msg, "No matching voice channels"); return; } channel = closestMatch; } if (args.oldChannel.members.size === 0) { void pluginData.state.common.sendErrorMessage(msg, "Voice channel is empty"); return; } if (args.oldChannel.id === channel.id) { void pluginData.state.common.sendErrorMessage(msg, "Cant move from and to the same channel!"); return; } const authorMember = await resolveMessageMember(msg); // Cant leave null, otherwise we get an assignment error in the catch let currMember = authorMember; const moveAmt = args.oldChannel.members.size; let errAmt = 0; for (const memberWithId of args.oldChannel.members) { currMember = memberWithId[1]; // Check for permissions but allow self-moves if (currMember.id !== authorMember.id && !canActOn(pluginData, authorMember, currMember)) { void pluginData.state.common.sendErrorMessage( msg, `Failed to move ${renderUsername(currMember)} (${currMember.id}): You cannot act on this member`, ); errAmt++; continue; } try { currMember.edit({ channel: channel.id, }); } catch { if (authorMember.id === currMember.id) { void pluginData.state.common.sendErrorMessage(msg, "Unknown error when trying to move members"); return; } void pluginData.state.common.sendErrorMessage( msg, `Failed to move ${renderUsername(currMember)} (${currMember.id})`, ); errAmt++; continue; } pluginData.getPlugin(LogsPlugin).logVoiceChannelForceMove({ mod: msg.author, member: currMember, oldChannel: args.oldChannel, newChannel: channel, }); } if (moveAmt !== errAmt) { void pluginData.state.common.sendSuccessMessage( msg, `${moveAmt - errAmt} members from **${args.oldChannel.name}** moved to **${channel.name}**`, ); } else { void pluginData.state.common.sendErrorMessage(msg, `Failed to move any members.`); } }, }); ================================================ FILE: backend/src/plugins/Utility/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zUtilityConfig } from "./types.js"; export const utilityPluginDocs: ZeppelinPluginDocs = { type: "stable", prettyName: "Utility", configSchema: zUtilityConfig, }; ================================================ FILE: backend/src/plugins/Utility/events/AutoJoinThreadEvt.ts ================================================ import { utilityEvt } from "../types.js"; export const AutoJoinThreadEvt = utilityEvt({ event: "threadCreate", async listener(meta) { const config = meta.pluginData.config.get(); if (config.autojoin_threads && meta.args.thread.joinable) { await meta.args.thread.join(); } }, }); export const AutoJoinThreadSyncEvt = utilityEvt({ event: "threadListSync", async listener(meta) { const config = meta.pluginData.config.get(); if (!config.autojoin_threads) return; for (const thread of meta.args.threads.values()) { if (!thread.joined && thread.joinable) { await thread.join(); } } }, }); ================================================ FILE: backend/src/plugins/Utility/functions/cleanMessages.ts ================================================ import { GuildBasedChannel, Snowflake, TextBasedChannel, User } from "discord.js"; import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { LogType } from "../../../data/LogType.js"; import { getBaseUrl } from "../../../pluginUtils.js"; import { chunkArray } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { UtilityPluginType } from "../types.js"; export async function cleanMessages( pluginData: GuildPluginData, channel: GuildBasedChannel & TextBasedChannel, savedMessages: SavedMessage[], mod: User, ) { pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, savedMessages[0].id); pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, savedMessages[0].id); // Delete & archive in ID order savedMessages = Array.from(savedMessages).sort((a, b) => (a.id > b.id ? 1 : -1)); const idsToDelete = savedMessages.map((m) => m.id) as Snowflake[]; // Make sure the deletions aren't double logged idsToDelete.forEach((id) => pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id)); pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, idsToDelete[0]); // Actually delete the messages (in chunks of 100) const chunks = chunkArray(idsToDelete, 100); await Promise.all( chunks.map((chunk) => Promise.all([channel.bulkDelete(chunk), pluginData.state.savedMessages.markBulkAsDeleted(chunk)]), ), ); // Create an archive const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild); const baseUrl = getBaseUrl(pluginData); const archiveUrl = pluginData.state.archives.getUrl(baseUrl, archiveId); pluginData.getPlugin(LogsPlugin).logClean({ mod, channel, count: savedMessages.length, archiveUrl, }); return { archiveUrl }; } ================================================ FILE: backend/src/plugins/Utility/functions/fetchChannelMessagesToClean.ts ================================================ import { GuildBasedChannel, Message, OmitPartialGroupDMChannel, Snowflake, TextBasedChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { humanizeDurationShort } from "../../../humanizeDuration.js"; import { allowTimeout } from "../../../RegExpRunner.js"; import { DAYS, getInviteCodesInString } from "../../../utils.js"; import { snowflakeToTimestamp } from "../../../utils/snowflakeToTimestamp.js"; import { UtilityPluginType } from "../types.js"; const MAX_CLEAN_COUNT = 300; const MAX_CLEAN_TIME = 1 * DAYS; const MAX_CLEAN_API_REQUESTS = 20; export interface FetchChannelMessagesToCleanOpts { count: number; beforeId: string; upToId?: string; authorId?: string; includePins?: boolean; onlyBotMessages?: boolean; onlyWithInvites?: boolean; matchContent?: RegExp; } export interface SuccessResult { messages: SavedMessage[]; note: string; } export interface ErrorResult { error: string; } export type FetchChannelMessagesToCleanResult = SuccessResult | ErrorResult; export async function fetchChannelMessagesToClean( pluginData: GuildPluginData, targetChannel: GuildBasedChannel & TextBasedChannel, opts: FetchChannelMessagesToCleanOpts, ): Promise { if (opts.count > MAX_CLEAN_COUNT || opts.count <= 0) { return { error: `Clean count must be between 1 and ${MAX_CLEAN_COUNT}` }; } const result: FetchChannelMessagesToCleanResult = { messages: [], note: "", }; const timestampCutoff = snowflakeToTimestamp(opts.beforeId) - MAX_CLEAN_TIME; let foundId = false; let pinIds: Set = new Set(); if (!opts.includePins) { pinIds = new Set((await targetChannel.messages.fetchPinned()).keys()); } const rawMessagesToClean: Array>> = []; let beforeId = opts.beforeId; let requests = 0; while (rawMessagesToClean.length < opts.count) { const potentialMessages = await targetChannel.messages.fetch({ before: beforeId, limit: 100, }); if (potentialMessages.size === 0) break; requests++; const filtered: Array>> = []; for (const message of potentialMessages.values()) { const contentString = message.content || ""; if (opts.authorId && message.author.id !== opts.authorId) continue; if (opts.onlyBotMessages && !message.author.bot) continue; if (pinIds.has(message.id)) continue; if (opts.onlyWithInvites && getInviteCodesInString(contentString).length === 0) continue; if (opts.upToId && message.id < opts.upToId) { foundId = true; break; } if (message.createdTimestamp < timestampCutoff) continue; if ( opts.matchContent && !(await pluginData.state.regexRunner.exec(opts.matchContent, contentString).catch(allowTimeout)) ) { continue; } filtered.push(message); } const remaining = opts.count - rawMessagesToClean.length; const withoutOverflow = filtered.slice(0, remaining); rawMessagesToClean.push(...withoutOverflow); beforeId = potentialMessages.lastKey()!; if (foundId) { break; } if (rawMessagesToClean.length < opts.count) { if (potentialMessages.last()!.createdTimestamp < timestampCutoff) { result.note = `stopped looking after reaching ${humanizeDurationShort(MAX_CLEAN_TIME)} old messages`; break; } if (requests >= MAX_CLEAN_API_REQUESTS) { result.note = `stopped looking after ${requests * 100} messages`; break; } } } // Discord messages -> SavedMessages if (rawMessagesToClean.length > 0) { const existingStored = await pluginData.state.savedMessages.getMultiple(rawMessagesToClean.map((m) => m.id)); const alreadyStored = existingStored.map((stored) => stored.id); const messagesToStore = rawMessagesToClean.filter((potentialMsg) => !alreadyStored.includes(potentialMsg.id)); await pluginData.state.savedMessages.createFromMessages(messagesToStore); result.messages = await pluginData.state.savedMessages.getMultiple(rawMessagesToClean.map((m) => m.id)); } return result; } ================================================ FILE: backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts ================================================ import { APIEmbed, ChannelType, Snowflake, StageChannel, VoiceChannel } from "discord.js"; import { GuildPluginData } from "vety"; import { humanizeDuration } from "../../../humanizeDuration.js"; import { EmbedWith, MINUTES, formatNumber, preEmbedPadding, trimLines, verboseUserMention } from "../../../utils.js"; import { UtilityPluginType } from "../types.js"; const TEXT_CHANNEL_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/740656843545772062/text-channel.png"; const VOICE_CHANNEL_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/740656845982662716/voice-channel.png"; const ANNOUNCEMENT_CHANNEL_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/740656841687564348/announcement-channel.png"; const STAGE_CHANNEL_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/839930647711186995/stage-channel.png"; const PUBLIC_THREAD_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/870343055855738921/public-thread.png"; const PRIVATE_THREAD_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/870343402447839242/private-thread.png"; const FORUM_CHANNEL_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/1091681253364875294/forum-channel-icon.png"; const MEDIA_CHANNEL_ICON = "https://cdn.discordapp.com/attachments/876134205229252658/1178335624940490792/media.png"; export async function getChannelInfoEmbed( pluginData: GuildPluginData, channelId: string, ): Promise { const channel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!channel) { return null; } const embed: EmbedWith<"fields"> = { fields: [], }; const icon = { [ChannelType.GuildVoice]: VOICE_CHANNEL_ICON, [ChannelType.GuildAnnouncement]: ANNOUNCEMENT_CHANNEL_ICON, [ChannelType.GuildStageVoice]: STAGE_CHANNEL_ICON, [ChannelType.PublicThread]: PUBLIC_THREAD_ICON, [ChannelType.PrivateThread]: PRIVATE_THREAD_ICON, [ChannelType.AnnouncementThread]: PUBLIC_THREAD_ICON, [ChannelType.GuildForum]: FORUM_CHANNEL_ICON, [ChannelType.GuildMedia]: MEDIA_CHANNEL_ICON, }[channel.type] ?? TEXT_CHANNEL_ICON; const channelType = { [ChannelType.GuildText]: "Text channel", [ChannelType.GuildVoice]: "Voice channel", [ChannelType.GuildCategory]: "Category channel", [ChannelType.GuildAnnouncement]: "Announcement channel", [ChannelType.GuildStageVoice]: "Stage channel", [ChannelType.PublicThread]: "Public Thread channel", [ChannelType.PrivateThread]: "Private Thread channel", [ChannelType.AnnouncementThread]: "News Thread channel", [ChannelType.GuildDirectory]: "Hub channel", [ChannelType.GuildForum]: "Forum channel", [ChannelType.GuildMedia]: "Media channel", }[channel.type] ?? "Channel"; embed.author = { name: `${channelType}: ${channel.name}`, icon_url: icon, }; let channelName = `#${channel.name}`; if ( channel.type === ChannelType.GuildVoice || channel.type === ChannelType.GuildCategory || channel.type === ChannelType.GuildStageVoice ) { channelName = channel.name; } const showMention = channel.type !== ChannelType.GuildCategory; embed.fields.push({ name: preEmbedPadding + "Channel information", value: trimLines(` Name: **${channelName}** ID: \`${channel.id}\` Created: **** Type: **${channelType}** ${showMention ? `Mention: <#${channel.id}>` : ""} `), }); if (channel.type === ChannelType.GuildVoice || channel.type === ChannelType.GuildStageVoice) { const voiceMembers = Array.from((channel as VoiceChannel | StageChannel).members.values()); const muted = voiceMembers.filter((vm) => vm.voice.mute || vm.voice.selfMute); const deafened = voiceMembers.filter((vm) => vm.voice.deaf || vm.voice.selfDeaf); const voiceOrStage = channel.type === ChannelType.GuildVoice ? "Voice" : "Stage"; embed.fields.push({ name: preEmbedPadding + `${voiceOrStage} information`, value: trimLines(` Users on ${voiceOrStage.toLowerCase()} channel: **${formatNumber(voiceMembers.length)}** Muted: **${formatNumber(muted.length)}** Deafened: **${formatNumber(deafened.length)}** `), }); } if (channel.type === ChannelType.GuildCategory) { const textChannels = pluginData.guild.channels.cache.filter( (ch) => ch.parentId === channel.id && ch.type !== ChannelType.GuildVoice, ); const voiceChannels = pluginData.guild.channels.cache.filter( (ch) => ch.parentId === channel.id && (ch.type === ChannelType.GuildVoice || ch.type === ChannelType.GuildStageVoice), ); embed.fields.push({ name: preEmbedPadding + "Category information", value: trimLines(` Text channels: **${textChannels.size}** Voice channels: **${voiceChannels.size}** `), }); } if (channel.isThread()) { const parentChannelName = channel.parent?.name ?? `<#${channel.parentId}>`; const memberCount = channel.memberCount ?? channel.members.cache.size; const owner = await channel.fetchOwner().catch(() => null); const ownerMention = owner?.user ? verboseUserMention(owner.user) : "Unknown#0000"; const humanizedArchiveTime = `Archive duration: **${humanizeDuration( (channel.autoArchiveDuration ?? 0) * MINUTES, )}**`; embed.fields.push({ name: preEmbedPadding + "Thread information", value: trimLines(` Parent channel: **#${parentChannelName}** Member count: **${memberCount}** Thread creator: ${ownerMention} ${channel.archived ? "Archived: **True**" : humanizedArchiveTime}`), }); } return embed; } ================================================ FILE: backend/src/plugins/Utility/functions/getCustomEmojiId.ts ================================================ const customEmojiRegex = /(?:?/i; export function getCustomEmojiId(str: string): string | null { const emojiIdMatch = str.match(customEmojiRegex); return emojiIdMatch?.[1] ?? null; } ================================================ FILE: backend/src/plugins/Utility/functions/getEmojiInfoEmbed.ts ================================================ import { APIEmbed } from "discord.js"; import { GuildPluginData } from "vety"; import { EmbedWith, preEmbedPadding, trimLines } from "../../../utils.js"; import { UtilityPluginType } from "../types.js"; export async function getEmojiInfoEmbed( pluginData: GuildPluginData, emojiId: string, ): Promise { const emoji = pluginData.guild.emojis.cache.find((e) => e.id === emojiId); if (!emoji) { return null; } const embed: EmbedWith<"fields" | "author"> = { fields: [], author: { name: `Emoji: ${emoji.name}`, icon_url: emoji.url, }, }; embed.fields!.push({ name: preEmbedPadding + "Emoji information", value: trimLines(` Name: **${emoji.name}** ID: \`${emoji.id}\` Animated: **${emoji.animated ? "Yes" : "No"}** `), }); return embed; } ================================================ FILE: backend/src/plugins/Utility/functions/getGuildPreview.ts ================================================ import { Client, GuildPreview, Snowflake } from "discord.js"; import { MINUTES, memoize } from "../../../utils.js"; /** * Memoized getGuildPreview */ export function getGuildPreview(client: Client, guildId: string): Promise { return memoize( () => client.fetchGuildPreview(guildId as Snowflake).catch(() => null), `getGuildPreview_${guildId}`, 10 * MINUTES, ); } ================================================ FILE: backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts ================================================ import { APIEmbed, ChannelType } from "discord.js"; import { GuildPluginData } from "vety"; import { EmbedWith, formatNumber, inviteHasCounts, isGroupDMInvite, isGuildInvite, preEmbedPadding, renderUsername, resolveInvite, trimLines, } from "../../../utils.js"; import { snowflakeToTimestamp } from "../../../utils/snowflakeToTimestamp.js"; import { UtilityPluginType } from "../types.js"; export async function getInviteInfoEmbed( pluginData: GuildPluginData, inviteCode: string, ): Promise { const invite = await resolveInvite(pluginData.client, inviteCode, true); if (!invite) { return null; } if (isGuildInvite(invite)) { const embed: EmbedWith<"fields"> = { fields: [], }; embed.author = { name: `Server invite: ${invite.guild.name}`, url: `https://discord.gg/${invite.code}`, }; if (invite.guild.icon) { embed.author.icon_url = `https://cdn.discordapp.com/icons/${invite.guild.id}/${invite.guild.icon}.png?size=256`; } if (invite.guild.description) { embed.description = invite.guild.description; } const serverCreatedAtTimestamp = snowflakeToTimestamp(invite.guild.id); const memberCount = inviteHasCounts(invite) ? invite.memberCount : 0; const presenceCount = inviteHasCounts(invite) ? invite.presenceCount : 0; embed.fields.push({ name: preEmbedPadding + "Server information", value: trimLines(` Name: **${invite.guild.name}** ID: \`${invite.guild.id}\` Created: **** Members: **${formatNumber(memberCount)}** (${formatNumber(presenceCount)} online) `), inline: true, }); if (invite.channel) { const channelName = invite.channel.type === ChannelType.GuildVoice ? `🔉 ${invite.channel.name}` : `#${invite.channel.name}`; const channelCreatedAtTimestamp = snowflakeToTimestamp(invite.channel.id); let channelInfo = trimLines(` Name: **${channelName}** ID: \`${invite.channel.id}\` Created: **** `); if (invite.channel.type !== ChannelType.GuildVoice) { channelInfo += `\nMention: <#${invite.channel.id}>`; } embed.fields.push({ name: preEmbedPadding + "Channel information", value: channelInfo, inline: true, }); } if (invite.inviter) { embed.fields.push({ name: preEmbedPadding + "Invite creator", value: trimLines(` Name: **${renderUsername(invite.inviter)}** ID: \`${invite.inviter.id}\` Mention: <@!${invite.inviter.id}> `), }); } return embed; } if (isGroupDMInvite(invite)) { const embed: EmbedWith<"fields"> = { fields: [], }; embed.author = { name: invite.channel.name ? `Group DM invite: ${invite.channel.name}` : `Group DM invite`, url: `https://discord.gg/${invite.code}`, }; // FIXME pending invite re-think /*if (invite.channel.icon) { embed.author.icon_url = `https://cdn.discordapp.com/channel-icons/${invite.channel.id}/${invite.channel.icon}.png?size=256`; }*/ const channelCreatedAtTimestamp = snowflakeToTimestamp(invite.channel!.id); embed.fields.push({ name: preEmbedPadding + "Group DM information", value: trimLines(` Name: ${invite.channel!.name ? `**${invite.channel!.name}**` : `_Unknown_`} ID: \`${invite.channel!.id}\` Created: **** Members: **${formatNumber((invite as any).memberCount)}** `), inline: true, }); if (invite.inviter) { embed.fields.push({ name: preEmbedPadding + "Invite creator", value: trimLines(` Name: **${renderUsername(invite.inviter.username, invite.inviter.discriminator)}** ID: \`${invite.inviter.id}\` Mention: <@!${invite.inviter.id}> `), inline: true, }); } return embed; } return null; } ================================================ FILE: backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts ================================================ import { APIEmbed, MessageType, Snowflake, TextChannel } from "discord.js"; import { GuildPluginData, getDefaultMessageCommandPrefix } from "vety"; import { EmbedWith, chunkMessageLines, messageLink, preEmbedPadding, renderUsername, trimEmptyLines, trimLines, } from "../../../utils.js"; import { UtilityPluginType } from "../types.js"; const MESSAGE_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/740685652152025088/message.png"; export async function getMessageInfoEmbed( pluginData: GuildPluginData, channelId: string, messageId: string, ): Promise { const message = await (pluginData.guild.channels.resolve(channelId as Snowflake) as TextChannel).messages .fetch(messageId as Snowflake) .catch(() => null); if (!message) { return null; } const embed: EmbedWith<"fields" | "author"> = { fields: [], author: { name: `Message: ${message.id}`, icon_url: MESSAGE_ICON, }, }; const type = { [MessageType.Default]: "Regular message", [MessageType.ChannelPinnedMessage]: "System message", [MessageType.UserJoin]: "System message", [MessageType.GuildBoost]: "System message", [MessageType.GuildBoostTier1]: "System message", [MessageType.GuildBoostTier2]: "System message", [MessageType.GuildBoostTier3]: "System message", [MessageType.ChannelFollowAdd]: "System message", [MessageType.GuildDiscoveryDisqualified]: "System message", [MessageType.GuildDiscoveryRequalified]: "System message", }[message.type] ?? "Unknown"; embed.fields.push({ name: preEmbedPadding + "Message information", value: trimEmptyLines( trimLines(` ID: \`${message.id}\` Channel: <#${message.channel.id}> Channel ID: \`${message.channel.id}\` Created: **** ${message.editedTimestamp ? `Edited at: ****` : ""} Type: **${type}** Link: [**Go to message ➔**](${messageLink(pluginData.guild.id, message.channel.id, message.id)}) `), ), }); const authorJoinedAtTS = message.member?.joinedTimestamp; embed.fields.push({ name: preEmbedPadding + "Author information", value: trimLines(` Name: **${renderUsername(message.author)}** ID: \`${message.author.id}\` Created: **** ${authorJoinedAtTS ? `Joined: ****` : ""} Mention: <@!${message.author.id}> `), }); const textContent = message.content || ""; const chunked = chunkMessageLines(textContent, 1014); for (const [i, chunk] of chunked.entries()) { embed.fields.push({ name: i === 0 ? preEmbedPadding + "Text content" : "[...]", value: chunk, }); } if (message.attachments.size) { const attachmentUrls = message.attachments.map((att) => att.url); embed.fields.push({ name: preEmbedPadding + "Attachments", value: attachmentUrls.join("\n"), }); } if (message.embeds.length) { const prefix = pluginData.fullConfig.prefix || getDefaultMessageCommandPrefix(pluginData.client); embed.fields.push({ name: preEmbedPadding + "Embeds", value: `Message contains an embed, use \`${prefix}source\` to see the embed source`, }); } return embed; } ================================================ FILE: backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts ================================================ import { APIEmbed, PermissionFlagsBits, Role } from "discord.js"; import { GuildPluginData } from "vety"; import { EmbedWith, preEmbedPadding, trimLines } from "../../../utils.js"; import { PERMISSION_NAMES } from "../../../utils/permissionNames.js"; import { UtilityPluginType } from "../types.js"; const MENTION_ICON = "https://cdn.discordapp.com/attachments/705009450855039042/839284872152481792/mention.png"; export async function getRoleInfoEmbed(pluginData: GuildPluginData, role: Role): Promise { const embed: EmbedWith<"fields" | "author" | "color"> = { fields: [], author: { name: `Role: ${role.name}`, icon_url: MENTION_ICON, }, color: role.color, }; const rolePerms = role.permissions.has(PermissionFlagsBits.Administrator) ? [PERMISSION_NAMES.Administrator] : role.permissions.toArray().map((p) => PERMISSION_NAMES[p]); // -1 because of the @everyone role const totalGuildRoles = pluginData.guild.roles.cache.size - 1; embed.fields.push({ name: preEmbedPadding + "Role information", value: trimLines(` Name: **${role.name}** ID: \`${role.id}\` Created: **** Position: **${role.position} / ${totalGuildRoles}** Color: **#${role.color.toString(16).toUpperCase().padStart(6, "0")}** Mentionable: **${role.mentionable ? "Yes" : "No"}** Hoisted: **${role.hoist ? "Yes" : "No"}** Permissions: \`${rolePerms.length ? rolePerms.join(", ") : "None"}\` Mention: <@&${role.id}> (\`<@&${role.id}>\`) `), }); return embed; } ================================================ FILE: backend/src/plugins/Utility/functions/getServerInfoEmbed.ts ================================================ import { APIEmbed, ChannelType, GuildPremiumTier, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { EmbedWith, MINUTES, formatNumber, inviteHasCounts, memoize, preEmbedPadding, renderUsername, resolveInvite, resolveUser, trimLines, } from "../../../utils.js"; import { idToTimestamp } from "../../../utils/idToTimestamp.js"; import { UtilityPluginType } from "../types.js"; import { getGuildPreview } from "./getGuildPreview.js"; const prettifyFeature = (feature: string): string => `\`${feature .split("_") .map((e) => `${e.substring(0, 1).toUpperCase()}${e.substring(1).toLowerCase()}`) .join(" ")}\``; export async function getServerInfoEmbed( pluginData: GuildPluginData, serverId: string, ): Promise { const thisServer = serverId === pluginData.guild.id ? pluginData.guild : null; const [restGuild, guildPreview] = await Promise.all([ thisServer ? memoize(() => pluginData.client.guilds.fetch(serverId as Snowflake), `getRESTGuild_${serverId}`, 10 * MINUTES) : null, getGuildPreview(pluginData.client, serverId), ]); if (!restGuild && !guildPreview) { return null; } const features = (restGuild || guildPreview)!.features; if (!thisServer && !features.includes("DISCOVERABLE")) { return null; } const embed: EmbedWith<"fields"> = { fields: [], }; embed.author = { name: `Server: ${(guildPreview || restGuild)!.name}`, icon_url: (guildPreview || restGuild)!.iconURL() ?? undefined, }; // BASIC INFORMATION const createdAtTs = Number(idToTimestamp((guildPreview || restGuild)!.id)!); const basicInformation: string[] = []; basicInformation.push(`Created: ****`); if (thisServer) { const owner = await resolveUser(pluginData.client, thisServer.ownerId, "Utility:getServerInfoEmbed"); const ownerName = renderUsername(owner.username, owner.discriminator); basicInformation.push(`Owner: **${ownerName}** (\`${thisServer.ownerId}\`)`); // basicInformation.push(`Voice region: **${thisServer.region}**`); Outdated, as automatic voice regions are fully live } if (features.length > 0) { basicInformation.push(`Features: ${features.map(prettifyFeature).join(", ")}`); } embed.description = `${preEmbedPadding}**Basic Information**\n${basicInformation.join("\n")}`; // IMAGE LINKS const iconUrl = `[Link](${(restGuild || guildPreview)!.iconURL()})`; const bannerUrl = restGuild?.banner ? `[Link](${restGuild.bannerURL()})` : "None"; const splashUrl = (restGuild || guildPreview)!.splash ? `[Link](${(restGuild || guildPreview)!.splashURL()})` : "None"; embed.fields.push( { name: "Server icon", value: iconUrl, inline: true, }, { name: "Invite splash", value: splashUrl, inline: true, }, { name: "Server banner", value: bannerUrl, inline: true, }, ); // MEMBER COUNTS const totalMembers = guildPreview?.approximateMemberCount || restGuild?.approximateMemberCount || restGuild?.memberCount || thisServer?.memberCount || thisServer?.members.cache.size || 0; let onlineMemberCount = (guildPreview?.approximatePresenceCount || restGuild?.approximatePresenceCount)!; if (onlineMemberCount == null && restGuild?.vanityURLCode) { // For servers with a vanity URL, we can also use the numbers from the invite for online count const invite = await resolveInvite(pluginData.client, restGuild.vanityURLCode!, true); if (invite && inviteHasCounts(invite)) { onlineMemberCount = invite.presenceCount; } } if (!onlineMemberCount && thisServer) { onlineMemberCount = thisServer.members.cache.filter((m) => m.presence?.status !== "offline").size; // Extremely inaccurate fallback } const offlineMemberCount = totalMembers - onlineMemberCount; let memberCountTotalLines = `Total: **${formatNumber(totalMembers)}**`; if (restGuild?.maximumMembers) { memberCountTotalLines += `\nMax: **${formatNumber(restGuild.maximumMembers)}**`; } let memberCountOnlineLines = `Online: **${formatNumber(onlineMemberCount)}**`; if (restGuild?.maximumPresences) { memberCountOnlineLines += `\nMax online: **${formatNumber(restGuild.maximumPresences)}**`; } embed.fields.push({ name: preEmbedPadding + "Members", inline: true, value: trimLines(` ${memberCountTotalLines} ${memberCountOnlineLines} Offline: **${formatNumber(offlineMemberCount)}** `), }); // CHANNEL COUNTS if (thisServer) { const categories = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildCategory); const textChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildText); const voiceChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildVoice); const forumChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildForum); const mediaChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildMedia); const threadChannelsText = thisServer.channels.cache.filter( (channel) => channel.isThread() && channel.parent?.type !== ChannelType.GuildForum, ); const threadChannelsForums = thisServer.channels.cache.filter( (channel) => channel.isThread() && channel.parent?.type === ChannelType.GuildForum, ); const threadChannelsMedia = thisServer.channels.cache.filter( (channel) => channel.isThread() && channel.parent?.type === ChannelType.GuildMedia, ); const announcementChannels = thisServer.channels.cache.filter( (channel) => channel.type === ChannelType.GuildAnnouncement, ); const stageChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildStageVoice); const totalChannels = thisServer.channels.cache.filter((channel) => !channel.isThread()).size; embed.fields.push({ name: preEmbedPadding + "Channels", inline: true, value: trimLines(` Total: **${totalChannels}** / 500 Categories: **${categories.size}** Text: **${textChannels.size}** (**${threadChannelsText.size} threads**) Forums: **${forumChannels.size}** (**${threadChannelsForums.size} threads**) Media: **${mediaChannels.size}** (**${threadChannelsMedia.size} threads**) Announcement: **${announcementChannels.size}** Voice: **${voiceChannels.size}** Stage: **${stageChannels.size}** `), }); } // OTHER STATS const otherStats: string[] = []; if (thisServer) { otherStats.push(`Roles: **${thisServer.roles.cache.size}** / 250`); } const roleLockedEmojis = (restGuild ? restGuild?.emojis?.cache.filter((e) => e.roles.cache.size) : guildPreview?.emojis.filter((e) => e.roles.length) )?.size ?? 0; if (restGuild) { const maxEmojis = { [GuildPremiumTier.None]: 50, [GuildPremiumTier.Tier1]: 100, [GuildPremiumTier.Tier2]: 150, [GuildPremiumTier.Tier3]: 250, }[restGuild.premiumTier] ?? 50; const maxStickers = { [GuildPremiumTier.None]: 0, [GuildPremiumTier.Tier1]: 15, [GuildPremiumTier.Tier2]: 30, [GuildPremiumTier.Tier3]: 60, }[restGuild.premiumTier] ?? 0; const availableEmojis = restGuild.emojis.cache.filter((e) => e.available); otherStats.push( `Emojis: **${availableEmojis.size}** / ${maxEmojis * 2}${ roleLockedEmojis ? ` (__${roleLockedEmojis} role-locked__)` : "" }${ availableEmojis.size < restGuild.emojis.cache.size ? ` (__+${restGuild.emojis.cache.size - availableEmojis.size} unavailable__)` : "" }`, ); otherStats.push(`Stickers: **${restGuild.stickers.cache.size}** / ${maxStickers}`); } else { otherStats.push( `Emojis: **${guildPreview!.emojis.size}**${roleLockedEmojis ? ` (__${roleLockedEmojis} role-locked__)` : ""}`, ); // otherStats.push(`Stickers: **${guildPreview!.stickers.size}**`); Wait on DJS } if (thisServer) { otherStats.push( `Boosts: **${thisServer.premiumSubscriptionCount ?? 0}**${ thisServer.premiumTier ? ` (level ${thisServer.premiumTier})` : "" }`, ); } embed.fields.push({ name: preEmbedPadding + "Other stats", inline: true, value: otherStats.join("\n"), }); if (!thisServer) { embed.footer = { text: "⚠️ Only showing publicly available information for this server", }; } return embed; } ================================================ FILE: backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts ================================================ import { APIEmbed } from "discord.js"; import { EmbedWith, preEmbedPadding } from "../../../utils.js"; import { snowflakeToTimestamp } from "../../../utils/snowflakeToTimestamp.js"; const SNOWFLAKE_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/742020790471491668/snowflake.png"; export async function getSnowflakeInfoEmbed(snowflake: string, showUnknownWarning = false): Promise { const embed: EmbedWith<"fields" | "author"> = { fields: [], author: { name: `Snowflake: ${snowflake}`, icon_url: SNOWFLAKE_ICON, }, }; if (showUnknownWarning) { embed.description = "This is a valid [snowflake ID](https://discord.com/developers/docs/reference#snowflakes), but I don't know what it's for."; } const createdAtMS = snowflakeToTimestamp(snowflake); embed.fields.push({ name: preEmbedPadding + "Basic information", value: `Created: ****`, }); return embed; } ================================================ FILE: backend/src/plugins/Utility/functions/getUserInfoEmbed.ts ================================================ import { APIEmbed } from "discord.js"; import { GuildPluginData } from "vety"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { EmbedWith, messageLink, preEmbedPadding, renderUsername, resolveMember, resolveUser, sorter, trimEmptyLines, trimLines, UnknownUser, } from "../../../utils.js"; import { UtilityPluginType } from "../types.js"; const MAX_ROLES_TO_DISPLAY = 15; const trimRoles = (roles: string[]) => roles.length > MAX_ROLES_TO_DISPLAY ? roles.slice(0, MAX_ROLES_TO_DISPLAY).join(", ") + `, and ${roles.length - MAX_ROLES_TO_DISPLAY} more roles` : roles.join(", "); export async function getUserInfoEmbed( pluginData: GuildPluginData, userId: string, compact = false, ): Promise { const user = await resolveUser(pluginData.client, userId, "Utility:getUserInfoEmbed"); if (!user || user instanceof UnknownUser) { return null; } const member = await resolveMember(pluginData.client, pluginData.guild, user.id); const embed: EmbedWith<"fields"> = { fields: [], }; embed.author = { name: `${user.bot ? "Bot" : "User"}: ${renderUsername(user)}`, }; const avatarURL = (member ?? user).displayAvatarURL(); embed.author.icon_url = avatarURL; if (compact) { embed.fields.push({ name: preEmbedPadding + `${user.bot ? "Bot" : "User"} information`, value: trimLines(` Profile: <@!${user.id}> Created: **** `), }); if (member) { embed.fields[0].value += `\n${user.bot ? "Added" : "Joined"}: ****`; } else { embed.fields.push({ name: preEmbedPadding + "!! NOTE !!", value: `${user.bot ? "Bot" : "User"} is not on the server`, }); } return embed; } const userInfoLines = [`ID: \`${user.id}\``, `Username: **${user.username}**`]; if (user.discriminator !== "0") userInfoLines.push(`Discriminator: **${user.discriminator}**`); if (user.globalName) userInfoLines.push(`Display Name: **${user.globalName}**`); userInfoLines.push(`Created: ****`); userInfoLines.push(`Mention: <@!${user.id}>`); embed.fields.push({ name: preEmbedPadding + `${user.bot ? "Bot" : "User"} information`, value: userInfoLines.join("\n"), }); if (member) { const roles = Array.from(member.roles.cache.values()).filter((r) => r.id !== pluginData.guild.id); roles.sort(sorter("position", "DESC")); embed.fields.push({ name: preEmbedPadding + "Member information", value: trimLines(` ${user.bot ? "Added" : "Joined"}: **** ${roles.length > 0 ? "Roles: " + trimRoles(roles.map((r) => `<@&${r.id}>`)) : ""} `), }); const voiceChannel = member.voice.channelId ? pluginData.guild.channels.cache.get(member.voice.channelId) : null; if (voiceChannel || member.voice.mute || member.voice.deaf) { embed.fields.push({ name: preEmbedPadding + "Voice information", value: trimEmptyLines(` ${voiceChannel ? `Current voice channel: **${voiceChannel.name ?? "None"}**` : ""} ${member.voice.serverMute ? "Server-muted: **Yes**" : ""} ${member.voice.serverDeaf ? "Server-deafened: **Yes**" : ""} ${member.voice.selfMute ? "Self-muted: **Yes**" : ""} ${member.voice.selfDeaf ? "Self-deafened: **Yes**" : ""} `), }); } } else { embed.fields.push({ name: preEmbedPadding + "Member information", value: `⚠ ${user.bot ? "Bot" : "User"} is not on the server`, }); } const cases = (await pluginData.state.cases.getByUserId(user.id)).filter((c) => !c.is_hidden); if (cases.length > 0) { cases.sort((a, b) => { return a.created_at < b.created_at ? 1 : -1; }); const caseSummary = cases.slice(0, 3).map((c) => { const summaryText = `${CaseTypes[c.type]} (#${c.case_number})`; if (c.log_message_id) { const [channelId, messageId] = c.log_message_id.split("-"); return `[${summaryText}](${messageLink(pluginData.guild.id, channelId, messageId)})`; } return summaryText; }); const summaryLabel = cases.length > 3 ? "Last 3 cases" : "Summary"; embed.fields.push({ name: preEmbedPadding + "Cases", value: trimLines(` Total cases: **${cases.length}** ${summaryLabel}: ${caseSummary.join(", ")} `), }); } return embed; } ================================================ FILE: backend/src/plugins/Utility/functions/hasPermission.ts ================================================ import { GuildMember, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { UtilityPluginType } from "../types.js"; export async function hasPermission( pluginData: GuildPluginData, member: GuildMember, channelId: Snowflake, permission: string, ) { return (await pluginData.config.getMatchingConfig({ member, channelId }))[permission]; } ================================================ FILE: backend/src/plugins/Utility/guildReloads.ts ================================================ import { TextChannel } from "discord.js"; export const activeReloads: Map = new Map(); ================================================ FILE: backend/src/plugins/Utility/refreshMembers.ts ================================================ import { Guild } from "discord.js"; import { HOURS, noop } from "../../utils.js"; const MEMBER_REFRESH_FREQUENCY = 1 * HOURS; // How often to do a full member refresh when using commands that need it const memberRefreshLog = new Map }>(); export async function refreshMembersIfNeeded(guild: Guild) { const lastRefresh = memberRefreshLog.get(guild.id); if (lastRefresh && Date.now() < lastRefresh.time + MEMBER_REFRESH_FREQUENCY) { return lastRefresh.promise; } const loadPromise = guild.members.fetch().then(noop); memberRefreshLog.set(guild.id, { time: Date.now(), promise: loadPromise, }); return loadPromise; } ================================================ FILE: backend/src/plugins/Utility/search.ts ================================================ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, GuildMember, Message, MessageComponentInteraction, OmitPartialGroupDMChannel, PermissionsBitField, Snowflake, User, } from "discord.js"; import escapeStringRegexp from "escape-string-regexp"; import { ArgsFromSignatureOrArray, GuildPluginData } from "vety"; import moment from "moment-timezone"; import { RegExpRunner, allowTimeout } from "../../RegExpRunner.js"; import { getBaseUrl } from "../../pluginUtils.js"; import { InvalidRegexError, MINUTES, inputPatternToRegExp, multiSorter, renderUsername, sorter, trimLines, } from "../../utils.js"; import { asyncFilter } from "../../utils/async.js"; import { hasDiscordPermissions } from "../../utils/hasDiscordPermissions.js"; import { banSearchSignature } from "./commands/BanSearchCmd.js"; import { searchCmdSignature } from "./commands/SearchCmd.js"; import { getUserInfoEmbed } from "./functions/getUserInfoEmbed.js"; import { refreshMembersIfNeeded } from "./refreshMembers.js"; import { UtilityPluginType } from "./types.js"; import Timeout = NodeJS.Timeout; const SEARCH_RESULTS_PER_PAGE = 15; const SEARCH_ID_RESULTS_PER_PAGE = 50; const SEARCH_EXPORT_LIMIT = 1_000_000; export enum SearchType { MemberSearch, BanSearch, } class SearchError extends Error {} type MemberSearchParams = ArgsFromSignatureOrArray; type BanSearchParams = ArgsFromSignatureOrArray; type RegexRunner = InstanceType["exec"]; function getOptimizedRegExpRunner(pluginData: GuildPluginData, isSafeRegex: boolean): RegexRunner { if (isSafeRegex) { return async (regex: RegExp, str: string) => { if (!regex.global) { const singleMatch = regex.exec(str); return singleMatch ? [singleMatch] : null; } const matches: RegExpExecArray[] = []; let match: RegExpExecArray | null; // tslint:disable-next-line:no-conditional-assignment while ((match = regex.exec(str)) != null) { matches.push(match); } return matches.length ? matches : null; }; } return pluginData.state.regexRunner.exec.bind(pluginData.state.regexRunner); } export async function displaySearch( pluginData: GuildPluginData, args: MemberSearchParams, searchType: SearchType.MemberSearch, msg: OmitPartialGroupDMChannel, ); export async function displaySearch( pluginData: GuildPluginData, args: BanSearchParams, searchType: SearchType.BanSearch, msg: OmitPartialGroupDMChannel, ); export async function displaySearch( pluginData: GuildPluginData, args: MemberSearchParams | BanSearchParams, searchType: SearchType, msg: OmitPartialGroupDMChannel, ) { // If we're not exporting, load 1 page of search results at a time and allow the user to switch pages with reactions let originalSearchMsg: OmitPartialGroupDMChannel; let searching = false; let currentPage = args.page || 1; let stopCollectionFn: () => void; let stopCollectionTimeout: Timeout; const perPage = args.ids ? SEARCH_ID_RESULTS_PER_PAGE : SEARCH_RESULTS_PER_PAGE; const loadSearchPage = async (page) => { if (searching) return; searching = true; // The initial message is created here, as well as edited to say "Searching..." on subsequent requests // We don't "await" this so we can start loading the search results immediately instead of after the message has been created/edited let searchMsgPromise: Promise; if (originalSearchMsg) { searchMsgPromise = originalSearchMsg.edit("Searching..."); } else { searchMsgPromise = msg.channel.send("Searching..."); searchMsgPromise.then((m) => (originalSearchMsg = m as OmitPartialGroupDMChannel)); } let searchResult; try { switch (searchType) { case SearchType.MemberSearch: searchResult = await performMemberSearch(pluginData, args as MemberSearchParams, page, perPage); break; case SearchType.BanSearch: searchResult = await performBanSearch(pluginData, args as BanSearchParams, page, perPage); break; } } catch (e) { if (e instanceof SearchError) { void pluginData.state.common.sendErrorMessage(msg, e.message); return; } if (e instanceof InvalidRegexError) { void pluginData.state.common.sendErrorMessage(msg, e.message); return; } throw e; } if (searchResult.totalResults === 0) { void pluginData.state.common.sendErrorMessage(msg, "No results found"); return; } const resultWord = searchResult.totalResults === 1 ? "matching member" : "matching members"; const headerText = searchResult.totalResults > perPage ? trimLines(` **Page ${searchResult.page}** (${searchResult.from}-${searchResult.to}) (total ${searchResult.totalResults}) `) : `Found ${searchResult.totalResults} ${resultWord}`; const resultList = args.ids ? formatSearchResultIdList(searchResult.results) : formatSearchResultList(searchResult.results); const result = trimLines(` ${headerText} \`\`\`js ${resultList} \`\`\` `); const searchMsg = await searchMsgPromise; const cfg = await pluginData.config.getForUser(msg.author); if (cfg.info_on_single_result && searchResult.totalResults === 1) { const embed = await getUserInfoEmbed(pluginData, searchResult.results[0].id, false); if (embed) { searchMsg.edit("Only one result:"); msg.channel.send({ embeds: [embed] }); return; } } currentPage = searchResult.page; // Set up pagination reactions if needed. The reactions are cleared after a timeout. if (searchResult.totalResults > perPage) { const idMod = `${searchMsg.id}:${moment.utc().valueOf()}`; const buttons: ButtonBuilder[] = [ new ButtonBuilder() .setStyle(ButtonStyle.Secondary) .setEmoji("⬅") .setCustomId(`previousButton:${idMod}`) .setDisabled(currentPage === 1), new ButtonBuilder() .setStyle(ButtonStyle.Secondary) .setEmoji("➡") .setCustomId(`nextButton:${idMod}`) .setDisabled(currentPage === searchResult.lastPage), new ButtonBuilder().setStyle(ButtonStyle.Secondary).setEmoji("🔄").setCustomId(`reloadButton:${idMod}`), ]; const row = new ActionRowBuilder().addComponents(buttons); await searchMsg.edit({ content: result, components: [row] }); const collector = searchMsg.createMessageComponentCollector({ time: 2 * MINUTES }); collector.on("collect", async (interaction: MessageComponentInteraction) => { if (msg.author.id !== interaction.user.id) { interaction .reply({ content: `You are not permitted to use these buttons.`, ephemeral: true }) // tslint:disable-next-line no-console .catch((err) => console.trace(err.message)); } else { if (interaction.customId === `previousButton:${idMod}` && currentPage > 1) { collector.stop(); await interaction.deferUpdate(); await loadSearchPage(currentPage - 1); } else if (interaction.customId === `nextButton:${idMod}` && currentPage < searchResult.lastPage) { collector.stop(); await interaction.deferUpdate(); await loadSearchPage(currentPage + 1); } else if (interaction.customId === `reloadButton:${idMod}`) { collector.stop(); await interaction.deferUpdate(); await loadSearchPage(currentPage); } else { await interaction.deferUpdate(); } } }); stopCollectionFn = async () => { collector.stop(); await searchMsg.edit({ content: searchMsg.content, components: [] }); }; clearTimeout(stopCollectionTimeout); stopCollectionTimeout = setTimeout(stopCollectionFn, 2 * MINUTES); } else { searchMsg.edit(result); } searching = false; }; loadSearchPage(currentPage); } export async function archiveSearch( pluginData: GuildPluginData, args: MemberSearchParams, searchType: SearchType.MemberSearch, msg: OmitPartialGroupDMChannel, ); export async function archiveSearch( pluginData: GuildPluginData, args: BanSearchParams, searchType: SearchType.BanSearch, msg: OmitPartialGroupDMChannel, ); export async function archiveSearch( pluginData: GuildPluginData, args: MemberSearchParams | BanSearchParams, searchType: SearchType, msg: OmitPartialGroupDMChannel, ) { let results; try { switch (searchType) { case SearchType.MemberSearch: results = await performMemberSearch(pluginData, args as MemberSearchParams, 1, SEARCH_EXPORT_LIMIT); break; case SearchType.BanSearch: results = await performBanSearch(pluginData, args as BanSearchParams, 1, SEARCH_EXPORT_LIMIT); break; } } catch (e) { if (e instanceof SearchError) { void pluginData.state.common.sendErrorMessage(msg, e.message); return; } if (e instanceof InvalidRegexError) { void pluginData.state.common.sendErrorMessage(msg, e.message); return; } throw e; } if (results.totalResults === 0) { void pluginData.state.common.sendErrorMessage(msg, "No results found"); return; } const resultList = args.ids ? formatSearchResultIdList(results.results) : formatSearchResultList(results.results); const archiveId = await pluginData.state.archives.create( trimLines(` Search results (total ${results.totalResults}): ${resultList} `), moment.utc().add(1, "hour"), ); const baseUrl = getBaseUrl(pluginData); const url = await pluginData.state.archives.getUrl(baseUrl, archiveId); await msg.channel.send(`Exported search results: ${url}`); } async function performMemberSearch( pluginData: GuildPluginData, args: MemberSearchParams, page = 1, perPage = SEARCH_RESULTS_PER_PAGE, ): Promise<{ results: GuildMember[]; totalResults: number; page: number; lastPage: number; from: number; to: number }> { await refreshMembersIfNeeded(pluginData.guild); let matchingMembers = Array.from(pluginData.guild.members.cache.values()); if (args.role) { const roleIds = args.role.split(","); matchingMembers = matchingMembers.filter((member) => { for (const role of roleIds) { if (!member.roles.cache.has(role as Snowflake)) return false; } return true; }); } if (args.voice) { matchingMembers = matchingMembers.filter((m) => m.voice.channelId); } if (args.bot) { matchingMembers = matchingMembers.filter((m) => m.user.bot); } if (args.query) { let isSafeRegex = true; let queryRegex: RegExp; if (args.regex) { const flags = args["case-sensitive"] ? "" : "i"; queryRegex = inputPatternToRegExp(args.query.trimStart()); queryRegex = new RegExp(queryRegex.source, flags); isSafeRegex = false; } else { queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); } const execRegExp = getOptimizedRegExpRunner(pluginData, isSafeRegex); /* FIXME if we ever get the intent for this again if (args["status-search"]) { matchingMembers = await asyncFilter(matchingMembers, async member => { if (member.game) { if (member.game.name && (await execRegExp(queryRegex, member.game.name).catch(allowTimeout))) { return true; } if (member.game.state && (await execRegExp(queryRegex, member.game.state).catch(allowTimeout))) { return true; } if (member.game.details && (await execRegExp(queryRegex, member.game.details).catch(allowTimeout))) { return true; } if (member.game.assets) { if ( member.game.assets.small_text && (await execRegExp(queryRegex, member.game.assets.small_text).catch(allowTimeout)) ) { return true; } if ( member.game.assets.large_text && (await execRegExp(queryRegex, member.game.assets.large_text).catch(allowTimeout)) ) { return true; } } if (member.game.emoji && (await execRegExp(queryRegex, member.game.emoji.name).catch(allowTimeout))) { return true; } } return false; }); } else { */ matchingMembers = await asyncFilter(matchingMembers, async (member) => { if (member.nickname && (await execRegExp(queryRegex, member.nickname).catch(allowTimeout))) { return true; } const fullUsername = renderUsername(member); if (await execRegExp(queryRegex, fullUsername).catch(allowTimeout)) return true; return false; }); // } FIXME in conjunction with above comment } const [, sortDir, sortBy] = (args.sort && args.sort.match(/^(-?)(.*)$/)) ?? [null, "ASC", "name"]; const realSortDir = sortDir === "-" ? "DESC" : "ASC"; if (sortBy === "id") { matchingMembers.sort(sorter((m) => BigInt(m.id), realSortDir)); } else { matchingMembers.sort( multiSorter([ [(m) => m.user.username.toLowerCase(), realSortDir], [(m) => m.discriminator, realSortDir], ]), ); } const lastPage = Math.max(1, Math.ceil(matchingMembers.length / perPage)); page = Math.min(lastPage, Math.max(1, page)); const from = (page - 1) * perPage; const to = Math.min(from + perPage, matchingMembers.length); const pageMembers = matchingMembers.slice(from, to); return { results: pageMembers, totalResults: matchingMembers.length, page, lastPage, from: from + 1, to, }; } async function performBanSearch( pluginData: GuildPluginData, args: BanSearchParams, page = 1, perPage = SEARCH_RESULTS_PER_PAGE, ): Promise<{ results: User[]; totalResults: number; page: number; lastPage: number; from: number; to: number }> { const member = pluginData.guild.members.cache.get(pluginData.client.user!.id); if (member && !hasDiscordPermissions(member.permissions, PermissionsBitField.Flags.BanMembers)) { throw new SearchError(`Unable to search bans: missing "Ban Members" permission`); } let matchingBans = (await pluginData.guild.bans.fetch({ cache: false })).map((x) => x.user); if (args.query) { let isSafeRegex = true; let queryRegex: RegExp; if (args.regex) { const flags = args["case-sensitive"] ? "" : "i"; queryRegex = inputPatternToRegExp(args.query.trimStart()); queryRegex = new RegExp(queryRegex.source, flags); isSafeRegex = false; } else { queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); } const execRegExp = getOptimizedRegExpRunner(pluginData, isSafeRegex); matchingBans = await asyncFilter(matchingBans, async (user) => { const fullUsername = renderUsername(user); if (await execRegExp(queryRegex, fullUsername).catch(allowTimeout)) return true; return false; }); } const [, sortDir, sortBy] = (args.sort && args.sort.match(/^(-?)(.*)$/)) ?? [null, "ASC", "name"]; const realSortDir = sortDir === "-" ? "DESC" : "ASC"; if (sortBy === "id") { matchingBans.sort(sorter((m) => BigInt(m.id), realSortDir)); } else { matchingBans.sort( multiSorter([ [(m) => m.username.toLowerCase(), realSortDir], [(m) => m.discriminator, realSortDir], ]), ); } const lastPage = Math.max(1, Math.ceil(matchingBans.length / perPage)); page = Math.min(lastPage, Math.max(1, page)); const from = (page - 1) * perPage; const to = Math.min(from + perPage, matchingBans.length); const pageMembers = matchingBans.slice(from, to); return { results: pageMembers, totalResults: matchingBans.length, page, lastPage, from: from + 1, to, }; } function formatSearchResultList(members: Array): string { const longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0); const lines = members.map((member) => { const paddedId = member.id.padEnd(longestId, " "); let line; if (member instanceof GuildMember) { line = `${paddedId} ${renderUsername(member)}`; if (member.nickname) line += ` (${member.nickname})`; } else { line = `${paddedId} ${renderUsername(member)}`; } return line; }); return lines.join("\n"); } function formatSearchResultIdList(members: Array): string { return members.map((m) => m.id).join(" "); } ================================================ FILE: backend/src/plugins/Utility/types.ts ================================================ import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "vety"; import { z } from "zod"; import { RegExpRunner } from "../../RegExpRunner.js"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { Supporters } from "../../data/Supporters.js"; import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zUtilityConfig = z.strictObject({ can_roles: z.boolean().default(false), can_level: z.boolean().default(false), can_search: z.boolean().default(false), can_clean: z.boolean().default(false), can_info: z.boolean().default(false), can_server: z.boolean().default(false), can_inviteinfo: z.boolean().default(false), can_channelinfo: z.boolean().default(false), can_messageinfo: z.boolean().default(false), can_userinfo: z.boolean().default(false), can_roleinfo: z.boolean().default(false), can_emojiinfo: z.boolean().default(false), can_snowflake: z.boolean().default(false), can_reload_guild: z.boolean().default(false), can_nickname: z.boolean().default(false), can_ping: z.boolean().default(false), can_source: z.boolean().default(false), can_vcmove: z.boolean().default(false), can_vckick: z.boolean().default(false), can_help: z.boolean().default(false), can_about: z.boolean().default(false), can_context: z.boolean().default(false), can_jumbo: z.boolean().default(false), jumbo_size: z.number().default(128), can_avatar: z.boolean().default(false), info_on_single_result: z.boolean().default(true), autojoin_threads: z.boolean().default(true), }); export interface UtilityPluginType extends BasePluginType { configSchema: typeof zUtilityConfig; state: { logs: GuildLogs; cases: GuildCases; savedMessages: GuildSavedMessages; archives: GuildArchives; supporters: Supporters; regexRunner: RegExpRunner; lastReload: number; common: pluginUtils.PluginPublicInterface; }; } export const utilityCmd = guildPluginMessageCommand(); export const utilityEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts ================================================ import { guildPlugin } from "vety"; import { GuildLogs } from "../../data/GuildLogs.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { SendWelcomeMessageEvt } from "./events/SendWelcomeMessageEvt.js"; import { WelcomeMessagePluginType, zWelcomeMessageConfig } from "./types.js"; export const WelcomeMessagePlugin = guildPlugin()({ name: "welcome_message", dependencies: () => [LogsPlugin], configSchema: zWelcomeMessageConfig, // prettier-ignore events: [ SendWelcomeMessageEvt, ], beforeLoad(pluginData) { const { state, guild } = pluginData; state.logs = new GuildLogs(guild.id); state.sentWelcomeMessages = new Set(); }, }); ================================================ FILE: backend/src/plugins/WelcomeMessage/docs.ts ================================================ import { ZeppelinPluginDocs } from "../../types.js"; import { zWelcomeMessageConfig } from "./types.js"; export const welcomeMessagePluginDocs: ZeppelinPluginDocs = { type: "stable", prettyName: "Welcome message", configSchema: zWelcomeMessageConfig, }; ================================================ FILE: backend/src/plugins/WelcomeMessage/events/SendWelcomeMessageEvt.ts ================================================ import { PermissionsBitField, Snowflake, TextChannel } from "discord.js"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { createChunkedMessage, renderRecursively, verboseChannelMention, verboseUserMention } from "../../../utils.js"; import { MessageContent } from "../../../utils.js"; import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions.js"; import { sendDM } from "../../../utils/sendDM.js"; import { guildToTemplateSafeGuild, memberToTemplateSafeMember, userToTemplateSafeUser, } from "../../../utils/templateSafeObjects.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { welcomeMessageEvt } from "../types.js"; export const SendWelcomeMessageEvt = welcomeMessageEvt({ event: "guildMemberAdd", async listener(meta) { const pluginData = meta.pluginData; const member = meta.args.member; const config = pluginData.config.get(); if (!config.message) return; if (!config.send_dm && !config.send_to_channel) return; // Only send welcome messages once per user (even if they rejoin) until the plugin is reloaded if (pluginData.state.sentWelcomeMessages.has(member.id)) { return; } pluginData.state.sentWelcomeMessages.add(member.id); const templateValues = new TemplateSafeValueContainer({ member: memberToTemplateSafeMember(member), user: userToTemplateSafeUser(member.user), guild: guildToTemplateSafeGuild(member.guild), }); const renderMessageText = (str: string) => renderTemplate(str, templateValues); let formatted: MessageContent; try { formatted = typeof config.message === "string" ? await renderMessageText(config.message) : ((await renderRecursively(config.message, renderMessageText)) as MessageContent); } catch (e) { if (e instanceof TemplateParseError) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Error formatting welcome message: ${e.message}`, }); return; } throw e; } if (config.send_dm) { try { await sendDM(member.user, formatted, "welcome message"); } catch { pluginData.getPlugin(LogsPlugin).logDmFailed({ source: "welcome message", user: member.user, }); } } if (config.send_to_channel) { const channel = meta.args.member.guild.channels.cache.get(config.send_to_channel as Snowflake); if (!channel || !(channel instanceof TextChannel)) return; if ( !hasDiscordPermissions( channel.permissionsFor(pluginData.client.user!.id), PermissionsBitField.Flags.SendMessages | PermissionsBitField.Flags.ViewChannel, ) ) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions to send welcome message in ${verboseChannelMention(channel)}`, }); return; } if ( typeof formatted === "object" && formatted.embeds && formatted.embeds.length > 0 && !hasDiscordPermissions( channel.permissionsFor(pluginData.client.user!.id), PermissionsBitField.Flags.EmbedLinks, ) ) { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions to send welcome message **with embeds** in ${verboseChannelMention(channel)}`, }); return; } try { if (typeof formatted === "string") { await createChunkedMessage(channel, formatted, { parse: ["users"], }); } else { await channel.send({ ...formatted, allowedMentions: { parse: ["users"], }, }); } } catch { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to send welcome message for ${verboseUserMention(member.user)} to ${verboseChannelMention(channel)}`, }); } } }, }); ================================================ FILE: backend/src/plugins/WelcomeMessage/types.ts ================================================ import { BasePluginType, guildPluginEventListener } from "vety"; import { z } from "zod"; import { GuildLogs } from "../../data/GuildLogs.js"; import { zMessageContent } from "../../utils.js"; export const zWelcomeMessageConfig = z.strictObject({ send_dm: z.boolean().default(false), send_to_channel: z.string().nullable().default(null), message: zMessageContent.nullable().default(null), }); export interface WelcomeMessagePluginType extends BasePluginType { configSchema: typeof zWelcomeMessageConfig; state: { logs: GuildLogs; sentWelcomeMessages: Set; }; } export const welcomeMessageEvt = guildPluginEventListener(); ================================================ FILE: backend/src/plugins/availablePlugins.ts ================================================ import { ZeppelinGlobalPluginInfo, ZeppelinGuildPluginInfo } from "../types.js"; import { AutoDeletePlugin } from "./AutoDelete/AutoDeletePlugin.js"; import { autoDeletePluginDocs } from "./AutoDelete/docs.js"; import { AutoReactionsPlugin } from "./AutoReactions/AutoReactionsPlugin.js"; import { autoReactionsPluginDocs } from "./AutoReactions/docs.js"; import { AutomodPlugin } from "./Automod/AutomodPlugin.js"; import { automodPluginDocs } from "./Automod/docs.js"; import { BotControlPlugin } from "./BotControl/BotControlPlugin.js"; import { botControlPluginDocs } from "./BotControl/docs.js"; import { CasesPlugin } from "./Cases/CasesPlugin.js"; import { casesPluginDocs } from "./Cases/docs.js"; import { CensorPlugin } from "./Censor/CensorPlugin.js"; import { censorPluginDocs } from "./Censor/docs.js"; import { CommonPlugin } from "./Common/CommonPlugin.js"; import { commonPluginDocs } from "./Common/docs.js"; import { CompanionChannelsPlugin } from "./CompanionChannels/CompanionChannelsPlugin.js"; import { companionChannelsPluginDocs } from "./CompanionChannels/docs.js"; import { ContextMenuPlugin } from "./ContextMenus/ContextMenuPlugin.js"; import { contextMenuPluginDocs } from "./ContextMenus/docs.js"; import { CountersPlugin } from "./Counters/CountersPlugin.js"; import { countersPluginDocs } from "./Counters/docs.js"; import { CustomEventsPlugin } from "./CustomEvents/CustomEventsPlugin.js"; import { customEventsPluginDocs } from "./CustomEvents/docs.js"; import { GuildAccessMonitorPlugin } from "./GuildAccessMonitor/GuildAccessMonitorPlugin.js"; import { guildAccessMonitorPluginDocs } from "./GuildAccessMonitor/docs.js"; import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigReloaderPlugin.js"; import { guildConfigReloaderPluginDocs } from "./GuildConfigReloader/docs.js"; import { GuildInfoSaverPlugin } from "./GuildInfoSaver/GuildInfoSaverPlugin.js"; import { guildInfoSaverPluginDocs } from "./GuildInfoSaver/docs.js"; import { InternalPosterPlugin } from "./InternalPoster/InternalPosterPlugin.js"; import { internalPosterPluginDocs } from "./InternalPoster/docs.js"; import { LocateUserPlugin } from "./LocateUser/LocateUserPlugin.js"; import { locateUserPluginDocs } from "./LocateUser/docs.js"; import { LogsPlugin } from "./Logs/LogsPlugin.js"; import { logsPluginDocs } from "./Logs/docs.js"; import { MessageSaverPlugin } from "./MessageSaver/MessageSaverPlugin.js"; import { messageSaverPluginDocs } from "./MessageSaver/docs.js"; import { ModActionsPlugin } from "./ModActions/ModActionsPlugin.js"; import { modActionsPluginDocs } from "./ModActions/docs.js"; import { MutesPlugin } from "./Mutes/MutesPlugin.js"; import { mutesPluginDocs } from "./Mutes/docs.js"; import { NameHistoryPlugin } from "./NameHistory/NameHistoryPlugin.js"; import { nameHistoryPluginDocs } from "./NameHistory/docs.js"; import { PersistPlugin } from "./Persist/PersistPlugin.js"; import { persistPluginDocs } from "./Persist/docs.js"; import { PhishermanPlugin } from "./Phisherman/PhishermanPlugin.js"; import { phishermanPluginDocs } from "./Phisherman/docs.js"; import { PingableRolesPlugin } from "./PingableRoles/PingableRolesPlugin.js"; import { pingableRolesPluginDocs } from "./PingableRoles/docs.js"; import { PostPlugin } from "./Post/PostPlugin.js"; import { postPluginDocs } from "./Post/docs.js"; import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin.js"; import { reactionRolesPluginDocs } from "./ReactionRoles/docs.js"; import { RemindersPlugin } from "./Reminders/RemindersPlugin.js"; import { remindersPluginDocs } from "./Reminders/docs.js"; import { RoleButtonsPlugin } from "./RoleButtons/RoleButtonsPlugin.js"; import { roleButtonsPluginDocs } from "./RoleButtons/docs.js"; import { RoleManagerPlugin } from "./RoleManager/RoleManagerPlugin.js"; import { roleManagerPluginDocs } from "./RoleManager/docs.js"; import { RolesPlugin } from "./Roles/RolesPlugin.js"; import { rolesPluginDocs } from "./Roles/docs.js"; import { SelfGrantableRolesPlugin } from "./SelfGrantableRoles/SelfGrantableRolesPlugin.js"; import { selfGrantableRolesPluginDocs } from "./SelfGrantableRoles/docs.js"; import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin.js"; import { slowmodePluginDocs } from "./Slowmode/docs.js"; import { SpamPlugin } from "./Spam/SpamPlugin.js"; import { spamPluginDocs } from "./Spam/docs.js"; import { StarboardPlugin } from "./Starboard/StarboardPlugin.js"; import { starboardPluginDocs } from "./Starboard/docs.js"; import { TagsPlugin } from "./Tags/TagsPlugin.js"; import { tagsPluginDocs } from "./Tags/docs.js"; import { TimeAndDatePlugin } from "./TimeAndDate/TimeAndDatePlugin.js"; import { timeAndDatePluginDocs } from "./TimeAndDate/docs.js"; import { UsernameSaverPlugin } from "./UsernameSaver/UsernameSaverPlugin.js"; import { usernameSaverPluginDocs } from "./UsernameSaver/docs.js"; import { UtilityPlugin } from "./Utility/UtilityPlugin.js"; import { utilityPluginDocs } from "./Utility/docs.js"; import { WelcomeMessagePlugin } from "./WelcomeMessage/WelcomeMessagePlugin.js"; import { welcomeMessagePluginDocs } from "./WelcomeMessage/docs.js"; import { CommandAliasesPlugin } from "./CommandAliases/CommandAliasesPlugin.js"; import { commandAliasesPluginDocs } from "./CommandAliases/docs.js"; export const availableGuildPlugins: ZeppelinGuildPluginInfo[] = [ { plugin: AutoDeletePlugin, docs: autoDeletePluginDocs, }, { plugin: AutomodPlugin, docs: automodPluginDocs, }, { plugin: AutoReactionsPlugin, docs: autoReactionsPluginDocs, }, { plugin: CasesPlugin, docs: casesPluginDocs, autoload: true, }, { plugin: CensorPlugin, docs: censorPluginDocs, }, { plugin: CommandAliasesPlugin, docs: commandAliasesPluginDocs, }, { plugin: CompanionChannelsPlugin, docs: companionChannelsPluginDocs, }, { plugin: ContextMenuPlugin, docs: contextMenuPluginDocs, }, { plugin: CountersPlugin, docs: countersPluginDocs, }, { plugin: CustomEventsPlugin, docs: customEventsPluginDocs, }, { plugin: GuildInfoSaverPlugin, docs: guildInfoSaverPluginDocs, autoload: true, }, // FIXME: New caching thing, or fix deadlocks with this plugin // { // plugin: GuildMemberCachePlugin, // docs: guildMemberCachePluginDocs, // autoload: true, // }, { plugin: InternalPosterPlugin, docs: internalPosterPluginDocs, }, { plugin: LocateUserPlugin, docs: locateUserPluginDocs, }, { plugin: LogsPlugin, docs: logsPluginDocs, }, { plugin: MessageSaverPlugin, docs: messageSaverPluginDocs, autoload: true, }, { plugin: ModActionsPlugin, docs: modActionsPluginDocs, }, { plugin: MutesPlugin, docs: mutesPluginDocs, autoload: true, }, { plugin: NameHistoryPlugin, docs: nameHistoryPluginDocs, autoload: true, }, { plugin: PersistPlugin, docs: persistPluginDocs, }, { plugin: PhishermanPlugin, docs: phishermanPluginDocs, }, { plugin: PingableRolesPlugin, docs: pingableRolesPluginDocs, }, { plugin: PostPlugin, docs: postPluginDocs, }, { plugin: ReactionRolesPlugin, docs: reactionRolesPluginDocs, }, { plugin: RemindersPlugin, docs: remindersPluginDocs, }, { plugin: RoleButtonsPlugin, docs: roleButtonsPluginDocs, }, { plugin: RoleManagerPlugin, docs: roleManagerPluginDocs, }, { plugin: RolesPlugin, docs: rolesPluginDocs, }, { plugin: SelfGrantableRolesPlugin, docs: selfGrantableRolesPluginDocs, }, { plugin: SlowmodePlugin, docs: slowmodePluginDocs, }, { plugin: SpamPlugin, docs: spamPluginDocs, }, { plugin: StarboardPlugin, docs: starboardPluginDocs, }, { plugin: TagsPlugin, docs: tagsPluginDocs, }, { plugin: TimeAndDatePlugin, docs: timeAndDatePluginDocs, autoload: true, }, { plugin: UsernameSaverPlugin, docs: usernameSaverPluginDocs, }, { plugin: UtilityPlugin, docs: utilityPluginDocs, }, { plugin: WelcomeMessagePlugin, docs: welcomeMessagePluginDocs, }, { plugin: CommonPlugin, docs: commonPluginDocs, autoload: true, }, ]; export const availableGlobalPlugins: ZeppelinGlobalPluginInfo[] = [ { plugin: GuildConfigReloaderPlugin, docs: guildConfigReloaderPluginDocs, }, { plugin: BotControlPlugin, docs: botControlPluginDocs, }, { plugin: GuildAccessMonitorPlugin, docs: guildAccessMonitorPluginDocs, }, ]; ================================================ FILE: backend/src/profiler.ts ================================================ import type { Vety } from "vety"; type Profiler = Vety["profiler"]; let profiler: Profiler | null = null; export function getProfiler(): Profiler | null { return profiler; } export function setProfiler(_profiler: Profiler) { profiler = _profiler; } ================================================ FILE: backend/src/rateLimitStats.ts ================================================ import { RateLimitData } from "discord.js"; type RateLimitLogItem = { timestamp: number; data: RateLimitData; }; const rateLimitLog: RateLimitLogItem[] = []; const MAX_RATE_LIMIT_LOG_ITEMS = 100; export function logRateLimit(data: RateLimitData) { rateLimitLog.push({ timestamp: Date.now(), data, }); if (rateLimitLog.length > MAX_RATE_LIMIT_LOG_ITEMS) { rateLimitLog.splice(0, rateLimitLog.length - MAX_RATE_LIMIT_LOG_ITEMS); } } export function getRateLimitStats(): RateLimitLogItem[] { return Array.from(rateLimitLog); } ================================================ FILE: backend/src/regExpRunners.ts ================================================ import { RegExpRunner } from "./RegExpRunner.js"; interface RunnerInfo { users: number; runner: RegExpRunner; } const runners: Map = new Map(); export function getRegExpRunner(key: string) { if (!runners.has(key)) { const runner = new RegExpRunner(); runners.set(key, { users: 0, runner, }); } const info = runners.get(key)!; info.users++; return info.runner; } export function discardRegExpRunner(key: string) { if (!runners.has(key)) { throw new Error(`No runners with key ${key}, cannot discard`); } const info = runners.get(key)!; info.users--; if (info.users <= 0) { info.runner.dispose(); runners.delete(key); } } ================================================ FILE: backend/src/restCallStats.ts ================================================ import { sorter } from "./utils.js"; Error.stackTraceLimit = Infinity; type CallStats = { method: string; path: string; source: string; count: number }; const restCallStats: Map = new Map(); const looseSnowflakeRegex = /\d{15,}/g; const queryParamsRegex = /\?.*$/g; export function logRestCall(method: string, path: string) { const anonymizedPath = path.replace(looseSnowflakeRegex, "0000").replace(queryParamsRegex, ""); const stackLines = (new Error().stack || "").split("\n").slice(10); // Remove initial fluff const firstSrcLine = stackLines.findIndex((line) => line.includes("/backend/src")); const source = stackLines .slice(firstSrcLine !== -1 ? firstSrcLine : -5) .filter((l) => !l.includes("processTicksAndRejections")) .join("\n"); const key = `${method}|${anonymizedPath}|${source}`; if (!restCallStats.has(key)) { restCallStats.set(key, { method, path: anonymizedPath, source, count: 0, }); } restCallStats.get(key)!.count++; } export function getTopRestCallStats(count: number): CallStats[] { const stats = Array.from(restCallStats.values()); stats.sort(sorter("count", "DESC")); return stats.slice(0, count); } ================================================ FILE: backend/src/staff.ts ================================================ import { env } from "./env.js"; /** * Zeppelin staff have full access to the dashboard */ export function isStaff(userId: string) { return (env.STAFF ?? []).includes(userId); } ================================================ FILE: backend/src/templateFormatter.test.ts ================================================ import test from "ava"; import { parseTemplate, renderParsedTemplate, renderTemplate, TemplateSafeValueContainer, } from "./templateFormatter.js"; test("Parses plain string templates correctly", (t) => { const result = parseTemplate("foo bar baz"); t.deepEqual(result, ["foo bar baz"]); }); test("Parses templates with variables correctly", (t) => { const result = parseTemplate("foo {bar} baz"); t.deepEqual(result, [ "foo ", { identifier: "bar", args: [], }, " baz", ]); }); test("Parses templates with function variables correctly", (t) => { const result = parseTemplate('foo {bar("str", 5.07)} baz'); t.deepEqual(result, [ "foo ", { identifier: "bar", args: ["str", 5.07], }, " baz", ]); }); test("Parses function variables with variable arguments correctly", (t) => { const result = parseTemplate('foo {bar("str", 5.07, someVar)} baz'); t.deepEqual(result, [ "foo ", { identifier: "bar", args: [ "str", 5.07, { identifier: "someVar", args: [], }, ], }, " baz", ]); }); test("Parses function variables with function variable arguments correctly", (t) => { const result = parseTemplate('foo {bar("str", 5.07, deeply(nested(8)))} baz'); t.deepEqual(result, [ "foo ", { identifier: "bar", args: [ "str", 5.07, { identifier: "deeply", args: [ { identifier: "nested", args: [8], }, ], }, ], }, " baz", ]); }); test("Renders a parsed template correctly", async (t) => { const parseResult = parseTemplate('foo {bar("str", 5.07, deeply(nested(8)))} baz'); const values = new TemplateSafeValueContainer({ bar(strArg, numArg, varArg) { return `${strArg} ${numArg} !${varArg}!`; }, deeply(varArg) { return `<${varArg}>`; }, nested(numArg) { return `?${numArg}?`; }, }); const renderResult = await renderParsedTemplate(parseResult, values); t.is(renderResult, "foo str 5.07 !! baz"); }); test("Supports base values in renderTemplate", async (t) => { const result = await renderTemplate('{if("", "+", "-")} {if(1, "+", "-")}'); t.is(result, "- +"); }); test("Edge case #1", async (t) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const result = await renderTemplate("{foo} {bar()}"); // No "Unclosed function" exception = success t.pass(); }); test("Parses empty string args as empty strings", async (t) => { const result = parseTemplate('{foo("")}'); t.deepEqual(result, [ { identifier: "foo", args: [""], }, ]); }); ================================================ FILE: backend/src/templateFormatter.ts ================================================ import seedrandom from "seedrandom"; import { get, has } from "./utils.js"; const TEMPLATE_CACHE_SIZE = 200; const templateCache: Map = new Map(); export class TemplateParseError extends Error {} interface ITemplateVar { identifier: string; args: Array; _state: { currentArg: string | ITemplateVar; currentArgType: "string" | "number" | "var" | null; inArg: boolean; inQuote: boolean; }; _parent: ITemplateVar | null; } function newTemplateVar(): ITemplateVar { return { identifier: "", args: [], _state: { inArg: false, currentArg: "", currentArgType: null, inQuote: false, }, _parent: null, }; } type ParsedTemplate = Array; export type TemplateSafeValue = | string | number | boolean | null | undefined | ((...args: any[]) => TemplateSafeValue | Promise) | TemplateSafeValueContainer | TemplateSafeValue[]; function isTemplateSafeValue(value: unknown): value is TemplateSafeValue { return ( value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "function" || (Array.isArray(value) && value.every((v) => isTemplateSafeValue(v))) || value instanceof TemplateSafeValueContainer ); } export class TemplateSafeValueContainer { // Fake property used for stricter type checks since TypeScript uses structural typing _isTemplateSafeValueContainer: true; [key: string]: TemplateSafeValue; constructor(data?: Record) { if (data) { ingestDataIntoTemplateSafeValueContainer(this, data); } } } export type TypedTemplateSafeValueContainer = TemplateSafeValueContainer & T; export function ingestDataIntoTemplateSafeValueContainer( target: TemplateSafeValueContainer, data: Record = {}, ) { for (const [key, value] of Object.entries(data)) { if (!isTemplateSafeValue(value)) { // tslint:disable:no-console console.error("=== CONTEXT FOR UNSAFE VALUE ==="); console.error("stringified:", JSON.stringify(value)); console.error("typeof:", typeof value); console.error("constructor name:", (value as any)?.constructor?.name); console.error("=== /CONTEXT FOR UNSAFE VALUE ==="); // tslint:enable:no-console throw new Error(`Unsafe value for key "${key}" in SafeTemplateValueContainer`); } target[key] = value; } } export function createTypedTemplateSafeValueContainer>( data: T, ): TypedTemplateSafeValueContainer { return new TemplateSafeValueContainer(data) as TypedTemplateSafeValueContainer; } function cleanUpParseResult(arr) { arr.forEach((item) => { if (typeof item === "object") { delete item._state; delete item._parent; if (item.args && item.args.length) { cleanUpParseResult(item.args); } } }); } export function parseTemplate(str: string): ParsedTemplate { const chars = [...str]; const result: ParsedTemplate = []; let inVar = false; let currentString = ""; let currentVar: ITemplateVar | null = null; let rootVar: ITemplateVar | null = null; let escapeNext = false; const dumpArg = () => { if (!currentVar) return; if (currentVar._state.currentArgType) { if (currentVar._state.currentArgType === "number") { if (isNaN(currentVar._state.currentArg as any)) { throw new TemplateParseError(`Invalid numeric argument: ${currentVar._state.currentArg}`); } currentVar.args.push(parseFloat(currentVar._state.currentArg as string)); } else { currentVar.args.push(currentVar._state.currentArg); } } currentVar._state.currentArg = ""; currentVar._state.currentArgType = null; }; const returnToParentVar = () => { if (!currentVar) return; currentVar = currentVar._parent; dumpArg(); }; const exitInjectedVar = () => { if (rootVar) { if (currentVar && currentVar !== rootVar) { throw new TemplateParseError(`Unclosed function!`); } result.push(rootVar); rootVar = null; } inVar = false; }; for (const [i, char] of chars.entries()) { if (inVar) { if (currentVar) { if (currentVar._state.inArg) { // We're parsing arguments if (currentVar._state.inQuote) { // We're in an open quote if (escapeNext) { currentVar._state.currentArg += char; escapeNext = false; } else if (char === "\\") { escapeNext = true; } else if (char === '"') { currentVar._state.inQuote = false; } else { currentVar._state.currentArg += char; } } else if (char === ")") { // Done with arguments dumpArg(); returnToParentVar(); } else if (char === ",") { // Comma -> dump argument, start new argument dumpArg(); } else if (currentVar._state.currentArgType === "number") { // We're parsing a number argument // The actual validation of whether this is a number is in dumpArg() currentVar._state.currentArg += char; } else if (/\s/.test(char)) { // Whitespace, ignore continue; } else if (char === '"') { // A double quote can start a string argument, but only if we haven't committed to some other type of argument already if (currentVar._state.currentArgType !== null) { throw new TemplateParseError(`Unexpected char ${char} at ${i}`); } currentVar._state.currentArgType = "string"; currentVar._state.inQuote = true; } else if (char.match(/(\d|-)/)) { // A number can start a string argument, but only if we haven't committed to some other type of argument already if (currentVar._state.currentArgType !== null) { throw new TemplateParseError(`Unexpected char ${char} at ${i}`); } currentVar._state.currentArgType = "number"; currentVar._state.currentArg += char; } else if (currentVar._state.currentArgType === null) { // Any other character starts a new var argument if we haven't committed to some other type of argument already currentVar._state.currentArgType = "var"; const newVar = newTemplateVar(); newVar._parent = currentVar; newVar.identifier += char; currentVar._state.currentArg = newVar; currentVar = newVar; } else { throw new TemplateParseError(`Unexpected char ${char} at ${i}`); } } else { if (char === "(") { currentVar._state.inArg = true; } else if (char === ",") { // We encountered a comma without ever getting into args // -> We're a value property, not a function, and we can return to our parent var returnToParentVar(); } else if (char === ")") { // We encountered a closing bracket without ever getting into args // -> We're a value property, and this closing bracket actually closes out PARENT var // -> "Return to parent var" twice returnToParentVar(); returnToParentVar(); } else if (char === "}") { // We encountered a closing curly bracket without ever getting into args // -> We're a value property, and the current injected var ends here exitInjectedVar(); } else { currentVar.identifier += char; } } } else { if (char === "}") { exitInjectedVar(); } else { throw new TemplateParseError(`Unexpected char ${char} at ${i}`); } } } else { if (escapeNext) { currentString += char; escapeNext = false; } else if (char === "\\") { escapeNext = true; } else if (char === "{") { if (currentString !== "") { result.push(currentString); currentString = ""; } const newVar = newTemplateVar(); currentVar = newVar; rootVar = newVar; inVar = true; } else { currentString += char; } } } if (inVar) { throw new TemplateParseError("Unterminated injected variable!"); } if (currentString !== "") { result.push(currentString); } // Clean-up cleanUpParseResult(result); return result; } async function evaluateTemplateVariable( theVar: ITemplateVar, values: TemplateSafeValueContainer, ): Promise { if (!(values instanceof TemplateSafeValueContainer)) { throw new Error("evaluateTemplateVariable() called with unsafe values"); } const value = has(values, theVar.identifier) ? get(values, theVar.identifier) : undefined; if (typeof value === "function") { // Don't allow running functions in nested objects if (values[theVar.identifier] == null) { return ""; } const args: any[] = []; for (const arg of theVar.args) { if (typeof arg === "object") { const argValue = await evaluateTemplateVariable(arg as ITemplateVar, values); args.push(argValue); } else { args.push(arg); } } const result = await value(...args); if (!isTemplateSafeValue(result)) { throw new Error(`Template function ${theVar.identifier} returned unsafe value`); } return result == null ? "" : result; } return value == null ? "" : value; } export async function renderParsedTemplate(parsedTemplate: ParsedTemplate, values: TemplateSafeValueContainer) { let result = ""; for (const part of parsedTemplate) { if (typeof part === "object") { result += await evaluateTemplateVariable(part, values); } else { result += part.toString(); } } return result; } const baseValues = { if(clause, andThen, andElse) { return clause ? andThen : andElse; }, and(...args) { for (const arg of args) { if (!arg) return false; } return true; }, or(...args) { for (const arg of args) { if (arg) return true; } return false; }, not(arg) { return !arg; }, concat(...args) { return [...args].join(""); }, concatArr(arr, separator = "") { if (!Array.isArray(arr)) return ""; return arr.join(separator); }, eq(...args) { if (args.length < 2) return true; for (let i = 1; i < args.length; i++) { if (args[i] !== args[i - 1]) return false; } return true; }, gt(arg1, arg2) { return arg1 > arg2; }, gte(arg1, arg2) { return arg1 >= arg2; }, lt(arg1, arg2) { return arg1 < arg2; }, lte(arg1, arg2) { return arg1 <= arg2; }, slice(arg1, start, end) { if (typeof arg1 !== "string") return ""; if (isNaN(start)) return ""; if (end != null && isNaN(end)) return ""; return arg1.slice(parseInt(start, 10), end && parseInt(end, 10)); }, lower(arg) { if (typeof arg !== "string") return arg; return arg.toLowerCase(); }, upper(arg) { if (typeof arg !== "string") return arg; return arg.toUpperCase(); }, upperFirst(arg) { if (typeof arg !== "string") return arg; return arg.charAt(0).toUpperCase() + arg.slice(1); }, ucfirst(arg) { return baseValues.upperFirst(arg); }, strlen(arg) { if (typeof arg !== "string") return 0; return [...arg].length; }, rand(from, to, seed = null) { if (isNaN(from)) return 0; if (to == null) { to = from; from = 1; } if (isNaN(to)) return 0; if (to > from) { [from, to] = [to, from]; } const randValue = seed != null ? seedrandom(seed)() : Math.random(); return Math.round(randValue * (to - from) + from); }, round(arg, decimals = 0) { if (typeof arg !== "number") { arg = parseFloat(arg); } if (Number.isNaN(arg)) return 0; return decimals === 0 ? Math.round(arg) : arg.toFixed(Math.max(0, Math.min(decimals, 100))); }, add(...args) { return args.reduce((result, arg) => { if (isNaN(arg)) return result; return result + parseFloat(arg); }, 0); }, sub(...args) { if (args.length === 0) return 0; return args.slice(1).reduce((result, arg) => { if (isNaN(arg)) return result; return result - parseFloat(arg); }, args[0]); }, mul(...args) { if (args.length === 0) return 0; return args.slice(1).reduce((result, arg) => { if (isNaN(arg)) return result; return result * parseFloat(arg); }, args[0]); }, div(...args) { if (args.length === 0) return 0; return args.slice(1).reduce((result, arg) => { if (isNaN(arg) || parseFloat(arg) === 0) return result; return result / parseFloat(arg); }, args[0]); }, cases(mod, ...cases) { if (cases.length === 0) return ""; if (isNaN(mod)) return ""; mod = parseInt(mod, 10) - 1; return cases[Math.max(0, mod % cases.length)]; }, choose(...cases) { const mod = Math.floor(Math.random() * cases.length) + 1; return baseValues.cases(mod, ...cases); }, }; export async function renderTemplate( template: string, values: TemplateSafeValueContainer = new TemplateSafeValueContainer(), includeBaseValues = true, ) { if (includeBaseValues) { values = new TemplateSafeValueContainer(Object.assign({}, baseValues, values)); } let parseResult: ParsedTemplate; if (templateCache.has(template)) { parseResult = templateCache.get(template)!; } else { parseResult = parseTemplate(template); // If our template cache is full, delete the first item if (templateCache.size >= TEMPLATE_CACHE_SIZE) { const firstKey = templateCache.keys().next().value!; templateCache.delete(firstKey); } templateCache.set(template, parseResult); } return renderParsedTemplate(parseResult, values); } ================================================ FILE: backend/src/threadsSignalFix.ts ================================================ /** * Hack for wiping out the threads signal handlers * See: https://github.com/andywer/threads.js/issues/388 * Make sure: * - This is imported before any real imports from "threads" * - This is imported as early as possible to avoid removing our own signal handlers */ import "threads"; import { env } from "./env.js"; if (!env.DEBUG) { process.removeAllListeners("SIGINT"); process.removeAllListeners("SIGTERM"); } ================================================ FILE: backend/src/types.ts ================================================ import { GlobalPluginBlueprint, GuildPluginBlueprint } from "vety"; import { z } from "zod"; import { zSnowflake } from "./utils.js"; export const zZeppelinGuildConfig = z.strictObject({ // From BaseConfig prefix: z.string().optional(), levels: z.record(zSnowflake, z.number()).optional(), plugins: z.record(z.string(), z.unknown()).optional(), }); /** * Wrapper for the string type that indicates the text will be parsed as Markdown later */ export type TMarkdown = string; export interface ZeppelinGuildPluginInfo { plugin: GuildPluginBlueprint; docs: ZeppelinPluginDocs; autoload?: boolean; } export interface ZeppelinGlobalPluginInfo { plugin: GlobalPluginBlueprint; docs: ZeppelinPluginDocs; } export type DocsPluginType = "stable" | "legacy" | "internal"; export interface ZeppelinPluginDocs { type: DocsPluginType; configSchema: z.ZodType; prettyName?: string; description?: TMarkdown; usageGuide?: TMarkdown; configurationGuide?: TMarkdown; } export interface CommandInfo { description?: TMarkdown; basicUsage?: TMarkdown; examples?: TMarkdown; usageGuide?: TMarkdown; parameterDescriptions?: { [key: string]: TMarkdown; }; optionDescriptions?: { [key: string]: TMarkdown; }; } ================================================ FILE: backend/src/uptime.ts ================================================ let start = 0; export function startUptimeCounter() { start = Date.now(); } export function getCurrentUptime() { return Date.now() - start; } export function getBotStartTime() { return start; } ================================================ FILE: backend/src/utils/DecayingCounter.ts ================================================ /** * This is not related to Zeppelin's counters feature */ export class DecayingCounter { protected value = 0; constructor(protected decayInterval: number) { setInterval(() => { this.value = Math.max(0, this.value - 1); }, decayInterval); } add(count = 1): number { this.value += count; return this.value; } get(): number { return this.value; } } ================================================ FILE: backend/src/utils/MessageBuffer.ts ================================================ import { StrictMessageContent } from "../utils.js"; import { calculateEmbedSize } from "./calculateEmbedSize.js"; import Timeout = NodeJS.Timeout; type ConsumeFn = (part: StrictMessageContent) => void; type ContentType = "mixed" | "plain" | "embeds"; export type MessageBufferContent = Pick; type Chunk = { type: ContentType; content: MessageBufferContent; }; export interface MessageBufferOpts { consume?: ConsumeFn; timeout?: number; textSeparator?: string; } const MAX_CHARS_PER_MESSAGE = 2000; const MAX_EMBED_LENGTH_PER_MESSAGE = 6000; const MAX_EMBEDS_PER_MESSAGE = 10; /** * Allows buffering and automatic partitioning of message contents. Useful for e.g. high volume log channels, message chunking, etc. */ export class MessageBuffer { protected autoConsumeFn: ConsumeFn | null = null; protected timeoutMs: number | null = null; protected textSeparator = ""; protected chunk: Chunk | null = null; protected chunkTimeout: Timeout | null = null; protected finalizedChunks: MessageBufferContent[] = []; constructor(opts: MessageBufferOpts = {}) { if (opts.consume) { this.autoConsumeFn = opts.consume; } if (opts.timeout) { this.timeoutMs = opts.timeout; } if (opts.textSeparator) { this.textSeparator = opts.textSeparator; } } push(content: MessageBufferContent): void { let contentType: ContentType; if (content.content && !content.embeds?.length) { contentType = "plain"; } else if (content.embeds?.length && !content.content) { contentType = "embeds"; } else { contentType = "mixed"; } // Plain text can't be merged with mixed or embeds if (contentType === "plain" && this.chunk && this.chunk.type !== "plain") { this.startNewChunk(contentType); } // Mixed can't be merged at all if (contentType === "mixed" && this.chunk) { this.startNewChunk(contentType); } if (!this.chunk) this.startNewChunk(contentType); const chunk = this.chunk!; if (content.content) { if (chunk.content.content && chunk.content.content.length + content.content.length > MAX_CHARS_PER_MESSAGE) { this.startNewChunk(contentType); } if (chunk.content.content == null || chunk.content.content === "") { chunk.content.content = content.content; } else { chunk.content.content += this.textSeparator + content.content; } } if (content.embeds) { if (chunk.content.embeds) { if (chunk.content.embeds.length + content.embeds.length > MAX_EMBEDS_PER_MESSAGE) { this.startNewChunk(contentType); } else { const existingEmbedsLength = chunk.content.embeds.reduce((sum, embed) => sum + calculateEmbedSize(embed), 0); const embedsLength = content.embeds.reduce((sum, embed) => sum + calculateEmbedSize(embed), 0); if (existingEmbedsLength + embedsLength > MAX_EMBED_LENGTH_PER_MESSAGE) { this.startNewChunk(contentType); } } } if (chunk.content.embeds == null) chunk.content.embeds = []; chunk.content.embeds.push(...content.embeds); } } protected startNewChunk(type: ContentType): void { if (this.chunk) { this.finalizeChunk(); } this.chunk = { type, content: {}, }; if (this.timeoutMs) { this.chunkTimeout = setTimeout(() => this.finalizeChunk(), this.timeoutMs); } } protected finalizeChunk(): void { if (!this.chunk) return; const chunk = this.chunk; this.chunk = null; if (this.chunkTimeout) { clearTimeout(this.chunkTimeout); this.chunkTimeout = null; } // Discard empty chunks if (!chunk.content.content && !chunk.content.embeds?.length) return; if (this.autoConsumeFn) { this.autoConsumeFn(chunk.content); return; } this.finalizedChunks.push(chunk.content); } consume(): StrictMessageContent[] { return Array.from(this.finalizedChunks); this.finalizedChunks = []; } finalizeAndConsume(): StrictMessageContent[] { this.finalizeChunk(); return this.consume(); } } ================================================ FILE: backend/src/utils/async.ts ================================================ import { Awaitable } from "./typeUtils.js"; export async function asyncReduce( arr: T[], callback: (accumulator: V, currentValue: T, index: number, array: T[]) => Awaitable, initialValue?: V, ): Promise { let accumulator; let arrayToIterate; if (initialValue !== undefined) { accumulator = initialValue; arrayToIterate = arr; } else { accumulator = arr[0]; arrayToIterate = arr.slice(1); } for (const [i, currentValue] of arrayToIterate.entries()) { accumulator = await callback(accumulator, currentValue, i, arr); } return accumulator; } export function asyncFilter( arr: T[], callback: (element: T, index: number, array: T[]) => Awaitable, ): Promise { return asyncReduce( arr, async (newArray, element, i, _arr) => { if (await callback(element, i, _arr)) { newArray.push(element); } return newArray; }, [], ); } export function asyncMap( arr: T[], callback: (currentValue: T, index: number, array: T[]) => Awaitable, ): Promise { return asyncReduce( arr, async (newArray, element, i, _arr) => { newArray.push(await callback(element, i, _arr)); return newArray; }, [], ); } ================================================ FILE: backend/src/utils/buildCustomId.ts ================================================ export function buildCustomId(namespace: string, data: any = {}) { return `${namespace}:${Date.now()}:${JSON.stringify(data)}`; } ================================================ FILE: backend/src/utils/calculateEmbedSize.ts ================================================ import { APIEmbed, EmbedData } from "discord.js"; function sumStringLengthsRecursively(obj: any): number { if (obj == null) return 0; if (typeof obj === "string") return obj.length; if (Array.isArray(obj)) { return obj.reduce((sum, item) => sum + sumStringLengthsRecursively(item), 0); } if (typeof obj === "object") { return Array.from(Object.values(obj)).reduce((sum: number, item) => sum + sumStringLengthsRecursively(item), 0); } return 0; } export function calculateEmbedSize(embed: APIEmbed | EmbedData): number { return sumStringLengthsRecursively(embed); } ================================================ FILE: backend/src/utils/canAssignRole.ts ================================================ import { Guild, GuildMember, PermissionsBitField, Role, Snowflake } from "discord.js"; import { getMissingPermissions } from "./getMissingPermissions.js"; import { hasDiscordPermissions } from "./hasDiscordPermissions.js"; export function canAssignRole(guild: Guild, member: GuildMember, roleId: string) { if (getMissingPermissions(member.permissions, PermissionsBitField.Flags.ManageRoles)) { return false; } if (roleId === guild.id) { return false; } const targetRole = guild.roles.cache.get(roleId as Snowflake); if (!targetRole) { return false; } const memberRoles = member.roles.cache; const highestRoleWithManageRoles = memberRoles.reduce((highest, role) => { if (!hasDiscordPermissions(role.permissions, PermissionsBitField.Flags.ManageRoles)) return highest; if (highest == null) return role; if (role.position > highest.position) return role; return highest; }, null); return highestRoleWithManageRoles && highestRoleWithManageRoles.position > targetRole.position; } ================================================ FILE: backend/src/utils/canReadChannel.ts ================================================ import { GuildMember, GuildTextBasedChannel } from "discord.js"; import { getMissingChannelPermissions } from "./getMissingChannelPermissions.js"; import { readChannelPermissions } from "./readChannelPermissions.js"; export function canReadChannel(channel: GuildTextBasedChannel, member: GuildMember) { // Not missing permissions required to read the channel = can read channel return !getMissingChannelPermissions(member, channel, readChannelPermissions); } ================================================ FILE: backend/src/utils/categorize.ts ================================================ type Categories = { [key: string]: (item: T) => boolean; }; type CategoryReturnType> = { [key in keyof C]: T[]; }; function initCategories>(categories: C): CategoryReturnType { return Object.keys(categories).reduce((map, key) => { map[key] = []; return map; }, {}) as CategoryReturnType; } export function categorize>(arr: T[], categories: C): CategoryReturnType { const result = initCategories(categories); const categoryEntries = Object.entries(categories); itemLoop: for (const item of arr) { for (const [category, fn] of categoryEntries) { if (fn(item)) { result[category].push(item); continue itemLoop; } } } return result; } ================================================ FILE: backend/src/utils/createPaginatedMessage.ts ================================================ import { Client, Message, MessageReaction, PartialMessageReaction, PartialUser, User } from "discord.js"; import { ContextResponseOptions, fetchContextChannel, GenericCommandSource } from "../pluginUtils.js"; import { MINUTES, noop } from "../utils.js"; import { Awaitable } from "./typeUtils.js"; import Timeout = NodeJS.Timeout; export type LoadPageFn = (page: number) => Awaitable; export interface PaginateMessageOpts { timeout: number; limitToUserId: string | null; } const defaultOpts: PaginateMessageOpts = { timeout: 5 * MINUTES, limitToUserId: null, }; export async function createPaginatedMessage( client: Client, context: GenericCommandSource, totalPages: number, loadPageFn: LoadPageFn, opts: Partial = {}, ): Promise { const fullOpts = { ...defaultOpts, ...opts } as PaginateMessageOpts; const channel = await fetchContextChannel(context); if (!channel.isSendable()) { throw new Error("Context channel is not sendable"); } const firstPageContent = await loadPageFn(1); const message = await channel.send(firstPageContent); let page = 1; let pageLoadId = 0; // Used to avoid race conditions when rapidly switching pages const reactionListener = async ( reactionMessage: MessageReaction | PartialMessageReaction, reactor: User | PartialUser, ) => { if (reactionMessage.message.id !== message.id) { return; } if (fullOpts.limitToUserId && reactor.id !== fullOpts.limitToUserId) { return; } if (reactor.id === client.user!.id) { return; } let pageDelta = 0; if (reactionMessage.emoji.name === "⬅️") { pageDelta = -1; } else if (reactionMessage.emoji.name === "➡️") { pageDelta = 1; } if (!pageDelta) { return; } const newPage = Math.max(Math.min(page + pageDelta, totalPages), 1); if (newPage === page) { return; } page = newPage; const thisPageLoadId = ++pageLoadId; const newPageContent = await loadPageFn(page); if (thisPageLoadId !== pageLoadId) { return; } message.edit(newPageContent).catch(noop); reactionMessage.users.remove(reactor.id).catch(noop); refreshTimeout(); }; client.on("messageReactionAdd", reactionListener); // The timeout after which reactions are removed and the pagination stops working // is refreshed each time the page is changed let timeout: Timeout; const refreshTimeout = () => { clearTimeout(timeout); timeout = setTimeout(() => { message.reactions.removeAll().catch(noop); client.off("messageReactionAdd", reactionListener); }, fullOpts.timeout); }; refreshTimeout(); // Add reactions message.react("⬅️").catch(noop); message.react("➡️").catch(noop); return message; } ================================================ FILE: backend/src/utils/crypt.test.ts ================================================ import test from "ava"; import { decrypt, encrypt } from "./crypt.js"; test("encrypt() followed by decrypt()", async (t) => { const original = "banana 123 👀 💕"; // Includes emojis to verify utf8 stuff works const encrypted = await encrypt(original); const decrypted = await decrypt(encrypted); t.is(decrypted, original); }); ================================================ FILE: backend/src/utils/crypt.ts ================================================ import { Pool, spawn, Worker } from "threads"; import { env } from "../env.js"; import "../threadsSignalFix.js"; import { MINUTES } from "../utils.js"; const pool = Pool(() => spawn(new Worker("./cryptWorker"), { timeout: 10 * MINUTES }), 8); export async function encrypt(data: string) { return pool.queue((w) => w.encrypt(data, env.KEY)); } export async function decrypt(data: string) { return pool.queue((w) => w.decrypt(data, env.KEY)); } ================================================ FILE: backend/src/utils/cryptHelpers.ts ================================================ import { decrypt, encrypt } from "./crypt.js"; export async function encryptJson(obj: any): Promise { const serialized = JSON.stringify(obj); return encrypt(serialized); } export async function decryptJson(encrypted: string): Promise { const decrypted = await decrypt(encrypted); return JSON.parse(decrypted); } ================================================ FILE: backend/src/utils/cryptWorker.ts ================================================ import crypto from "crypto"; import { expose } from "threads/worker"; const ALGORITHM = "aes-256-gcm"; function encrypt(str, key) { // Based on https://gist.github.com/rjz/15baffeab434b8125ca4d783f4116d81 const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(ALGORITHM, key, iv); let encrypted = cipher.update(str, "utf8", "base64"); encrypted += cipher.final("base64"); return `${iv.toString("base64")}.${cipher.getAuthTag().toString("base64")}.${encrypted}`; } function decrypt(encrypted, key) { // Based on https://gist.github.com/rjz/15baffeab434b8125ca4d783f4116d81 const [iv, authTag, encryptedStr] = encrypted.split("."); const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, "base64")); decipher.setAuthTag(Buffer.from(authTag, "base64")); let decrypted = decipher.update(encryptedStr, "base64", "utf8"); decrypted += decipher.final("utf8"); return decrypted; } const toExpose = { encrypt, decrypt }; expose(toExpose); export type CryptFns = typeof toExpose; ================================================ FILE: backend/src/utils/easyProfiler.ts ================================================ import type { Vety } from "vety"; import { performance } from "perf_hooks"; import { noop, SECONDS } from "../utils.js"; type Profiler = Vety["profiler"]; let _profilingEnabled = false; export const profilingEnabled = () => { return _profilingEnabled; }; export const enableProfiling = () => { _profilingEnabled = true; }; export const disableProfiling = () => { _profilingEnabled = false; }; export const startProfiling = (profiler: Profiler, key: string) => { if (!profilingEnabled()) { return noop; } const startTime = performance.now(); return () => { profiler.addDataPoint(key, performance.now() - startTime); }; }; export const calculateBlocking = (coarseness = 10) => { if (!profilingEnabled()) { return () => 0; } let last = performance.now(); let result = 0; const interval = setInterval(() => { const now = performance.now(); const blockedTime = Math.max(0, now - last - coarseness); result += blockedTime; last = now; }, coarseness); setTimeout(() => clearInterval(interval), 10 * SECONDS); return () => { clearInterval(interval); return result; }; }; ================================================ FILE: backend/src/utils/erisAllowedMentionsToDjsMentionOptions.ts ================================================ import { MessageMentionOptions, MessageMentionTypes, Snowflake } from "discord.js"; export function erisAllowedMentionsToDjsMentionOptions( allowedMentions: ErisAllowedMentionFormat | undefined, ): MessageMentionOptions | undefined { if (allowedMentions === undefined) return undefined; const parse: MessageMentionTypes[] = []; let users: Snowflake[] | undefined; let roles: Snowflake[] | undefined; if (Array.isArray(allowedMentions.users)) { users = allowedMentions.users as Snowflake[]; } else if (allowedMentions.users === true) { parse.push("users"); } if (Array.isArray(allowedMentions.roles)) { roles = allowedMentions.roles as Snowflake[]; } else if (allowedMentions.roles === true) { parse.push("roles"); } if (allowedMentions.everyone === true) { parse.push("everyone"); } const mentions: MessageMentionOptions = { parse, users, roles, repliedUser: allowedMentions.repliedUser, }; return mentions; } export interface ErisAllowedMentionFormat { everyone?: boolean | undefined; users?: boolean | string[] | undefined; roles?: boolean | string[] | undefined; repliedUser?: boolean | undefined; } ================================================ FILE: backend/src/utils/filterObject.ts ================================================ type FilterResult = { [K in keyof T]?: T[K]; }; /** * Filter an object's properties based on its values and keys * @return New object with filtered properties */ export function filterObject( object: T, filterFn: (value: T[K], key: K) => boolean, ): FilterResult { return Object.fromEntries( Object.entries(object).filter(([key, value]) => filterFn(value as any, key as keyof T)), ) as FilterResult; } ================================================ FILE: backend/src/utils/findMatchingAuditLogEntry.ts ================================================ import { AuditLogEvent, Guild, GuildAuditLogsEntry } from "discord.js"; import { SECONDS, sleep } from "../utils.js"; const BATCH_DEBOUNCE_TIME = 2 * SECONDS; const BATCH_FETCH_COUNT_INCREMENT = 10; type Batch = { _waitUntil: number; _fetchCount: number; _promise: Promise; join: () => Promise; }; const batches = new Map(); /** * Find a recent audit log entry matching the given criteria. * This function will debounce and batch simultaneous calls into one audit log request. */ export async function findMatchingAuditLogEntry( guild: Guild, action?: AuditLogEvent, targetId?: string, ): Promise { let candidates: GuildAuditLogsEntry[]; if (batches.has(guild.id)) { candidates = await batches.get(guild.id)!.join(); } else { const batch: Batch = { _waitUntil: Date.now(), _fetchCount: 0, _promise: new Promise(async (resolve) => { await sleep(BATCH_DEBOUNCE_TIME); do { await sleep(Math.max(0, batch._waitUntil - Date.now())); } while (Date.now() < batch._waitUntil); const result = await guild .fetchAuditLogs({ limit: batch._fetchCount, }) .catch((err) => { // tslint:disable-next-line:no-console console.warn(`[DEBUG] Audit log error in ${guild.id} (${guild.name}): ${err.message}`); return null; }); const _candidates = Array.from(result?.entries.values() ?? []); batches.delete(guild.id); // TODO: Figure out the type resolve(_candidates as any); }), join() { batch._waitUntil = Date.now() + BATCH_DEBOUNCE_TIME; batch._fetchCount = Math.min(100, batch._fetchCount + BATCH_FETCH_COUNT_INCREMENT); return batch._promise; }, }; batches.set(guild.id, batch); candidates = await batch.join(); } return candidates.find( (entry) => (action == null || entry.action === action) && (targetId == null || (entry.target as any)?.id === targetId), ); } ================================================ FILE: backend/src/utils/formatZodIssue.ts ================================================ import { ZodIssue } from "zod"; export function formatZodIssue(issue: ZodIssue): string { const path = issue.path.join("/"); return `${path}: ${issue.message}`; } ================================================ FILE: backend/src/utils/getChunkedEmbedFields.ts ================================================ import { EmbedField } from "discord.js"; import { chunkMessageLines, emptyEmbedValue } from "../utils.js"; export function getChunkedEmbedFields(name: string, value: string): EmbedField[] { const fields: EmbedField[] = []; const chunks = chunkMessageLines(value, 1014); for (let i = 0; i < chunks.length; i++) { if (i === 0) { fields.push({ name, value: chunks[i], inline: false, }); } else { fields.push({ name: emptyEmbedValue, value: chunks[i], inline: false, }); } } return fields; } ================================================ FILE: backend/src/utils/getGuildPrefix.ts ================================================ import { getDefaultMessageCommandPrefix, GuildPluginData } from "vety"; export function getGuildPrefix(pluginData: GuildPluginData) { return pluginData.fullConfig.prefix || getDefaultMessageCommandPrefix(pluginData.client); } ================================================ FILE: backend/src/utils/getMissingChannelPermissions.ts ================================================ import { GuildMember, GuildTextBasedChannel } from "discord.js"; import { getMissingPermissions } from "./getMissingPermissions.js"; /** * @param requiredPermissions Bitmask of required permissions * @return Bitmask of missing permissions */ export function getMissingChannelPermissions( member: GuildMember, channel: GuildTextBasedChannel, requiredPermissions: number | bigint, ): bigint { const memberChannelPermissions = channel.permissionsFor(member.id); if (!memberChannelPermissions) return BigInt(requiredPermissions); return getMissingPermissions(memberChannelPermissions, requiredPermissions); } ================================================ FILE: backend/src/utils/getMissingPermissions.ts ================================================ import { PermissionsBitField } from "discord.js"; /** * @param resolvedPermissions A Permission object from e.g. GuildChannel#permissionsFor() or Member#permission * @param requiredPermissions Bitmask of required permissions * @return Bitmask of missing permissions */ export function getMissingPermissions( resolvedPermissions: PermissionsBitField | Readonly, requiredPermissions: number | bigint, ): bigint { const allowedPermissions = resolvedPermissions; const nRequiredPermissions = requiredPermissions; if (allowedPermissions.bitfield & PermissionsBitField.Flags.Administrator) { return BigInt(0); } return BigInt(nRequiredPermissions) & ~allowedPermissions.bitfield; } ================================================ FILE: backend/src/utils/getOrFetchGuildMember.ts ================================================ import { Guild, GuildMember } from "discord.js"; const getOrFetchGuildMemberPromises: Map> = new Map(); /** * Gets a guild member from cache or fetches it from the API if not cached. * Concurrent requests are merged. */ export async function getOrFetchGuildMember(guild: Guild, memberId: string): Promise { const cachedMember = guild.members.cache.get(memberId); if (cachedMember) { return cachedMember; } const key = `${guild.id}-${memberId}`; if (!getOrFetchGuildMemberPromises.has(key)) { getOrFetchGuildMemberPromises.set( key, guild.members .fetch(memberId) .catch(() => undefined) .finally(() => { getOrFetchGuildMemberPromises.delete(key); }), ); } return getOrFetchGuildMemberPromises.get(key)!; } ================================================ FILE: backend/src/utils/getOrFetchUser.ts ================================================ import { Client, User } from "discord.js"; import { redis } from "../data/redis.js"; import { incrementDebugCounter } from "../debugCounters.js"; const getOrFetchUserPromises: Map> = new Map(); const UNKNOWN_KEY = "__UNKNOWN__"; const baseCacheTimeSeconds = 60 * 60; // 1 hour const cacheTimeJitterSeconds = 5 * 60; // 5 minutes // Use jitter on cache time to avoid tons of keys expiring at the same time const generateCacheTime = () => { const jitter = Math.floor(Math.random() * cacheTimeJitterSeconds); return baseCacheTimeSeconds + jitter; }; /** * Gets a user from cache or fetches it from the API if not cached. * Concurrent requests are merged. */ export async function getOrFetchUser(bot: Client, userId: string): Promise { // 1. Check Discord.js cache const cachedUser = bot.users.cache.get(userId); if (cachedUser) { incrementDebugCounter("getOrFetchUser:djsCache"); return cachedUser; } // 2. Check Redis const redisCacheKey = `cache:user:${userId}`; const userData = await redis.get(redisCacheKey); if (userData) { if (userData === UNKNOWN_KEY) { incrementDebugCounter("getOrFetchUser:redisCache:unknown"); return undefined; } incrementDebugCounter("getOrFetchUser:redisCache:hit"); // @ts-expect-error Replace with a proper solution once that exists return new User(bot, JSON.parse(userData)); } if (!getOrFetchUserPromises.has(userId)) { incrementDebugCounter("getOrFetchUser:fresh"); getOrFetchUserPromises.set( userId, bot.users .fetch(userId) .catch(async () => { return undefined; }) .then(async (user) => { const cacheValue = user ? JSON.stringify(user.toJSON()) : UNKNOWN_KEY; await redis.set(redisCacheKey, cacheValue, { expiration: { type: "EX", value: generateCacheTime(), }, }); return user; }) .finally(() => { getOrFetchUserPromises.delete(userId); }), ); } return getOrFetchUserPromises.get(userId)!; } ================================================ FILE: backend/src/utils/getPermissionNames.ts ================================================ import { PermissionsBitField } from "discord.js"; const permissionNumberToName: Map = new Map(); const ignoredPermissionConstants = ["all", "allGuild", "allText", "allVoice"]; for (const key in PermissionsBitField.Flags) { if (ignoredPermissionConstants.includes(key)) continue; permissionNumberToName.set(BigInt(PermissionsBitField.Flags[key]), key); } /** * @param permissions Bitmask of permissions to get the names for */ export function getPermissionNames(permissions: number | bigint): string[] { const permissionNames: string[] = []; for (const [permissionNumber, permissionName] of permissionNumberToName.entries()) { if (BigInt(permissions) & permissionNumber) { permissionNames.push(permissionName); } } return permissionNames; } ================================================ FILE: backend/src/utils/hasDiscordPermissions.ts ================================================ import { PermissionsBitField } from "discord.js"; /** * @param resolvedPermissions A Permission object from e.g. GuildChannel#permissionsOf() or Member#permission * @param requiredPermissions Bitmask of required permissions */ export function hasDiscordPermissions( resolvedPermissions: PermissionsBitField | Readonly | null, requiredPermissions: number | bigint, ) { if (resolvedPermissions == null) { return false; } if (resolvedPermissions.has(PermissionsBitField.Flags.Administrator)) { return true; } const nRequiredPermissions = BigInt(requiredPermissions); return Boolean((resolvedPermissions.bitfield! & nRequiredPermissions) === nRequiredPermissions); } ================================================ FILE: backend/src/utils/idToTimestamp.ts ================================================ import { Snowflake, SnowflakeUtil } from "discord.js"; export function idToTimestamp(id: string): string | null { if (typeof id === "number") return null; return SnowflakeUtil.deconstruct(id as Snowflake).timestamp.toString(); } ================================================ FILE: backend/src/utils/intToRgb.ts ================================================ export function intToRgb(int: number): [number, number, number] { const r = int >> 16; const g = (int - (r << 16)) >> 8; const b = int - (r << 16) - (g << 8); return [r, g, b]; } ================================================ FILE: backend/src/utils/isDefaultSticker.ts ================================================ const defaultStickerIds = [ "749044136589393960", "749045492352155769", "749045743976710154", "749046077629399122", "749046696482439188", "749047112028651530", "749049128012742676", "749051158542417980", "749051341325729913", "749051517964648458", "749051844663181383", "749052011751932006", "749052505308266645", "749052707536371812", "749052944682582036", "749053210760577245", "749053441527251087", "749053689419006003", "749053927907131433", "749054120345993216", "749054292937277450", "749054660769218631", "749054894585151518", "749055120263872532", "754112474868875294", "755244355563815073", "755244428305760266", "755244598799892490", "755244649655959615", "755490897143136446", "781291131828699156", "781291442961383434", "781291606493495306", "781321379546398740", "781321702805340200", "781321874650562560", "781321970301796372", "781322427820277791", "781322566060343296", "781322673527193620", "781322765641973770", "781322967127818240", "781323072102858782", "781323157239103548", "781323249505927198", "781323366921404426", "781323471249604648", "781323560756707328", "781323628267962408", "781323712640974858", "781323769960202280", "781323880723251220", "781324010952458270", "781324114685329417", "781324245468315668", "781324376884248596", "781324451014246460", "781324562905432064", "781324642736144424", "781324722394103808", "813950454420471818", "813950661292064808", "813950759296172092", "813950952436531213", "813951067557462106", "813951129544818708", "813951478803857408", "813951723822645278", "813951924604895242", "813952523408113694", "813952588650381332", "813952646083772486", "813952751200763934", "813952825520291902", "813952903064584202", "809207198856904764", "809207265092698112", "809207315822936064", "809207399054442526", "809207795999572038", "809207857773936710", "809207919115239525", "809208197235343410", "809208263987953694", "809208353884471376", "809208424419426344", "809208728251138088", "809208771834019850", "809209078261874688", "809209146846871562", "809209216966852628", "809209266556764241", "809209320494333952", "809209482902503444", "809209627450671114", "809209856321650698", "809209923765272586", "809210027524620328", "809210129978228766", "809210201033932891", "809210344311619584", "809210578433736724", "809210750702583868", "809210904263917618", "809211336633614346", "818596923887583302", "818596976521248819", "818597244017049652", "818597355619483688", "818597454483161098", "818597555608092722", "818597623397220362", "818597707132043285", "818597810047680532", "818597885671243776", "818598022798770186", "818598125077266432", "818598371324592218", "818598476883165194", "818599312882794506", "754104467573571584", "754106820079124480", "754107009720385556", "754107496884338698", "754107539200671765", "754107634172297306", "754108691493683221", "754108771852222564", "754108811354046554", "754108835895181322", "754108890559283200", "754108923509997568", "754108948356792320", "754108992195919903", "754109038693974057", "754109076933443614", "754109137830281297", "754109419821727885", "754109474691612782", "754109519113617478", "754109542526091434", "754109580069437481", "754109748999225374", "754109772449710080", "754109815877402634", "754109869325549638", "754109908995276810", "754109937872928857", "754109983108497468", "754110021574328400", "823973720266899506", "823973812748025937", "823974092700254238", "823974203929526292", "823974288897343518", "823974429834477578", "823974530686648440", "823974669748666418", "823974764057722910", "823974837156446208", "823974930399232020", "823975146263412786", "823976022025306152", "823976102976290866", "823976251269054494", "751604756748959874", "751605093065031760", "751605170818777108", "751605236476543086", "751605353585836101", "751605453842022562", "751605541687787550", "751605606070091887", "751605670654246972", "751605738270359592", "751605803932319754", "751605873083678802", "751605941375598672", "751606014054236261", "751606065447305216", "751606120493350982", "751606190383038604", "751606254073544784", "751606317936017458", "751606379340365864", "751606441315401848", "751606491542192200", "751606539600527410", "751606636698927157", "751606719611928586", "751606808837357608", "751606868115193948", "751606917494734959", "751606992849862706", "751607061762277396", "819128604311027752", "819129296374595614", "819130301702995968", "819131032259133440", "819131232642007062", "819131655738228796", "819131835635466280", "819131978401316864", "819132831023628298", "819139462373310494", "819139728128344064", "819140386551365642", "819140940018352178", "819141435474706472", "819145601031733269", "772963467622744075", "772963523630071828", "772963562553081886", "772970847232458782", "772972089963577354", "772973760457605182", "772974139053047829", "772974519786799114", "772975031487168582", "772975484874522674", "772975929998835722", "772976230718111764", "772976562831097906", "772976718939160606", "772976899152543745", "773897032485175296", "773898515990315028", "773899933131604008", "773901313578369044", "773902656442728488", "773903221272870973", "773903633951490098", "773904449440579624", "773909554005540894", "773911171987144754", "773912319839043625", "773912616425881630", "773914273075429387", "773914595756081172", "773914892800622612", "776241800930787349", "776241838813216788", "776241877862187048", "776241909374910534", "776242510334525460", "776242536820899861", "776242590148198400", "776242614663512095", "776242643717455882", "776242672562077696", "776242703834284132", "776242899662405712", "776242924542492672", "776242962072993792", "776243107654008872", "776243140750606366", "776243172258611200", "776243957259698226", "776243988038025287", "776244033244627004", "776244136725970974", "776244212806713344", "776244240753098752", "776244473184256000", "776244492948209674", "776244520928542731", "776244545096122379", "776244577509179412", "776244610917335070", "776244640239452200", "816086581509095424", "816086770541396028", "816086882823831613", "816086934266839040", "816087074310193162", "816087132447178774", "816087220753924096", "816087273548415006", "816087483640709131", "816087668630618152", "816087792291282944", "816087883252760617", "816087973053464606", "816088051121258596", "816088135334494267", "831569193391489054", "831569335696228352", "831569414746144798", "831569534459576381", "831569719814914108", "831569909288140832", "831570117057970176", "831570479415689309", "831570715471380550", "831571053569769492", "831571151535996988", "831571320377835540", "831571470824112138", "831571594055778304", "831571726223540294", ]; export function isDefaultSticker(id: string): boolean { return defaultStickerIds.includes(id); } ================================================ FILE: backend/src/utils/isDmChannel.ts ================================================ import type { Channel, DMChannel } from "discord.js"; export function isDmChannel(channel: Channel): channel is DMChannel { return channel.isDMBased(); } ================================================ FILE: backend/src/utils/isGuildChannel.ts ================================================ import type { Channel, GuildBasedChannel } from "discord.js"; export function isGuildChannel(channel: Channel): channel is GuildBasedChannel { return "guild" in channel && channel.guild !== null; } ================================================ FILE: backend/src/utils/isScalar.ts ================================================ export function isScalar(value: unknown): value is string | number | boolean | null | undefined { return value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"; } ================================================ FILE: backend/src/utils/isThreadChannel.ts ================================================ import type { AnyThreadChannel, Channel } from "discord.js"; export function isThreadChannel(channel: Channel): channel is AnyThreadChannel { return channel.isThread(); } ================================================ FILE: backend/src/utils/isValidTimezone.ts ================================================ import moment from "moment-timezone"; const validTimezones = moment.tz.names(); export function isValidTimezone(input: string) { return validTimezones.includes(input); } ================================================ FILE: backend/src/utils/loadYamlSafely.ts ================================================ import yaml from "js-yaml"; import { validateNoObjectAliases } from "./validateNoObjectAliases.js"; /** * Loads a YAML file safely while removing object anchors/aliases (including arrays) */ export function loadYamlSafely(yamlStr: string): any { let loaded = yaml.load(yamlStr); if (loaded == null || typeof loaded !== "object") { loaded = {}; } validateNoObjectAliases(loaded); return loaded; } ================================================ FILE: backend/src/utils/lockNameHelpers.ts ================================================ import { GuildMember, Message, User } from "discord.js"; import { SavedMessage } from "../data/entities/SavedMessage.js"; export function allStarboardsLock() { return `starboards`; } export function banLock(user: GuildMember | User | { id: string }) { return `ban-${user.id}`; } export function counterIdLock(counterId: number | string) { return `counter-${counterId}`; } export function memberRolesLock(member: GuildMember | User | { id: string }) { return `member-roles-${member.id}`; } export function messageLock(message: Message | SavedMessage | { id: string }) { return `message-${message.id}`; } export function muteLock(user: GuildMember | User | { id: string }) { return `mute-${user.id}`; } ================================================ FILE: backend/src/utils/mergeRegexes.ts ================================================ import { categorize } from "./categorize.js"; const hasBackreference = /(?:^|[^\\]|[\\]{2})\\\d+/; export function mergeRegexes(sourceRegexes: RegExp[], flags: string): RegExp[] { const categories = categorize(sourceRegexes, { hasBackreferences: (regex) => hasBackreference.exec(regex.source) !== null, safeToMerge: () => true, }); const regexes: RegExp[] = []; if (categories.safeToMerge.length) { const merged = categories.safeToMerge.map((r) => `(?:${r.source})`).join("|"); regexes.push(new RegExp(merged, flags)); } regexes.push(...categories.hasBackreferences); return regexes; } ================================================ FILE: backend/src/utils/mergeWordsIntoRegex.ts ================================================ import escapeStringRegexp from "escape-string-regexp"; export function mergeWordsIntoRegex(words: string[], flags?: string) { const source = words.map((word) => `(?:${escapeStringRegexp(word)})`).join("|"); return new RegExp(source, flags); } ================================================ FILE: backend/src/utils/messageHasContent.ts ================================================ import { MessageCreateOptions } from "discord.js"; import { StrictMessageContent } from "../utils.js"; function embedHasContent(embed: any) { for (const [, value] of Object.entries(embed)) { if (typeof value === "string" && value.trim() !== "") { return true; } if (typeof value === "object" && value != null && embedHasContent(value)) { return true; } if (value != null) { return true; } } return false; } export function messageHasContent(content: string | MessageCreateOptions | StrictMessageContent): boolean { if (typeof content === "string") { return content.trim() !== ""; } if (content.content != null && content.content.trim() !== "") { return true; } if (content.embeds) { for (const embed of content.embeds) { if (embed && embedHasContent(embed)) { return true; } } } return false; } ================================================ FILE: backend/src/utils/messageIsEmpty.ts ================================================ import { MessageCreateOptions } from "discord.js"; import { StrictMessageContent } from "../utils.js"; import { messageHasContent } from "./messageHasContent.js"; export function messageIsEmpty(content: string | MessageCreateOptions | StrictMessageContent): boolean { return !messageHasContent(content); } ================================================ FILE: backend/src/utils/missingPermissionError.ts ================================================ import { getPermissionNames } from "./getPermissionNames.js"; export function missingPermissionError(missingPermissions: number | bigint): string { const permissionNames = getPermissionNames(missingPermissions); return `Missing permissions: **${permissionNames.join("**, **")}**`; } ================================================ FILE: backend/src/utils/multipleSlashOptions.ts ================================================ import { AttachmentSlashCommandOption, slashOptions } from "vety"; type AttachmentSlashOptions = Omit; export function generateAttachmentSlashOptions(amount: number, options: AttachmentSlashOptions) { return new Array(amount).fill(0).map((_, i) => { return slashOptions.attachment({ name: amount > 1 ? `${options.name}${i + 1}` : options.name, description: options.description, required: options.required ?? false, }); }); } export function retrieveMultipleOptions(amount: number, options: any, name: string) { return new Array(amount) .fill(0) .map((_, i) => options[amount > 1 ? `${name}${i + 1}` : name]) .filter((a) => a); } ================================================ FILE: backend/src/utils/normalizeText.test.ts ================================================ import test from "ava"; import { normalizeText } from "./normalizeText.js"; test("Replaces special characters", (t) => { const from = "𝗧:regional_indicator_e:ᔕ7 𝗧:regional_indicator_e:ᔕ7 𝗧:regional_indicator_e:ᔕ7"; const to = "test test test"; t.deepEqual(normalizeText(from), to); }); test("Does not change lowercase ASCII text", (t) => { const text = "lorem ipsum dolor sit amet consectetur adipiscing elit"; t.deepEqual(normalizeText(text), text); }); test("Replaces whitespace", (t) => { const from = "foo bar"; const to = "foo bar"; t.deepEqual(normalizeText(from), to); }); test("Result is always lowercase", (t) => { const from = "TEST"; const to = "test"; t.deepEqual(normalizeText(from), to); }); ================================================ FILE: backend/src/utils/normalizeText.ts ================================================ import stripMarks from "strip-combining-marks"; const REPLACED_CHARS_PATTERNS = { "0": "0|⓪|₀|⁰|𝟢|𝟘|0|𝟎|𝟬|𝟶", "1": "⑴|➀|❶|⓵|①|₁|¹|𝟣|𝟙|1|𝟏|𝟭|𝟷", "2": "⑵|➋|➁|❷|⓶|②|₂|²|𝟤|𝟚|2|𝟐|𝟮|𝟸", "3": "⑶|➌|➂|❸|⓷|③|₃|³|𝟥|𝟛|3|𝟑|𝟯|𝟹", "4": "⑷|➍|➃|❹|⓸|④|₄|⁴|𝟦|𝟜|4|𝟒|𝟰|𝟺", "5": "⑸|➎|➄|❺|⓹|⑤|₅|⁵|𝟧|𝟝|5|𝟓|𝟱|𝟻", "6": "⑹|➏|➅|❻|⓺|⑥|₆|⁶|𝟨|𝟞|6|𝟔|𝟲|𝟼", "7": "⑺|➐|➆|❼|⓻|⑦|₇|⁷|𝟩|𝟟|7|𝟕|𝟳|𝟽", "8": "⑻|➑|➇|❽|⓼|⑧|₈|⁸|𝟪|𝟠|8|𝟖|𝟴|𝟾", "9": "⑼|➒|➈|❾|⓽|⑨|₉|⁹|𝟫|𝟡|9|𝟗|𝟵|𝟿", a: [ "ム|a|A|@|🇦|🅰|🅐|🄰|𝞪|𝞐|𝝰|𝝖|𝜶|𝜜|𝛼|𝛢|𝛂|𝚨|𝚊|𝙰|𝙖|𝘼|𝘢|𝘈|𝗮|𝗔|𝖺|𝖠|𝖆|𝕬|𝕒|𝔸|𝔞|𝔄|𝓪|𝓐|𝒶|𝒜|𝒂|𝑨|𝑎", "𝐴|𝐚|𝐀|𐊠|ꭺ|ꓯ|ꓮ|ꋬ|卂|Ɐ|ⓐ|Ⓐ|⒜|⍺|∆|∀|₳|ₐ|ᾼ|Ὰ|Ᾱ|Ᾰ|ᾷ|ᾶ|ᾴ|ᾳ|ᾲ|ᾱ|ᾰ|ᾏ|ᾎ|ᾍ|ᾌ|ᾋ|ᾊ|ᾉ|ᾈ|ᾇ|ᾆ|ᾅ|ᾄ|ᾃ|ᾂ|ᾁ", "ᾀ|ὰ|ἇ|ἆ|ἅ|ἄ|ἃ|ἂ|ἁ|ἀ|ặ|Ặ|ẵ|Ẵ|ẳ|Ẳ|ằ|Ằ|ắ|Ắ|ậ|Ậ|ẫ|Ẫ|ẩ|Ẩ|ầ|Ầ|ấ|Ấ|ả|Ả|ạ|Ạ|ẚ|ḁ|Ḁ|ᵃ|ᴬ|ᴀ|ᗩ|ᗅ|ᗄ|Ꮧ|Ꭿ", "Ꭺ|ለ|ค|බ|Թ|ӓ|Ӓ|Ѧ|а|Д|А|α|ά|Λ|Δ|Α|Ά|ɒ|ɑ|ɐ|Ⱥ|ȧ|Ȧ|ǻ|Ǻ|ǟ|ǎ|Ǎ|ą|Ą|ă|Ă|ā|Ā|å|ä|ã|â|á|à|Å|Ä|Ã|Â|Á|À", "ª|a|A|@|:regional_indicator_a:|4", ].join("|"), b: [ "b|B|🇧|🅱|🅑|🄱|𝞫|𝞑|𝝱|𝝗|𝜷|𝜝|𝛽|𝛣|𝛃|𝚩|𝚋|𝙱|𝙗|𝘽|𝘣|𝘉|𝗯|𝗕|𝖻|𝖡|𝖇|𝕻|𝕭|𝕓|𝔹|𝔟|𝔓|𝔅|𝓫|𝓑|𝒷|𝒃|𝑩|𝑏|𝐵|𝐛", "𝐁|𐑂|𐌁|𐊡|𐊂|ꮟ|ꞵ|Ꞵ|ꓭ|ꓐ|乃|ⓑ|Ⓑ|⒝|ℬ|ḇ|Ḇ|ḅ|Ḅ|ḃ|Ḃ|ᵇ|ᴮ|ᛒ|ᙠ|ᗷ|ᖯ|ᏼ|Ᏼ|Ᏸ|Ꮟ|ც|Ⴆ|๖|๒|฿|ط|ҍ|ѣ|ь|ъ|в|Ь|В", "Б|ϐ|β|Β|ʙ|ɮ|ɞ|ƅ|Ƅ|ƀ|ß|b|B|:regional_indicator_b:", ].join("|"), c: [ "c|C|🝌|🇨|🅲|🅒|🄲|𝚌|𝙲|𝙘|𝘾|𝘤|𝘊|𝗰|𝗖|𝖼|𝖢|𝖈|𝕮|𝕔|𝔠|𝓬|𝓒|𝒸|𝒞|𝒄|𝑪|𝑐|𝐶|𝐜|𝐂|𐑋|𐐽|𐐣|𐐕|𐌂|𐊢|ꮯ|ꓛ|ꓚ|匚|ⲥ|Ⲥ|ⓒ|Ⓒ|⒞", "↻|ↄ|Ↄ|ⅽ|Ⅽ|ℭ|℃|ℂ|₵|ḉ|Ḉ|ᶜ|ᴐ|ᴄ|ᑢ|ᑕ|Ꮳ|Ꮯ|ፈ|ር|ᄃ|ၥ|၁|ང|උ|ҫ|Ҁ|с|С|Ͻ|Ϲ|ϲ|Ϛ|ς|ͻ|ʗ|ɕ|ɔ|Ȼ|ƈ|Ɔ|č|Č|ċ|Ċ|ĉ", "Ĉ|ć|Ć|ç|Ç|©|¢|c|C|:regional_indicator_c:", ].join("|"), d: [ "d|D|🇩|🅳|🅓|🄳|𝚍|𝙳|𝙙|𝘿|𝘥|𝘋|𝗱|𝗗|𝖽|𝖣|𝖉|𝕯|𝕕|𝔻|𝔡|𝔇|𝓭|𝓓|𝒹|𝒟|𝒅|𝑫|𝑑|𝐷|𝐝|𝐃|ꭰ|ꓷ|ꓓ|ꓒ|ⓓ|Ⓓ|⒟|∂|ↁ|ⅾ|Ⅾ", "ⅆ|ⅅ|₫|ḓ|Ḓ|ḑ|Ḑ|ḏ|Ḏ|ḍ|Ḍ|ḋ|Ḋ|ᵈ|ᴰ|ᴅ|ᗬ|ᗪ|ᗡ|ᗞ|ᕲ|ᑯ|Ꮷ|Ꮄ|Ꭰ|໓|๔|ծ|ժ|ԃ|ԁ|ɗ|ɖ|ƌ|Ɗ|đ|Đ|ď|Ď|Ð|d|D|:regional_indicator_d:", ].join("|"), e: [ "ミ|e|E|ﻉ|🇪|🅴|🅔|🄴|𝞷|𝞢|𝞔|𝝽|𝝨|𝝚|𝝃|𝜮|𝜠|𝜉|𝛴|𝛦|𝛏|𝚺|𝚬|𝚎|𝙴|𝙚|𝙀|𝘦|𝘌|𝗲|𝗘|𝖾|𝖤|𝖊|𝕰|𝕖|𝔼|𝔢|𝔈|𝓮|𝓔|𝒆|𝑬|𝑒|𝐸", "𝐞|𝐄|𐐩|𐐁|𐊆|ꮛ|ꭼ|ꬲ|ꞓ|ꝫ|ꓱ|ꓰ|乇|㉫|ⵉ|ⴺ|ⴹ|ⳍ|ⲉ|ⓔ|Ⓔ|⒠|⋿|⋴|∑|∊|∈|∃|ⅇ|⅀|ℰ|ℯ|℮|ℇ|€|ₑ|Ὲ|ὲ|Ἕ|Ἔ|Ἓ|Ἒ|Ἑ|Ἐ|ἕ", "ἔ|ἓ|ἒ|ἑ|ἐ|ệ|Ệ|ễ|Ễ|ể|Ể|ề|Ề|ế|Ế|ẽ|Ẽ|ẻ|Ẻ|Ẹ|ḝ|Ḝ|ḛ|Ḛ|ḙ|Ḙ|ḗ|Ḗ|ḕ|Ḕ|ᵉ|ᴱ|ᴈ|ᴇ|ᘿ|ᗴ|Ꮛ|Ꭼ|ჳ|ཇ|ԑ|Ԑ|ӡ|ә|Ә|ҿ|ҽ", "є|э|з|е|Е|ϵ|ξ|ε|έ|Σ|Ξ|Ε|ʒ|ɜ|ɛ|ə|ɘ|Ɇ|ȝ|ǝ|ƺ|Ʃ|Ɛ|Ə|Ǝ|ě|Ě|ę|Ę|ė|Ė|ĕ|Ĕ|ē|Ē|ë|ê|é|è|Ë|Ê|É|È|£|e|E|:regional_indicator_e:|3", ].join("|"), f: [ "f|F|ךּ|🇫|🅵|🅕|🄵|𝟋|𝚏|𝙵|𝙛|𝙁|𝘧|𝘍|𝗳|𝗙|𝖿|𝖥|𝖋|𝕱|𝕗|𝔽|𝔣|𝔉|𝓯|𝓕|𝒻|𝒇|𝑭|𝑓|𝐹|𝐟|𝐅|𐊥|𐊇|ꬵ|ꟻ|ꞙ|Ꞙ|ꜰ|ꓞ|ꓝ|千|ⓕ|Ⓕ", "⒡|Ⅎ|ℱ|℉|₣|ẝ|ḟ|Ḟ|ᶠ|ᖷ|ᖵ|ᖴ|Ꮈ|ན|ғ|ϝ|Ϝ|ʄ|ɟ|ƒ|Ƒ|ſ|f|F|:regional_indicator_f:", ].join("|"), // conflicts with T: Ŧ g: [ "g|G|ﻮ|פֿ|𠂎|🇬|🅶|🅖|🄶|𝚐|𝙶|𝙜|𝙂|𝘨|𝘎|𝗴|𝗚|𝗀|𝖦|𝖌|𝕲|𝕘|𝔾|𝔤|𝔊|𝓰|𝓖|𝒢|𝒈|𝑮|𝑔|𝐺|𝐠|𝐆|ꮐ|ꓖ|ⓖ|Ⓖ|⒢|⅁|ℊ|₲|ḡ", "Ḡ|ᶃ|ᵍ|ᴳ|ᘜ|ᏻ|Ᏻ|Ꮹ|Ꮐ|Ꮆ|ງ|ق|ց|ԍ|Ԍ|Б|ʛ|ɢ|ɡ|ɠ|ɓ|ǵ|Ǵ|ǫ|ǧ|Ǧ|Ǥ|ƃ|ģ|Ģ|ġ|Ġ|ğ|Ğ|ĝ|Ĝ|g|G|:regional_indicator_g:", ].join("|"), h: [ "h|H|🇭|🅷|🅗|🄷|𝞖|𝝜|𝜢|𝛨|𝚮|𝚑|𝙷|𝙝|𝙃|𝘩|𝘏|𝗵|𝗛|𝗁|𝖧|𝖍|𝕳|𝕙|𝔥|𝓱|𝓗|𝒽|𝒉|𝑯|𝐻|𝐡|𝐇|𐋏|ꮋ|ꓧ|卄|ん|Ⲏ|Ⱨ|ⓗ", "Ⓗ|⒣|ℎ|ℍ|ℌ|ℋ𝑖|ℋ|ₕ|ῌ|Ὴ|ᾟ|ᾞ|ᾝ|ᾜ|ᾛ|ᾚ|ᾙ|ᾘ|Ἧ|Ἦ|Ἥ|Ἤ|Ἣ|Ἢ|Ἡ|Ἠ|ẖ|ḫ|Ḫ|ḩ|Ḩ|ḧ|Ḧ|ḥ|Ḥ|ḣ|Ḣ|ᴴ|ᕼ|Ᏺ|Ꮒ|Ꮋ|ዠ|ዞ", "հ|ԋ|Ԋ|Ӊ|ӈ|һ|ђ|н|Н|Ћ|Η|Ή|ʱ|ʰ|ʜ|ɧ|ɦ|ɥ|Ƕ|ħ|Ħ|ĥ|Ĥ|h|\\#|H|:regional_indicator_h:", ].join("|"), i: [ "ノ|i|I|!|ﺍ|ﺁ|🇮|🅸|🅘|🄸|𝚒|𝙸|𝙞|𝙄|𝘪|𝘐|𝗶|𝗜|𝗂|𝖨|𝖎|𝕴|𝕚|𝕀|𝔦|𝓲|𝓘|𝒾|𝒊|𝑰|𝑗|𝑖|𝐼|𝐢|𝐈|𐌠|𐌉|𐊊|ꭵ|ꙇ|ꓲ|丨|ⵑ|ⵏ|Ⲓ|ⓘ|Ⓘ|⒤|⍳|∣", "ⅼ|ⅰ|Ⅰ|ⅈ|ℹ|ℑ|ℐ|ⁱ|Ὶ|Ῑ|Ῐ|ῗ|ῖ|ῒ|ῑ|ῐ|ὶ|Ἷ|Ἶ|Ἵ|Ἴ|Ἳ|Ἲ|Ἱ|Ἰ|ἷ|ἶ|ἵ|ἴ|ἳ|ἲ|ἱ|ἰ|ị|Ị|ỉ|Ỉ|ḯ|Ḯ|ḭ|Ḭ|ᶤ|ᵢ|ᴵ|ᛁ|ᓰ|Ꮖ|Ꭵ|ར", "เ|ߊ|۱|ٱ|١|ا|أ|آ|ו|׀|ӏ|ї|і|І|ϊ|ι|ί|Ι|ΐ|ɪ|ɨ|ǐ|Ǐ|ǃ|Ɨ|ł|ı|İ|į|Į|ĭ|Ĭ|ī|Ī|ĩ|Ĩ|ï|î|í|ì|Ï|Î|Í|Ì|¡|i", "\\│|\\ǀ|I|:regional_indicator_i:|1|!", ].join("|"), j: [ "フ|j|J|ﻝ|🇯|🅹|🅙|🄹|𝚓|𝙹|𝙟|𝙅|𝘫|𝘑|𝗷|𝗝|𝗃|𝖩|𝖏|𝕵|𝕛|𝕁|𝔧|𝔍|𝓳|𝓙|𝒿|𝒥|𝒋|𝑱|𝐽|𝐣|𝐉|ꭻ|Ʝ|ꞁ|ꓙ|ⱼ|ⓙ|Ⓙ|⒥|ⅉ|ᴶ|ᴊ|ᒚ|ᒎ|ᒍ|Ꮰ|Ꭻ|ว", "ڶ|ل|ز|נ|ן|ј|Ј|ϳ|Ϳ|ʲ|ʝ|Ɉ|ǰ|ĵ|Ĵ|j|J|:regional_indicator_j:", ].join("|"), k: [ "k|K|🇰|🅺|🅚|🄺|𝟆|𝞳|𝞙|𝞌|𝝹|𝝟|𝝒|𝜿|𝜥|𝜘|𝜅|𝛫|𝛞|𝛋|𝚱|𝚔|𝙺|𝙠|𝙆|𝘬|𝘒|𝗸|𝗞|𝗄|𝖪|𝖐|𝕶|𝕜|𝕂|𝔨|𝔎|𝓴|𝓚|𝓀|𝒦|𝒌|𝑲", "𝑘|𝐾|𝐤|𝐊|𐒼|ꮶ|Ꝁ|ꓗ|ⲕ|Ⲕ|ⓚ|Ⓚ|⒦|⋊|K|₭|ₖ|ḵ|Ḵ|ḳ|Ḳ|ḱ|Ḱ|ᵏ|ᴷ|ᴋ|ᛕ|ᖽᐸ|Ꮶ|ӄ|Ӄ|Ҡ|ҟ|Ҝ|қ|к|К|Ќ|ϰ|ϗ|κ|Κ|ʞ|ƙ|ĸ", "ķ|Ķ|k|K|:regional_indicator_k:", ].join("|"), l: [ "レ|l|L|ﺎ|ﺂ|🇱|🅻|🅛|🄻|𝚕|𝙻|𝙡|𝙇|𝘭|𝘓|𝗹|𝗟|𝗅|𝖫|𝖑|𝕷|𝕝|𝕃|𝔩|𝔏|𝓵|𝓛|𝓁|𝒍|𝑳|𝑙|𝐿|𝐥|𝐋|𐑃|𐐛|ꮮ|Ꝉ|ꓡ|ㄥ|し|ⳑ|Ⳑ|Ⱡ|ⓛ|Ⓛ|⒧", "Ⅼ|⅃|⅂|ℓ|ℒ|ₗ|ḽ|Ḽ|ḻ|Ḻ|ḹ|Ḹ|ḷ|Ḷ|ᴸ|ᒺ|ᒪ|Ꮮ|Ꮭ|Ꮁ|ᄂ|Ӏ|ˡ|ʟ|ʆ|ʅ|ɭ|ɫ|ƪ|Ɩ|ł|Ł|ŀ|Ŀ|ľ|Ľ|ļ|Ļ|ĺ|Ĺ|l|L|:regional_indicator_l:", ].join("|"), m: [ "ᄊ|m|M|🇲|🅼|🅜|🄼|𝞛|𝝡|𝜧|𝛭|𝚳|𝚖|𝙼|𝙢|𝙈|𝘮|𝘔|𝗺|𝗠|𝗆|𝖬|𝖒|𝕸|𝕞|𝕄|𝔪|𝔐|𝓶|𝓜|𝓂|𝒎|𝑴|𝑚|𝑀|𝐦|𝐌|𐌑", "𐊰|ꮇ|ꓟ|爪|Ⲙ|ⓜ|Ⓜ|⒨|Ⅿ|ℳ|₥|ₘ|ṃ|Ṃ|ṁ|Ṁ|ḿ|Ḿ|ᵐ|ᴹ|ᴍ|៣|ᛖ|ᘻ|ᗰ|Ꮇ|๓|Ӎ|м|М|ϻ|Ϻ|Μ|ʍ|ɱ|ɯ|m|M|:regional_indicator_m:", ].join("|"), n: [ "n|N|🇳|🅽|🅝|🄽|𝞜|𝝢|𝜨|𝛮|𝚴|𝚗|𝙽|𝙣|𝙉|𝘯|𝘕|𝗻|𝗡|𝗇|𝖭|𝖓|𝕹|𝕟|𝔫|𝔑|𝓷|𝓝|𝓃|𝒩|𝒏|𝑵|𝑛|𝑁|𝐧|𝐍|𐑍|𐐥|ꓵ|ꓠ|刀|几", "Ⲡ|Ⲛ|ⓝ|Ⓝ|⒩|⋂|∏|ℿ|ℕ|₦|ₙ|ⁿ|ῇ|ῆ|ῄ|ῃ|ῂ|ᾗ|ᾖ|ᾕ|ᾔ|ᾓ|ᾒ|ᾑ|ᾐ|ὴ|ἧ|ἦ|ἥ|ἤ|ἣ|ἢ|ἡ|ἠ|ṋ|Ṋ|ṉ|Ṉ|ṇ|Ṇ|ṅ|Ṅ|ᶰ|ᴺ|ᴎ|ហ", "ᘉ|ᑎ|Ꮑ|ቡ|በ|ຖ|ภ|ก|מ|ռ|ո|ղ|Ռ|Ո|ӣ|ѝ|й|и|П|Й|И|Ѝ|Ϟ|η|ή|Π|Ν|ͷ|Ͷ|ɴ|ɳ|ɲ|Ǹ|ƞ|Ɲ|ŋ|ʼn|ň|Ň|ņ|Ņ|ń|Ń|ñ|Ñ|n|N|:regional_indicator_n:", ].join("|"), o: [ "o|O|ﻬ|ﻫ|ﻪ|ﻩ|ﮭ|ﮬ|ﮫ|ﮪ|ﮩ|ﮨ|ﮧ|ﮦ|🇴|🅾|🅞|🄾|𝞼|𝞸|𝞞|𝞂|𝝾|𝝤|𝝈|𝝄|𝜪|𝜎|𝜊|𝛰|𝛔|𝛐|𝚶|𝚘|𝙾|𝙤|𝙊|𝘰|𝘖|𝗼|𝗢|𝗈|𝖮|𝖔|𝕺", "𝕠|𝕆|𝔬|𝔒|𝓸|𝓞|𝒪|𝒐|𝑶|𝑜|𝑂|𝐨|𝐎|𐓪|𐓃|𐓂|𐐬|𐐄|𐊫|𐊒|ꬽ|Ꙩ|ꓳ|㊉|ㄖ|の|〇|ⵙ|ⵔ|ⲟ|Ⲟ|⨀|✿|☉|ⓞ|Ⓞ|⒪|⍥|⊙|∅|ℴ|ₒ", "Ὼ|Ὸ|ᾯ|ᾮ|ᾭ|ᾬ|ᾫ|ᾪ|ᾩ|ᾨ|ὸ|Ὧ|Ὦ|Ὥ|Ὤ|Ὣ|Ὢ|Ὡ|Ὠ|Ὅ|Ὄ|Ὃ|Ὂ|Ὁ|Ὀ|ὅ|ὄ|ὃ|ὂ|ὁ|ὀ|ỡ|Ỡ|ở|Ở|ờ|Ờ|ớ|Ớ|ộ|Ộ|Ỗ|ổ|Ổ|ồ|Ồ|ố|Ố|ỏ|Ỏ", "ọ|Ọ|ṓ|Ṓ|ṑ|Ṑ|ṏ|Ṏ|ṍ|Ṍ|ð|ᵒ|ᴼ|ᴑ|ᴏ|ᗝ|ᓍ|Ꮎ|Ꭷ|ዐ|ჿ|၀|ဝ|໐|๐|๏|ට|൦|ഠ|೦|౦|௦|୦|ଠ|૦|੦|০|०|߀|۵|۝|ە|ہ|ھ|٥|ه|ס|օ", "Օ|Ө|ӧ|Ӧ|ѻ|о|Ф|О|ό|φ|σ|ο|θ|Ο|Θ|˚|ʘ|ǿ|Ǿ|ǒ|Ǒ|Ʊ|ơ|Ơ|ő|Ő|ŏ|Ŏ|ō|Ō|ø|ö|õ|ô|ó|ò|ð|Ø|Ö|Õ|Ô|Ó|Ò|º|°|o|O|♡|:regional_indicator_o:|0", ].join("|"), p: [ "ア|p|P|🇵|🅿|🅟|🄿|𝟈|𝞺|𝞠ϱ|𝞠|𝞎|𝞀|𝝦|𝝔|𝝆|𝜬|𝜚|𝜌|𝛲|𝛠|𝛒|𝚸|𝚙|𝙿|𝙥|𝙋|𝘱|𝘗|𝗽|𝗣|𝗉|𝖯|𝖕|𝕡|𝔭|𝓹|𝓟|𝓅|𝒫|𝒑|𝑷|𝑝|𝑃|𝐩|𝐏", "𐓄|𐊕|ꮲ|ꓑ|卩|ⲣ|Ⲣ|Ᵽ|ⓟ|Ⓟ|⒫|⍴|ℙ|℘|₱|ₚ|‽|Ῥ|ῥ|ῤ|ṗ|Ṗ|ṕ|Ṕ|ᵖ|ᴾ|ᴩ|ᴘ|ᕵ|ᑭ|Ꮲ|Ꭾ|ק|ք|բ|Ԁ|Ҏ|р|Р|ϸ|Ϸ|ϱ|ρ|Ρ|ƿ|Ƥ|þ|Þ|¶", "p|P|:regional_indicator_p:", ].join("|"), q: [ "q|Q|🇶|🆀|🅠|🅀|𝚚|𝚀|𝙦|𝙌|𝘲|𝘘|𝗾|𝗤|𝗊|𝖰|𝖖|𝕼|𝕢|𝔮|𝔔|𝓺|𝓠|𝓆|𝒬|𝒒|𝑸|𝑞|𝑄|𝐪|𝐐|𐌒|𐊭|ꟼ|Ꝗ|ゐ|ⵕ|ⓠ|Ⓠ|⒬|ℚ|ợ|ᶐ|ᕴ", "ᑫ|Ꭴ|๑|۹|ף|զ|գ|ԛ|Ҩ|ϥ|ϙ|Ϙ|Ω|ʠ|ɋ|Ɋ|ǭ|Ǭ|Ǫ|ƍ|q|Q|:regional_indicator_q:", ].join("|"), r: [ "r|R|🇷|🆁|🅡|🅁|𝞒|𝝘|𝜞|𝛤|𝚪|𝚛|𝚁|𝙧|𝙍|𝘳|𝘙|𝗿|𝗥|𝗋|𝖱|𝖗|𝕽|𝕣|𝔯|𝓻|𝓡|𝓇|𝒓|𝑹|𝑟|𝑅|𝐫|𝐑|𐒴|ꮢ|ꮁ|ꭱ|ꭈ|ꭇ|ꓣ|尺|ⲅ|Ɽ|ⓡ|Ⓡ|⒭|℞", "ℝ|ℜ|ℛ|ṟ|Ṟ|ṝ|Ṝ|ṛ|Ṛ|ṙ|Ṙ|ᵣ|ᴿ|ᴦ|ᴚ|ᴙ|ᚱ|ᖇ|Ꮢ|Ꭱ|འ|ཞ|ર|ր|Ի|я|г|Я|ʳ|ʁ|ʀ|ɿ|ɾ|ɼ|ɹ|Ɍ|Ʀ|ř|Ř|ŗ|Ŗ|ŕ|Ŕ|®|r|R|:regional_indicator_r:", ].join("|"), s: [ "s|S|$|ﮎ|🇸|🆂|🅢|🅂|𝚜|𝚂|𝙨|𝙎|𝘴|𝘚|𝘀|𝗦|𝗌|𝖲|𝖘|𝕾|𝕤|𝕊|𝔰|𝔖|𝓼|𝓢|𝓈|𝒮|𝒔|𝑺|𝑠|𝑆|𝐬|𝐒|𐑈|𐐠|𐊖|ꮪ|ꜱ|ꙅ|Ꙅ|ꓢ|꒚|丂|ⓢ|Ⓢ|⒮|∫|₴", "ₛ|ṩ|Ṩ|ṧ|Ṧ|ṥ|Ṥ|ṣ|Ṣ|ṡ|Ṡ|ᴤ|ᔕ|Ꮪ|Ꮥ|Ꭶ|ร|ى|ֆ|Տ|ѕ|Ѕ|ϩ|ˢ|ʃ|ʂ|Ș|ƽ|ƨ|Ƨ|š|Š|ş|Ş|ŝ|Ŝ|ś|Ś|§|s|\\$|S|:regional_indicator_s:|5", ].join("|"), t: [ "イ|ィ|t|T|🇹|🆃|🅣|🅃|𝞽|𝚝|𝚃|𝙩|𝙏|𝘵|𝘛|𝘁|𝗧|𝗍|𝖳|𝖙|𝕿|𝕥|𝕋|𝔱|𝔗|𝓽|𝓣|𝓉|𝒯|𝒕|𝑻|𝑡|𝑇|𝐭|𝐓|𐌕|𐊱|𐊗|ꭲ|ꓔ|꓄|丅|ㄒ|Ⲧ|Ⲅ|⟙|ⓣ|Ⓣ|⒯", "⊥|⊤|ℾ|₮|ₜ|†|ẗ|ṱ|Ṱ|ṯ|Ṯ|ṭ|Ṭ|ṫ|Ṫ|ᵗ|ᵀ|ᴛ|ᖶ|ᒥ|Ꮦ|Ꮏ|Ꮁ|Ꭲ|ኮ|ح|է|Շ|Ի|Ե|ҭ|т|Т|Г|ϯ|Ϯ|τ|π|Τ|Γ|Ͳ|ʈ|ʇ|ɬ|ȶ|ț|Ț|ǂ|Ʈ|Ƭ|ƫ|ƚ", "ŧ|ť|Ť|ţ|Ţ|t|T|:regional_indicator_t:|7", ].join("|"), // conflicts with F: Ŧ u: [ "u|U|🇺|🆄|🅤|🅄|𝞵|𝝻|𝝁|𝜇|𝛍|𝚞|𝚄|𝙪|𝙐|𝘶|𝘜|𝘂|𝗨|𝗎|𝖴|𝖚|𝖀|𝕦|𝕌|𝔲|𝔘|𝓾|𝓤|𝓊|𝒰|𝒖|𝑼|𝑢|𝑈|𝐮|𝐔|𐓶|𐓎|ꭒ|ꭎ|ꞟ|ꓴ|ㄩ|ひ|ⓤ", "Ⓤ|⒰|⋃|∪|∩|℧|ῧ|ῦ|ῢ|ῡ|ῠ|ὺ|ὗ|ὖ|ὕ|ὔ|ὓ|ὒ|ὑ|ὐ|ự|Ự|Ữ|ử|Ử|ừ|Ừ|ứ|Ứ|ủ|Ủ|ụ|Ụ|ṻ|Ṻ|ṹ|Ṹ|ṷ|Ṷ|ṵ|Ṵ|ṳ|Ṳ|ᵾ|ᵤ|ᵘ|ᵁ|ᴜ|ᘴ|ᘮ", "ᓑ|ᑘ|ᑌ|ᐡ|Ꮼ|ሆ|ሀ|ย|น|પ|և|ս|մ|Ս|Մ|Ц|ύ|ϋ|υ|μ|ΰ|ʋ|ʊ|Ʉ|Ȕ|ǜ|Ǜ|ǚ|Ǚ|ǘ|Ǘ|ǖ|Ǖ|ǔ|Ǔ|Ʊ|ư|Ư|ų|Ų|ű|Ű|ů|Ů|ŭ|Ŭ|ū|Ū|ũ|Ũ|û", "ú|ù|Ü|Û|Ú|Ù|µ|u|U|:regional_indicator_u:", ].join("|"), v: [ "v|V|🇻|🆅|🅥|🅅|𝝼|𝝂|𝜈|𝛎|𝚟|𝚅|𝙫|𝙑|𝘷|𝘝|𝘃|𝗩|𝗏|𝖵|𝖛|𝖁|𝕧|𝕍|𝔳|𝔙|𝓿|𝓥|𝓋|𝒱|𝒗|𝑽|𝑣|𝑉|𝐯|𝐕|𐓘|𐒰|𐌡|𐊍|ꮩ|ꓦ|ꓥ|ⴸ|ⴷ|ⱽ|ⓥ|Ⓥ", "⒱|⋁|∨|√|ⅴ|Ⅴ|℣|ṿ|Ṿ|ṽ|Ṽ|ᵥ|ᵛ|ᴧ|ᴠ|ᐺ|ᐱ|ᐯ|Ꮩ|Ꮙ|ง|۸|۷|٨|٧|ש|ע|ט|Ѷ|ѵ|Ѵ|Л|ν|Λ|ʌ|ʋ|Ʌ|Ɣ|v|V|:regional_indicator_v:", ].join("|"), w: [ "w|W|🇼|🆆|🅦|🅆|𝟉|𝟂|𝞏|𝞈|𝝕|𝝎|𝜛|𝜔|𝜋|𝛡|𝛚|𝛑|𝚠|𝚆|𝙬|𝙒|𝘸|𝘞|𝘄|𝗪|𝗐|𝖶|𝖜|𝖂|𝕨|𝕎|𝔴|𝔚|𝔀|𝓦|𝓌|𝒲|𝒘|𝑾|𝑤", "𝑊|𝐰|𝐖|𐓑|ꮃ|ꞷ|ꙍ|ꓪ|山|ⲱ|ⓦ|Ⓦ|⒲|⍵|ℼ|₩|ῷ|ῶ|ῴ|ῳ|ῲ|ẘ|ẉ|Ẉ|ẇ|Ẇ|ẅ|Ẅ|ẃ|Ẃ|ẁ|Ẁ|ᵂ|ᴡ|ᘺ|ᗯ|Ꮿ|Ꮤ|Ꮚ|Ꮗ|Ꮃ|ሠ|ཡ|ຟ|ฬ", "ฝ|చ|ա|ԝ|Ԝ|ѡ|Ѡ|ш|Щ|ϖ|ώ|ω|ψ|ʷ|ʍ|ɯ|ŵ|Ŵ|w|W|:regional_indicator_w:", ].join("|"), x: [ "メ|x|X|אָ|אַ|🇽|🆇|🅧|🅇|𝟀|𝞆|𝝌|𝜒|𝛘|𝚡|𝚇|𝙭|𝙓|𝘹|𝘟|𝘅|𝗫|𝗑|𝖷|𝖝|𝖃|𝕩|𝕏|𝔵|𝔛|𝔁|𝓧|𝓍|𝒳|𝒙|𝑿|𝑥|𝑋|𝐱|𝐗|𐌢|𐌗|𐊴|𐊐|ꭕ|ꭓ|Ꭓ|ꓫ|꒼", "乂|〤|ⵝ|ⲭ|Ⲭ|⨯|⤬|⤫|╳|ⓧ|Ⓧ|⒳|⌧|ⅹ|Ⅹ|ℵ|ₓ|ẍ|Ẍ|ẋ|Ẋ|ᚷ|᙮|᙭|ᕽ|ᕁ|ጀ|ჯ|א|Ӿ|Ӽ|ҳ|х|Х|Ж|χ|Χ|ˣ|ɤ|×|x|X|:regional_indicator_x:", ].join("|"), y: [ "リ|y|Y|🇾|🆈|🅨|🅈|𝞬|𝝲|𝜸|𝛾|𝛄|𝚢|𝚈|𝙮|𝙔|𝘺|𝘠|𝘆|𝗬|𝗒|𝖸|𝖞|𝖄|𝕪|𝕐|𝔶|𝔜|𝔂|𝓨|𝓎|𝒴|𝒚|𝒀|𝑦|𝑌|𝐲|𝐘|𐊲|ꭚ|ꓬ|ꐯ|ꌦ|ㄚ|Ⲩ|ⓨ|Ⓨ|⒴", "⅄|ℽ|Ὺ|Ῡ|Ῠ|Ὗ|Ὕ|Ὓ|Ὑ|ỿ|ỹ|Ỹ|ỷ|Ỷ|ỵ|Ỵ|ỳ|Ỳ|ẙ|ẏ|Ẏ|ᶌ|ᖻ|Ꮍ|Ꭹ|ყ|ฯ|ץ|վ|կ|Ӳ|Ӌ|ұ|ү|Ү|ч|у|У|Ў|ϔ|ϓ|ϒ|γ|Υ|Ύ|ˠ|ʸ|ʏ|ʎ|ɣ|Ɏ|Ƴ", "Ÿ|ŷ|Ŷ|ÿ|ý|Ý|¥|y|Y|:regional_indicator_y:", ].join("|"), z: [ "z|Z|🇿|🆉|🅩|🅉|𝚣|𝚉|𝙯|𝙕|𝘻|𝘡|𝘇|𝗭|𝗓|𝖹|𝖟|𝖅|𝕫|𝔷|𝔃|𝓩|𝓏|𝒵|𝒛|𝒁|𝑧|𝑍|𝐳|𝐙|ꮓ|ꓜ|乙|Ⱬ|☡|ⓩ|Ⓩ|⒵|ℨ|ℤ|ẕ|Ẕ|ẓ|Ẓ|ẑ|Ẑ|ᶻ|ᴢ|ᙆ", "ᘔ|Ꮓ|ፚ|ຊ|չ|ζ|Ζ|ʑ|ʐ|ɀ|ȥ|ƹ|ƶ|Ƶ|ž|Ž|ż|Ż|ź|Ź|z|Z|:regional_indicator_z:", ].join("|"), " ": "\\s+", // multiple whitespace -> space ".": ".", ",": ",|‘", "?": "?", }; const REPLACED_CHARS: Record = Array.from(Object.entries(REPLACED_CHARS_PATTERNS)).reduce( (obj, [to, from]) => { obj[to] = new RegExp(from, "gm"); return obj; }, {}, ); const NORMAL_CHARS_REGEX = /^[a-z2689:.\-_+()*&^%><;"'}{~,]+$/i; function containsOnlyNormalChars(text: string) { return NORMAL_CHARS_REGEX.test(text); } /** * Normalizes the input text to only lowercase ASCII letters and special characters */ export function normalizeText(text: string) { if (!containsOnlyNormalChars(text)) { for (const to in REPLACED_CHARS) { text = text.replace(REPLACED_CHARS[to], to); } } return stripMarks(text.toLowerCase()); } ================================================ FILE: backend/src/utils/parseColor.ts ================================================ import _parseColor from "parse-color"; // Accepts 100,100,100 and 100 100 100 const isRgb = /^(\d{1,3})\D+(\d{1,3})\D+(\d{1,3})$/; const isPartialHex = /^([0-9a-f]{3}|[0-9a-f]{6})$/i; /** * Parses a color from the input string. The following formats are accepted: * - any CSS color format (hex, rgb(), color names, etc.) * - rrr, ggg, bbb * - rrr ggg bbb * @return Parsed color as `[r, g, b]` or `null` if no color could be parsed */ export function parseColor(input: string): null | [number, number, number] { const rgbMatch = input.match(isRgb); if (rgbMatch) { const r = parseInt(rgbMatch[1], 10); const g = parseInt(rgbMatch[2], 10); const b = parseInt(rgbMatch[3], 10); if (r > 255 || g > 255 || b > 255) { return null; } return [r, g, b]; } if (input.match(isPartialHex)) { input = `#${input}`; } const cssColorMatch = _parseColor(input); if (cssColorMatch.rgb) { return cssColorMatch.rgb; } return null; } ================================================ FILE: backend/src/utils/parseCustomId.ts ================================================ import { logger } from "../logger.js"; const customIdFormat = /^([^:]+):\d+:(.*)$/; export function parseCustomId(customId: string): { namespace: string; data: any } { const parts = customId.match(customIdFormat); if (!parts) { return { namespace: "", data: null, }; } let parsedData: any; try { parsedData = JSON.parse(parts[2]); } catch (err) { logger.debug(`Error while parsing custom id data (custom id: ${customId}): ${String(err)}`); return { namespace: "", data: null, }; } return { namespace: parts[1], // Skipping timestamp data: parsedData, }; } ================================================ FILE: backend/src/utils/parseFuzzyTimezone.ts ================================================ import escapeStringRegexp from "escape-string-regexp"; import moment from "moment-timezone"; const normalizeTzName = (str) => str.replace(/[^a-zA-Z0-9+-]/g, "").toLowerCase(); const validTimezones = moment.tz.names(); const normalizedTimezoneMap = validTimezones.reduce((map, tz) => { map.set(normalizeTzName(tz), tz); return map; }, new Map()); const normalizedTimezones = Array.from(normalizedTimezoneMap.keys()); export function parseFuzzyTimezone(input: string) { const normalizedInput = normalizeTzName(input); if (normalizedTimezoneMap.has(normalizedInput)) { return normalizedTimezoneMap.get(normalizedInput); } const searchRegex = new RegExp(`.*${escapeStringRegexp(normalizedInput)}.*`); for (const tz of normalizedTimezones) { if (searchRegex.test(tz)) { const result = normalizedTimezoneMap.get(tz); // Ignore Etc/GMT timezones unless explicitly specified, as they have confusing functionality // with the inverted +/- sign if (result.startsWith("Etc/GMT")) continue; return result; } } return null; } ================================================ FILE: backend/src/utils/permissionNames.ts ================================================ import type { PermissionFlagsBits } from "discord.js"; import { EMPTY_CHAR } from "../utils.js"; export const PERMISSION_NAMES = { AddReactions: "Add Reactions", Administrator: "Administrator", AttachFiles: "Attach Files", BanMembers: "Ban Members", ChangeNickname: "Change Nickname", Connect: "Connect", CreateInstantInvite: "Create Invite", CreatePrivateThreads: "Create Private Threads", CreatePublicThreads: "Create Public Threads", DeafenMembers: "Deafen Members", EmbedLinks: "Embed Links", KickMembers: "Kick Members", ManageChannels: "Manage Channels", ManageEmojisAndStickers: "Manage Emojis and Stickers", ManageGuild: "Manage Server", ManageMessages: "Manage Messages", ManageNicknames: "Manage Nicknames", ManageRoles: "Manage Roles", ManageThreads: "Manage Threads", ManageWebhooks: "Manage Webhooks", MentionEveryone: `Mention @${EMPTY_CHAR}everyone, @${EMPTY_CHAR}here, and All Roles`, MoveMembers: "Move Members", MuteMembers: "Mute Members", PrioritySpeaker: "Priority Speaker", ReadMessageHistory: "Read Message History", RequestToSpeak: "Request to Speak", SendMessages: "Send Messages", SendMessagesInThreads: "Send Messages in Threads", SendTTSMessages: "Send Text-To-Speech Messages", Speak: "Speak", UseEmbeddedActivities: "Start Embedded Activities", Stream: "Video", UseApplicationCommands: "Use Application Commands", UseExternalEmojis: "Use External Emoji", UseExternalStickers: "Use External Stickers", UseVAD: "Use Voice Activity", ViewAuditLog: "View Audit Log", ViewChannel: "View Channels", ViewGuildInsights: "View Guild Insights", ModerateMembers: "Moderate Members", ManageEvents: "Manage Events", ManageGuildExpressions: "Manage Expressions", SendVoiceMessages: "Send Voice Messages", UseExternalSounds: "Use External Sounds", UseSoundboard: "Use Soundboard", ViewCreatorMonetizationAnalytics: "View Creator Monetization Analytics", CreateGuildExpressions: "Create Guild Expressions", CreateEvents: "Create Events", SendPolls: "Send Polls", UseExternalApps: "Use External Apps", PinMessages: "Pin Messages", } as const satisfies Record; ================================================ FILE: backend/src/utils/readChannelPermissions.ts ================================================ import { PermissionsBitField } from "discord.js"; /** * Bitmask of permissions required to read messages in a channel */ export const readChannelPermissions = PermissionsBitField.Flags.ViewChannel | PermissionsBitField.Flags.ReadMessageHistory; /** * Bitmask of permissions required to read messages in a channel (bigint) */ export const nReadChannelPermissions = BigInt(readChannelPermissions); ================================================ FILE: backend/src/utils/registerEventListenersFromMap.ts ================================================ import { EventEmitter } from "events"; export function registerEventListenersFromMap(eventEmitter: EventEmitter, map: Map) { for (const [event, listener] of map.entries()) { eventEmitter.on(event, listener); } } ================================================ FILE: backend/src/utils/resolveChannelIds.ts ================================================ import { CategoryChannel, Channel } from "discord.js"; import { isDmChannel } from "./isDmChannel.js"; import { isGuildChannel } from "./isGuildChannel.js"; import { isThreadChannel } from "./isThreadChannel.js"; type ResolvedChannelIds = { category: string | null; channel: string | null; thread: string | null; }; export function resolveChannelIds(channel: Channel): ResolvedChannelIds { if (isDmChannel(channel)) { return { category: null, channel: channel.id, thread: null, }; } if (isThreadChannel(channel)) { return { category: channel.parent?.parentId || null, channel: channel.parentId, thread: channel.id, }; } if (channel instanceof CategoryChannel) { return { category: channel.id, channel: null, thread: null, }; } if (isGuildChannel(channel)) { return { category: channel.parentId, channel: channel.id, thread: null, }; } return { category: null, channel: channel.id, thread: null, }; } ================================================ FILE: backend/src/utils/resolveMessageTarget.ts ================================================ import { GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildPluginData } from "vety"; import { getChannelIdFromMessageId } from "../data/getChannelIdFromMessageId.js"; import { isSnowflake } from "../utils.js"; const channelAndMessageIdRegex = /^(\d+)[-/](\d+)$/; const messageLinkRegex = /^https:\/\/(?:\w+\.)?discord(?:app)?\.com\/channels\/\d+\/(\d+)\/(\d+)$/i; export interface MessageTarget { channel: GuildTextBasedChannel; messageId: string; } export async function resolveMessageTarget(pluginData: GuildPluginData, value: string) { const result = await (async () => { if (isSnowflake(value)) { const channelId = await getChannelIdFromMessageId(value); if (!channelId) { return null; } return { channelId, messageId: value, }; } const channelAndMessageIdMatch = value.match(channelAndMessageIdRegex); if (channelAndMessageIdMatch) { return { channelId: channelAndMessageIdMatch[1], messageId: channelAndMessageIdMatch[2], }; } const messageLinkMatch = value.match(messageLinkRegex); if (messageLinkMatch) { return { channelId: messageLinkMatch[1], messageId: messageLinkMatch[2], }; } })(); if (!result) { return null; } const channel = pluginData.guild.channels.resolve(result.channelId as Snowflake); if (!channel?.isTextBased()) { return null; } return { channel, messageId: result.messageId, }; } ================================================ FILE: backend/src/utils/rgbToInt.ts ================================================ export function rgbToInt(rgb: [number, number, number]) { return (rgb[0] << 16) + (rgb[1] << 8) + rgb[2]; } ================================================ FILE: backend/src/utils/sendDM.ts ================================================ import { User } from "discord.js"; import { logger } from "../logger.js"; import { HOURS, createChunkedMessage, isDiscordAPIError } from "../utils.js"; import { MessageContent } from "../utils.js"; import Timeout = NodeJS.Timeout; let dmsDisabled = false; let dmsDisabledTimeout: Timeout; function disableDMs(duration) { dmsDisabled = true; clearTimeout(dmsDisabledTimeout); dmsDisabledTimeout = setTimeout(() => (dmsDisabled = false), duration); } export class DMError extends Error {} const error20026 = "The bot cannot currently send DMs"; export async function sendDM( user: User, content: MessageContent, source: string, ) { if (dmsDisabled) { throw new DMError(error20026); } logger.debug(`Sending ${source} DM to ${user.id}`); try { if (typeof content === "string") { await createChunkedMessage(user, content); } else { await user.send(content); } } catch (e) { if (isDiscordAPIError(e) && e.code === 20026) { logger.warn(`Received error code 20026: ${e.message}`); logger.warn("Disabling attempts to send DMs for 1 hour"); disableDMs(1 * HOURS); throw new DMError(error20026); } throw e; } } ================================================ FILE: backend/src/utils/snowflakeToTimestamp.ts ================================================ import { isValidSnowflake } from "../utils.js"; /** * @return Unix timestamp in milliseconds */ export function snowflakeToTimestamp(snowflake: string) { if (!isValidSnowflake(snowflake)) { throw new Error(`Invalid snowflake: ${snowflake}`); } // https://discord.com/developers/docs/reference#snowflakes-snowflake-id-format-structure-left-to-right return Number(BigInt(snowflake) >> 22n) + 1_420_070_400_000; } ================================================ FILE: backend/src/utils/stripMarkdown.ts ================================================ export function stripMarkdown(str) { return str.replace(/[*_|~`]/g, ""); } ================================================ FILE: backend/src/utils/templateSafeObjects.ts ================================================ import { Emoji, Guild, GuildBasedChannel, GuildMember, Message, PartialGuildMember, PartialUser, Role, Snowflake, StageInstance, Sticker, StickerFormatType, User, } from "discord.js"; import { GuildPluginData } from "vety"; import { Case } from "../data/entities/Case.js"; import { ISavedMessageAttachmentData, ISavedMessageData, ISavedMessageEmbedData, ISavedMessageStickerData, SavedMessage, } from "../data/entities/SavedMessage.js"; import { TemplateSafeValueContainer, TypedTemplateSafeValueContainer, ingestDataIntoTemplateSafeValueContainer, } from "../templateFormatter.js"; import { UnknownUser, renderUsername } from "../utils.js"; type InputProps = Omit< { [K in keyof T]: T[K]; }, "_isTemplateSafeValueContainer" >; export class TemplateSafeGuild extends TemplateSafeValueContainer { id: Snowflake; name: string; constructor(data: InputProps) { super(); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeUser extends TemplateSafeValueContainer { id: Snowflake | string; username: string; discriminator: string; globalName?: string; mention: string; tag: string; avatarURL: string; bot?: boolean; createdAt?: number; renderedUsername: string; constructor(data: InputProps) { super(); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeUnknownUser extends TemplateSafeValueContainer { id: Snowflake; username: string; discriminator: string; constructor(data: InputProps) { super(); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeRole extends TemplateSafeValueContainer { id: Snowflake; name: string; createdAt: number; hexColor: string; hoist: boolean; constructor(data: InputProps) { super(); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeMember extends TemplateSafeUser { user: TemplateSafeUser; nick: string; roles: TemplateSafeRole[]; joinedAt?: number; guildAvatarURL: string; guildName: string; constructor(data: InputProps) { super({}); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeUnknownMember extends TemplateSafeUnknownUser { user: TemplateSafeUnknownUser; constructor(data: InputProps) { super({}); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeChannel extends TemplateSafeValueContainer { id: Snowflake; name: string; mention: string; parentId?: Snowflake; constructor(data: InputProps) { super(); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeStage extends TemplateSafeValueContainer { channelId: Snowflake; channelMention: string; createdAt: number; discoverable: boolean; topic: string; constructor(data: InputProps) { super(); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeEmoji extends TemplateSafeValueContainer { id: Snowflake; name: string; createdAt?: number; animated: boolean; identifier: string; mention: string; constructor(data: InputProps) { super(); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeSticker extends TemplateSafeValueContainer { id: Snowflake; guildId?: Snowflake; packId?: Snowflake; name: string; description: string; tags: string; format: string; animated: boolean; url: string; constructor(data: InputProps) { super(); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeSavedMessage extends TemplateSafeValueContainer { id: string; guild_id: string; channel_id: string; user_id: string; is_bot: boolean; data: TemplateSafeSavedMessageData; constructor(data: InputProps) { super(); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeSavedMessageData extends TemplateSafeValueContainer { attachments?: Array>; author: TypedTemplateSafeValueContainer<{ username: string; discriminator: string; }>; content: string; embeds?: Array>; stickers?: Array>; timestamp: number; reference?: TypedTemplateSafeValueContainer; constructor(data: InputProps) { super(); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeCase extends TemplateSafeValueContainer { id: number; guild_id: string; case_number: number; user_id: string; user_name: string; mod_id: string | null; mod_name: string | null; type: number; audit_log_id: string | null; created_at: string; is_hidden: boolean; pp_id: string | null; pp_name: string | null; log_message_id: string | null; constructor(data: InputProps) { super(); ingestDataIntoTemplateSafeValueContainer(this, data); } } export class TemplateSafeMessage extends TemplateSafeValueContainer { id: string; content: string; author: TemplateSafeUser; channel: TemplateSafeChannel; constructor(data: InputProps) { super(); ingestDataIntoTemplateSafeValueContainer(this, data); } } // =================== // CONVERTER FUNCTIONS // =================== export function guildToTemplateSafeGuild(guild: Guild): TemplateSafeGuild { return new TemplateSafeGuild({ id: guild.id, name: guild.name, }); } export function userToTemplateSafeUser(user: User | UnknownUser | PartialUser): TemplateSafeUser { if (user instanceof User) { return new TemplateSafeUser({ id: user.id, username: user.username, discriminator: user.discriminator, globalName: user.globalName, mention: `<@${user.id}>`, tag: user.tag, avatarURL: user.displayAvatarURL?.() || "", bot: user.bot, createdAt: user.createdTimestamp, renderedUsername: renderUsername(user), }); } return new TemplateSafeUser({ id: user.id, username: user.username || "Unknown", discriminator: user.discriminator || "0000", mention: `<@${user.id}>`, tag: user.tag || "Unknown#0000", renderedUsername: user.tag || "Unknown", }); } export function roleToTemplateSafeRole(role: Role): TemplateSafeRole { return new TemplateSafeRole({ id: role.id, name: role.name, createdAt: role.createdTimestamp, hexColor: role.hexColor, hoist: role.hoist, }); } export function memberToTemplateSafeMember(member: GuildMember | PartialGuildMember): TemplateSafeMember { const templateSafeUser = userToTemplateSafeUser(member.user!); return new TemplateSafeMember({ ...templateSafeUser, user: templateSafeUser, nick: member.nickname ?? "*None*", roles: [...member.roles.cache.mapValues((r) => roleToTemplateSafeRole(r)).values()], joinedAt: member.joinedTimestamp ?? undefined, guildAvatarURL: member.displayAvatarURL(), guildName: member.guild.name, }); } export function channelToTemplateSafeChannel(channel: GuildBasedChannel): TemplateSafeChannel { return new TemplateSafeChannel({ id: channel.id, name: channel.name, mention: `<#${channel.id}>`, parentId: channel.parentId ?? undefined, }); } export function stageToTemplateSafeStage(stage: StageInstance): TemplateSafeStage { return new TemplateSafeStage({ channelId: stage.channelId, channelMention: `<#${stage.channelId}>`, createdAt: stage.createdTimestamp, discoverable: !stage.discoverableDisabled, topic: stage.topic, }); } export function emojiToTemplateSafeEmoji(emoji: Emoji): TemplateSafeEmoji { return new TemplateSafeEmoji({ id: emoji.id!, name: emoji.name!, createdAt: emoji.createdTimestamp ?? undefined, animated: emoji.animated ?? false, identifier: emoji.identifier, mention: emoji.animated ? `` : `<:${emoji.name}:${emoji.id}>`, }); } export function stickerToTemplateSafeSticker(sticker: Sticker): TemplateSafeSticker { return new TemplateSafeSticker({ id: sticker.id, guildId: sticker.guildId ?? undefined, packId: sticker.packId ?? undefined, name: sticker.name, description: sticker.description ?? "", tags: sticker.tags ?? "", format: sticker.format, animated: sticker.format === StickerFormatType.PNG ? false : true, url: sticker.url, }); } export function savedMessageToTemplateSafeSavedMessage(savedMessage: SavedMessage): TemplateSafeSavedMessage { return new TemplateSafeSavedMessage({ id: savedMessage.id, channel_id: savedMessage.channel_id, guild_id: savedMessage.guild_id, is_bot: savedMessage.is_bot, user_id: savedMessage.user_id, data: new TemplateSafeSavedMessageData({ attachments: (savedMessage.data.attachments ?? []).map( (att) => new TemplateSafeValueContainer({ id: att.id, contentType: att.contentType, name: att.name, proxyURL: att.proxyURL, size: att.size, spoiler: att.spoiler, url: att.url, width: att.width, }) as TypedTemplateSafeValueContainer, ), author: new TemplateSafeValueContainer({ username: savedMessage.data.author.username, discriminator: savedMessage.data.author.discriminator, }) as TypedTemplateSafeValueContainer, content: savedMessage.data.content, embeds: (savedMessage.data.embeds ?? []).map( (embed) => new TemplateSafeValueContainer({ title: embed.title, description: embed.description, url: embed.url, timestamp: embed.timestamp, color: embed.color, fields: (embed.fields ?? []).map( (field) => new TemplateSafeValueContainer({ name: field.name, value: field.value, inline: field.inline, }), ), author: embed.author ? new TemplateSafeValueContainer({ name: embed.author?.name, url: embed.author?.url, iconURL: embed.author?.iconURL, proxyIconURL: embed.author?.proxyIconURL, }) : undefined, thumbnail: embed.thumbnail ? new TemplateSafeValueContainer({ url: embed.thumbnail?.url, proxyURL: embed.thumbnail?.url, height: embed.thumbnail?.height, width: embed.thumbnail?.width, }) : undefined, image: embed.image ? new TemplateSafeValueContainer({ url: embed.image?.url, proxyURL: embed.image?.url, height: embed.image?.height, width: embed.image?.width, }) : undefined, video: embed.video ? new TemplateSafeValueContainer({ url: embed.video?.url, proxyURL: embed.video?.url, height: embed.video?.height, width: embed.video?.width, }) : undefined, footer: embed.footer ? new TemplateSafeValueContainer({ text: embed.footer.text, iconURL: embed.footer.iconURL, proxyIconURL: embed.footer.proxyIconURL, }) : undefined, }) as TypedTemplateSafeValueContainer, ), stickers: (savedMessage.data.stickers ?? []).map( (sticker) => new TemplateSafeValueContainer({ format: sticker.format, guildId: sticker.guildId, id: sticker.id, name: sticker.name, description: sticker.description, available: sticker.available, type: sticker.type, }) as TypedTemplateSafeValueContainer, ), timestamp: savedMessage.data.timestamp, reference: savedMessage.data.reference ? (new TemplateSafeValueContainer({ messageId: savedMessage.data.reference.messageId ?? null, channelId: savedMessage.data.reference.channelId ?? null, guildId: savedMessage.data.reference.guildId ?? null, }) as TypedTemplateSafeValueContainer) : undefined, }), }); } export function caseToTemplateSafeCase(theCase: Case): TemplateSafeCase { return new TemplateSafeCase({ id: theCase.id, guild_id: theCase.guild_id, case_number: theCase.case_number, user_id: theCase.user_id, user_name: theCase.user_name, mod_id: theCase.mod_id, mod_name: theCase.mod_name, type: theCase.type, audit_log_id: theCase.audit_log_id, created_at: theCase.created_at, is_hidden: theCase.is_hidden, pp_id: theCase.pp_id, pp_name: theCase.pp_name, log_message_id: theCase.log_message_id, }); } export function messageToTemplateSafeMessage(message: Message): TemplateSafeMessage { return new TemplateSafeMessage({ id: message.id, content: message.content, author: userToTemplateSafeUser(message.author), channel: channelToTemplateSafeChannel(message.channel as GuildBasedChannel), }); } export function getTemplateSafeMemberLevel(pluginData: GuildPluginData, member: TemplateSafeMember): number { if (member.id === pluginData.guild.ownerId) { return 99999; } const levels = pluginData.fullConfig.levels ?? {}; for (const [id, level] of Object.entries(levels)) { if (member.id === id || member.roles?.find((r) => r.id === id)) { return level as number; } } return 0; } ================================================ FILE: backend/src/utils/typeUtils.ts ================================================ // From https://stackoverflow.com/a/56370310/316944 export type Tail = ((...t: T) => void) extends (h: any, ...r: infer R) => void ? R : never; export declare type WithRequiredProps = T & { // https://mariusschulz.com/blog/mapped-type-modifiers-in-typescript#removing-the-mapped-type-modifier [PK in K]-?: Exclude; }; // https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/ export type Awaited = T extends PromiseLike ? Awaited : T; export type Awaitable = T | Promise; export type DeepMutable = { -readonly [P in keyof T]: DeepMutable; }; // From https://stackoverflow.com/a/70262876/316944 export declare abstract class As { private static readonly $as$: unique symbol; private [As.$as$]: Record; } export type Brand = T & As; ================================================ FILE: backend/src/utils/unregisterEventListenersFromMap.ts ================================================ import { EventEmitter } from "events"; export function unregisterEventListenersFromMap(eventEmitter: EventEmitter, map: Map) { for (const [event, listener] of map.entries()) { eventEmitter.off(event, listener); } } ================================================ FILE: backend/src/utils/validateNoObjectAliases.test.ts ================================================ import test from "ava"; import { ObjectAliasError, validateNoObjectAliases } from "./validateNoObjectAliases.js"; test("validateNoObjectAliases() disallows object aliases at top level", (t) => { const obj: any = { objectRef: { foo: "bar", }, }; obj.otherProp = obj.objectRef; t.throws(() => validateNoObjectAliases(obj), { instanceOf: ObjectAliasError }); }); test("validateNoObjectAliases() disallows aliases to nested objects", (t) => { const obj: any = { nested: { objectRef: { foo: "bar", }, }, }; obj.otherProp = obj.nested.objectRef; t.throws(() => validateNoObjectAliases(obj), { instanceOf: ObjectAliasError }); }); test("validateNoObjectAliases() disallows nested object aliases", (t) => { const obj: any = { nested: { objectRef: { foo: "bar", }, }, }; obj.otherProp = { alsoNested: { ref: obj.nested.objectRef, }, }; t.throws(() => validateNoObjectAliases(obj), { instanceOf: ObjectAliasError }); }); ================================================ FILE: backend/src/utils/validateNoObjectAliases.ts ================================================ const scalarTypes = ["string", "number", "boolean", "bigint"]; export class ObjectAliasError extends Error {} /** * Removes object aliases/anchors from a loaded YAML object */ export function validateNoObjectAliases(obj: T, seen?: WeakSet): void { if (!seen) { seen = new WeakSet(); } for (const [, value] of Object.entries(obj)) { if (value == null || scalarTypes.includes(typeof value)) { continue; } if (seen.has(value)) { throw new ObjectAliasError("Object aliases are not allowed"); } validateNoObjectAliases(value, seen); seen.add(value); } } ================================================ FILE: backend/src/utils/waitForInteraction.ts ================================================ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageActionRowComponentBuilder, MessageComponentInteraction, MessageCreateOptions, } from "discord.js"; import moment from "moment-timezone"; import { v4 as uuidv4 } from "uuid"; import { GenericCommandSource, isContextInteraction, sendContextResponse } from "../pluginUtils.js"; import { noop, createDisabledButtonRow } from "../utils.js"; export async function waitForButtonConfirm( context: GenericCommandSource, toPost: Omit, options?: WaitForOptions, ): Promise { return new Promise(async (resolve) => { const contextIsInteraction = isContextInteraction(context); const idMod = `${context.id}-${moment.utc().valueOf()}`; const row = new ActionRowBuilder().addComponents([ new ButtonBuilder() .setStyle(ButtonStyle.Success) .setLabel(options?.confirmText || "Confirm") .setCustomId(`confirmButton:${idMod}:${uuidv4()}`), new ButtonBuilder() .setStyle(ButtonStyle.Danger) .setLabel(options?.cancelText || "Cancel") .setCustomId(`cancelButton:${idMod}:${uuidv4()}`), ]); const message = await sendContextResponse(context, { ...toPost, components: [row] }, true); const collector = message.createMessageComponentCollector({ time: 10000 }); collector.on("collect", (interaction: MessageComponentInteraction) => { if (options?.restrictToId && options.restrictToId !== interaction.user.id) { interaction .reply({ content: `You are not permitted to use these buttons.`, ephemeral: true }) .catch(noop); } else if (interaction.customId.startsWith(`confirmButton:${idMod}:`)) { if (!contextIsInteraction) { message.delete().catch(noop); } else { interaction.update({ components: [createDisabledButtonRow(row)] }).catch(noop); } resolve(true); } else if (interaction.customId.startsWith(`cancelButton:${idMod}:`)) { if (!contextIsInteraction) { message.delete().catch(noop); } else { interaction.update({ components: [createDisabledButtonRow(row)] }).catch(noop); } resolve(false); } }); collector.on("end", () => { if (!contextIsInteraction) { if (message.deletable) message.delete().catch(noop); } else { message.edit({ components: [createDisabledButtonRow(row)] }).catch(noop); } resolve(false); }); }); } export interface WaitForOptions { restrictToId?: string; confirmText?: string; cancelText?: string; } ================================================ FILE: backend/src/utils/zColor.ts ================================================ import { z } from "zod"; import { parseColor } from "./parseColor.js"; import { rgbToInt } from "./rgbToInt.js"; export const zColor = z.string().transform((val, ctx) => { const parsedColor = parseColor(val); if (parsedColor == null) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid color", }); return z.NEVER; } return rgbToInt(parsedColor); }); ================================================ FILE: backend/src/utils/zValidTimezone.ts ================================================ import { ZodString } from "zod"; import { isValidTimezone } from "./isValidTimezone.js"; export function zValidTimezone(z: Z) { return z.refine((val) => isValidTimezone(val), { message: "Invalid timezone", }); } ================================================ FILE: backend/src/utils/zodDeepPartial.ts ================================================ /* Modified version of https://gist.github.com/jaens/7e15ae1984bb338c86eb5e452dee3010 Original version's license: Copyright 2024, Jaen - https://github.com/jaens Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { z } from "zod"; import { $ZodRecordKey, $ZodType } from "zod/v4/core"; const RESOLVING = Symbol("mapOnSchema/resolving"); export function mapOnSchema( schema: T, fn: (schema: $ZodType) => TResult, ): TResult; /** * Applies {@link fn} to each element of the schema recursively, replacing every schema with its return value. * The rewriting is applied bottom-up (ie. {@link fn} will get called on "children" first). */ export function mapOnSchema(schema: $ZodType, fn: (schema: $ZodType) => $ZodType): $ZodType { // Cache results to support recursive schemas const results = new Map<$ZodType, $ZodType | typeof RESOLVING>(); function mapElement(s: $ZodType) { const value = results.get(s); if (value === RESOLVING) { throw new Error("Recursive schema access detected"); } else if (value !== undefined) { return value; } results.set(s, RESOLVING); const result = mapOnSchema(s, fn); results.set(s, result); return result; } function mapInner() { if (schema instanceof z.ZodObject) { const newShape: Record = {}; for (const [key, value] of Object.entries(schema.shape)) { newShape[key] = mapElement(value); } return new z.ZodObject({ ...schema.def, shape: newShape, }); } else if (schema instanceof z.ZodArray) { return new z.ZodArray({ ...schema.def, type: "array", element: mapElement(schema.def.element), }); } else if (schema instanceof z.ZodMap) { return new z.ZodMap({ ...schema.def, keyType: mapElement(schema.def.keyType), valueType: mapElement(schema.def.valueType), }); } else if (schema instanceof z.ZodSet) { return new z.ZodSet({ ...schema.def, valueType: mapElement(schema.def.valueType), }); } else if (schema instanceof z.ZodOptional) { return new z.ZodOptional({ ...schema.def, innerType: mapElement(schema.def.innerType), }); } else if (schema instanceof z.ZodNullable) { return new z.ZodNullable({ ...schema.def, innerType: mapElement(schema.def.innerType), }); } else if (schema instanceof z.ZodDefault) { return new z.ZodDefault({ ...schema.def, innerType: mapElement(schema.def.innerType), }); } else if (schema instanceof z.ZodReadonly) { return new z.ZodReadonly({ ...schema.def, innerType: mapElement(schema.def.innerType), }); } else if (schema instanceof z.ZodLazy) { return new z.ZodLazy({ ...schema.def, // NB: This leaks `fn` into the schema, but there is no other way to support recursive schemas getter: () => mapElement(schema._def.getter()), }); } else if (schema instanceof z.ZodPromise) { return new z.ZodPromise({ ...schema.def, innerType: mapElement(schema.def.innerType), }); } else if (schema instanceof z.ZodCatch) { return new z.ZodCatch({ ...schema.def, innerType: mapElement(schema._def.innerType), }); } else if (schema instanceof z.ZodTuple) { return new z.ZodTuple({ ...schema.def, items: schema.def.items.map((item: $ZodType) => mapElement(item)), rest: schema.def.rest && mapElement(schema.def.rest), }); } else if (schema instanceof z.ZodDiscriminatedUnion) { return new z.ZodDiscriminatedUnion({ ...schema.def, options: schema.options.map((option) => mapOnSchema(option, fn)), }); } else if (schema instanceof z.ZodUnion) { return new z.ZodUnion({ ...schema.def, options: schema.options.map((option) => mapOnSchema(option, fn)), }); } else if (schema instanceof z.ZodIntersection) { return new z.ZodIntersection({ ...schema.def, right: mapElement(schema.def.right), left: mapElement(schema.def.left), }); } else if (schema instanceof z.ZodRecord) { return new z.ZodRecord({ ...schema.def, keyType: mapElement(schema.def.keyType) as $ZodRecordKey, valueType: mapElement(schema.def.valueType), }); } else { return schema; } } return fn(mapInner()); } export function deepPartial(schema: T): T { return mapOnSchema(schema, (s) => (s instanceof z.ZodObject ? s.partial() : s)) as T; } /** Make all object schemas "strict" (ie. fail on unknown keys), except if they are marked as `.passthrough()` */ export function deepStrict(schema: T): T { return mapOnSchema(schema, (s) => s instanceof z.ZodObject /* && s.def.unknownKeys !== "passthrough" */ ? s.strict() : s, ) as T; } export function deepStrictAll(schema: T): T { return mapOnSchema(schema, (s) => (s instanceof z.ZodObject ? s.strict() : s)) as T; } ================================================ FILE: backend/src/utils.test.ts ================================================ import test from "ava"; import { z } from "zod"; import { convertDelayStringToMS, convertMSToDelayString, getUrlsInString, zAllowedMentions } from "./utils.js"; import { ErisAllowedMentionFormat } from "./utils/erisAllowedMentionsToDjsMentionOptions.js"; type AssertEquals = TActual extends TExpected ? true : false; test("getUrlsInString(): detects full links", (t) => { const urls = getUrlsInString("foo https://google.com/ bar"); t.is(urls.length, 1); t.is(urls[0].hostname, "google.com"); }); test("getUrlsInString(): detects partial links", (t) => { const urls = getUrlsInString("foo google.com bar"); t.is(urls.length, 1); t.is(urls[0].hostname, "google.com"); }); test("getUrlsInString(): detects subdomains", (t) => { const urls = getUrlsInString("foo photos.google.com bar"); t.is(urls.length, 1); t.is(urls[0].hostname, "photos.google.com"); }); test("delay strings: basic support", (t) => { const delayString = "2w4d7h32m17s"; const expected = 1_582_337_000; t.is(convertDelayStringToMS(delayString), expected); }); test("delay strings: default unit (minutes)", (t) => { t.is(convertDelayStringToMS("10"), 10 * 60 * 1000); }); test("delay strings: custom default unit", (t) => { t.is(convertDelayStringToMS("10", "s"), 10 * 1000); }); test("delay strings: reverse conversion", (t) => { const ms = 1_582_337_020; const expected = "2w4d7h32m17s20x"; t.is(convertMSToDelayString(ms), expected); }); test("delay strings: reverse conversion (conservative)", (t) => { const ms = 1_209_600_000; const expected = "2w"; t.is(convertMSToDelayString(ms), expected); }); test("tAllowedMentions matches Eris's AllowedMentions", (t) => { type TAllowedMentions = z.infer; // eslint-disable-next-line @typescript-eslint/no-unused-vars const typeTest: AssertEquals = true; t.pass(); }); ================================================ FILE: backend/src/utils.ts ================================================ import { ActionRowBuilder, APIEmbed, ButtonBuilder, ChannelType, Client, DiscordAPIError, DiscordjsTypeError, EmbedData, EmbedType, Emoji, escapeCodeBlock, Guild, GuildBasedChannel, GuildChannel, GuildMember, GuildTextBasedChannel, Invite, InviteGuild, InviteType, LimitedCollection, Message, MessageActionRowComponentBuilder, MessageCreateOptions, MessageMentionOptions, PartialGroupDMChannel, PartialMessage, RoleResolvable, SendableChannels, Sticker, User, } from "discord.js"; import emojiRegex from "emoji-regex"; import fs from "fs"; import https from "https"; import { isEqual } from "lodash-es"; import { performance } from "perf_hooks"; import tlds from "tlds" with { type: "json" }; import tmp from "tmp"; import { URL } from "url"; import { z, ZodError, ZodPipe, ZodRecord, ZodString, ZodTransform } from "zod"; import { ISavedMessageAttachmentData, SavedMessage } from "./data/entities/SavedMessage.js"; import { delayStringMultipliers, humanizeDuration } from "./humanizeDuration.js"; import { getProfiler } from "./profiler.js"; import { SimpleCache } from "./SimpleCache.js"; import { sendDM } from "./utils/sendDM.js"; import { Brand } from "./utils/typeUtils.js"; import { waitForButtonConfirm } from "./utils/waitForInteraction.js"; import { GenericCommandSource } from "./pluginUtils.js"; import { getOrFetchUser } from "./utils/getOrFetchUser.js"; import { incrementDebugCounter } from "./debugCounters.js"; const fsp = fs.promises; export const MS = 1; export const SECONDS = 1000 * MS; export const MINUTES = 60 * SECONDS; export const HOURS = 60 * MINUTES; export const DAYS = 24 * HOURS; export const WEEKS = 7 * DAYS; export const YEARS = (365 + 1 / 4 - 1 / 100 + 1 / 400) * DAYS; export const MONTHS = YEARS / 12; export const EMPTY_CHAR = "\u200b"; // https://discord.com/developers/docs/reference#snowflakes export const MIN_SNOWFLAKE = 0b000000000000000000000000000000000000000000_00001_00001_000000000001; // 0b111111111111111111111111111111111111111111_11111_11111_111111111111 without _ which BigInt doesn't support export const MAX_SNOWFLAKE = BigInt("0b1111111111111111111111111111111111111111111111111111111111111111"); const snowflakePattern = /^[1-9]\d+$/; export function isValidSnowflake(str: string) { if (!str.match(snowflakePattern)) return false; if (parseInt(str, 10) < MIN_SNOWFLAKE) return false; if (BigInt(str) > MAX_SNOWFLAKE) return false; return true; } export const DISCORD_HTTP_ERROR_NAME = "DiscordHTTPError"; export const DISCORD_REST_ERROR_NAME = "DiscordAPIError"; export function isDiscordHTTPError(err: Error | string) { return typeof err === "object" && err.constructor?.name === DISCORD_HTTP_ERROR_NAME; } export function isDiscordAPIError(err: Error | string): err is DiscordAPIError { return err instanceof DiscordAPIError; } export function isDiscordJsTypeError(err: unknown): err is DiscordjsTypeError { return err instanceof DiscordjsTypeError; } // null | undefined -> undefined export function zNullishToUndefined( type: T, ): ZodPipe> | undefined>> { return type.transform((v) => v ?? undefined); } export function getScalarDifference( base: T, object: T, ignoreKeys: string[] = [], ): Map { base = stripObjectToScalars(base) as T; object = stripObjectToScalars(object) as T; const diff = new Map(); for (const [key, value] of Object.entries(object)) { if (!isEqual(value, base[key]) && !ignoreKeys.includes(key)) { diff.set(key, { was: base[key], is: value }); } } return diff; } // This is a stupid, messy solution that is not extendable at all. // If anyone plans on adding anything to this, they should rewrite this first. // I just want to get this done and this works for now :) export function prettyDifference(diff: Map): Map { const toReturn = new Map(); for (let [key, difference] of diff) { if (key === "rateLimitPerUser") { difference.is = humanizeDuration(difference.is * 1000); difference.was = humanizeDuration(difference.was * 1000); key = "slowmode"; } toReturn.set(key, { was: difference.was, is: difference.is }); } return toReturn; } export function differenceToString(diff: Map): string { let toReturn = ""; diff = prettyDifference(diff); for (const [key, difference] of diff) { toReturn += `**${key[0].toUpperCase() + key.slice(1)}**: \`${difference.was}\` ➜ \`${difference.is}\`\n`; } return toReturn; } // https://stackoverflow.com/a/49262929/316944 export type Not = T & Exclude; export function nonNullish(v: V): v is NonNullable { return v != null; } export type GuildInvite = Invite & { guild: InviteGuild | Guild }; export type GroupDMInvite = Invite & { channel: PartialGroupDMChannel; }; export function zBoundedCharacters(min: number, max: number) { return z.string().refine( (str) => { const len = [...str].length; // Unicode aware character split return len >= min && len <= max; }, { message: `String must be between ${min} and ${max} characters long`, }, ); } export const zSnowflake = z.string().refine((str) => isSnowflake(str), { message: "Invalid snowflake ID", }); const regexWithFlags = /^\/(.*?)\/([i]*)$/; export class InvalidRegexError extends Error {} /** * This function supports two input syntaxes for regexes: // and just */ export function inputPatternToRegExp(pattern: string) { const advancedSyntaxMatch = pattern.match(regexWithFlags); const [finalPattern, flags] = advancedSyntaxMatch ? [advancedSyntaxMatch[1], advancedSyntaxMatch[2]] : [pattern, ""]; try { return new RegExp(finalPattern, flags); } catch (e) { throw new InvalidRegexError(e.message); } } export function zRegex(zStr: T) { return zStr.refine((str) => { try { inputPatternToRegExp(str); return true; } catch (err) { if (err instanceof InvalidRegexError) { return false; } throw err; } }); } export const zEmbedInput = z .strictObject({ title: z.string().optional(), description: z.string().optional(), url: z.string().optional(), timestamp: z.string().optional(), color: z.number().optional(), footer: z.optional( z.object({ text: z.string(), icon_url: z.string().optional(), }), ), image: z.optional( z.object({ url: z.string().optional(), width: z.number().optional(), height: z.number().optional(), }), ), thumbnail: z.optional( z.object({ url: z.string().optional(), width: z.number().optional(), height: z.number().optional(), }), ), video: z.optional( z.object({ url: z.string().optional(), width: z.number().optional(), height: z.number().optional(), }), ), provider: z.optional( z.object({ name: z.string(), url: z.string().optional(), }), ), fields: z.optional( z.array( z.object({ name: z.string().optional(), value: z.string().optional(), inline: z.boolean().optional(), }), ), ), author: z .optional( z.object({ name: z.string(), url: z.string().optional(), width: z.number().optional(), height: z.number().optional(), }), ) .nullable(), }) .meta({ id: "embedInput", }); export type EmbedWith = APIEmbed & Pick, T>; export const zStrictMessageContent = z .strictObject({ content: z.string().optional(), tts: z.boolean().optional(), embeds: z.union([z.array(zEmbedInput), zEmbedInput]).optional(), embed: zEmbedInput.optional(), }) .transform((data) => { if (data.embed) { data.embeds = [data.embed]; delete data.embed; } if (data.embeds && !Array.isArray(data.embeds)) { data.embeds = [data.embeds]; } return data as StrictMessageContent; }) .meta({ id: "strictMessageContent", }); export type ZStrictMessageContent = z.infer; export type StrictMessageContent = { content?: string; tts?: boolean; embeds?: APIEmbed[]; }; export type MessageContent = string | StrictMessageContent; export const zMessageContent = z.union([zBoundedCharacters(0, 4000), zStrictMessageContent]); export function validateAndParseMessageContent(input: unknown): StrictMessageContent { if (input == null) { return {}; } if (typeof input !== "object") { return { content: String(input) }; } // Migrate embed -> embeds if ((input as any).embed) { (input as any).embeds = [(input as any).embed]; delete (input as any).embed; } dropNullValuesRecursively(input); try { return zStrictMessageContent.parse(input) as unknown as StrictMessageContent; } catch (err) { if (err instanceof ZodError) { // TODO: Allow error to be thrown and handle at use location return {}; } throw err; } } function dropNullValuesRecursively(obj: any) { if (obj == null) { return; } if (Array.isArray(obj)) { for (const item of obj) { dropNullValuesRecursively(item); } } if (typeof obj !== "object") { return; } for (const [key, value] of Object.entries(obj)) { if (value == null) { delete obj[key]; continue; } dropNullValuesRecursively(value); } } /** * Mirrors AllowedMentions from Eris */ export const zAllowedMentions = z.strictObject({ everyone: zNullishToUndefined(z.boolean().nullable().optional()), users: zNullishToUndefined( z .union([z.boolean(), z.array(z.string())]) .nullable() .optional(), ), roles: zNullishToUndefined( z .union([z.boolean(), z.array(z.string())]) .nullable() .optional(), ), replied_user: zNullishToUndefined(z.boolean().nullable().optional()), }); export function dropPropertiesByName(obj, propName) { if (Object.hasOwn(obj, propName)) { delete obj[propName]; } for (const value of Object.values(obj)) { if (typeof value === "object" && value !== null && !Array.isArray(value)) { dropPropertiesByName(value, propName); } } } export function zBoundedRecord>( record: TRecord, minKeys: number, maxKeys: number, ): TRecord { return record.refine( (data) => { const len = Object.keys(data).length; return len >= minKeys && len <= maxKeys; }, { message: `Object must have ${minKeys}-${maxKeys} keys`, }, ); } export const zDelayString = z .string() .max(32) .refine((str) => convertDelayStringToMS(str) !== null, { message: "Invalid delay string", }); // To avoid running into issues with the JS max date vaLue, we cap maximum delay strings *far* below that. // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#The_ECMAScript_epoch_and_timestamps const MAX_DELAY_STRING_AMOUNT = 100 * 365 * DAYS; /** * Turns a "delay string" such as "1h30m" to milliseconds */ export function convertDelayStringToMS(str, defaultUnit = "m"): number | null { const regex = /^([0-9]+)\s*((?:mo?)|[ywdhs])?[a-z]*\s*/; let match; let ms = 0; str = str.trim(); // tslint:disable-next-line while (str !== "" && (match = str.match(regex)) !== null) { ms += match[1] * ((match[2] && delayStringMultipliers[match[2]]) || delayStringMultipliers[defaultUnit]); str = str.slice(match[0].length); } // Invalid delay string if (str !== "") { return null; } if (ms > MAX_DELAY_STRING_AMOUNT) { return null; } return ms; } export function convertMSToDelayString(ms: number): string { let result = ""; let remaining = ms; for (const [abbr, multiplier] of Object.entries(delayStringMultipliers)) { if (multiplier <= remaining) { const amount = Math.floor(remaining / multiplier); result += `${amount}${abbr}`; remaining -= amount * multiplier; } if (remaining === 0) break; } return result; } export function successMessage(str: string, emoji = "<:zep_check:906897402101891093>") { return emoji ? `${emoji} ${str}` : str; } export function errorMessage(str, emoji = "⚠") { return emoji ? `${emoji} ${str}` : str; } export function get(obj, path, def?): any { let cursor = obj; const pathParts = path .split(".") .map((s) => s.trim()) .filter((s) => s !== ""); for (const part of pathParts) { // hasOwn check here is necessary to prevent prototype traversal in tags if (!Object.hasOwn(cursor, part)) return def; cursor = cursor[part]; if (cursor === undefined) return def; if (cursor == null) return null; } return cursor; } export function has(obj, path): boolean { return get(obj, path) !== undefined; } export function stripObjectToScalars(obj, includedNested: string[] = []) { const result = Array.isArray(obj) ? [] : {}; for (const key in obj) { if ( obj[key] == null || typeof obj[key] === "string" || typeof obj[key] === "number" || typeof obj[key] === "boolean" ) { result[key] = obj[key]; } else if (typeof obj[key] === "object") { const prefix = `${key}.`; const nestedNested = includedNested .filter((p) => p === key || p.startsWith(prefix)) .map((p) => (p === key ? p : p.slice(prefix.length))); if (nestedNested.length) { result[key] = stripObjectToScalars(obj[key], nestedNested); } } } return result; } export const snowflakeRegex = /[1-9][0-9]{5,19}/; export type Snowflake = Brand; const isSnowflakeRegex = new RegExp(`^${snowflakeRegex.source}$`); export function isSnowflake(v: unknown): v is Snowflake { return typeof v === "string" && isSnowflakeRegex.test(v); } export function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } const realLinkRegex = /https?:\/\/\S+/; // http://anything or https://anything const plainLinkRegex = /((?!https?:\/\/)\S)+\.\S+/; // anything.anything, without http:// or https:// preceding it // Both of the above, with precedence on the first one const urlRegex = new RegExp(`(${realLinkRegex.source}|${plainLinkRegex.source})`, "g"); const protocolRegex = /^[a-z]+:\/\//; interface MatchedURL extends URL { input: string; } export function getUrlsInString(str: string, onlyUnique = false): MatchedURL[] { let matches = [...(str.match(urlRegex) ?? [])]; if (onlyUnique) { matches = unique(matches); } return matches.reduce((urls, match) => { const withProtocol = protocolRegex.test(match) ? match : `https://${match}`; let matchUrl: MatchedURL; try { matchUrl = new URL(withProtocol) as MatchedURL; matchUrl.input = match; } catch { return urls; } let hostname = matchUrl.hostname.toLowerCase(); if (hostname.length > 3) { hostname = hostname.replace(/[^a-z]+$/, ""); } const hostnameParts = hostname.split("."); const tld = hostnameParts[hostnameParts.length - 1]; if (tlds.includes(tld)) { urls.push(matchUrl); } return urls; }, []); } export function parseInviteCodeInput(str: string): string { const parsedInviteCodes = getInviteCodesInString(str); if (parsedInviteCodes.length) { return parsedInviteCodes[0]; } return str; } export function isNotNull(value: T): value is Exclude { return value != null; } // discord.com/invite/ // discordapp.com/invite/ // discord.gg/invite/ // discord.gg/ // discord.com/friend-invite/ const quickInviteDetection = /discord(?:app)?\.com\/(?:friend-)?invite\/([a-z0-9-]+)|discord\.gg\/(?:\S+\/)?([a-z0-9-]+)/gi; const isInviteHostRegex = /(?:^|\.)(?:discord.gg|discord.com|discordapp.com)$/i; const longInvitePathRegex = /^\/(?:friend-)?invite\/([a-z0-9-]+)$/i; export function getInviteCodesInString(str: string): string[] { const inviteCodes: string[] = []; // Clean up markdown str = str.replace(/[|*_~]/g, ""); // Quick detection const quickDetectionMatch = str.matchAll(quickInviteDetection); if (quickDetectionMatch) { inviteCodes.push(...[...quickDetectionMatch].map((m) => m[1] || m[2])); } // Deep detection via URL parsing const linksInString = getUrlsInString(str, true); const potentialInviteLinks = linksInString.filter((url) => isInviteHostRegex.test(url.hostname)); const withNormalizedPaths = potentialInviteLinks.map((url) => { url.pathname = url.pathname.replace(/\/{2,}/g, "/").replace(/\/+$/g, ""); return url; }); const codesFromInviteLinks = withNormalizedPaths .map((url) => { // discord.gg/[anything/] if (url.hostname === "discord.gg") { const parts = url.pathname.split("/").filter(Boolean); return parts[parts.length - 1]; } // discord.com/invite/[/anything] // discordapp.com/invite/[/anything] // discord.com/friend-invite/[/anything] // discordapp.com/friend-invite/[/anything] const longInviteMatch = url.pathname.match(longInvitePathRegex); if (longInviteMatch) { return longInviteMatch[1]; } return null; }) .filter(Boolean) as string[]; inviteCodes.push(...codesFromInviteLinks); return unique(inviteCodes); } export const unicodeEmojiRegex = emojiRegex(); export const customEmojiRegex = //; const matchAllEmojiRegex = new RegExp(`(${unicodeEmojiRegex.source})|(${customEmojiRegex.source})`, "g"); export function getEmojiInString(str: string): string[] { return str.match(matchAllEmojiRegex) || []; } export function isEmoji(str: string): boolean { return str.match(`^(${unicodeEmojiRegex.source})|(${customEmojiRegex.source})$`) !== null; } export function isUnicodeEmoji(str: string): boolean { return str.match(`^${unicodeEmojiRegex.source}$`) !== null; } export function trimLines(str: string) { return str .trim() .split("\n") .map((l) => l.trim()) .join("\n") .trim(); } export function trimEmptyLines(str: string) { return str .split("\n") .filter((l) => l.trim() !== "") .join("\n"); } export function asSingleLine(str: string) { return trimLines(str).replace(/\n/g, " "); } export function trimEmptyStartEndLines(str: string) { const lines = str.split("\n"); let emptyLinesAtStart = 0; let emptyLinesAtEnd = 0; for (const line of lines) { if (line.match(/^\s*$/)) { emptyLinesAtStart++; } else { break; } } for (let i = lines.length - 1; i > 0; i--) { if (lines[i].match(/^\s*$/)) { emptyLinesAtEnd++; } else { break; } } return lines.slice(emptyLinesAtStart, emptyLinesAtEnd ? -1 * emptyLinesAtEnd : undefined).join("\n"); } export function trimIndents(str: string, indentLength: number) { const regex = new RegExp(`^\\s{0,${indentLength}}`, "g"); return str .split("\n") .map((line) => line.replace(regex, "")) .join("\n"); } export function indentLine(str: string, indentLength: number) { return " ".repeat(indentLength) + str; } export function indentLines(str: string, indentLength: number) { return str .split("\n") .map((line) => indentLine(line, indentLength)) .join("\n"); } export const emptyEmbedValue = "\u200b"; export const preEmbedPadding = emptyEmbedValue + "\n"; export const embedPadding = "\n" + emptyEmbedValue; export const userMentionRegex = /<@!?([0-9]+)>/g; export const roleMentionRegex = /<@&([0-9]+)>/g; export const channelMentionRegex = /<#([0-9]+)>/g; export function getUserMentions(str: string) { const regex = new RegExp(userMentionRegex.source, "g"); const userIds: string[] = []; let match; // tslint:disable-next-line while ((match = regex.exec(str)) !== null) { userIds.push(match[1]); } return userIds; } export function getRoleMentions(str: string) { const regex = new RegExp(roleMentionRegex.source, "g"); const roleIds: string[] = []; let match; // tslint:disable-next-line while ((match = regex.exec(str)) !== null) { roleIds.push(match[1]); } return roleIds; } /** * Disable link previews in the given string by wrapping links in < > */ export function disableLinkPreviews(str: string): string { return str.replace(/(?"); } export function deactivateMentions(content: string): string { return content.replace(/@/g, "@\u200b"); } export function useMediaUrls(content: string): string { return content.replace(/cdn\.discord(app)?\.com/g, "media.discordapp.net"); } export function chunkArray(arr: T[], chunkSize): T[][] { const chunks: T[][] = []; let currentChunk: T[] = []; for (let i = 0; i < arr.length; i++) { currentChunk.push(arr[i]); if ((i !== 0 && (i + 1) % chunkSize === 0) || i === arr.length - 1) { chunks.push(currentChunk); currentChunk = []; } } return chunks; } export function chunkLines(str: string, maxChunkLength = 2000): string[] { if (str.length < maxChunkLength) { return [str]; } const chunks: string[] = []; while (str.length) { if (str.length <= maxChunkLength) { chunks.push(str); break; } const slice = str.slice(0, maxChunkLength); const lastLineBreakIndex = slice.lastIndexOf("\n"); if (lastLineBreakIndex === -1) { chunks.push(str.slice(0, maxChunkLength)); str = str.slice(maxChunkLength); } else { chunks.push(str.slice(0, lastLineBreakIndex)); str = str.slice(lastLineBreakIndex + 1); } } return chunks; } /** * Chunks a long message to multiple smaller messages, retaining leading and trailing line breaks, open code blocks, etc. * * Default maxChunkLength is 1990, a bit under the message length limit of 2000, so we have space to add code block * shenanigans to the start/end when needed. Take this into account when choosing a custom maxChunkLength as well. */ export function chunkMessageLines(str: string, maxChunkLength = 1990): string[] { const chunks = chunkLines(str, maxChunkLength); let openCodeBlock = false; return chunks.map((chunk) => { // If the chunk starts with a newline, add an invisible unicode char so Discord doesn't strip it away if (chunk[0] === "\n") chunk = "\u200b" + chunk; // If the chunk ends with a newline, add an invisible unicode char so Discord doesn't strip it away if (chunk[chunk.length - 1] === "\n") chunk = chunk + "\u200b"; // If the previous chunk had an open code block, open it here again if (openCodeBlock) { openCodeBlock = false; if (chunk.startsWith("```")) { // Edge case: chunk starts with a code block delimiter, e.g. the previous chunk and this one were split right before the end of a code block // Fix: just strip the code block delimiter away from here, we don't need it anymore chunk = chunk.slice(3); } else { chunk = "```" + chunk; } } // If the chunk has an open code block, close it and open it again in the next chunk const codeBlockDelimiters = chunk.match(/```/g); if (codeBlockDelimiters && codeBlockDelimiters.length % 2 !== 0) { chunk += "```"; openCodeBlock = true; } return chunk; }); } export async function createChunkedMessage( channel: SendableChannels | User, messageText: string, allowedMentions?: MessageMentionOptions, ) { const chunks = chunkMessageLines(messageText); for (const chunk of chunks) { await channel.send({ content: chunk, allowedMentions }); } } /** * Downloads the file from the given URL to a temporary file, with retry support */ export function downloadFile(attachmentUrl: string, retries = 3): Promise<{ path: string; deleteFn: () => void }> { return new Promise((resolve) => { tmp.file((err, path, fd, deleteFn) => { if (err) throw err; const writeStream = fs.createWriteStream(path); https .get(attachmentUrl, (res) => { res.pipe(writeStream); writeStream.on("finish", () => { writeStream.end(); resolve({ path, deleteFn, }); }); }) .on("error", (httpsErr) => { fsp.unlink(path); if (retries === 0) { throw httpsErr; } else { console.warn("File download failed, retrying. Error given:", httpsErr.message); // tslint:disable-line resolve(downloadFile(attachmentUrl, retries - 1)); } }); }); }); } type ItemWithRanking = [T, number]; export function simpleClosestStringMatch(searchStr: string, haystack: string[]): string | null; export function simpleClosestStringMatch>( searchStr, haystack: T[], getter: (item: T) => string, ): T | null; export function simpleClosestStringMatch(searchStr, haystack, getter?) { const normalizedSearchStr = searchStr.toLowerCase(); // See if any haystack item contains a part of the search string const itemsWithRankings: Array> = haystack.map((item) => { const itemStr: string = getter ? getter(item) : item; const normalizedItemStr = itemStr.toLowerCase(); let i = 0; do { if (!normalizedItemStr.includes(normalizedSearchStr.slice(0, i + 1))) break; i++; } while (i < normalizedSearchStr.length); if (i > 0 && normalizedItemStr.startsWith(normalizedSearchStr.slice(0, i))) { // Slightly prioritize items that *start* with the search string i += 0.5; } return [item, i] as ItemWithRanking; }); // Sort by best match itemsWithRankings.sort((a, b) => { return a[1] > b[1] ? -1 : 1; }); if (itemsWithRankings[0][1] === 0) { return null; } return itemsWithRankings[0][0]; } type sorterDirection = "ASC" | "DESC"; type sorterGetterFn = (any) => any; type sorterGetterFnWithDirection = [sorterGetterFn, sorterDirection]; type sorterGetterResolvable = string | sorterGetterFn; type sorterGetterResolvableWithDirection = [sorterGetterResolvable, sorterDirection]; type sorterFn = (a: any, b: any) => number; function resolveGetter(getter: sorterGetterResolvable): sorterGetterFn { if (typeof getter === "string") { return (obj) => obj[getter]; } return getter; } export function multiSorter(getters: Array): sorterFn { const resolvedGetters: sorterGetterFnWithDirection[] = getters.map((getter) => { if (Array.isArray(getter)) { return [resolveGetter(getter[0]), getter[1]] as sorterGetterFnWithDirection; } else { return [resolveGetter(getter), "ASC"] as sorterGetterFnWithDirection; } }); return (a, b) => { for (const getter of resolvedGetters) { const aVal = getter[0](a); const bVal = getter[0](b); if (aVal > bVal) return getter[1] === "ASC" ? 1 : -1; if (aVal < bVal) return getter[1] === "ASC" ? -1 : 1; } return 0; }; } export function sorter(getter: sorterGetterResolvable, direction: sorterDirection = "ASC"): sorterFn { return multiSorter([[getter, direction]]); } export function noop() { // IT'S LITERALLY NOTHING } export type CustomEmoji = { id: string; } & Emoji; export type UserNotificationMethod = { type: "dm" } | { type: "channel"; channel: GuildTextBasedChannel }; export const disableUserNotificationStrings = ["no", "none", "off"]; export interface UserNotificationResult { method: UserNotificationMethod | null; success: boolean; text?: string; } export function createUserNotificationError(text: string): UserNotificationResult { return { method: null, success: false, text, }; } /** * Attempts to notify the user using one of the specified methods. Only the first one that succeeds will be used. * @param methods List of methods to try, in priority order */ export async function notifyUser( user: User, body: string, methods: UserNotificationMethod[], ): Promise { if (methods.length === 0) { return { method: null, success: true }; } let lastError: Error | null = null; for (const method of methods) { if (method.type === "dm") { try { await sendDM(user, body, "mod action notification"); return { method, success: true, text: "user notified with a direct message", }; } catch (e) { lastError = e; } } else if (method.type === "channel") { try { await method.channel.send({ content: `<@!${user.id}> ${body}`, allowedMentions: { users: [user.id] }, }); return { method, success: true, text: `user notified in <#${method.channel.id}>`, }; } catch (e) { lastError = e; } } } const errorText = lastError ? `failed to message user: ${lastError.message}` : `failed to message user`; return { method: null, success: false, text: errorText, }; } export function ucfirst(str) { if (typeof str !== "string" || str === "") return str; return str[0].toUpperCase() + str.slice(1); } export class UnknownUser { public id: string; public username = "Unknown"; public discriminator = "0000"; public tag = "Unknown#0000"; constructor(props = {}) { for (const key in props) { this[key] = props[key]; } } } export function isObjectLiteral(obj) { let deepestPrototype = obj; while (Object.getPrototypeOf(deepestPrototype) != null) { deepestPrototype = Object.getPrototypeOf(deepestPrototype); } return Object.getPrototypeOf(obj) === deepestPrototype; } const keyMods = ["+", "-", "="]; export function deepKeyIntersect(obj, keyReference) { const result = {}; for (let [key, value] of Object.entries(obj)) { if (!Object.hasOwn(keyReference, key)) { // Temporary solution so we don't erase keys with modifiers // Modifiers will be removed soon(tm) so we can remove this when that happens as well let found = false; for (const mod of keyMods) { if (Object.hasOwn(keyReference, mod + key)) { key = mod + key; found = true; break; } } if (!found) continue; } if (Array.isArray(value)) { // Also temp (because modifier shenanigans) result[key] = keyReference[key]; } else if ( value != null && typeof value === "object" && typeof keyReference[key] === "object" && isObjectLiteral(value) ) { result[key] = deepKeyIntersect(value, keyReference[key]); } else { result[key] = value; } } return result; } const unknownUsers = new Set(); const unknownMembers = new Set(); export function resolveUserId(bot: Client, value: string) { if (value == null) { return null; } // Just a user ID? if (isValidSnowflake(value)) { return value; } // A user mention? const mentionMatch = value.match(/^<@!?(\d+)>$/); if (mentionMatch) { return mentionMatch[1]; } // a username const usernameMatch = value.match(/^@?(\S{3,})$/); if (usernameMatch) { const profiler = getProfiler(); const start = performance.now(); const user = bot.users.cache.find((u) => u.tag === usernameMatch[1]); profiler?.addDataPoint("utils:resolveUserId:usernameMatch", performance.now() - start); if (user) { return user.id; } } return null; } /** * Finds a matching User for the passed user id, user mention, or full username (with discriminator). * If a user is not found, returns an UnknownUser instead. */ export function getUser(client: Client, userResolvable: string): User | UnknownUser { const id = resolveUserId(client, userResolvable); return id ? client.users.resolve(id as Snowflake) || new UnknownUser({ id }) : new UnknownUser(); } /** * Resolves a User from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. * If the user is not found in the cache, it's fetched from the API. */ export async function resolveUser(bot: Client, value: unknown, context?: string): Promise { if (typeof value !== "string") { return new UnknownUser(); } const userId = resolveUserId(bot, value); if (!userId) { return new UnknownUser(); } incrementDebugCounter(`resolveUser:${context ?? "unknown"}`); return (await getOrFetchUser(bot, userId)) ?? new UnknownUser(); } /** * Resolves a guild Member from the passed user id, user mention, or full username (with discriminator). * If the member is not found in the cache, it's fetched from the API. */ export async function resolveMember( bot: Client, guild: Guild, value: string, fresh = false, ): Promise { const userId = resolveUserId(bot, value); if (!userId) return null; // If we have the member cached, return that directly if (guild.members.cache.has(userId as Snowflake) && !fresh) { return guild.members.cache.get(userId as Snowflake) || null; } // We don't want to spam the API by trying to fetch unknown members again and again, // so we cache the fact that they're "unknown" for a while const unknownKey = `${guild.id}-${userId}`; if (unknownMembers.has(unknownKey)) { return null; } const freshMember = await guild.members.fetch({ user: userId as Snowflake, force: true }).catch(noop); if (freshMember) { // freshMember.id = userId; // I dont even know why this is here -Dark return freshMember; } unknownMembers.add(unknownKey); setTimeout(() => unknownMembers.delete(unknownKey), 15 * MINUTES); return null; } /** * Resolves a role from the passed role ID, role mention, or role name. * In the event of duplicate role names, this function will return the first one it comes across. * * FIXME: Define "first one it comes across" better */ export async function resolveRoleId(bot: Client, guildId: string, value: string) { if (value == null) { return null; } // Role mention const mentionMatch = value.match(/^<@&?(\d+)>$/); if (mentionMatch) { return mentionMatch[1]; } // Role name const roleList = (await bot.guilds.fetch(guildId as Snowflake)).roles.cache; const role = roleList.filter((x) => x.name.toLocaleLowerCase() === value.toLocaleLowerCase()); if (role.size >= 1) { return role.firstKey(); } // Role ID const idMatch = value.match(/^\d+$/); if (idMatch) { return value; } return null; } export class UnknownRole { public id: string; public name: string; constructor(props = {}) { for (const key in props) { this[key] = props[key]; } } } export function resolveRole(guild: Guild, roleResolvable: RoleResolvable) { const roleId = guild.roles.resolveId(roleResolvable); return guild.roles.resolve(roleId) ?? new UnknownRole({ id: roleId, name: roleId }); } const inviteCache = new SimpleCache>(10 * MINUTES, 200); type ResolveInviteReturnType = Promise; export async function resolveInvite( client: Client, code: string, withCounts?: T, ): ResolveInviteReturnType { const key = `${code}:${withCounts ? 1 : 0}`; if (inviteCache.has(key)) { return inviteCache.get(key) as ResolveInviteReturnType; } const promise = client.fetchInvite(code).catch(() => null); inviteCache.set(key, promise); return promise as ResolveInviteReturnType; } const internalStickerCache: LimitedCollection = new LimitedCollection({ maxSize: 500 }); export async function resolveStickerId(bot: Client, id: Snowflake): Promise { const cachedSticker = internalStickerCache.get(id); if (cachedSticker) return cachedSticker; const fetchedSticker = await bot.fetchSticker(id).catch(() => null); if (fetchedSticker) { internalStickerCache.set(id, fetchedSticker); } return fetchedSticker; } export async function confirm( context: GenericCommandSource, userId: string, content: MessageCreateOptions, ): Promise { return waitForButtonConfirm(context, content, { restrictToId: userId }); } export function createDisabledButtonRow( row: ActionRowBuilder ): ActionRowBuilder { const newRow = new ActionRowBuilder(); for (const component of row.components) { if (component instanceof ButtonBuilder) { newRow.addComponents( ButtonBuilder.from(component).setDisabled(true) ); } } return newRow; } export function messageSummary(msg: SavedMessage) { // Regular text content let result = "```\n" + (msg.data.content ? escapeCodeBlock(msg.data.content) : "") + "```"; // Rich embed const richEmbed = (msg.data.embeds || []).find((e) => (e as EmbedData).type === EmbedType.Rich); if (richEmbed) result += "Embed:```" + escapeCodeBlock(JSON.stringify(richEmbed)) + "```"; // Attachments if (msg.data.attachments && msg.data.attachments.length) { result += "Attachments:\n" + msg.data.attachments.map((a: ISavedMessageAttachmentData) => disableLinkPreviews(a.url)).join("\n") + "\n"; } return result; } export function verboseUserMention(user: User | UnknownUser): string { if (user.id == null) { return `**${renderUsername(user.username, user.discriminator)}**`; } return `<@!${user.id}> (**${renderUsername(user.username, user.discriminator)}**, \`${user.id}\`)`; } export function verboseUserName(user: User | UnknownUser): string { if (user.id == null) { return `**${renderUsername(user.username, user.discriminator)}**`; } return `**${renderUsername(user.username, user.discriminator)}** (\`${user.id}\`)`; } export function verboseChannelMention(channel: GuildBasedChannel): string { const plainTextName = channel.type === ChannelType.GuildVoice || channel.type === ChannelType.GuildStageVoice ? channel.name : `#${channel.name}`; return `<#${channel.id}> (**${plainTextName}**, \`${channel.id}\`)`; } export function messageLink(message: Message): string; export function messageLink(guildId: string, channelId: string, messageId: string): string; export function messageLink(guildIdOrMessage: string | Message | null, channelId?: string, messageId?: string): string { let guildId; if (guildIdOrMessage == null) { // Full arguments without a guild id -> DM/Group chat guildId = "@me"; } else if (guildIdOrMessage instanceof Message) { // Message object as the only argument guildId = (guildIdOrMessage.channel as GuildChannel).guild?.id ?? "@me"; channelId = guildIdOrMessage.channel.id; messageId = guildIdOrMessage.id; } else { // Full arguments with all IDs guildId = guildIdOrMessage; } return `https://discord.com/channels/${guildId}/${channelId}/${messageId}`; } export function isValidEmbed(embed: any): boolean { return zEmbedInput.safeParse(embed).success; } const formatter = new Intl.NumberFormat("en-US"); export function formatNumber(numberToFormat: number): string { return formatter.format(numberToFormat); } interface IMemoizedItem { createdAt: number; value: any; } const memoizeCache: Map = new Map(); export function memoize(fn: () => T, key?, time?): T { const realKey = key ?? fn; if (memoizeCache.has(realKey)) { const memoizedItem = memoizeCache.get(realKey)!; if (!time || memoizedItem.createdAt > Date.now() - time) { return memoizedItem.value; } memoizeCache.delete(realKey); } const value = fn(); memoizeCache.set(realKey, { createdAt: Date.now(), value, }); return value; } export function lazyMemoize unknown>(fn: T, key?: string, time?: number): T { return (() => { return memoize(fn, key, time); }) as T; } type RecursiveRenderFn = (str: string) => string | Promise; export async function renderRecursively(value, fn: RecursiveRenderFn) { if (Array.isArray(value)) { const result: any[] = []; for (const item of value) { result.push(await renderRecursively(item, fn)); } return result; } else if (value === null) { return null; } else if (typeof value === "object") { const result = {}; for (const [prop, _value] of Object.entries(value)) { result[prop] = await renderRecursively(_value, fn); } return result; } else if (typeof value === "string") { return fn(value); } return value; } export function isValidEmoji(emoji: string): boolean { return isUnicodeEmoji(emoji) || isSnowflake(emoji); } export function canUseEmoji(client: Client, emoji: string): boolean { if (isUnicodeEmoji(emoji)) { return true; } else if (isSnowflake(emoji)) { for (const guild of client.guilds.cache) { if (guild[1].emojis.cache.some((e) => (e as any).id === emoji)) { return true; } } } else { throw new Error(`Invalid emoji ${emoji}`); } return false; } /** * Trims any empty lines from the beginning and end of the given string * and indents matching the first line's indent */ export function trimMultilineString(str) { const emptyLinesTrimmed = trimEmptyStartEndLines(str); const lines = emptyLinesTrimmed.split("\n"); const firstLineIndentation = (lines[0].match(/^ +/g) || [""])[0].length; return trimIndents(emptyLinesTrimmed, firstLineIndentation); } export const trimPluginDescription = trimMultilineString; export function isFullMessage(msg: Message | PartialMessage): msg is Message { return (msg as Message).createdAt != null; } export function isGuildInvite(invite: Invite): invite is GuildInvite { return invite.type === InviteType.Guild; } export function isGroupDMInvite(invite: Invite): invite is GroupDMInvite { return invite.type === InviteType.GroupDM; } export function inviteHasCounts(invite: Invite): invite is Invite & { memberCount: number; presenceCount: number } { return invite.memberCount != null; } export function asyncMap(arr: T[], fn: (item: T) => Promise): Promise { return Promise.all(arr.map((item) => fn(item))); } export function unique(arr: T[]): T[] { return Array.from(new Set(arr)); } // From https://github.com/microsoft/TypeScript/pull/29955#issuecomment-470062531 export function isTruthy(value: T): value is Exclude { return Boolean(value); } export const DBDateFormat = "YYYY-MM-DD HH:mm:ss"; export function renderUsername(memberOrUser: GuildMember | UnknownUser | User): string; export function renderUsername(username: string, discriminator: string): string; export function renderUsername(username: string | User | GuildMember | UnknownUser, discriminator?: string): string { if (username instanceof GuildMember) return username.user.tag; if (username instanceof User || username instanceof UnknownUser) return username.tag; if (discriminator === "0") { return username; } return `${username}#${discriminator}`; } export function renderUserUsername(user: User | UnknownUser): string { return renderUsername(user.username, user.discriminator); } type Entries = Array< { [Key in keyof T]-?: [Key, T[Key]]; }[keyof T] >; export function entries(object: T) { return Object.entries(object) as Entries; } export function keys(object: T) { return Object.keys(object) as Array; } export function values(object: T) { return Object.values(object) as Array; } ================================================ FILE: backend/src/validateActiveConfigs.ts ================================================ import { YAMLException } from "js-yaml"; import { validateGuildConfig } from "./configValidator.js"; import { Configs } from "./data/Configs.js"; import { connect, disconnect } from "./data/db.js"; import { loadYamlSafely } from "./utils/loadYamlSafely.js"; import { ObjectAliasError } from "./utils/validateNoObjectAliases.js"; function writeError(key: string, error: string) { const indented = error .split("\n") .map((s) => " ".repeat(64) + s) .join("\n"); const prefix = `Invalid config ${key}:`; const prefixed = prefix + indented.slice(prefix.length); console.log(prefixed + "\n\n"); } connect().then(async () => { const configs = new Configs(); const activeConfigs = await configs.getActive(); for (const config of activeConfigs) { if (config.key === "global") { continue; } let parsed: unknown; try { parsed = loadYamlSafely(config.config); } catch (err) { if (err instanceof ObjectAliasError) { writeError(config.key, err.message); continue; } if (err instanceof YAMLException) { writeError(config.key, `invalid YAML: ${err.message}`); continue; } throw err; } const errors = await validateGuildConfig(parsed); if (errors) { writeError(config.key, errors); } } await disconnect(); process.exit(0); }); ================================================ FILE: backend/start-dev.js ================================================ /** * This file starts the bot and api processes in tandem. * Used with tsc-watch for restarting on watch. */ import childProcess from "node:child_process"; childProcess.spawn("pnpm", ["run", "start-bot-dev"], { stdio: [process.stdin, process.stdout, process.stderr], }); childProcess.spawn("pnpm", ["run", "start-api-dev"], { stdio: [process.stdin, process.stdout, process.stderr], }); ================================================ FILE: backend/tsconfig.json ================================================ { "extends": "../tsconfig.base.json", "compilerOptions": { "moduleResolution": "NodeNext", "module": "NodeNext", "baseUrl": "./src", "rootDir": "./src", "outDir": "./dist", "composite": true }, "include": ["src/**/*.ts", "src/**/*.json"], "references": [ { "path": "../shared/tsconfig.json" } ] } ================================================ FILE: build-image.sh ================================================ docker build \ -t dragory/zeppelin \ --build-arg COMMIT_HASH=$(git rev-parse HEAD) \ --build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ . ================================================ FILE: config-checker/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: config-checker/index.html ================================================ Zeppelin config checker

Config

Errors

================================================ FILE: config-checker/package.json ================================================ { "name": "config-checker", "private": true, "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", "build": "tsc && vite build", "preview": "vite preview" }, "devDependencies": { "typescript": "~5.8.3", "vite": "^6.3.5" }, "dependencies": { "monaco-editor": "^0.52.2", "monaco-yaml": "^5.4.0" } } ================================================ FILE: config-checker/public/config-schema.json ================================================ { "type": "object", "properties": { "prefix": { "type": "string" }, "levels": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "number" } }, "plugins": { "type": "object", "properties": { "auto_delete": { "type": "object", "properties": { "config": { "type": "object", "properties": { "enabled": { "default": false, "type": "boolean" }, "delay": { "default": "5s", "type": "string", "maxLength": 32 } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "enabled": { "default": false, "type": "boolean" }, "delay": { "default": "5s", "type": "string", "maxLength": 32 } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "automod": { "type": "object", "properties": { "config": { "type": "object", "properties": { "rules": { "default": {}, "type": "object", "propertyNames": { "type": "string", "maxLength": 100 }, "additionalProperties": { "type": "object", "properties": { "enabled": { "default": true, "type": "boolean" }, "pretty_name": { "type": "string" }, "presets": { "default": [], "maxItems": 25, "type": "array", "items": { "type": "string", "maxLength": 100 } }, "affects_bots": { "default": false, "type": "boolean" }, "affects_self": { "default": false, "type": "boolean" }, "cooldown": { "default": null, "anyOf": [ { "type": "string", "maxLength": 32 }, { "type": "null" } ] }, "allow_further_rules": { "default": false, "type": "boolean" }, "triggers": { "type": "array", "items": { "type": "object", "properties": { "any_message": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "match_words": { "type": "object", "properties": { "words": { "maxItems": 1024, "type": "array", "items": { "type": "string", "maxLength": 2000 } }, "case_sensitive": { "default": false, "type": "boolean" }, "only_full_words": { "default": true, "type": "boolean" }, "normalize": { "default": false, "type": "boolean" }, "loose_matching": { "default": false, "type": "boolean" }, "loose_matching_threshold": { "default": 1, "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "strip_markdown": { "default": false, "type": "boolean" }, "match_messages": { "default": true, "type": "boolean" }, "match_embeds": { "default": false, "type": "boolean" }, "match_visible_names": { "default": false, "type": "boolean" }, "match_usernames": { "default": false, "type": "boolean" }, "match_nicknames": { "default": false, "type": "boolean" }, "match_custom_status": { "default": false, "type": "boolean" } }, "required": [ "words" ], "additionalProperties": false }, "match_regex": { "type": "object", "properties": { "patterns": { "maxItems": 512, "type": "array", "items": { "type": "string", "maxLength": 2000 } }, "case_sensitive": { "default": false, "type": "boolean" }, "normalize": { "default": false, "type": "boolean" }, "strip_markdown": { "default": false, "type": "boolean" }, "match_messages": { "default": true, "type": "boolean" }, "match_embeds": { "default": false, "type": "boolean" }, "match_visible_names": { "default": false, "type": "boolean" }, "match_usernames": { "default": false, "type": "boolean" }, "match_nicknames": { "default": false, "type": "boolean" }, "match_custom_status": { "default": false, "type": "boolean" } }, "required": [ "patterns" ], "additionalProperties": false }, "match_invites": { "type": "object", "properties": { "include_guilds": { "maxItems": 255, "type": "array", "items": { "type": "string" } }, "exclude_guilds": { "maxItems": 255, "type": "array", "items": { "type": "string" } }, "include_invite_codes": { "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } }, "exclude_invite_codes": { "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } }, "include_custom_invite_codes": { "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } }, "exclude_custom_invite_codes": { "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } }, "allow_group_dm_invites": { "default": false, "type": "boolean" }, "match_messages": { "default": true, "type": "boolean" }, "match_embeds": { "default": false, "type": "boolean" }, "match_visible_names": { "default": false, "type": "boolean" }, "match_usernames": { "default": false, "type": "boolean" }, "match_nicknames": { "default": false, "type": "boolean" }, "match_custom_status": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "match_links": { "type": "object", "properties": { "include_domains": { "maxItems": 700, "type": "array", "items": { "type": "string", "maxLength": 255 } }, "exclude_domains": { "maxItems": 700, "type": "array", "items": { "type": "string", "maxLength": 255 } }, "include_subdomains": { "default": true, "type": "boolean" }, "include_words": { "maxItems": 700, "type": "array", "items": { "type": "string", "maxLength": 2000 } }, "exclude_words": { "maxItems": 700, "type": "array", "items": { "type": "string", "maxLength": 2000 } }, "include_regex": { "maxItems": 512, "type": "array", "items": { "type": "string", "maxLength": 2000 } }, "exclude_regex": { "maxItems": 512, "type": "array", "items": { "type": "string", "maxLength": 2000 } }, "phisherman": { "type": "object", "properties": { "include_suspected": { "type": "boolean" }, "include_verified": { "type": "boolean" } }, "required": [], "additionalProperties": false }, "include_malicious": { "default": false, "type": "boolean" }, "only_real_links": { "default": true, "type": "boolean" }, "match_messages": { "default": true, "type": "boolean" }, "match_embeds": { "default": true, "type": "boolean" }, "match_visible_names": { "default": false, "type": "boolean" }, "match_usernames": { "default": false, "type": "boolean" }, "match_nicknames": { "default": false, "type": "boolean" }, "match_custom_status": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "match_attachment_type": { "type": "object", "properties": { "whitelist_enabled": { "default": false, "type": "boolean" }, "filetype_whitelist": { "default": [], "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } }, "blacklist_enabled": { "default": false, "type": "boolean" }, "filetype_blacklist": { "default": [], "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } } }, "required": [], "additionalProperties": false }, "match_mime_type": { "type": "object", "properties": { "whitelist_enabled": { "default": false, "type": "boolean" }, "mime_type_whitelist": { "default": [], "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } }, "blacklist_enabled": { "default": false, "type": "boolean" }, "mime_type_blacklist": { "default": [], "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } } }, "required": [], "additionalProperties": false }, "member_join": { "type": "object", "properties": { "only_new": { "default": false, "type": "boolean" }, "new_threshold": { "default": "1h", "type": "string", "maxLength": 32 } }, "required": [], "additionalProperties": false }, "member_leave": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "role_added": { "default": [], "anyOf": [ { "type": "string" }, { "maxItems": 255, "type": "array", "items": { "type": "string" } } ] }, "role_removed": { "default": [], "anyOf": [ { "type": "string" }, { "maxItems": 255, "type": "array", "items": { "type": "string" } } ] }, "message_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "amount", "within" ], "additionalProperties": false }, "mention_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "amount", "within" ], "additionalProperties": false }, "link_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "amount", "within" ], "additionalProperties": false }, "attachment_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "amount", "within" ], "additionalProperties": false }, "emoji_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "amount", "within" ], "additionalProperties": false }, "line_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "amount", "within" ], "additionalProperties": false }, "character_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "amount", "within" ], "additionalProperties": false }, "member_join_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 } }, "required": [ "amount", "within" ], "additionalProperties": false }, "sticker_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "amount", "within" ], "additionalProperties": false }, "thread_create_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 } }, "required": [ "amount", "within" ], "additionalProperties": false }, "counter_trigger": { "type": "object", "properties": { "counter": { "type": "string", "maxLength": 100 }, "trigger": { "type": "string", "maxLength": 100 }, "reverse": { "type": "boolean" } }, "required": [ "counter", "trigger" ], "additionalProperties": false }, "note": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "warn": { "type": "object", "properties": { "manual": { "default": true, "type": "boolean" }, "automatic": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false }, "mute": { "type": "object", "properties": { "manual": { "default": true, "type": "boolean" }, "automatic": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false }, "unmute": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "kick": { "type": "object", "properties": { "manual": { "default": true, "type": "boolean" }, "automatic": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false }, "ban": { "type": "object", "properties": { "manual": { "default": true, "type": "boolean" }, "automatic": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false }, "unban": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "antiraid_level": { "type": "object", "properties": { "level": { "anyOf": [ { "type": "string", "maxLength": 100 }, { "type": "null" } ] }, "only_on_change": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "level", "only_on_change" ], "additionalProperties": false }, "thread_create": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "thread_delete": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "thread_archive": { "type": "object", "properties": { "locked": { "type": "boolean" } }, "required": [], "additionalProperties": false }, "thread_unarchive": { "type": "object", "properties": { "locked": { "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [], "additionalProperties": false } }, "actions": { "type": "object", "properties": { "clean": { "default": false, "type": "boolean" }, "warn": { "type": "object", "properties": { "reason": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "notify": { "default": null, "anyOf": [ { "anyOf": [ { "const": "dm" }, { "const": "channel" } ] }, { "type": "null" } ] }, "notifyChannel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "postInCaseLog": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "hide_case": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "mute": { "type": "object", "properties": { "reason": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "duration": { "default": null, "anyOf": [ { "type": "string", "maxLength": 32 }, { "type": "null" } ] }, "notify": { "default": null, "anyOf": [ { "anyOf": [ { "const": "dm" }, { "const": "channel" } ] }, { "type": "null" } ] }, "notifyChannel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": null, "anyOf": [ { "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "restore_roles_on_mute": { "default": null, "anyOf": [ { "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "postInCaseLog": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "hide_case": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "kick": { "type": "object", "properties": { "reason": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "notify": { "default": null, "anyOf": [ { "anyOf": [ { "const": "dm" }, { "const": "channel" } ] }, { "type": "null" } ] }, "notifyChannel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "postInCaseLog": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "hide_case": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "ban": { "type": "object", "properties": { "reason": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "duration": { "default": null, "anyOf": [ { "type": "string", "maxLength": 32 }, { "type": "null" } ] }, "notify": { "default": null, "anyOf": [ { "anyOf": [ { "const": "dm" }, { "const": "channel" } ] }, { "type": "null" } ] }, "notifyChannel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "deleteMessageDays": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "postInCaseLog": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "hide_case": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "alert": { "type": "object", "properties": { "channel": { "type": "string" }, "text": { "type": "string" }, "allowed_mentions": { "default": null, "anyOf": [ { "type": "object", "properties": { "everyone": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "users": { "anyOf": [ { "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "roles": { "anyOf": [ { "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "replied_user": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, { "type": "null" } ] } }, "required": [ "channel", "text" ] }, "change_nickname": { "anyOf": [ { "type": "string" }, { "type": "object", "properties": { "name": { "type": "string" } }, "required": [ "name" ], "additionalProperties": false } ] }, "log": { "default": true, "type": "boolean" }, "add_roles": { "type": "array", "items": { "type": "string" } }, "remove_roles": { "default": [], "type": "array", "items": { "type": "string" } }, "set_antiraid_level": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "reply": { "anyOf": [ { "type": "string" }, { "type": "object", "properties": { "text": { "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "auto_delete": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string", "maxLength": 32 }, { "type": "number" } ] }, { "type": "null" } ] }, "inline": { "default": false, "type": "boolean" } }, "required": [ "text" ], "additionalProperties": false } ] }, "add_to_counter": { "type": "object", "properties": { "counter": { "type": "string" }, "amount": { "type": "number" } }, "required": [ "counter", "amount" ] }, "set_counter": { "type": "object", "properties": { "counter": { "type": "string" }, "value": { "type": "number", "minimum": 0, "maximum": 2147483647 } }, "required": [ "counter", "value" ], "additionalProperties": false }, "set_slowmode": { "type": "object", "properties": { "channels": { "default": [], "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "duration": { "default": "10s", "anyOf": [ { "type": "string", "maxLength": 32 }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "start_thread": { "type": "object", "properties": { "name": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "auto_archive": { "type": "string", "maxLength": 32 }, "private": { "default": false, "type": "boolean" }, "slowmode": { "default": null, "anyOf": [ { "type": "string", "maxLength": 32 }, { "type": "null" } ] }, "limit_per_channel": { "default": 5, "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [ "name", "auto_archive" ], "additionalProperties": false }, "archive_thread": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "change_perms": { "type": "object", "properties": { "target": { "type": "string" }, "channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "perms": { "type": "object", "properties": { "CreateInstantInvite": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "KickMembers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "BanMembers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "Administrator": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageChannels": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageGuild": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "AddReactions": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ViewAuditLog": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "PrioritySpeaker": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "Stream": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ViewChannel": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SendMessages": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SendTTSMessages": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageMessages": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "EmbedLinks": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "AttachFiles": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ReadMessageHistory": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MentionEveryone": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseExternalEmojis": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ViewGuildInsights": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "Connect": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "Speak": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MuteMembers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "DeafenMembers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MoveMembers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseVAD": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ChangeNickname": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageNicknames": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageRoles": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageWebhooks": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageEmojisAndStickers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageGuildExpressions": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseApplicationCommands": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "RequestToSpeak": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageEvents": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageThreads": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CreatePublicThreads": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CreatePrivateThreads": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseExternalStickers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SendMessagesInThreads": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseEmbeddedActivities": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ModerateMembers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ViewCreatorMonetizationAnalytics": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseSoundboard": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CreateGuildExpressions": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CreateEvents": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseExternalSounds": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SendVoiceMessages": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SendPolls": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseExternalApps": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CREATE_INSTANT_INVITE": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "KICK_MEMBERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "BAN_MEMBERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ADMINISTRATOR": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_CHANNELS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_GUILD": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ADD_REACTIONS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "VIEW_AUDIT_LOG": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "PRIORITY_SPEAKER": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "STREAM": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "VIEW_CHANNEL": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SEND_MESSAGES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SEND_TTSMESSAGES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_MESSAGES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "EMBED_LINKS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ATTACH_FILES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "READ_MESSAGE_HISTORY": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MENTION_EVERYONE": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "USE_EXTERNAL_EMOJIS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "VIEW_GUILD_INSIGHTS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CONNECT": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SPEAK": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MUTE_MEMBERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "DEAFEN_MEMBERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MOVE_MEMBERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "USE_VAD": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CHANGE_NICKNAME": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_NICKNAMES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_ROLES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_WEBHOOKS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_EMOJIS_AND_STICKERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "USE_APPLICATION_COMMANDS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "REQUEST_TO_SPEAK": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_EVENTS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_THREADS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CREATE_PUBLIC_THREADS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CREATE_PRIVATE_THREADS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "USE_EXTERNAL_STICKERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SEND_MESSAGES_IN_THREADS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "USE_EMBEDDED_ACTIVITIES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MODERATE_MEMBERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "required": [ "target", "perms" ], "additionalProperties": false }, "pause_invites": { "type": "object", "properties": { "paused": { "type": "boolean" } }, "required": [ "paused" ], "additionalProperties": false } }, "required": [], "additionalProperties": false } }, "required": [ "triggers", "actions" ], "additionalProperties": false } }, "antiraid_levels": { "default": [ "low", "medium", "high" ], "maxItems": 10, "type": "array", "items": { "type": "string", "maxLength": 100 } }, "can_set_antiraid": { "default": false, "type": "boolean" }, "can_view_antiraid": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "rules": { "default": {}, "type": "object", "propertyNames": { "type": "string", "maxLength": 100 }, "additionalProperties": { "type": "object", "properties": { "enabled": { "default": true, "type": "boolean" }, "pretty_name": { "type": "string" }, "presets": { "default": [], "maxItems": 25, "type": "array", "items": { "type": "string", "maxLength": 100 } }, "affects_bots": { "default": false, "type": "boolean" }, "affects_self": { "default": false, "type": "boolean" }, "cooldown": { "default": null, "anyOf": [ { "type": "string", "maxLength": 32 }, { "type": "null" } ] }, "allow_further_rules": { "default": false, "type": "boolean" }, "triggers": { "type": "array", "items": { "type": "object", "properties": { "any_message": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "match_words": { "type": "object", "properties": { "words": { "maxItems": 1024, "type": "array", "items": { "type": "string", "maxLength": 2000 } }, "case_sensitive": { "default": false, "type": "boolean" }, "only_full_words": { "default": true, "type": "boolean" }, "normalize": { "default": false, "type": "boolean" }, "loose_matching": { "default": false, "type": "boolean" }, "loose_matching_threshold": { "default": 1, "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "strip_markdown": { "default": false, "type": "boolean" }, "match_messages": { "default": true, "type": "boolean" }, "match_embeds": { "default": false, "type": "boolean" }, "match_visible_names": { "default": false, "type": "boolean" }, "match_usernames": { "default": false, "type": "boolean" }, "match_nicknames": { "default": false, "type": "boolean" }, "match_custom_status": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "match_regex": { "type": "object", "properties": { "patterns": { "maxItems": 512, "type": "array", "items": { "type": "string", "maxLength": 2000 } }, "case_sensitive": { "default": false, "type": "boolean" }, "normalize": { "default": false, "type": "boolean" }, "strip_markdown": { "default": false, "type": "boolean" }, "match_messages": { "default": true, "type": "boolean" }, "match_embeds": { "default": false, "type": "boolean" }, "match_visible_names": { "default": false, "type": "boolean" }, "match_usernames": { "default": false, "type": "boolean" }, "match_nicknames": { "default": false, "type": "boolean" }, "match_custom_status": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "match_invites": { "type": "object", "properties": { "include_guilds": { "maxItems": 255, "type": "array", "items": { "type": "string" } }, "exclude_guilds": { "maxItems": 255, "type": "array", "items": { "type": "string" } }, "include_invite_codes": { "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } }, "exclude_invite_codes": { "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } }, "include_custom_invite_codes": { "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } }, "exclude_custom_invite_codes": { "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } }, "allow_group_dm_invites": { "default": false, "type": "boolean" }, "match_messages": { "default": true, "type": "boolean" }, "match_embeds": { "default": false, "type": "boolean" }, "match_visible_names": { "default": false, "type": "boolean" }, "match_usernames": { "default": false, "type": "boolean" }, "match_nicknames": { "default": false, "type": "boolean" }, "match_custom_status": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "match_links": { "type": "object", "properties": { "include_domains": { "maxItems": 700, "type": "array", "items": { "type": "string", "maxLength": 255 } }, "exclude_domains": { "maxItems": 700, "type": "array", "items": { "type": "string", "maxLength": 255 } }, "include_subdomains": { "default": true, "type": "boolean" }, "include_words": { "maxItems": 700, "type": "array", "items": { "type": "string", "maxLength": 2000 } }, "exclude_words": { "maxItems": 700, "type": "array", "items": { "type": "string", "maxLength": 2000 } }, "include_regex": { "maxItems": 512, "type": "array", "items": { "type": "string", "maxLength": 2000 } }, "exclude_regex": { "maxItems": 512, "type": "array", "items": { "type": "string", "maxLength": 2000 } }, "phisherman": { "type": "object", "properties": { "include_suspected": { "type": "boolean" }, "include_verified": { "type": "boolean" } }, "required": [], "additionalProperties": false }, "include_malicious": { "default": false, "type": "boolean" }, "only_real_links": { "default": true, "type": "boolean" }, "match_messages": { "default": true, "type": "boolean" }, "match_embeds": { "default": true, "type": "boolean" }, "match_visible_names": { "default": false, "type": "boolean" }, "match_usernames": { "default": false, "type": "boolean" }, "match_nicknames": { "default": false, "type": "boolean" }, "match_custom_status": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "match_attachment_type": { "type": "object", "properties": { "whitelist_enabled": { "default": false, "type": "boolean" }, "filetype_whitelist": { "default": [], "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } }, "blacklist_enabled": { "default": false, "type": "boolean" }, "filetype_blacklist": { "default": [], "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } } }, "required": [], "additionalProperties": false }, "match_mime_type": { "type": "object", "properties": { "whitelist_enabled": { "default": false, "type": "boolean" }, "mime_type_whitelist": { "default": [], "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } }, "blacklist_enabled": { "default": false, "type": "boolean" }, "mime_type_blacklist": { "default": [], "maxItems": 255, "type": "array", "items": { "type": "string", "maxLength": 32 } } }, "required": [], "additionalProperties": false }, "member_join": { "type": "object", "properties": { "only_new": { "default": false, "type": "boolean" }, "new_threshold": { "default": "1h", "type": "string", "maxLength": 32 } }, "required": [], "additionalProperties": false }, "member_leave": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "role_added": { "default": [], "anyOf": [ { "type": "string" }, { "maxItems": 255, "type": "array", "items": { "type": "string" } } ] }, "role_removed": { "default": [], "anyOf": [ { "type": "string" }, { "maxItems": 255, "type": "array", "items": { "type": "string" } } ] }, "message_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "mention_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "link_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "attachment_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "emoji_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "line_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "character_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "member_join_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 } }, "required": [], "additionalProperties": false }, "sticker_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 }, "per_channel": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "thread_create_spam": { "type": "object", "properties": { "amount": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, "within": { "type": "string", "maxLength": 32 } }, "required": [], "additionalProperties": false }, "counter_trigger": { "type": "object", "properties": { "counter": { "type": "string", "maxLength": 100 }, "trigger": { "type": "string", "maxLength": 100 }, "reverse": { "type": "boolean" } }, "required": [], "additionalProperties": false }, "note": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "warn": { "type": "object", "properties": { "manual": { "default": true, "type": "boolean" }, "automatic": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false }, "mute": { "type": "object", "properties": { "manual": { "default": true, "type": "boolean" }, "automatic": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false }, "unmute": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "kick": { "type": "object", "properties": { "manual": { "default": true, "type": "boolean" }, "automatic": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false }, "ban": { "type": "object", "properties": { "manual": { "default": true, "type": "boolean" }, "automatic": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false }, "unban": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "antiraid_level": { "type": "object", "properties": { "level": { "anyOf": [ { "type": "string", "maxLength": 100 }, { "type": "null" } ] }, "only_on_change": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "thread_create": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "thread_delete": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "thread_archive": { "type": "object", "properties": { "locked": { "type": "boolean" } }, "required": [], "additionalProperties": false }, "thread_unarchive": { "type": "object", "properties": { "locked": { "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [], "additionalProperties": false } }, "actions": { "type": "object", "properties": { "clean": { "default": false, "type": "boolean" }, "warn": { "type": "object", "properties": { "reason": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "notify": { "default": null, "anyOf": [ { "anyOf": [ { "const": "dm" }, { "const": "channel" } ] }, { "type": "null" } ] }, "notifyChannel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "postInCaseLog": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "hide_case": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "mute": { "type": "object", "properties": { "reason": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "duration": { "default": null, "anyOf": [ { "type": "string", "maxLength": 32 }, { "type": "null" } ] }, "notify": { "default": null, "anyOf": [ { "anyOf": [ { "const": "dm" }, { "const": "channel" } ] }, { "type": "null" } ] }, "notifyChannel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": null, "anyOf": [ { "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "restore_roles_on_mute": { "default": null, "anyOf": [ { "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "postInCaseLog": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "hide_case": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "kick": { "type": "object", "properties": { "reason": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "notify": { "default": null, "anyOf": [ { "anyOf": [ { "const": "dm" }, { "const": "channel" } ] }, { "type": "null" } ] }, "notifyChannel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "postInCaseLog": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "hide_case": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "ban": { "type": "object", "properties": { "reason": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "duration": { "default": null, "anyOf": [ { "type": "string", "maxLength": 32 }, { "type": "null" } ] }, "notify": { "default": null, "anyOf": [ { "anyOf": [ { "const": "dm" }, { "const": "channel" } ] }, { "type": "null" } ] }, "notifyChannel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "deleteMessageDays": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "postInCaseLog": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "hide_case": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "alert": { "type": "object", "properties": { "channel": { "type": "string" }, "text": { "type": "string" }, "allowed_mentions": { "default": null, "anyOf": [ { "type": "object", "properties": { "everyone": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "users": { "anyOf": [ { "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "roles": { "anyOf": [ { "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "replied_user": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, { "type": "null" } ] } }, "required": [] }, "change_nickname": { "anyOf": [ { "type": "string" }, { "type": "object", "properties": { "name": { "type": "string" } }, "required": [], "additionalProperties": false } ] }, "log": { "default": true, "type": "boolean" }, "add_roles": { "type": "array", "items": { "type": "string" } }, "remove_roles": { "default": [], "type": "array", "items": { "type": "string" } }, "set_antiraid_level": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "reply": { "anyOf": [ { "type": "string" }, { "type": "object", "properties": { "text": { "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "auto_delete": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string", "maxLength": 32 }, { "type": "number" } ] }, { "type": "null" } ] }, "inline": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } ] }, "add_to_counter": { "type": "object", "properties": { "counter": { "type": "string" }, "amount": { "type": "number" } }, "required": [] }, "set_counter": { "type": "object", "properties": { "counter": { "type": "string" }, "value": { "type": "number", "minimum": 0, "maximum": 2147483647 } }, "required": [], "additionalProperties": false }, "set_slowmode": { "type": "object", "properties": { "channels": { "default": [], "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "duration": { "default": "10s", "anyOf": [ { "type": "string", "maxLength": 32 }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "start_thread": { "type": "object", "properties": { "name": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "auto_archive": { "type": "string", "maxLength": 32 }, "private": { "default": false, "type": "boolean" }, "slowmode": { "default": null, "anyOf": [ { "type": "string", "maxLength": 32 }, { "type": "null" } ] }, "limit_per_channel": { "default": 5, "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "archive_thread": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "change_perms": { "type": "object", "properties": { "target": { "type": "string" }, "channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "perms": { "type": "object", "properties": { "CreateInstantInvite": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "KickMembers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "BanMembers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "Administrator": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageChannels": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageGuild": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "AddReactions": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ViewAuditLog": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "PrioritySpeaker": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "Stream": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ViewChannel": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SendMessages": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SendTTSMessages": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageMessages": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "EmbedLinks": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "AttachFiles": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ReadMessageHistory": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MentionEveryone": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseExternalEmojis": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ViewGuildInsights": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "Connect": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "Speak": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MuteMembers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "DeafenMembers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MoveMembers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseVAD": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ChangeNickname": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageNicknames": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageRoles": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageWebhooks": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageEmojisAndStickers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageGuildExpressions": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseApplicationCommands": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "RequestToSpeak": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageEvents": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ManageThreads": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CreatePublicThreads": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CreatePrivateThreads": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseExternalStickers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SendMessagesInThreads": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseEmbeddedActivities": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ModerateMembers": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ViewCreatorMonetizationAnalytics": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseSoundboard": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CreateGuildExpressions": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CreateEvents": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseExternalSounds": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SendVoiceMessages": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SendPolls": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "UseExternalApps": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CREATE_INSTANT_INVITE": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "KICK_MEMBERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "BAN_MEMBERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ADMINISTRATOR": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_CHANNELS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_GUILD": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ADD_REACTIONS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "VIEW_AUDIT_LOG": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "PRIORITY_SPEAKER": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "STREAM": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "VIEW_CHANNEL": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SEND_MESSAGES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SEND_TTSMESSAGES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_MESSAGES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "EMBED_LINKS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "ATTACH_FILES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "READ_MESSAGE_HISTORY": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MENTION_EVERYONE": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "USE_EXTERNAL_EMOJIS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "VIEW_GUILD_INSIGHTS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CONNECT": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SPEAK": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MUTE_MEMBERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "DEAFEN_MEMBERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MOVE_MEMBERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "USE_VAD": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CHANGE_NICKNAME": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_NICKNAMES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_ROLES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_WEBHOOKS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_EMOJIS_AND_STICKERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "USE_APPLICATION_COMMANDS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "REQUEST_TO_SPEAK": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_EVENTS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MANAGE_THREADS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CREATE_PUBLIC_THREADS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "CREATE_PRIVATE_THREADS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "USE_EXTERNAL_STICKERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "SEND_MESSAGES_IN_THREADS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "USE_EMBEDDED_ACTIVITIES": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "MODERATE_MEMBERS": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "required": [], "additionalProperties": false }, "pause_invites": { "type": "object", "properties": { "paused": { "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [], "additionalProperties": false } }, "required": [], "additionalProperties": false } }, "antiraid_levels": { "default": [ "low", "medium", "high" ], "maxItems": 10, "type": "array", "items": { "type": "string", "maxLength": 100 } }, "can_set_antiraid": { "default": false, "type": "boolean" }, "can_view_antiraid": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "auto_reactions": { "type": "object", "properties": { "config": { "type": "object", "properties": { "can_manage": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "can_manage": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "cases": { "type": "object", "properties": { "config": { "type": "object", "properties": { "log_automatic_actions": { "default": true, "type": "boolean" }, "case_log_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "show_relative_times": { "default": true, "type": "boolean" }, "relative_time_cutoff": { "default": "1w", "type": "string", "maxLength": 32 }, "case_colors": { "default": null, "anyOf": [ { "type": "object", "properties": { "ban": { "type": "string" }, "unban": { "type": "string" }, "note": { "type": "string" }, "warn": { "type": "string" }, "kick": { "type": "string" }, "mute": { "type": "string" }, "unmute": { "type": "string" }, "deleted": { "type": "string" }, "softban": { "type": "string" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] }, "case_icons": { "default": null, "anyOf": [ { "type": "object", "properties": { "ban": { "type": "string" }, "unban": { "type": "string" }, "note": { "type": "string" }, "warn": { "type": "string" }, "kick": { "type": "string" }, "mute": { "type": "string" }, "unmute": { "type": "string" }, "deleted": { "type": "string" }, "softban": { "type": "string" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "log_automatic_actions": { "default": true, "type": "boolean" }, "case_log_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "show_relative_times": { "default": true, "type": "boolean" }, "relative_time_cutoff": { "default": "1w", "type": "string", "maxLength": 32 }, "case_colors": { "default": null, "anyOf": [ { "type": "object", "properties": { "ban": { "type": "string" }, "unban": { "type": "string" }, "note": { "type": "string" }, "warn": { "type": "string" }, "kick": { "type": "string" }, "mute": { "type": "string" }, "unmute": { "type": "string" }, "deleted": { "type": "string" }, "softban": { "type": "string" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] }, "case_icons": { "default": null, "anyOf": [ { "type": "object", "properties": { "ban": { "type": "string" }, "unban": { "type": "string" }, "note": { "type": "string" }, "warn": { "type": "string" }, "kick": { "type": "string" }, "mute": { "type": "string" }, "unmute": { "type": "string" }, "deleted": { "type": "string" }, "softban": { "type": "string" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "censor": { "type": "object", "properties": { "config": { "type": "object", "properties": { "filter_zalgo": { "default": false, "type": "boolean" }, "filter_invites": { "default": false, "type": "boolean" }, "invite_guild_whitelist": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "invite_guild_blacklist": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "invite_code_whitelist": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "invite_code_blacklist": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "allow_group_dm_invites": { "default": false, "type": "boolean" }, "filter_domains": { "default": false, "type": "boolean" }, "domain_whitelist": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "domain_blacklist": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "blocked_tokens": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "blocked_words": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "blocked_regex": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string", "maxLength": 1000 } }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "filter_zalgo": { "default": false, "type": "boolean" }, "filter_invites": { "default": false, "type": "boolean" }, "invite_guild_whitelist": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "invite_guild_blacklist": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "invite_code_whitelist": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "invite_code_blacklist": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "allow_group_dm_invites": { "default": false, "type": "boolean" }, "filter_domains": { "default": false, "type": "boolean" }, "domain_whitelist": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "domain_blacklist": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "blocked_tokens": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "blocked_words": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "blocked_regex": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string", "maxLength": 1000 } }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "companion_channels": { "type": "object", "properties": { "config": { "type": "object", "properties": { "entries": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "voice_channel_ids": { "type": "array", "items": { "type": "string" } }, "text_channel_ids": { "type": "array", "items": { "type": "string" } }, "permissions": { "type": "number" }, "enabled": { "default": true, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "voice_channel_ids", "text_channel_ids", "permissions" ], "additionalProperties": false } } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "entries": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "voice_channel_ids": { "type": "array", "items": { "type": "string" } }, "text_channel_ids": { "type": "array", "items": { "type": "string" } }, "permissions": { "type": "number" }, "enabled": { "default": true, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "context_menu": { "type": "object", "properties": { "config": { "type": "object", "properties": { "can_use": { "default": false, "type": "boolean" }, "can_open_mod_menu": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "can_use": { "default": false, "type": "boolean" }, "can_open_mod_menu": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "counters": { "type": "object", "properties": { "config": { "type": "object", "properties": { "counters": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "pretty_name": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "per_channel": { "default": false, "type": "boolean" }, "per_user": { "default": false, "type": "boolean" }, "initial_value": { "default": 0, "type": "number", "minimum": 0, "maximum": 2147483647 }, "triggers": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "anyOf": [ { "type": "object", "properties": { "pretty_name": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "condition": { "type": "string" }, "reverse_condition": { "type": "string" } }, "required": [ "condition" ], "additionalProperties": false }, { "type": "string" } ] } }, "decay": { "default": null, "anyOf": [ { "type": "object", "properties": { "amount": { "type": "number" }, "every": { "type": "string", "maxLength": 32 } }, "required": [ "amount", "every" ], "additionalProperties": false }, { "type": "null" } ] }, "can_view": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "can_edit": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "can_reset_all": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "triggers" ], "additionalProperties": false } }, "can_view": { "default": false, "type": "boolean" }, "can_edit": { "default": false, "type": "boolean" }, "can_reset_all": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "counters": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "pretty_name": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "per_channel": { "default": false, "type": "boolean" }, "per_user": { "default": false, "type": "boolean" }, "initial_value": { "default": 0, "type": "number", "minimum": 0, "maximum": 2147483647 }, "triggers": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "anyOf": [ { "type": "object", "properties": { "pretty_name": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "condition": { "type": "string" }, "reverse_condition": { "type": "string" } }, "required": [], "additionalProperties": false }, { "type": "string" } ] } }, "decay": { "default": null, "anyOf": [ { "type": "object", "properties": { "amount": { "type": "number" }, "every": { "type": "string", "maxLength": 32 } }, "required": [], "additionalProperties": false }, { "type": "null" } ] }, "can_view": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "can_edit": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "can_reset_all": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "can_view": { "default": false, "type": "boolean" }, "can_edit": { "default": false, "type": "boolean" }, "can_reset_all": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "custom_events": { "type": "object", "properties": { "config": { "type": "object", "properties": { "events": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "name": { "type": "string" }, "trigger": { "type": "object", "properties": { "type": { "const": "command" }, "name": { "type": "string" }, "params": { "type": "string" }, "can_use": { "type": "boolean" } }, "required": [ "type", "name", "params", "can_use" ], "additionalProperties": false }, "actions": { "maxItems": 10, "type": "array", "items": { "anyOf": [ { "type": "object", "properties": { "type": { "const": "add_role" }, "target": { "type": "string" }, "role": { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] } }, "required": [ "type", "target", "role" ], "additionalProperties": false }, { "type": "object", "properties": { "type": { "const": "create_case" }, "case_type": { "type": "string" }, "mod": { "type": "string" }, "target": { "type": "string" }, "reason": { "type": "string" } }, "required": [ "type", "case_type", "mod", "target", "reason" ], "additionalProperties": false }, { "type": "object", "properties": { "type": { "const": "move_to_vc" }, "target": { "type": "string" }, "channel": { "type": "string" } }, "required": [ "type", "target", "channel" ], "additionalProperties": false }, { "type": "object", "properties": { "type": { "const": "message" }, "channel": { "type": "string" }, "content": { "type": "string" } }, "required": [ "type", "channel", "content" ], "additionalProperties": false }, { "type": "object", "properties": { "type": { "const": "make_role_mentionable" }, "role": { "type": "string" }, "timeout": { "type": "string", "maxLength": 32 } }, "required": [ "type", "role", "timeout" ], "additionalProperties": false }, { "type": "object", "properties": { "type": { "const": "make_role_unmentionable" }, "role": { "type": "string" } }, "required": [ "type", "role" ], "additionalProperties": false }, { "type": "object", "properties": { "type": { "const": "set_channel_permission_overrides" }, "channel": { "type": "string" }, "overrides": { "maxItems": 15, "type": "array", "items": { "type": "object", "properties": { "type": { "anyOf": [ { "const": "member" }, { "const": "role" } ] }, "id": { "type": "string" }, "allow": { "type": "number" }, "deny": { "type": "number" } }, "required": [ "type", "id", "allow", "deny" ], "additionalProperties": false } } }, "required": [ "type", "channel", "overrides" ], "additionalProperties": false } ] } } }, "required": [ "name", "trigger", "actions" ], "additionalProperties": false } } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "events": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "name": { "type": "string" }, "trigger": { "type": "object", "properties": { "type": { "const": "command" }, "name": { "type": "string" }, "params": { "type": "string" }, "can_use": { "type": "boolean" } }, "required": [], "additionalProperties": false }, "actions": { "maxItems": 10, "type": "array", "items": { "anyOf": [ { "type": "object", "properties": { "type": { "const": "add_role" }, "target": { "type": "string" }, "role": { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] } }, "required": [], "additionalProperties": false }, { "type": "object", "properties": { "type": { "const": "create_case" }, "case_type": { "type": "string" }, "mod": { "type": "string" }, "target": { "type": "string" }, "reason": { "type": "string" } }, "required": [], "additionalProperties": false }, { "type": "object", "properties": { "type": { "const": "move_to_vc" }, "target": { "type": "string" }, "channel": { "type": "string" } }, "required": [], "additionalProperties": false }, { "type": "object", "properties": { "type": { "const": "message" }, "channel": { "type": "string" }, "content": { "type": "string" } }, "required": [], "additionalProperties": false }, { "type": "object", "properties": { "type": { "const": "make_role_mentionable" }, "role": { "type": "string" }, "timeout": { "type": "string", "maxLength": 32 } }, "required": [], "additionalProperties": false }, { "type": "object", "properties": { "type": { "const": "make_role_unmentionable" }, "role": { "type": "string" } }, "required": [], "additionalProperties": false }, { "type": "object", "properties": { "type": { "const": "set_channel_permission_overrides" }, "channel": { "type": "string" }, "overrides": { "maxItems": 15, "type": "array", "items": { "type": "object", "properties": { "type": { "anyOf": [ { "const": "member" }, { "const": "role" } ] }, "id": { "type": "string" }, "allow": { "type": "number" }, "deny": { "type": "number" } }, "required": [], "additionalProperties": false } } }, "required": [], "additionalProperties": false } ] } } }, "required": [], "additionalProperties": false } } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "guild_info_saver": { "type": "object", "properties": { "config": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": {}, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "internal_poster": { "type": "object", "properties": { "config": { "default": {}, "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "default": {}, "type": "object", "properties": {}, "required": [], "additionalProperties": false } }, "required": [], "additionalProperties": false } } }, "required": [] }, "locate_user": { "type": "object", "properties": { "config": { "type": "object", "properties": { "can_where": { "default": false, "type": "boolean" }, "can_alert": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "can_where": { "default": false, "type": "boolean" }, "can_alert": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "logs": { "type": "object", "properties": { "config": { "type": "object", "properties": { "channels": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "include": { "default": [], "type": "array", "items": { "type": "string" } }, "exclude": { "default": [], "type": "array", "items": { "type": "string" } }, "batched": { "default": true, "type": "boolean" }, "batch_time": { "default": 1000, "type": "number", "minimum": 250, "maximum": 5000 }, "excluded_users": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "excluded_message_regexes": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "excluded_channels": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "excluded_categories": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "excluded_threads": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "exclude_bots": { "default": false, "type": "boolean" }, "excluded_roles": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "format": { "default": {}, "type": "object", "properties": { "MEMBER_WARN": { "default": "{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_MUTE": { "default": "{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_UNMUTE": { "default": "{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_MUTE_EXPIRED": { "default": "{timestamp} 🔊 {userMention(member)}'s mute expired", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_KICK": { "default": "{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_BAN": { "default": "{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_UNBAN": { "default": "{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_FORCEBAN": { "default": "{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_SOFTBAN": { "default": "{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_JOIN": { "default": "{timestamp} 📥 {new} {userMention(member)} joined (created )", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_LEAVE": { "default": "{timestamp} 📤 {userMention(member)} left the server", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_ROLE_ADD": { "default": "{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_ROLE_REMOVE": { "default": "{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_NICK_CHANGE": { "default": "{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_USERNAME_CHANGE": { "default": "{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_RESTORE": { "default": "{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CHANNEL_CREATE": { "default": "{timestamp} 🖊 Channel {channelMention(channel)} was created", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CHANNEL_DELETE": { "default": "{timestamp} 🗑 Channel {channelMention(channel)} was deleted", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CHANNEL_UPDATE": { "default": "{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "THREAD_CREATE": { "default": "{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "THREAD_DELETE": { "default": "{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "THREAD_UPDATE": { "default": "{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "ROLE_CREATE": { "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "ROLE_DELETE": { "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "ROLE_UPDATE": { "default": "{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_EDIT": { "default": "{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE": { "default": "{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE_BULK": { "default": "{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE_BARE": { "default": "{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_JOIN": { "default": "{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_LEAVE": { "default": "{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_MOVE": { "default": "{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STAGE_INSTANCE_CREATE": { "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STAGE_INSTANCE_DELETE": { "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STAGE_INSTANCE_UPDATE": { "default": "{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "EMOJI_CREATE": { "default": "{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "EMOJI_DELETE": { "default": "{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "EMOJI_UPDATE": { "default": "{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STICKER_CREATE": { "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STICKER_DELETE": { "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STICKER_UPDATE": { "default": "{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "COMMAND": { "default": "{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_SPAM_DETECTED": { "default": "{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\n{archiveUrl}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CENSOR": { "default": "{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\n```{messageText}```", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CLEAN": { "default": "{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\n{archiveUrl}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CASE_CREATE": { "default": "{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MASSUNBAN": { "default": "{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MASSBAN": { "default": "{timestamp} ⚒ {userMention(mod)} massbanned {count} users", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MASSMUTE": { "default": "{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_MUTE": { "default": "{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_UNMUTE": { "default": "{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_BAN": { "default": "{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_UNBAN": { "default": "{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_JOIN_WITH_PRIOR_RECORDS": { "default": "{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "OTHER_SPAM_DETECTED": { "default": "{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_ROLE_CHANGES": { "default": "{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_FORCE_MOVE": { "default": "{timestamp} 🎙 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_FORCE_DISCONNECT": { "default": "{timestamp} 🎙 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CASE_UPDATE": { "default": "{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_MUTE_REJOIN": { "default": "{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SCHEDULED_MESSAGE": { "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "POSTED_SCHEDULED_MESSAGE": { "default": "{timestamp} 📨 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "BOT_ALERT": { "default": "{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "AUTOMOD_ACTION": { "default": "{timestamp} 🤖 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\n{matchSummary}\nActions taken: **{actionsTaken}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SCHEDULED_REPEATED_MESSAGE": { "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "REPEATED_MESSAGE": { "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE_AUTO": { "default": "{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SET_ANTIRAID_USER": { "default": "{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SET_ANTIRAID_AUTO": { "default": "{timestamp} ⚔ Anti-raid automatically set to **{level}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_NOTE": { "default": "{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CASE_DELETE": { "default": "{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "DM_FAILED": { "default": "{timestamp} 🚧 Failed to send DM ({source}) to {userMention(user)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] } }, "required": [], "additionalProperties": false }, "timestamp_format": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "include_embed_timestamp": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "format": { "default": {}, "type": "object", "properties": { "MEMBER_WARN": { "default": "{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_MUTE": { "default": "{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_UNMUTE": { "default": "{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_MUTE_EXPIRED": { "default": "{timestamp} 🔊 {userMention(member)}'s mute expired", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_KICK": { "default": "{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_BAN": { "default": "{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_UNBAN": { "default": "{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_FORCEBAN": { "default": "{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_SOFTBAN": { "default": "{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_JOIN": { "default": "{timestamp} 📥 {new} {userMention(member)} joined (created )", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_LEAVE": { "default": "{timestamp} 📤 {userMention(member)} left the server", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_ROLE_ADD": { "default": "{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_ROLE_REMOVE": { "default": "{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_NICK_CHANGE": { "default": "{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_USERNAME_CHANGE": { "default": "{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_RESTORE": { "default": "{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CHANNEL_CREATE": { "default": "{timestamp} 🖊 Channel {channelMention(channel)} was created", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CHANNEL_DELETE": { "default": "{timestamp} 🗑 Channel {channelMention(channel)} was deleted", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CHANNEL_UPDATE": { "default": "{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "THREAD_CREATE": { "default": "{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "THREAD_DELETE": { "default": "{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "THREAD_UPDATE": { "default": "{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "ROLE_CREATE": { "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "ROLE_DELETE": { "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "ROLE_UPDATE": { "default": "{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_EDIT": { "default": "{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE": { "default": "{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE_BULK": { "default": "{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE_BARE": { "default": "{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_JOIN": { "default": "{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_LEAVE": { "default": "{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_MOVE": { "default": "{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STAGE_INSTANCE_CREATE": { "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STAGE_INSTANCE_DELETE": { "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STAGE_INSTANCE_UPDATE": { "default": "{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "EMOJI_CREATE": { "default": "{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "EMOJI_DELETE": { "default": "{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "EMOJI_UPDATE": { "default": "{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STICKER_CREATE": { "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STICKER_DELETE": { "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STICKER_UPDATE": { "default": "{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "COMMAND": { "default": "{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_SPAM_DETECTED": { "default": "{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\n{archiveUrl}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CENSOR": { "default": "{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\n```{messageText}```", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CLEAN": { "default": "{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\n{archiveUrl}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CASE_CREATE": { "default": "{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MASSUNBAN": { "default": "{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MASSBAN": { "default": "{timestamp} ⚒ {userMention(mod)} massbanned {count} users", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MASSMUTE": { "default": "{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_MUTE": { "default": "{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_UNMUTE": { "default": "{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_BAN": { "default": "{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_UNBAN": { "default": "{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_JOIN_WITH_PRIOR_RECORDS": { "default": "{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "OTHER_SPAM_DETECTED": { "default": "{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_ROLE_CHANGES": { "default": "{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_FORCE_MOVE": { "default": "{timestamp} 🎙 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_FORCE_DISCONNECT": { "default": "{timestamp} 🎙 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CASE_UPDATE": { "default": "{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_MUTE_REJOIN": { "default": "{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SCHEDULED_MESSAGE": { "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "POSTED_SCHEDULED_MESSAGE": { "default": "{timestamp} 📨 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "BOT_ALERT": { "default": "{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "AUTOMOD_ACTION": { "default": "{timestamp} 🤖 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\n{matchSummary}\nActions taken: **{actionsTaken}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SCHEDULED_REPEATED_MESSAGE": { "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "REPEATED_MESSAGE": { "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE_AUTO": { "default": "{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SET_ANTIRAID_USER": { "default": "{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SET_ANTIRAID_AUTO": { "default": "{timestamp} ⚔ Anti-raid automatically set to **{level}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_NOTE": { "default": "{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CASE_DELETE": { "default": "{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "DM_FAILED": { "default": "{timestamp} 🚧 Failed to send DM ({source}) to {userMention(user)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] } }, "required": [], "additionalProperties": false }, "ping_user": { "default": true, "type": "boolean" }, "allow_user_mentions": { "default": false, "type": "boolean" }, "timestamp_format": { "default": "[]", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "include_embed_timestamp": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "channels": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "include": { "default": [], "type": "array", "items": { "type": "string" } }, "exclude": { "default": [], "type": "array", "items": { "type": "string" } }, "batched": { "default": true, "type": "boolean" }, "batch_time": { "default": 1000, "type": "number", "minimum": 250, "maximum": 5000 }, "excluded_users": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "excluded_message_regexes": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "excluded_channels": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "excluded_categories": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "excluded_threads": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "exclude_bots": { "default": false, "type": "boolean" }, "excluded_roles": { "default": null, "anyOf": [ { "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "format": { "default": {}, "type": "object", "properties": { "MEMBER_WARN": { "default": "{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_MUTE": { "default": "{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_UNMUTE": { "default": "{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_MUTE_EXPIRED": { "default": "{timestamp} 🔊 {userMention(member)}'s mute expired", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_KICK": { "default": "{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_BAN": { "default": "{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_UNBAN": { "default": "{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_FORCEBAN": { "default": "{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_SOFTBAN": { "default": "{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_JOIN": { "default": "{timestamp} 📥 {new} {userMention(member)} joined (created )", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_LEAVE": { "default": "{timestamp} 📤 {userMention(member)} left the server", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_ROLE_ADD": { "default": "{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_ROLE_REMOVE": { "default": "{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_NICK_CHANGE": { "default": "{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_USERNAME_CHANGE": { "default": "{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_RESTORE": { "default": "{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CHANNEL_CREATE": { "default": "{timestamp} 🖊 Channel {channelMention(channel)} was created", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CHANNEL_DELETE": { "default": "{timestamp} 🗑 Channel {channelMention(channel)} was deleted", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CHANNEL_UPDATE": { "default": "{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "THREAD_CREATE": { "default": "{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "THREAD_DELETE": { "default": "{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "THREAD_UPDATE": { "default": "{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "ROLE_CREATE": { "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "ROLE_DELETE": { "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "ROLE_UPDATE": { "default": "{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_EDIT": { "default": "{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE": { "default": "{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE_BULK": { "default": "{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE_BARE": { "default": "{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_JOIN": { "default": "{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_LEAVE": { "default": "{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_MOVE": { "default": "{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STAGE_INSTANCE_CREATE": { "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STAGE_INSTANCE_DELETE": { "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STAGE_INSTANCE_UPDATE": { "default": "{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "EMOJI_CREATE": { "default": "{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "EMOJI_DELETE": { "default": "{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "EMOJI_UPDATE": { "default": "{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STICKER_CREATE": { "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STICKER_DELETE": { "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STICKER_UPDATE": { "default": "{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "COMMAND": { "default": "{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_SPAM_DETECTED": { "default": "{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\n{archiveUrl}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CENSOR": { "default": "{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\n```{messageText}```", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CLEAN": { "default": "{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\n{archiveUrl}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CASE_CREATE": { "default": "{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MASSUNBAN": { "default": "{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MASSBAN": { "default": "{timestamp} ⚒ {userMention(mod)} massbanned {count} users", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MASSMUTE": { "default": "{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_MUTE": { "default": "{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_UNMUTE": { "default": "{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_BAN": { "default": "{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_UNBAN": { "default": "{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_JOIN_WITH_PRIOR_RECORDS": { "default": "{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "OTHER_SPAM_DETECTED": { "default": "{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_ROLE_CHANGES": { "default": "{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_FORCE_MOVE": { "default": "{timestamp} 🎙 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_FORCE_DISCONNECT": { "default": "{timestamp} 🎙 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CASE_UPDATE": { "default": "{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_MUTE_REJOIN": { "default": "{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SCHEDULED_MESSAGE": { "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "POSTED_SCHEDULED_MESSAGE": { "default": "{timestamp} 📨 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "BOT_ALERT": { "default": "{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "AUTOMOD_ACTION": { "default": "{timestamp} 🤖 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\n{matchSummary}\nActions taken: **{actionsTaken}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SCHEDULED_REPEATED_MESSAGE": { "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "REPEATED_MESSAGE": { "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE_AUTO": { "default": "{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SET_ANTIRAID_USER": { "default": "{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SET_ANTIRAID_AUTO": { "default": "{timestamp} ⚔ Anti-raid automatically set to **{level}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_NOTE": { "default": "{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CASE_DELETE": { "default": "{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "DM_FAILED": { "default": "{timestamp} 🚧 Failed to send DM ({source}) to {userMention(user)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] } }, "required": [], "additionalProperties": false }, "timestamp_format": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "include_embed_timestamp": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "format": { "default": {}, "type": "object", "properties": { "MEMBER_WARN": { "default": "{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_MUTE": { "default": "{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_UNMUTE": { "default": "{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_MUTE_EXPIRED": { "default": "{timestamp} 🔊 {userMention(member)}'s mute expired", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_KICK": { "default": "{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_BAN": { "default": "{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_UNBAN": { "default": "{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_FORCEBAN": { "default": "{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_SOFTBAN": { "default": "{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_JOIN": { "default": "{timestamp} 📥 {new} {userMention(member)} joined (created )", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_LEAVE": { "default": "{timestamp} 📤 {userMention(member)} left the server", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_ROLE_ADD": { "default": "{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_ROLE_REMOVE": { "default": "{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_NICK_CHANGE": { "default": "{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_USERNAME_CHANGE": { "default": "{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_RESTORE": { "default": "{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CHANNEL_CREATE": { "default": "{timestamp} 🖊 Channel {channelMention(channel)} was created", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CHANNEL_DELETE": { "default": "{timestamp} 🗑 Channel {channelMention(channel)} was deleted", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CHANNEL_UPDATE": { "default": "{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "THREAD_CREATE": { "default": "{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "THREAD_DELETE": { "default": "{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "THREAD_UPDATE": { "default": "{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "ROLE_CREATE": { "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "ROLE_DELETE": { "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "ROLE_UPDATE": { "default": "{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_EDIT": { "default": "{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE": { "default": "{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE_BULK": { "default": "{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE_BARE": { "default": "{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_JOIN": { "default": "{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_LEAVE": { "default": "{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_MOVE": { "default": "{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STAGE_INSTANCE_CREATE": { "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STAGE_INSTANCE_DELETE": { "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STAGE_INSTANCE_UPDATE": { "default": "{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "EMOJI_CREATE": { "default": "{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "EMOJI_DELETE": { "default": "{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "EMOJI_UPDATE": { "default": "{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STICKER_CREATE": { "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STICKER_DELETE": { "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "STICKER_UPDATE": { "default": "{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\n{differenceString}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "COMMAND": { "default": "{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_SPAM_DETECTED": { "default": "{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\n{archiveUrl}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CENSOR": { "default": "{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\n```{messageText}```", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CLEAN": { "default": "{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\n{archiveUrl}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CASE_CREATE": { "default": "{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MASSUNBAN": { "default": "{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MASSBAN": { "default": "{timestamp} ⚒ {userMention(mod)} massbanned {count} users", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MASSMUTE": { "default": "{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_MUTE": { "default": "{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_UNMUTE": { "default": "{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_BAN": { "default": "{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_TIMED_UNBAN": { "default": "{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_JOIN_WITH_PRIOR_RECORDS": { "default": "{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "OTHER_SPAM_DETECTED": { "default": "{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_ROLE_CHANGES": { "default": "{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_FORCE_MOVE": { "default": "{timestamp} 🎙 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "VOICE_CHANNEL_FORCE_DISCONNECT": { "default": "{timestamp} 🎙 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CASE_UPDATE": { "default": "{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_MUTE_REJOIN": { "default": "{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SCHEDULED_MESSAGE": { "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "POSTED_SCHEDULED_MESSAGE": { "default": "{timestamp} 📨 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "BOT_ALERT": { "default": "{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "AUTOMOD_ACTION": { "default": "{timestamp} 🤖 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\n{matchSummary}\nActions taken: **{actionsTaken}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SCHEDULED_REPEATED_MESSAGE": { "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "REPEATED_MESSAGE": { "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MESSAGE_DELETE_AUTO": { "default": "{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}{replyInfo}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SET_ANTIRAID_USER": { "default": "{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "SET_ANTIRAID_AUTO": { "default": "{timestamp} ⚔ Anti-raid automatically set to **{level}**", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "MEMBER_NOTE": { "default": "{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "CASE_DELETE": { "default": "{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] }, "DM_FAILED": { "default": "{timestamp} 🚧 Failed to send DM ({source}) to {userMention(user)}", "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] } }, "required": [], "additionalProperties": false }, "ping_user": { "default": true, "type": "boolean" }, "allow_user_mentions": { "default": false, "type": "boolean" }, "timestamp_format": { "default": "[]", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "include_embed_timestamp": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "message_saver": { "type": "object", "properties": { "config": { "type": "object", "properties": { "can_manage": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "can_manage": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "mod_actions": { "type": "object", "properties": { "config": { "type": "object", "properties": { "dm_on_warn": { "default": true, "type": "boolean" }, "dm_on_kick": { "default": false, "type": "boolean" }, "dm_on_ban": { "default": false, "type": "boolean" }, "message_on_warn": { "default": false, "type": "boolean" }, "message_on_kick": { "default": false, "type": "boolean" }, "message_on_ban": { "default": false, "type": "boolean" }, "message_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "warn_message": { "default": "You have received a warning on the {guildName} server: {reason}", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "kick_message": { "default": "You have been kicked from the {guildName} server. Reason given: {reason}", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "ban_message": { "default": "You have been banned from the {guildName} server. Reason given: {reason}", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "tempban_message": { "default": "You have been banned from the {guildName} server for {banTime}. Reason given: {reason}", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "alert_on_rejoin": { "default": false, "type": "boolean" }, "alert_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "warn_notify_enabled": { "default": false, "type": "boolean" }, "warn_notify_threshold": { "default": 5, "type": "number" }, "warn_notify_message": { "default": "The user already has **{priorWarnings}** warnings!\n Please check their prior cases and assess whether or not to warn anyways.\n Proceed with the warning?", "type": "string" }, "ban_delete_message_days": { "default": 1, "type": "number" }, "attachment_link_reaction": { "default": "warn", "anyOf": [ { "anyOf": [ { "const": "none" }, { "const": "warn" }, { "const": "restrict" } ] }, { "type": "null" } ] }, "can_note": { "default": false, "type": "boolean" }, "can_warn": { "default": false, "type": "boolean" }, "can_mute": { "default": false, "type": "boolean" }, "can_kick": { "default": false, "type": "boolean" }, "can_ban": { "default": false, "type": "boolean" }, "can_unban": { "default": false, "type": "boolean" }, "can_view": { "default": false, "type": "boolean" }, "can_addcase": { "default": false, "type": "boolean" }, "can_massunban": { "default": false, "type": "boolean" }, "can_massban": { "default": false, "type": "boolean" }, "can_massmute": { "default": false, "type": "boolean" }, "can_hidecase": { "default": false, "type": "boolean" }, "can_deletecase": { "default": false, "type": "boolean" }, "can_act_as_other": { "default": false, "type": "boolean" }, "create_cases_for_manual_actions": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "dm_on_warn": { "default": true, "type": "boolean" }, "dm_on_kick": { "default": false, "type": "boolean" }, "dm_on_ban": { "default": false, "type": "boolean" }, "message_on_warn": { "default": false, "type": "boolean" }, "message_on_kick": { "default": false, "type": "boolean" }, "message_on_ban": { "default": false, "type": "boolean" }, "message_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "warn_message": { "default": "You have received a warning on the {guildName} server: {reason}", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "kick_message": { "default": "You have been kicked from the {guildName} server. Reason given: {reason}", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "ban_message": { "default": "You have been banned from the {guildName} server. Reason given: {reason}", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "tempban_message": { "default": "You have been banned from the {guildName} server for {banTime}. Reason given: {reason}", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "alert_on_rejoin": { "default": false, "type": "boolean" }, "alert_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "warn_notify_enabled": { "default": false, "type": "boolean" }, "warn_notify_threshold": { "default": 5, "type": "number" }, "warn_notify_message": { "default": "The user already has **{priorWarnings}** warnings!\n Please check their prior cases and assess whether or not to warn anyways.\n Proceed with the warning?", "type": "string" }, "ban_delete_message_days": { "default": 1, "type": "number" }, "attachment_link_reaction": { "default": "warn", "anyOf": [ { "anyOf": [ { "const": "none" }, { "const": "warn" }, { "const": "restrict" } ] }, { "type": "null" } ] }, "can_note": { "default": false, "type": "boolean" }, "can_warn": { "default": false, "type": "boolean" }, "can_mute": { "default": false, "type": "boolean" }, "can_kick": { "default": false, "type": "boolean" }, "can_ban": { "default": false, "type": "boolean" }, "can_unban": { "default": false, "type": "boolean" }, "can_view": { "default": false, "type": "boolean" }, "can_addcase": { "default": false, "type": "boolean" }, "can_massunban": { "default": false, "type": "boolean" }, "can_massban": { "default": false, "type": "boolean" }, "can_massmute": { "default": false, "type": "boolean" }, "can_hidecase": { "default": false, "type": "boolean" }, "can_deletecase": { "default": false, "type": "boolean" }, "can_act_as_other": { "default": false, "type": "boolean" }, "create_cases_for_manual_actions": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "mutes": { "type": "object", "properties": { "config": { "type": "object", "properties": { "mute_role": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "move_to_voice_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "kick_from_voice_channel": { "default": false, "type": "boolean" }, "dm_on_mute": { "default": false, "type": "boolean" }, "dm_on_update": { "default": false, "type": "boolean" }, "message_on_mute": { "default": false, "type": "boolean" }, "message_on_update": { "default": false, "type": "boolean" }, "message_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "mute_message": { "default": "You have been muted on the {guildName} server. Reason given: {reason}", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "timed_mute_message": { "default": "You have been muted on the {guildName} server for {time}. Reason given: {reason}", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "update_mute_message": { "default": "Your mute on the {guildName} server has been updated to {time}.", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "can_view_list": { "default": false, "type": "boolean" }, "can_cleanup": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "mute_role": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "move_to_voice_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "kick_from_voice_channel": { "default": false, "type": "boolean" }, "dm_on_mute": { "default": false, "type": "boolean" }, "dm_on_update": { "default": false, "type": "boolean" }, "message_on_mute": { "default": false, "type": "boolean" }, "message_on_update": { "default": false, "type": "boolean" }, "message_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "mute_message": { "default": "You have been muted on the {guildName} server. Reason given: {reason}", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "timed_mute_message": { "default": "You have been muted on the {guildName} server for {time}. Reason given: {reason}", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "update_mute_message": { "default": "Your mute on the {guildName} server has been updated to {time}.", "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "can_view_list": { "default": false, "type": "boolean" }, "can_cleanup": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "name_history": { "type": "object", "properties": { "config": { "type": "object", "properties": { "can_view": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "can_view": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "persist": { "type": "object", "properties": { "config": { "type": "object", "properties": { "persisted_roles": { "default": [], "type": "array", "items": { "type": "string" } }, "persist_nicknames": { "default": false, "type": "boolean" }, "persist_voice_mutes": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "persisted_roles": { "default": [], "type": "array", "items": { "type": "string" } }, "persist_nicknames": { "default": false, "type": "boolean" }, "persist_voice_mutes": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "phisherman": { "type": "object", "properties": { "config": { "type": "object", "properties": { "api_key": { "default": null, "anyOf": [ { "type": "string", "maxLength": 255 }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "api_key": { "default": null, "anyOf": [ { "type": "string", "maxLength": 255 }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "pingable_roles": { "type": "object", "properties": { "config": { "type": "object", "properties": { "can_manage": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "can_manage": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "post": { "type": "object", "properties": { "config": { "type": "object", "properties": { "can_post": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "can_post": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "reaction_roles": { "type": "object", "properties": { "config": { "type": "object", "properties": { "auto_refresh_interval": { "default": 900000, "type": "number", "minimum": 900000 }, "remove_user_reactions": { "default": true, "type": "boolean" }, "can_manage": { "default": false, "type": "boolean" }, "button_groups": { "default": null, "type": "null" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "auto_refresh_interval": { "default": 900000, "type": "number", "minimum": 900000 }, "remove_user_reactions": { "default": true, "type": "boolean" }, "can_manage": { "default": false, "type": "boolean" }, "button_groups": { "default": null, "type": "null" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "reminders": { "type": "object", "properties": { "config": { "type": "object", "properties": { "can_use": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "can_use": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "role_buttons": { "type": "object", "properties": { "config": { "type": "object", "properties": { "buttons": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "message": { "anyOf": [ { "type": "object", "properties": { "channel_id": { "type": "string" }, "message_id": { "type": "string" } }, "required": [ "channel_id", "message_id" ], "additionalProperties": false }, { "type": "object", "properties": { "channel_id": { "type": "string" }, "content": { "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] } }, "required": [ "channel_id", "content" ], "additionalProperties": false } ] }, "options": { "maxItems": 25, "type": "array", "items": { "type": "object", "properties": { "role_id": { "type": "string" }, "label": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "emoji": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "style": { "default": null, "anyOf": [ { "anyOf": [ { "const": 1 }, { "const": 2 }, { "const": 3 }, { "const": 4 }, { "const": "PRIMARY" }, { "const": "SECONDARY" }, { "const": "SUCCESS" }, { "const": "DANGER" } ] }, { "type": "null" } ] }, "start_new_row": { "default": false, "type": "boolean" } }, "required": [ "role_id" ], "additionalProperties": false } }, "exclusive": { "default": false, "type": "boolean" } }, "required": [ "message", "options" ], "additionalProperties": false } }, "can_reset": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "buttons": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "message": { "anyOf": [ { "type": "object", "properties": { "channel_id": { "type": "string" }, "message_id": { "type": "string" } }, "required": [], "additionalProperties": false }, { "type": "object", "properties": { "channel_id": { "type": "string" }, "content": { "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] } }, "required": [], "additionalProperties": false } ] }, "options": { "maxItems": 25, "type": "array", "items": { "type": "object", "properties": { "role_id": { "type": "string" }, "label": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "emoji": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "style": { "default": null, "anyOf": [ { "anyOf": [ { "const": 1 }, { "const": 2 }, { "const": 3 }, { "const": 4 }, { "const": "PRIMARY" }, { "const": "SECONDARY" }, { "const": "SUCCESS" }, { "const": "DANGER" } ] }, { "type": "null" } ] }, "start_new_row": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "exclusive": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "can_reset": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "role_manager": { "type": "object", "properties": { "config": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": {}, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "roles": { "type": "object", "properties": { "config": { "type": "object", "properties": { "can_assign": { "default": false, "type": "boolean" }, "can_mass_assign": { "default": false, "type": "boolean" }, "assignable_roles": { "default": [], "maxItems": 100, "type": "array", "items": { "type": "string" } } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "can_assign": { "default": false, "type": "boolean" }, "can_mass_assign": { "default": false, "type": "boolean" }, "assignable_roles": { "default": [], "maxItems": 100, "type": "array", "items": { "type": "string" } } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "self_grantable_roles": { "type": "object", "properties": { "config": { "type": "object", "properties": { "entries": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "roles": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "maxItems": 100, "type": "array", "items": { "type": "string" } } }, "can_use": { "default": false, "type": "boolean" }, "can_ignore_cooldown": { "default": false, "type": "boolean" }, "max_roles": { "default": 0, "type": "number" } }, "required": [ "roles" ], "additionalProperties": false } }, "mention_roles": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "entries": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "roles": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "maxItems": 100, "type": "array", "items": { "type": "string" } } }, "can_use": { "default": false, "type": "boolean" }, "can_ignore_cooldown": { "default": false, "type": "boolean" }, "max_roles": { "default": 0, "type": "number" } }, "required": [], "additionalProperties": false } }, "mention_roles": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "slowmode": { "type": "object", "properties": { "config": { "type": "object", "properties": { "use_native_slowmode": { "default": true, "type": "boolean" }, "can_manage": { "default": false, "type": "boolean" }, "is_affected": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "use_native_slowmode": { "default": true, "type": "boolean" }, "can_manage": { "default": false, "type": "boolean" }, "is_affected": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "spam": { "type": "object", "properties": { "config": { "type": "object", "properties": { "max_censor": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [ "interval", "count" ], "additionalProperties": false }, { "type": "null" } ] }, "max_messages": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [ "interval", "count" ], "additionalProperties": false }, { "type": "null" } ] }, "max_mentions": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [ "interval", "count" ], "additionalProperties": false }, { "type": "null" } ] }, "max_links": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [ "interval", "count" ], "additionalProperties": false }, { "type": "null" } ] }, "max_attachments": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [ "interval", "count" ], "additionalProperties": false }, { "type": "null" } ] }, "max_emojis": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [ "interval", "count" ], "additionalProperties": false }, { "type": "null" } ] }, "max_newlines": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [ "interval", "count" ], "additionalProperties": false }, { "type": "null" } ] }, "max_duplicates": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [ "interval", "count" ], "additionalProperties": false }, { "type": "null" } ] }, "max_characters": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [ "interval", "count" ], "additionalProperties": false }, { "type": "null" } ] }, "max_voice_moves": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [ "interval", "count" ], "additionalProperties": false }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "max_censor": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] }, "max_messages": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] }, "max_mentions": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] }, "max_links": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] }, "max_attachments": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] }, "max_emojis": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] }, "max_newlines": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] }, "max_duplicates": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] }, "max_characters": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] }, "max_voice_moves": { "default": null, "anyOf": [ { "type": "object", "properties": { "interval": { "type": "number" }, "count": { "type": "number" }, "mute": { "default": false, "type": "boolean" }, "mute_time": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "remove_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "restore_roles_on_mute": { "default": false, "anyOf": [ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "clean": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "starboard": { "type": "object", "properties": { "config": { "type": "object", "properties": { "boards": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "channel_id": { "type": "string" }, "stars_required": { "type": "number" }, "star_emoji": { "default": [ "⭐" ], "type": "array", "items": { "type": "string" } }, "allow_selfstars": { "default": false, "type": "boolean" }, "copy_full_embed": { "default": false, "type": "boolean" }, "enabled": { "default": true, "type": "boolean" }, "show_star_count": { "default": true, "type": "boolean" }, "color": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [ "channel_id", "stars_required" ], "additionalProperties": false } }, "can_migrate": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "boards": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "channel_id": { "type": "string" }, "stars_required": { "type": "number" }, "star_emoji": { "default": [ "⭐" ], "type": "array", "items": { "type": "string" } }, "allow_selfstars": { "default": false, "type": "boolean" }, "copy_full_embed": { "default": false, "type": "boolean" }, "enabled": { "default": true, "type": "boolean" }, "show_star_count": { "default": true, "type": "boolean" }, "color": { "default": null, "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "can_migrate": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "tags": { "type": "object", "properties": { "config": { "type": "object", "properties": { "prefix": { "default": "!!", "type": "string" }, "delete_with_command": { "default": true, "type": "boolean" }, "user_tag_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "global_tag_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "user_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "allow_mentions": { "default": false, "type": "boolean" }, "global_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "auto_delete_command": { "default": false, "type": "boolean" }, "categories": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "prefix": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "delete_with_command": { "default": false, "type": "boolean" }, "user_tag_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "user_category_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "global_tag_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "allow_mentions": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "global_category_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "auto_delete_command": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "tags": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] } }, "can_use": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "tags" ], "additionalProperties": false } }, "can_create": { "default": false, "type": "boolean" }, "can_use": { "default": false, "type": "boolean" }, "can_list": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "prefix": { "default": "!!", "type": "string" }, "delete_with_command": { "default": true, "type": "boolean" }, "user_tag_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "global_tag_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "user_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "allow_mentions": { "default": false, "type": "boolean" }, "global_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "auto_delete_command": { "default": false, "type": "boolean" }, "categories": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "prefix": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "delete_with_command": { "default": false, "type": "boolean" }, "user_tag_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "user_category_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "global_tag_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "allow_mentions": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "global_category_cooldown": { "default": null, "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "number" } ] }, { "type": "null" } ] }, "auto_delete_command": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "tags": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "anyOf": [ { "type": "string" }, { "$ref": "#/$defs/strictMessageContent" } ] } }, "can_use": { "default": null, "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "can_create": { "default": false, "type": "boolean" }, "can_use": { "default": false, "type": "boolean" }, "can_list": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "time_and_date": { "type": "object", "properties": { "config": { "type": "object", "properties": { "timezone": { "default": "Etc/UTC", "type": "string" }, "date_formats": { "default": { "date": "MMM D, YYYY", "time": "H:mm", "pretty_datetime": "MMM D, YYYY [at] H:mm z" }, "type": "object", "properties": { "date": { "default": "MMM D, YYYY", "type": "string" }, "time": { "default": "H:mm", "type": "string" }, "pretty_datetime": { "default": "MMM D, YYYY [at] H:mm z", "type": "string" } }, "required": [], "additionalProperties": false }, "can_set_timezone": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "timezone": { "default": "Etc/UTC", "type": "string" }, "date_formats": { "default": { "date": "MMM D, YYYY", "time": "H:mm", "pretty_datetime": "MMM D, YYYY [at] H:mm z" }, "type": "object", "properties": { "date": { "default": "MMM D, YYYY", "type": "string" }, "time": { "default": "H:mm", "type": "string" }, "pretty_datetime": { "default": "MMM D, YYYY [at] H:mm z", "type": "string" } }, "required": [], "additionalProperties": false }, "can_set_timezone": { "default": false, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "username_saver": { "type": "object", "properties": { "config": { "type": "object", "properties": {}, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": {}, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "utility": { "type": "object", "properties": { "config": { "type": "object", "properties": { "can_roles": { "default": false, "type": "boolean" }, "can_level": { "default": false, "type": "boolean" }, "can_search": { "default": false, "type": "boolean" }, "can_clean": { "default": false, "type": "boolean" }, "can_info": { "default": false, "type": "boolean" }, "can_server": { "default": false, "type": "boolean" }, "can_inviteinfo": { "default": false, "type": "boolean" }, "can_channelinfo": { "default": false, "type": "boolean" }, "can_messageinfo": { "default": false, "type": "boolean" }, "can_userinfo": { "default": false, "type": "boolean" }, "can_roleinfo": { "default": false, "type": "boolean" }, "can_emojiinfo": { "default": false, "type": "boolean" }, "can_snowflake": { "default": false, "type": "boolean" }, "can_reload_guild": { "default": false, "type": "boolean" }, "can_nickname": { "default": false, "type": "boolean" }, "can_ping": { "default": false, "type": "boolean" }, "can_source": { "default": false, "type": "boolean" }, "can_vcmove": { "default": false, "type": "boolean" }, "can_vckick": { "default": false, "type": "boolean" }, "can_help": { "default": false, "type": "boolean" }, "can_about": { "default": false, "type": "boolean" }, "can_context": { "default": false, "type": "boolean" }, "can_jumbo": { "default": false, "type": "boolean" }, "jumbo_size": { "default": 128, "type": "number" }, "can_avatar": { "default": false, "type": "boolean" }, "info_on_single_result": { "default": true, "type": "boolean" }, "autojoin_threads": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "can_roles": { "default": false, "type": "boolean" }, "can_level": { "default": false, "type": "boolean" }, "can_search": { "default": false, "type": "boolean" }, "can_clean": { "default": false, "type": "boolean" }, "can_info": { "default": false, "type": "boolean" }, "can_server": { "default": false, "type": "boolean" }, "can_inviteinfo": { "default": false, "type": "boolean" }, "can_channelinfo": { "default": false, "type": "boolean" }, "can_messageinfo": { "default": false, "type": "boolean" }, "can_userinfo": { "default": false, "type": "boolean" }, "can_roleinfo": { "default": false, "type": "boolean" }, "can_emojiinfo": { "default": false, "type": "boolean" }, "can_snowflake": { "default": false, "type": "boolean" }, "can_reload_guild": { "default": false, "type": "boolean" }, "can_nickname": { "default": false, "type": "boolean" }, "can_ping": { "default": false, "type": "boolean" }, "can_source": { "default": false, "type": "boolean" }, "can_vcmove": { "default": false, "type": "boolean" }, "can_vckick": { "default": false, "type": "boolean" }, "can_help": { "default": false, "type": "boolean" }, "can_about": { "default": false, "type": "boolean" }, "can_context": { "default": false, "type": "boolean" }, "can_jumbo": { "default": false, "type": "boolean" }, "jumbo_size": { "default": 128, "type": "number" }, "can_avatar": { "default": false, "type": "boolean" }, "info_on_single_result": { "default": true, "type": "boolean" }, "autojoin_threads": { "default": true, "type": "boolean" } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "welcome_message": { "type": "object", "properties": { "config": { "type": "object", "properties": { "send_dm": { "default": false, "type": "boolean" }, "send_to_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "message": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "send_dm": { "default": false, "type": "boolean" }, "send_to_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "message": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] }, "common": { "type": "object", "properties": { "config": { "type": "object", "properties": { "success_emoji": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "error_emoji": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "attachment_storing_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false }, "overrides": { "type": "array", "items": { "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" }, "config": { "type": "object", "properties": { "success_emoji": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "error_emoji": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "attachment_storing_channel": { "default": null, "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "required": [ "config" ], "additionalProperties": false } } }, "required": [] } }, "required": [], "additionalProperties": false } }, "required": [], "additionalProperties": false, "$defs": { "__schema0": { "$ref": "#/$defs/overrideCriteria" }, "overrideCriteria": { "id": "overrideCriteria", "type": "object", "properties": { "channel": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "category": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "level": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "user": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "role": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "thread": { "anyOf": [ { "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, { "type": "null" } ] }, "is_thread": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] }, "thread_type": { "anyOf": [ { "enum": [ "public", "private" ] }, { "type": "null" } ] }, "extra": {}, "zzz_dummy_property_do_not_use": {}, "all": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "any": { "type": "array", "items": { "$ref": "#/$defs/overrideCriteria" } }, "not": { "$ref": "#/$defs/overrideCriteria" } }, "required": [], "additionalProperties": false }, "strictMessageContent": { "id": "strictMessageContent", "type": "object", "properties": { "content": { "type": "string" }, "tts": { "type": "boolean" }, "embeds": { "anyOf": [ { "type": "array", "items": { "$ref": "#/$defs/embedInput" } }, { "$ref": "#/$defs/embedInput" } ] }, "embed": { "$ref": "#/$defs/embedInput" } }, "required": [], "additionalProperties": false }, "embedInput": { "id": "embedInput", "type": "object", "properties": { "title": { "type": "string" }, "description": { "type": "string" }, "url": { "type": "string" }, "timestamp": { "type": "string" }, "color": { "type": "number" }, "footer": { "type": "object", "properties": { "text": { "type": "string" }, "icon_url": { "type": "string" } }, "required": [ "text" ] }, "image": { "type": "object", "properties": { "url": { "type": "string" }, "width": { "type": "number" }, "height": { "type": "number" } }, "required": [] }, "thumbnail": { "type": "object", "properties": { "url": { "type": "string" }, "width": { "type": "number" }, "height": { "type": "number" } }, "required": [] }, "video": { "type": "object", "properties": { "url": { "type": "string" }, "width": { "type": "number" }, "height": { "type": "number" } }, "required": [] }, "provider": { "type": "object", "properties": { "name": { "type": "string" }, "url": { "type": "string" } }, "required": [ "name" ] }, "fields": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "value": { "type": "string" }, "inline": { "type": "boolean" } }, "required": [] } }, "author": { "anyOf": [ { "type": "object", "properties": { "name": { "type": "string" }, "url": { "type": "string" }, "width": { "type": "number" }, "height": { "type": "number" } }, "required": [ "name" ] }, { "type": "null" } ] } }, "required": [], "additionalProperties": false } }, "$schema": "https://json-schema.org/draft-2020-12/schema" } ================================================ FILE: config-checker/src/main.ts ================================================ import * as monaco from "monaco-editor"; import { configureMonacoYaml } from "monaco-yaml"; import schemaUri from "/config-schema.json?url"; window.MonacoEnvironment = { getWorker(_, label) { switch (label) { case "editorWorkerService": return new Worker(new URL("monaco-editor/esm/vs/editor/editor.worker.js", import.meta.url), { type: "module" }); case "yaml": return new Worker(new URL("./yaml.worker.js", import.meta.url), { type: "module" }) default: throw new Error(`Unknown label ${label}`); } }, }; configureMonacoYaml(monaco, { enableSchemaRequest: true, schemas: [{ fileMatch: ["**/config.yaml"], uri: schemaUri, }], }); const initialModel = monaco.editor.createModel("# Paste your config here to check it\n", undefined, monaco.Uri.parse("file:///config.yaml")); initialModel.updateOptions({ tabSize: 2 }); const editorRoot = document.getElementById("editor")!; const errorsRoot = document.getElementById("errors")!; monaco.editor.defineTheme("zeppelin", { base: "vs-dark", inherit: true, rules: [], colors: { "editor.background": "#00000000", "editor.focusBorder": "#00000000", "list.focusOutline": "#00000000", "editorStickyScroll.background": "#070c11", }, }); monaco.editor.create(editorRoot, { automaticLayout: true, model: initialModel, quickSuggestions: { other: true, comments: true, strings: true, }, theme: "zeppelin", minimap: { enabled: false, }, }); function showErrors(markers: monaco.editor.IMarker[]) { if (markers.length) { markers.sort((a, b) => a.startLineNumber - b.startLineNumber); const frag = document.createDocumentFragment(); for (const marker of markers) { const error = document.createElement("div"); error.classList.add("error"); const lineMarker = document.createElement("strong"); lineMarker.innerText = `Line ${marker.startLineNumber}: `; const errorText = document.createElement("span"); errorText.innerText = marker.message; error.append(lineMarker, errorText); frag.append(error); } errorsRoot.replaceChildren(frag); } else { const success = document.createElement("div"); success.classList.add("noErrors"); success.innerText = "No errors!"; errorsRoot.replaceChildren(success); } } monaco.editor.onDidChangeMarkers(([uri]) => { const markers = monaco.editor.getModelMarkers({ resource: uri }); showErrors(markers); }); showErrors([]); ================================================ FILE: config-checker/src/style.css ================================================ *, *::before, *::after { box-sizing: border-box; } body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; font-size: 14px; background-color: black; background: linear-gradient(45deg, #040a0e, #27699e); color: #f8f8f8; margin: 0; } .wrap { height: 100vh; display: flex; flex-direction: column; padding: 16px; gap: 16px; } .section { background-color: #000000b8; display: flex; flex-direction: column; border-radius: 4px; overflow: hidden; box-shadow: 0 0 12px rgba(0, 0, 0, 0.397); } .title { flex: 0 0 32px; background-color: #ffffff11; display: flex; align-items: center; padding-left: 10px; } .title h1 { margin: 0; font-size: 12px; line-height: 1; text-transform: uppercase; } .content { flex: 1 1 auto; display: flex; flex-direction: column; overflow: hidden; } .editor-wrap { flex: 0 0 100%; display: flex; flex-direction: column; } #editor { flex: 0 0 100%; } .monaco-editor { outline: 0 !important; } .errors-wrap { flex: 0 0 100%; display: flex; flex-direction: column; padding: 10px; overflow-y: auto; } #errors { } .error { color: hsl(10.7deg 58.76% 57.09%); } .noErrors { color: hsl(93.81deg 56.52% 52.07%); font-weight: 700; } ================================================ FILE: config-checker/src/vite-env.d.ts ================================================ /// ================================================ FILE: config-checker/src/yaml.worker.js ================================================ import "monaco-yaml/yaml.worker.js"; ================================================ FILE: config-checker/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, "include": ["src"] } ================================================ FILE: dashboard/.editorconfig ================================================ root = true [*] end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 ================================================ FILE: dashboard/.eslintrc.json ================================================ { "extends": ["../.eslintrc.js"], "rules": { "@typescript-eslint/no-unused-vars": 0, "no-self-assign": 0, "no-empty": 0, "@typescript-eslint/no-var-requires": 0 } } ================================================ FILE: dashboard/.gitignore ================================================ /.cache /dist /node_modules ================================================ FILE: dashboard/.prettierignore ================================================ /dist ================================================ FILE: dashboard/index.html ================================================ Zeppelin - Moderation bot for Discord
================================================ FILE: dashboard/package.json ================================================ { "name": "@zeppelinbot/dashboard", "version": "1.0.0", "description": "", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "devDependencies": { "@tailwindcss/vite": "^4.1.8", "@vitejs/plugin-vue": "^5.2.4", "@vue/tsconfig": "^0.7.0", "@zeppelinbot/shared": "workspace:*", "cross-env": "^7.0.3", "highlight.js": "^11.8.0", "humanize-duration": "^3.27.0", "js-yaml": "^4.1.0", "marked": "^5.1.0", "moment": "^2.29.4", "postcss-nesting": "^13.0.1", "tailwindcss": "^4.1.8", "vite": "npm:rolldown-vite@latest", "vue": "^3.5.13", "vue-material-design-icons": "^5.3.1", "vue-router": "^4.5.0", "vue-tsc": "^2.2.10", "vue3-ace-editor": "^2.2.4", "vue3-highlightjs": "^1.0.5", "vuex": "^4.1.0" }, "dependencies": { "@fastify/static": "^7.0.1", "ace-builds": "1.43.4", "fastify": "^4.26.2" }, "browserslist": [ "last 2 Chrome versions" ] } ================================================ FILE: dashboard/postcss.config.js ================================================ import nesting from "postcss-nesting"; /** @type {import('postcss-load-config').Config} */ const config = { plugins: [nesting] } export default config; ================================================ FILE: dashboard/public/env.js ================================================ // Don't edit this directly, it uses env vars in prod via serve.js window.API_URL = "/api"; ================================================ FILE: dashboard/serve.js ================================================ import Fastify from "fastify"; import fastifyStatic from "@fastify/static"; import path from "node:path"; const fastify = Fastify({ // We already get logs from nginx, so disable here logger: false, }); fastify.addHook("preHandler", (req, reply, done) => { if (req.url === "/env.js") { reply.header("Content-Type", "application/javascript; charset=utf8"); reply.send(`window.API_URL = ${JSON.stringify(process.env.API_URL)};`); } done(); }); fastify.register(fastifyStatic, { root: path.join(import.meta.dirname, "dist"), wildcard: false, }); fastify.get("*", (req, reply) => { reply.sendFile("index.html"); }); fastify.listen({ port: 3002, host: '0.0.0.0' }, (err, address) => { if (err) { throw err; } console.log(`Server listening on ${address}`); }); process.on("SIGTERM", () => { fastify.close().then(() => { process.exit(0); }); }); ================================================ FILE: dashboard/src/api.ts ================================================ import { RootStore } from "./store"; type QueryParamObject = { [key: string]: string | null }; export class ApiError extends Error { public body: any; public status: number; public res: Response; constructor(message: string, body: object, status: number, res: Response) { super(message); this.body = body; this.status = status; this.res = res; } } function buildQueryString(params: QueryParamObject) { if (Object.keys(params).length === 0) return ""; return ( "?" + Array.from(Object.entries(params)) .map((pair) => `${encodeURIComponent(pair[0])}=${encodeURIComponent(pair[1] || "")}`) .join("&") ); } export function request(resource, fetchOpts: RequestInit = {}) { return fetch(`${window.API_URL}/${resource}`, fetchOpts).then(async (res) => { if (!res.ok) { if (res.status === 401) { RootStore.dispatch("auth/expiredLogin"); return; } const body = await res.json(); throw new ApiError(res.statusText, body, res.status, res); } return res.json(); }); } export function get(resource: string, params: QueryParamObject = {}) { const headers: Record = RootStore.state.auth.apiKey ? { "X-Api-Key": RootStore.state.auth.apiKey } : {}; return request(resource + buildQueryString(params), { method: "GET", headers, }); } export function post(resource: string, params: QueryParamObject = {}) { const headers: Record = RootStore.state.auth.apiKey ? { "X-Api-Key": RootStore.state.auth.apiKey } : {}; return request(resource, { method: "POST", body: JSON.stringify(params), headers: { ...headers, "Content-Type": "application/json", }, }); } type FormPostOpts = { target?: string; }; export function formPost(resource: string, body: Record = {}, opts: FormPostOpts = {}) { body["X-Api-Key"] = RootStore.state.auth.apiKey; const form = document.createElement("form"); form.action = `${window.API_URL}/${resource}`; form.method = "POST"; form.enctype = "multipart/form-data"; if (opts.target != null) { form.target = opts.target; } for (const [key, value] of Object.entries(body)) { const input = document.createElement("input"); input.type = "hidden"; input.name = key; input.value = value; form.appendChild(input); } document.body.appendChild(form); form.submit(); setTimeout(() => { document.body.removeChild(form); }, 1); } ================================================ FILE: dashboard/src/auth.ts ================================================ import { NavigationGuard } from "vue-router"; import { RootStore } from "./store"; const isAuthenticated = async () => { if (RootStore.state.auth.apiKey) return true; // We have an API key -> authenticated if (RootStore.state.auth.loadedInitialAuth) return false; // No API key and initial auth data was already loaded -> not authenticated await RootStore.dispatch("auth/loadInitialAuth"); // Initial auth data wasn't loaded yet (per above check) -> load it now if (RootStore.state.auth.apiKey) return true; return false; // Still no API key -> not authenticated }; export const authGuard: NavigationGuard = async (to, from, next) => { if (await isAuthenticated()) return next(); window.location.href = `${window.API_URL}/auth/login`; }; export const loginCallbackGuard: NavigationGuard = async (to, from, next) => { if (to.query.apiKey) { await RootStore.dispatch("auth/setApiKey", { key: to.query.apiKey }); window.location.href = "/dashboard"; } else { window.location.href = `/?error=noAccess`; } return next(); }; export const authRedirectGuard: NavigationGuard = async (to, form, next) => { if (await isAuthenticated()) return next("/dashboard"); window.location.href = `${window.API_URL}/auth/login`; return next(); }; ================================================ FILE: dashboard/src/components/App.vue ================================================ ================================================ FILE: dashboard/src/components/Expandable.vue ================================================ ================================================ FILE: dashboard/src/components/PrivacyPolicy.vue ================================================ ================================================ FILE: dashboard/src/components/Splash.vue ================================================ ================================================ FILE: dashboard/src/components/Tab.vue ================================================ ================================================ FILE: dashboard/src/components/Tabs.vue ================================================ ================================================ FILE: dashboard/src/components/Title.vue ================================================ ================================================ FILE: dashboard/src/components/dashboard/GuildAccess.vue ================================================ ================================================ FILE: dashboard/src/components/dashboard/GuildConfigEditor.vue ================================================ ================================================ FILE: dashboard/src/components/dashboard/GuildImportExport.vue ================================================ ================================================ FILE: dashboard/src/components/dashboard/GuildInfo.vue ================================================ ================================================ FILE: dashboard/src/components/dashboard/GuildList.vue ================================================ ================================================ FILE: dashboard/src/components/dashboard/Layout.vue ================================================