diff --git a/.github/workflows/verify-configs.yml b/.github/workflows/verify-configs.yml new file mode 100644 index 00000000..cb85537c --- /dev/null +++ b/.github/workflows/verify-configs.yml @@ -0,0 +1,16 @@ +name: Verify configs + +on: + push: + branches: [ main ] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: node scripts/verify-config-defaults.js \ No newline at end of file diff --git a/LICENSE b/LICENSE index 55ca2671..18202d7a 100644 --- a/LICENSE +++ b/LICENSE @@ -4,12 +4,12 @@ License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. Parameters Licensor: ScootKit UG (haftungsbeschränkt) -Licensed Work: ScootKit/CustomDCBot. The Licensed Work is (c) 2025 ScootKit UG (haftungsbeschränkt) +Licensed Work: ScootKit/CustomDCBot. The Licensed Work is (c) 2026 ScootKit UG (haftungsbeschränkt) Additional Use Grant: You may make production use of the Licensed Work, provided such use does not include offering the Licensed Work to third parties on a hosted or embedded basis which is competitive with ScootKit UG (haftungsbeschränkt)'s products. -Change Date: Six years from the date the Licensed Work is published. +Change Date: Eight years from the date the Licensed Work is published. Change License: MIT License For information about alternative licensing arrangements for the Licensed Work, diff --git a/README.md b/README.md index d8928ed4..8aad868e 100644 --- a/README.md +++ b/README.md @@ -1,358 +1,300 @@ # Custom-Bot v3 -Create your own discord bot - Fully customizable and with a lot of features. This bot is for advanced JS-Users, you -should only use it if you have some experience with Javascript, discord.js and JSON files. +Create your own Discord bot - fully customizable and modular. This bot is for advanced JS users with experience in +JavaScript, discord.js, and JSON configuration. ---- +## Don't want to self-host? Use SCNX (free) -## Get your own Custom-Bot completely free and with a modern webinterface and a lot more features! +This repository is the **DIY path**: clone, configure JSON files by hand, run a Node process, manage uptime yourself. +If that doesn't sound fun, the same bot is available as a fully managed service at **[scnx.xyz](https://scnx.xyz)**. -Go check it out on our [website](https://scnx.xyz) or get started in the [dashboard](https://scnx.app). -In addition to the here -available features we offer: +* **Free plan** - no credit card required. Hosting is ad-supported: watch one short ad in the dashboard every 7 days + to keep the bot running. Skip a week and the bot pauses until you log in. +* **Paid plans** start at **€4.99 / month** - no ads, higher limits, and access to premium modules and tiers. -* Free hosting -* Custom-Commands -* Easy-to-use Embed-Editor -* Self-Roles -* Send and edit messages in specific channels -* Easy-to-use Configuration-Editor -* Human-Readable Issue Reporting - never look at logs again -* and a modern dashboard -* and *a lot* more - for free +What you get with the hosted version that you do **not** get here: -[Get started now](https://scnx.xyz) - it's free - forever! +* **Zero setup.** Invite the bot, log in to the [dashboard](https://scnx.app), pick your modules, done. No Node, no + JSON, no server. +* **Hosted for you.** We run the bot so you don't have to keep a Node process alive. Restarts and updates are handled + on our side; Discord-side outages are still Discord's problem, but you're not on the hook for the rest. The free + plan keeps running as long as you watch the weekly ad; paid plans drop the ad requirement. +* **Visual editors.** Drag-and-drop embed editor, point-and-click configuration, live previews. No more hand-writing + JSON for every welcome message. +* **Custom slash commands.** Build commands in the dashboard with no code. +* **Send and edit messages anywhere.** Rich message editor for any channel, any time. +* **AI features.** AI chat channels, AI-generated images, and other LLM-backed modules - configured from the dashboard, + no API keys to manage. +* **Human-readable error reports.** When something breaks, you see what and why - in your language - in your dashboard. +* **More modules.** Several modules (anti-nuke, applications, giveaways, advanced logging, RSS / Twitter / YouTube / + TikTok integrations, and more) are exclusive to the hosted version. +* **Translated UI.** Dashboard and bot fully translated into 20+ languages. -## Applicable [license](LICENSE) terms if you use this bot +**[Get started at scnx.xyz](https://scnx.xyz)** - the free plan covers most communities; upgrade only if you outgrow +it. -We really love open-source. It does not make sense financially to publish this Source-Code publicly (as our business -model is to host these bots on [SCNX](https://scnx.xyz), but we still do it. -While this project does not fit the [definition of Open Source](https://opensource.org/osd-annotated) -set forward by the Open Source Initiative, -we are committed to allowing you as much freedom as possible. -Please read the [license](LICENSE) and follow it. +### Running professional customer support on Discord? -Here's a summary: +The OSS `tickets` module here is fine for small communities, but if you're running **paid customer support** through +Discord - ticket assignments, estimated wait times, voice support, escalation, analytics - SCNX ships a **separate, +dedicated support bot** with a feature set built for that use case. It's a different (paid) product, not a module of +this +bot. See [scnx.xyz](https://scnx.xyz/support-bot) for details. -* You may use the bot on your server and change the source code (as long as you follow the license). -* You have to retain a link to the [LICENSE](LICENSE) and this repository in your bot, most likely in your `/help` - command. -* All changes you make to this codebase are subject to these license terms, you cannot remove the link to the license, - even if you change large parts of the bot. -* You may not create a competitor to [SCNX](https://scnx.xyz) or other ScootKit products using this source code. -* You may not use the "ScootKit" brand name or any other trademarks outside of the LICENSE notice. +> **Heads up on support.** Our customer support team only handles the SCNX hosted version. Tickets, live help, and +> account assistance all go through scnx.app. The OSS version in this repo is community-supported - GitHub issues +> only, best-effort, no SLA. -Please read the full [license](LICENSE), as the terms laid out there apply. This is not legal advice. +## Why two versions? -Failure to abide by these terms might result in deactivation of your bot from Discord or legal action being taken -(but we'll act in good faith and usually try to solve the issue before doing anything drastic). +You might wonder why the same bot exists in two flavors. Short version: -## Support development +* **OSS (this repo).** The base bot, modular core, and a curated set of modules that work standalone. Good for + self-hosters, learners, and people who want to fork. Released under [BUSL-1.1](#license---read-before-using). +* **SCNX (hosted).** Everything in OSS, plus the dashboard, several integration modules (AI features, anti-nuke, + giveaways, the social-platform notifiers, etc.), the visual editors, the customer support, and the managed + infrastructure. + +We split it this way because the dashboard, infra, and several modules cost money to build and run. Giving them away +for free would mean we couldn't pay the people who build them. Keeping a strong OSS core (with a non-compete clause via +BUSL) lets us be transparent about how the bot works, accept community contributions, and let advanced users self-host + +- without subsidizing competitors who would just rebrand and resell our work. + +If you want the rebrand-and-resell freedom that an MIT/Apache license would give you, the [LICENSE](LICENSE) explains +how to negotiate commercial terms. For everyone else, the [Additional Use Grant](LICENSE) is broad enough to cover +the use cases people actually have: run the bot in your own community, modify it, contribute back, learn from it. + +## License - read before using + +> **This is NOT MIT, Apache, or any other permissive license.** It is the +> [Business Source License 1.1](LICENSE) (BUSL-1.1, the same license used by MariaDB, CockroachDB, and Sentry). Read +> the full [LICENSE](LICENSE) before deploying anything based on this code. + +### What you can do + +* **Self-host for your own community.** Run this bot on your own server, in your own Discord guild, for your own + members. No fees, no permission needed. +* **Modify and contribute.** Fork the repo, change the code, build your own modules. Pull requests welcome. +* **Learn from it.** Read the source, copy patterns into unrelated projects, write tutorials. -As mentioned above, our business model is to host these bots for servers - it does not really make sense to publish our -product here - but we do it anyway - but we need your support! Feel free to [contribute](.github/CONTRIBUTING.md) or -becoming a [GitHub Sponsor](https://github.com/sponsors/ScootKit/). Thank you so much <3 +### What you CANNOT do -## Need help? +* **You may NOT offer this bot - or any modified version, fork, or derivative - as a hosted, managed, or embedded + service to third parties in a way that competes with [scnx.app](https://scnx.app).** That includes selling, + reselling, "free with ads," white-labeling, or running it for paying customers. This is the explicit "Additional Use + Grant" carve-out in the license, and we enforce it. +* **You may NOT relicense or sublicense this code** under MIT, Apache, GPL, or anything else. The license travels with + the code. -Are you stuck? Please do not ask on our Discord (unless you are using our hosted version), instead ask in -the [discussions-tab](https://github.com/ScootKit/CustomDCBot/discussions). +### What you MUST do if you publish modifications -## Need something even more custom? +* **Publish your source.** Any modified or derivative work distributed to third parties must be made available under + this same license. +* **Document your changes.** State what you changed and when. +* **Carry the license.** Display BUSL-1.1 prominently on every copy or fork. -We are happy to give you a quote for individual requirements. Please email `sales@sc-network.net` with your -requirements. +### When does it become MIT? -### Table of contents +The license auto-converts to **MIT License** eight years after a given version is published (or four years after +its first public distribution, whichever comes first). After that date, that specific version is fully permissive. +Newer versions stay BUSL until their own clock runs out. -[Installation](#installation)\ -[Features](#features)\ -[Configuration](#configuration)\ -[Modules](#modules)\ -[Add your own module (or API)](#add-your-own-modules) +### Need a commercial license? -### Installation +If your intended use isn't covered by the Additional Use Grant - for example, you want to host this bot for paying +customers - email **oss@scootkit.net** to negotiate commercial terms. Operating outside the license without an +agreement is a violation and we will pursue it. + +This summary is not legal advice. The [LICENSE](LICENSE) file is the authoritative document. + +## Support development + +Development of this bot is funded by [SCNX](https://scnx.xyz) - our hosted version. Every paid SCNX customer keeps the +OSS core moving forward. If you want to help in other ways, [contribute code](.github/CONTRIBUTING.md): bug fixes, new +modules, and documentation improvements are all welcome via pull request. + +## Installation 1. Clone this repo 2. Run `npm ci` 3. Run `npm run generate-config` -4. Replace your token in the `config/config.json` file. +4. Replace your token in `config/config.json` 5. Start the bot with `npm start` -6. The bot is now generating a `modules.json` and a `strings.json` file inside your `config` directory. You - can [change](#configuration) them. - -When reading thought the code, you may encounter code "tracking" / "issue reporting" parts of the bot. -This part is only enabled in the SCNX-Version and only used to allow users to see (configuration) issues of their bot -and to allow our team to detect bugs more easily (users can opt-out of that if they want to; we use the sentry-sdk for -that, but don't actually send any data to them, instead to our glitchtip instance - the open-source-version does neither -of that). -This open-source-version won't contact SCNX, SC Network and won't share any information with us, don't worry. You -can verify this by looking at the source code, which you should do before executing any code from the internet. - -### Features - -* Everything is split in different [modules](#modules) - you can enable, configure and disable it how you want -* Highly configurable - The goal with this bot is that you can change *everything* -* Add your own modules -* Easy configuration - Every config field has a description in an example file - -### Configuration - -You can find all the configuration-files inside your `config` folder. Every **enabled module** will have their own -folder with config-files inside them. **These files are generated automatically**. Every module has slightly different -configuration options. Every module has example files. Inside these files are more information about every configuration -option. -Some config values also support [embeds](https://discordjs.guide/popular-topics/embeds.html). This is the case -if `allowEmbed` is true.\ -You either input a string (normal Discord message), or an embed object with the following values: - -* `title`: Title of the embed -* `message`: Message outside the embed (optional) -* `description`: Description of the embed (optional) -* `color`: Color of the embed, must be - a [ColorResolvable](https://old.discordjs.dev/#/docs/discord.js/13.16.0/typedef/ColorResolvable) (optional) -* `url`: URL of the embed (optional) -* `image`: Image of the embed, should be an url (optional) -* `thumbnail`: Thumbnail-Image of the embed, should be an url (optional) -* `author` (optional): - * `name`: Name of the author - * `img`: Image of the author, should be an url -* `fields`: Fields of the embed, must be an array - of [EmbedFieldData](https://old.discordjs.dev/#/docs/discord.js/13.16.0/typedef/EmbedFieldData) (optional) -* `footer`: Footer value (optional, default: global footer value) -* `footerImgUrl`: URL to image of the footer (optional, default: global footer value) - -The footer of the embed is global and is defined in your global `strings.json` file. The timestamp is set automatically -to the current time. - -### Modules - -The bot is split in modules. Each module can register their own commands, events and even database models, so they can -do basically anything. Every module can register "example-config-files" witch are files with information about the -config file, so the bot can automatically check configs and do all the boring stuff for you. - -### Add your own modules - -As per the [License](LICENSE) you *have* to make *every* of your modules publicly available under the same license. -Please read the license for more information. - -**Before you make a module**: -Please create an issue with your suggestion and claim that you are working on it so nobody is working on the same -thing (;\ -Also please read the [Rules for modules](#rules-for-modules).\ -**Submit a module**: Simply create a pull request, and we will check your module and merge it then (; - -#### Rules for modules - -Every module should - -* Use Slash-Commands wherever possible -* Should provide a file with exported functions which other modules can use to manipulate data or perform actions in - your module (eg: an economy module should provide a file with exported functions like `User.addToBalance()`) -* Answer with ephemeral messages wherever it makes sense -* Create as few commands as possible (we have a limit to 100 commands in total), so please try to - use [Sub-Commands](https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups) - wherever possible (eg: instead of having /ban, /kick, /mute etc, have a /moderate command with sub-commands) -* Use the newest features of the discord api and discord.js (buttons, selects, etc) if possible -* Process and Store only needed user information and data -* Support localization (you don't need to translate everything, you only need to support translations, read - more [here](#Localization) -* protect sensitive slash-commands with the proper [`defaultMemberPermissions`](#interaction-command) settings -* must comply with our [end-user documentation requirements](https://docs.scnx.xyz/oss/create-module-docs) -* follow our [terms of service](https://sc-net.work/tos), [Discord's Terms of Service](https://discord.com/tos) and - the [Discord Developer Terms of Service](https://discord.com/developers/docs/legal). A module should not allow users - to bypass or break the mentioned documents. This includes but is not limited to Nitro-Only-Features. - -#### Localization - -We'd like to offer SCNX and this bot in as many languages as possible. Because of this, we highly encourage you to use -translationable systems in your module. - -* Localizations of not-user-editable strings: Use `localize(key, string, replace = {})` from `src/functions/localize.js` - to localize strings. Translations of these strings happen - on [Weblate](https://localize.sc-network.net/projects/custombot/locales/) - * `key`: Key of the string (usually your module name, check out any files in `locales` to get an idea how this - works) - * `string`: Name of the string - * `replace` (optional, object): Will replace `%` in the source string by `` -* Localizations of configuration-files and user-editable strings: All localizable configuration fields are an object - with values keyed based on language codes. - Example: `{"description": {"de": "Beschreibung des Feldes", "en": "Description of the field"}`. Each field needs to - have at least an English value, as every other language will default back to English. - -#### module.json - -Every module has to contain a `module.json` file with the following content: - -* `name` of the module. Should be the same as the name of your dictionary. -* `humanReadableName`: [Localized](#localization) name of the module, shown to users -* `author` - * `name`: Name of the author - * `link`: Link to the author - * `scnxOrgID`: [SCNX](https://scnx.xyz)-Organisation-ID of the developer (allows you to accept donations in the - dashboard and will show up to users in the dashboard) -* `openSourceURL`: URL to the Source-Code of the module licensed under an Open-Source-License (will show - donation-banners in the SCNX Dashboard (if orgID is set) and qualifies (qualified) developers for financial support - from the Open-Source-Pool of SCNX) -* `description`: [Localized](#localization) short description of the module -* `cli` (optional): [CLI-File](#cli-files) of your module -* `commands-dir` (optional): Directory inside your module folder where all - the [interaction-command-files](#interaction-command) are in -* `on-load-event` (optional): File with exported `onLoad` function in it. Gets executed when your commands got loaded - successfully; at this point the Client is not logged in yet, so you can't communicate with Discord (yet). -* `events-dir` (optional): Directory inside your module folder where all the [event-files](#events) are in -* `models-dir` (optional): Directory inside your module folder where all the models-files are in -* `config-example-files` (optional, seriously leave this out when you don't have config files): Array - of [config-files](#example-config-file) inside your module directory. -* `tags` (optional): Array of tags. -* `fa-icon`: Used for matching of icons in our dashboard. We will fill this out for you, please do not set this field. - -#### Interaction-Command - -Note: Interaction-Commands get loaded after the configuration got checked.\ -An interaction-command ("slash command") file has to export the following things: - -* `run` (function; provided arguments: `interaction`): - * Without subcommands: Function that gets triggered if the interactions is being used - * With subcommands: Optional function that gets triggered after the subcommand functions (if specified) got executed -* `beforeSubcommand` (optional, only if subcommands exit): Function which gets executed before the function in - subcommands gets executed -* `autoComplete` (only required if any of your options use `autocomplete`): Object of functions, sorted by - subcommandgroup, subcommand and option name -* `subcommands` (only required if subcommands exist): Object of functions, sorted by subcommandgroup and subcommand -* `help` -* `config` (both for !help and slash-commands) - * `name`: Name of the command (should be the same name as the file name) - * `description`: Description of the command - * `restricted`: Can this command only be run one of the bot operators (e.g. config reloading, change status or ..., - boolean) - * `defaultMemberPermissions`: This will determine which users can use your commands by default - leave `null` (or `undefined`) to allow usage by @everyone, otherwise, use [PermissionsResolvable](https://old.discordjs.dev/#/docs/discord.js/main/typedef/PermissionResolvable). - * `options`: - * [ApplicationCommandOptionData](https://old.discordjs.dev/#/docs/discord.js/13.16.0/typedef/ApplicationCommandData) - OR - * Async function - returning [ApplicationCommandOptionData](https://old.discordjs.dev/#/docs/discord.js/13.16.0/typedef/ApplicationCommandData) ( - gets called with `client` as argument) - -#### Message-Command - -Starting V3, message-commands are no longer supported. Please use [Interaction-Commands](#interaction-command) -instead. Read more in [CHANGELOG.md](CHANGELOG.md). - -#### Events - -An event file should export the following things: - -* `run`: Function that gets triggered if the event gets executed (provided arguments: `client` (discord.js Client) and - all the arguments that gets past by discord.js for this event) -* `allowPartial` (optional, default: `false`): Boolean determining whether the `run` function should be called if the event - has [partial structures](https://discordjs.guide/popular-topics/partials.html#enabling-partials). When enabling, - please make sure you handle partial data correctly. - -#### CLI-Files - -A CLI-File should export the following things: - -* `commands`: Array of the following objects: - * `command`: Command which should be entered in the CLI - * `description`: Description of the command - * `run`: Function which should be executed when the command gets executed. The function gets executed with an object - of following structure as argument: - * `input`: The whole input - * `args`: Array of arguments (split by spaces) - * `client`: [Client](https://old.discordjs.dev/#/docs/discord.js/13.16.0/class/Client) - * `cliCommands`: Array of all CLICommands - -Note: We might allow users to execute CLI-Commands via the Dashboard in the future. This is not supported right now. - -#### Config-Elements - -Certain configuration may contain an array of multiple objects with different values - these are called " -Config-Elements". - -To add a new Config-Element to your configuration -use `node add-config-element-object.js `. - -#### Example config-file - -An example config file should include the following things: - -* `filename`: Name of the generated config file -* `humanname`: [Localized](#localization) name of the file, shown to users -* `description`: [Localized](#localization) description of the file, shown to users -* `configElements` (boolean, default: `false`): If enabled the configuration-file will be an array of an object of the - content-fields -* `elementLimits` (optional, if configElements = `true`): Configuration to limit the amount of configuration elements - that guilds with a specific plan -* `commandsWarnings`: This field is used to indicate, that users need to manually set up the permissions for commands in - their discord-server-settings - * `normal`: Array of commands which that can be configured without any limitation in the discord-server-settings - * `special`: Array of commands that need special configuration in addition to editing the permissions in the - server-settings - * `name`: Name of the command - * `info`: Key by language; Information about the command; used to explain users what exactly they should do -* `content`: Array of content fields: - * `field_name`: Name of the config field - * `default`: [Localized](#localization) default value of this field - * `type`: Can be `channelID`, `userID`, `imgURL`, `select`, `timezone` (treated as string, please check validity - before using), `roleID` - , `boolean`, `integer`, `array`, `emoji`, `keyed` (codename for an JS-Object) - or `string` - * `description`: [Localized](#localization) description of this field - * `humanname`: [Localized](#localization) name of this field show to users - * `allowEmbed` (if type === `array, keyed or string`): Allow the usage of an [embed](#configuration) (Note: Please - use the build-in function in `src/functions/helpers.js`) - * `content` (if type === `array`): Type (see `type` above) of every value - * `content` (if type === `channelID`): Array of - supported [ChannelType](https://old.discordjs.dev/#/docs/discord.js/13.16.0/typedef/ChannelType)s ( - default: `['GUILD_TEXT', 'GUILD_VOICE', 'GUILD_CATEGORY', 'GUILD_NEWS', 'GUILD_STAGE_VOICE']`). To improve user - experience, we recommend adding information about supported types into `description`. The bot will verify that the - channel is inside the bot's guild. - * `content` (if type === `select`): Array of the possible options - * `content` (if type === `keyed`): - * `key`: Type (see `type` above) of the index of every value - * `value`: Type as string (see `type` above) of the value of every value - * `params`: (if type === `string`, array, optional) Possible parameters - * `name`: Name of the parameter (e.g. `%mention%`) - * `description`: [Localized](#localization) Description of the parameter (e.g. `Mention of the user`) - * `isImage`: If true, users will be able to set this parameter as Image, Author-Icon, Footer-Icon or Thumbnail - of an embed (only if `allowEmbed` is enabled) - * `allowNull` (default: `false`, optional): If the value of this field can be empty - * `disableKeyEdits` (if type === `keyed`): If enabled the user can not edit the keys of the object - * `elementToggle` (if type === `boolean`): If this option gets turned off, other fields of the config-element / file - will not be rendered in the dashboard - * `dependsOn` (a name of any (other) boolean-field): If the referenced boolean field (the value of this option - should be equal to the `field.field_name` of a boolean field) is turned off, the field will be not be rendered in - the dashboard - * `links` (optional): Array of links displayed below the field description in the SCNX Dashboard - * `label`: [Localized](#localization) label of the link displayed to the user - * `url`: URL the user will be redirected to on click - -#### `botReady`-Event and Config-Reload - -If you plan to use the [ready](https://old.discordjs.dev/#/docs/discord.js/13.16.0/class/Client?scrollTo=e-ready) event of -discord.js to run some action when the client is ready, and you need to load some configuration-files you should use -the `botReady`-event instead. Please remember that this event gets re-emitted on configuration reloading. If you set -callbacks that get executed later or similar please remember to remove them on `configReload`. If you set intervals, -please push the return value to `client.intervals` to get them removed on `configReload` or do it manually. - -#### Helper-Functions - -The bot includes a lot of functions to make your live easier. Check out the file `src/functions/helpers.js`. - -### Support for developers - -As we earn some money with hosting your modules for users, we have decided to give you some (remember, we need to pay -for hosting) of this money. Here are the main ways to earn some pocket-cash with developing for SCNX: - -* [Open-Source-Developer-Pool](https://faq.scnx.app/open-source-developer-pool/): We give you a monthly amount for each - paying server using your module -* [Bounties](https://faq.scnx.app/open-source-developer-pool/#bounties): We give you a small amount of money for merged - pull-requests and contributions - We support a lot of payout-methods, learn more [here](https://faq.scnx.app/scnx-referrals-faq/#payout-methods). - -© Simon Csaba, 2020-2023 - -ScootKit is a trademark, registered in Germany. - -We ♥ you - yes you. \ No newline at end of file +6. The bot generates `modules.json` and `strings.json` in your `config` directory - see [Configuration](#configuration) + for details + +When reading the code, you may encounter tracking/issue-reporting sections. These are only active in the SCNX version +and are used for bug detection and user-facing diagnostics (users can opt out; we use Sentry SDK with our own Glitchtip +instance). The open-source version does not contact SCNX or share any data. + +## Features + +* **Modular architecture** - enable, configure, and disable each module independently +* **Highly configurable** - every message, role, channel, and behavior can be customized +* **Custom modules** - add your own modules with commands, events, and database models +* **Auto-generated configs** - every config field has a description and default value + +## Configuration + +All configuration files live in your `config` folder. Each enabled module gets its own subfolder with config files. +These files are auto-generated with defaults and descriptions. + +For embed-capable fields (`allowEmbed: true`), the value can be a plain string or an embed object with: `title`, +`message`, `description`, `color`, `url`, `image`, `thumbnail`, `author`, `fields`, `footer`, `footerImgUrl`. The footer +and timestamp are controlled globally via `strings.json`. + +For full details on writing config files, see [developer-docs/configuration.md](developer-docs/configuration.md). + +## Developer Documentation + +Full guides live in [developer-docs/](developer-docs/) (start with the [index](developer-docs/README.md)). The +short version: + +**Module authors - start here:** + +| Document | Covers | +|--------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------| +| [Writing a module](developer-docs/writing-a-module.md) | File layout, `module.json`, lifecycle, end-to-end example | +| [Events](developer-docs/events.md) | Handler shape, `botReadyAt` / `allowPartial` / `ignoreBotReadyCheck` gates, custom `botReady` / `configReload` events | +| [Slash commands](developer-docs/commands.md) | `config` / `run` / `subcommands` / `autocomplete`, options, permissions, deferring | +| [Database models](developer-docs/database-models.md) | Sequelize `Model.init` pattern, conventions, `sequelize.sync()` behavior, associations | +| [Localization](developer-docs/localization.md) | Adding strings to `locales/en.json`, using `localize()`, runtime fallback | + +**Configuration schema:** + +| Document | Covers | +|---------------------------------------------------------------|-----------------------------------------------------------------------------------| +| [Configuration files](developer-docs/configuration.md) | Schema reference: field types, defaults, `dependsOn`, `elementToggle`, validation | +| [Country localization](developer-docs/config-localization.md) | How user-facing config strings are extracted and translated | + +**Operations:** + +| Document | Covers | +|------------------------------------------|--------------------------------------| +| [Migration](developer-docs/migration.md) | Upgrading between major bot versions | + +**Message schemas** (canonical reference at docs.scnx.xyz): + +* [V2 schema](https://docs.scnx.xyz/docs/scnx-api/reference/message-schema-v2/) - legacy, parsed when `_schema` is + absent +* [V3 schema](https://docs.scnx.xyz/docs/scnx-api/reference/message-schema-v3/) - tag with `"_schema": "v3"` +* [V4 schema](https://docs.scnx.xyz/docs/scnx-api/reference/message-schema-v4/) - tag with `"_schema": "v4"` + +## Creating modules + +As per the [license](LICENSE), you **must** make every module publicly available under the same license. + +Before building a module, create an issue with your suggestion so nobody duplicates work. Submit modules via pull +request. + +### Module structure + +``` +modules/your-module/ + module.json # Module metadata (required) + configs/ + config.json # Configuration schema + commands/ + your-command.js # Slash commands + events/ + botReady.js # Event handlers + messageCreate.js + models/ + YourModel.js # Sequelize models +``` + +### module.json + +```json +{ + "name": "your-module", + "humanReadableName": { + "en": "Your Module", + "de": "Dein Modul" + }, + "description": { + "en": "Short description", + "de": "Kurze Beschreibung" + }, + "author": { + "name": "Your Name", + "link": "https://your-site.com" + }, + "commands-dir": "/commands", + "events-dir": "/events", + "models-dir": "/models", + "config-example-files": [ + "configs/config.json" + ] +} +``` + +Optional fields: `cli`, `on-load-event`, `tags`, `openSourceURL`, `fa-icon` (set by us - browse and request icons +at https://scnx.app/developers/icons). + +### Commands + +Export `run`, `config`, and optionally `subcommands`, `beforeSubcommand`, `autoComplete`: + +```js +module.exports.run = async function (interaction) { /* ... */ +}; + +module.exports.config = { + name: 'your-command', + description: localize('your-module', 'command-description'), + defaultMemberPermissions: null, // null = everyone, ['Administrator'] = admin only + options: [] // or async function(client) { return [...]; } +}; +``` + +Use subcommands over separate commands - there's a 100-command limit. Use +`disabled: function(client) { return !condition; }` to conditionally hide commands. + +### Events + +Export a `run` function: + +```js +module.exports.run = async function (client, ...args) { /* ... */ +}; +``` + +Use `botReady` instead of discord.js `ready` when you need configs loaded. Remember that `botReady` re-fires on config +reload - clean up intervals by pushing to `client.intervals` or `client.jobs`. + +### Models + +Use Sequelize models with the standard pattern. See [developer-docs/migration.md](developer-docs/migration.md) for +adding fields to existing models. + +### Rules for modules + +* Use slash commands with subcommands wherever possible +* Reply with ephemeral messages where it makes sense +* Export functions for cross-module interaction +* Use the newest Discord API features (buttons, selects, modals) +* Process and store only needed user data +* Support localization (see below) +* Follow the [SCNX ToS](https://scootk.it/scnx-tos), [Discord ToS](https://discord.com/tos), + and [Discord Developer ToS](https://discord.com/developers/docs/legal) + +### Localization + +Use `localize(module, key, replacements)` from `src/functions/localize.js` for non-user-editable strings. Translations +happen on [Weblate](https://localize.sc-network.net/projects/custombot/locales/). + +For user-editable strings in config files (`humanName`, `description`, defaults), use **plain English strings**. +Translations live separately in `config-localizations/.json` and are extracted by a script - see +[developer-docs/config-localization.md](developer-docs/config-localization.md). The deprecated `{ "en": "...", "de": +"..." }` inline format is rejected by `npm run verify-configs`. + +### Helper functions + +Check `src/functions/helpers.js` for utilities: `embedType()`, `formatDiscordUserName()`, `parseEmbedColor()`, +`formatDate()`, `truncate()`, and more. + +--- + +Copyright © 2026 ScootKit UG (haftungsbeschränkt). [BUSL-1.1](LICENSE) applies. \ No newline at end of file diff --git a/config-generator/config.json b/config-generator/config.json index 8ef98cf5..9b46816d 100644 --- a/config-generator/config.json +++ b/config-generator/config.json @@ -1,51 +1,29 @@ { - "description": { - "en": "Configure the basic features of the bot here", - "de": "Generelle Konfiguration deines Bots" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the basic features of the bot here", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "token", "humanName": {}, - "default": { - "en": "yourtokengoeshere" - }, - "description": { - "en": "Replace this with your token" - }, + "default": "yourtokengoeshere", + "description": "Replace this with your token", "hidden": true, "type": "string" }, { "name": "prefix", - "humanName": { - "en": "Prefix of your bot", - "de": "Prefix deines Botes" - }, - "default": { - "en": "!" - }, - "description": { - "en": "Set the prefix of your bot here", - "de": "Dein eigener Prefix - Wir empfehlen ihn einfach bei ! zu belassen." - }, + "humanName": "Prefix of your bot", + "default": "!", + "description": "Set the prefix of your bot here", "hidden": true, "type": "string" }, { "name": "botOperators", "humanName": {}, - "default": { - "en": [] - }, - "description": { - "en": "Bot operators can reload the configuration and perform system relevant actions with this bot. Please only add users you really trust (and yourself of course)" - }, + "default": [], + "description": "Bot operators can reload the configuration and perform system relevant actions with this bot. Please only add users you really trust (and yourself of course)", "hidden": true, "type": "array", "content": "string" @@ -53,57 +31,30 @@ { "name": "guildID", "humanName": {}, - "default": { - "en": "489786377261678592" - }, - "description": { - "en": "Replace this the id of the guild the bot should work in." - }, + "default": "489786377261678592", + "description": "Replace this the id of the guild the bot should work in.", "hidden": true, "type": "guildID" }, { "name": "disableStatus", - "humanName": { - "en": "Disable Bot-Status", - "de": "Bot-Status deaktivieren" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, the bot won't have a status in discord", - "de": "Wenn aktiviert wird der Bot keinen Status in Discord haben" - }, + "humanName": "Disable Bot-Status", + "default": false, + "description": "If enabled, the bot won't have a status in discord", "type": "boolean" }, { "name": "user_presence", - "humanName": { - "en": "Bot-Status" - }, - "default": { - "en": "Change this in your Bot-Configuration on scnx.app: https://scootk.it/change-status", - "de": "Ändere das in deiner Bot-Konfiguration auf scnx.app: https://scootk.it/change-status" - }, - "description": { - "en": "This will show up in Discord as \"Playing \"", - "de": "Das wird in Discord als \"Spielt \" angezeigt" - }, + "humanName": "Bot-Status", + "default": "your bot status", + "description": "This will show up in Discord as \"Playing \"", "type": "string" }, { "name": "logLevel", - "humanName": { - "en": "Logging-Level" - }, - "default": { - "en": "debug" - }, - "description": { - "en": "Log-Level of the bot. Leave it as it is, if you don't know what this means", - "de": "Log-Level des Bots. Belasse es wie es ist, wenn du nicht weißt, was das bedeutet." - }, + "humanName": "Logging-Level", + "default": "debug", + "description": "Log-Level of the bot. Leave it as it is, if you don't know what this means", "hidden": true, "type": "select", "content": [ @@ -117,64 +68,32 @@ }, { "name": "logChannelID", - "humanName": { - "en": "Log-Channel", - "de": "Log-Kanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "Default log-channel for most modules and used to log relevant information", - "de": "Standard Log-Kanal für viele Module. Wird außerdem genutzt, um wesentliche Bot-Informationen zu senden" - }, + "humanName": "Log-Channel", + "default": "", + "description": "Default log-channel for most modules and used to log relevant information", "type": "channelID", "allowNull": true }, { "name": "timezone", - "humanName": { - "en": "Timezone", - "de": "Zeitzone" - }, - "default": { - "en": "Europe/Berlin" - }, - "description": { - "en": "Timezone the bot runs in", - "de": "Zeitzone in der der Bot laufen soll" - }, + "humanName": "Timezone", + "default": "Europe/Berlin", + "description": "Timezone the bot runs in", "type": "timezone" }, { "name": "disableEveryoneProtection", - "humanName": { - "en": "Allow @everyone / @here pings", - "de": "@everyone und @here Pings erlauben" - }, - "default": { - "en": false - }, - "description": { - "en": "Allows @everyone and @here pings for messages configurable in the dashboard", - "de": "Erlaubt @everyone und @here pings in im Dashboard anpassbaren Nachrichten" - }, + "humanName": "Allow @everyone / @here pings", + "default": false, + "description": "Allows @everyone and @here pings for messages configurable in the dashboard", "type": "boolean" }, { "name": "syncCommandGlobally", - "humanName": { - "en": "Sync module commands as global commands", - "de": "Speichere Modul-Befehle als Globale-Befehle" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, module-commands will be synced to discord as global commands. They will show up on other servers, but won't work. Syncing can take up to 2 hours, so changes may not be reflected immediately.", - "de": "Wenn aktiviert, werden Befehle von Modulen als Globale-Befehle zu Discord gesendet. Sie werden auf anderen Servern angezeigt, werden aber nicht funktionieren. Synchronisierung kann bis zu 2 Stunden dauern." - }, + "humanName": "Sync module commands as global commands", + "default": false, + "description": "If enabled, module-commands will be synced to discord as global commands. They will show up on other servers, but won't work. Syncing can take up to 2 hours, so changes may not be reflected immediately.", "type": "boolean" } ] -} \ No newline at end of file +} diff --git a/config-generator/strings.json b/config-generator/strings.json index 5fbdd4af..9d63e777 100644 --- a/config-generator/strings.json +++ b/config-generator/strings.json @@ -1,203 +1,104 @@ { - "description": { - "en": "Configure strings & messages of your bot here", - "de": "Passe die Nachrichten deines Botes an" - }, - "humanName": { - "en": "Messages", - "de": "Nachrichten" - }, + "description": "Configure strings & messages of your bot here", + "humanName": "Messages", "filename": "strings.json", "content": [ { "name": "addAtToUsernames", - "humanName": { - "en": "Add @ to usernames", - "de": "@ zu Nutzernamen hinzufügen" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, every username will be prefixed by an \"@\". Example: \"scderox\" -> \"@scderox\"", - "de": "Wenn aktiviert, wird vor jedem Nutzername ein \"@\" eingefügt. Beispiel: \"scderox\" -> \"@scderox\"" - }, + "humanName": "Add @ to usernames", + "default": false, + "description": "If enabled, every username will be prefixed by an \"@\". Example: \"scderox\" -> \"@scderox\"", "type": "boolean" }, { "name": "footer", - "humanName": { - "en": "Embed-Footer" - }, - "default": { - "en": "Powered by scnx.xyz ⚡" - }, - "description": { - "en": "Footer of every embed", - "de": "Footer jedes Embeds" - }, + "humanName": "Embed-Footer", + "default": "Powered by scnx.xyz ⚡", + "description": "Footer of every embed", "type": "string", "pro": true }, { "name": "footerImgUrl", - "humanName": { - "en": "Embed-Footer-Image-URL", - "de": "Embed-Footer-Bild-URL" - }, - "default": { - "en": "https://scnx.xyz/favicon.png" - }, + "humanName": "Embed-Footer-Image-URL", + "default": "https://scnx.xyz/favicon.png", "allowNull": true, - "description": { - "en": "Footer-Image of every embed", - "de": "Footer-Bild von jedem Embed" - }, + "description": "Footer-Image of every embed", "type": "imgURL", "pro": true }, { "name": "need_args", - "humanName": { - "en": "More arguments are needed", - "de": "Mehr Argumente werden benötigt" - }, - "default": { - "en": "This command needs more arguments - you passed %count%, but you need to provide at least %neededCount%.", - "de": "This command needs more arguments - you passed %count%, but you need to provide at least %neededCount%." - }, - "description": { - "en": "This message gets sent if there are not enough arguments specified", - "de": "Diese Nachricht wird versendet, wenn eine oder mehrere Argumente für einen Befehl fehlen" - }, + "humanName": "More arguments are needed", + "default": "This command needs more arguments - you passed %count%, but you need to provide at least %neededCount%.", + "description": "This message gets sent if there are not enough arguments specified", "type": "string", "allowEmbed": true, "params": [ { "name": "count", - "description": { - "en": "Count of arguments provided", - "de": "Anzahl von angegebenen Parameter" - } + "description": "Count of arguments provided" }, { "name": "neededCount", - "description": { - "en": "Count of arguments needed", - "de": "Anzahl von benötigten Argumenten" - } + "description": "Count of arguments needed" } ] }, { "name": "updated_roles", - "humanName": { - "en": "Roles updated", - "de": "Rollen erfolgreich geupdated" - }, - "default": { - "en": "✅ Updated roles according to your settings", - "de": "✅ Rollen-Änderungen übernommen" - }, - "description": { - "en": "This message gets sent after a user selects self-roles on a self-role-element.", - "de": "Diese Nachricht wird gesendet, wenn ein Nutzer eine Self-Rolle auswählt." - }, + "humanName": "Roles updated", + "default": "✅ Updated roles according to your settings", + "description": "This message gets sent after a user selects self-roles on a self-role-element.", "type": "string", "allowEmbed": true }, { "name": "added_role", - "humanName": { - "en": "Role added", - "de": "Rolle erfolgreich hinzugefügt" - }, - "default": { - "en": "✅ Role %role% successfully added", - "de": "✅ Rolle %role% erfolgreich hinzugefügt" - }, - "description": { - "en": "This message gets sent when a user adds a role to themselves.", - "de": "Diese Nachricht wird gesendet, wenn ein Nutzer sich selbst eine Self-Rolle hinzufügt." - }, + "humanName": "Role added", + "default": "✅ Role %role% successfully added", + "description": "This message gets sent when a user adds a role to themselves.", "type": "string", "allowEmbed": true, "params": [ { "name": "role", - "description": { - "en": "Name of the role", - "de": "Name der Rolle" - } + "description": "Name of the role" } ] }, { "name": "removed_role", - "humanName": { - "en": "Role removed", - "de": "Rolle erfolgreich entfernt" - }, - "default": { - "en": "✅ Role %role% successfully removed", - "de": "✅ Rolle %role% erfolgreich entfernt" - }, - "description": { - "en": "This message gets sent when a user removes a role from themselves.", - "de": "Diese Nachricht wird gesendet, wenn ein Nutzer sich selbst eine Self-Rolle entfernt." - }, + "humanName": "Role removed", + "default": "✅ Role %role% successfully removed", + "description": "This message gets sent when a user removes a role from themselves.", "type": "string", "allowEmbed": true, "params": [ { "name": "role", - "description": { - "en": "Name of the role", - "de": "Name der Rolle" - } + "description": "Name of the role" } ] }, { "name": "not_enough_permissions", - "humanName": { - "en": "Not enough permissions", - "de": "Nicht genügend Rechte" - }, - "default": { - "en": "Seems like you don't have enough permissions.", - "de": "Scheint als hättest du nicht genügend Rechte." - }, - "description": { - "en": "This message gets sent if an user don't hase enough permissions", - "de": "Diese Nachricht wird versendet, wenn der Nutzer nicht genügen Rechte hat" - }, + "humanName": "Not enough permissions", + "default": "Seems like you don't have enough permissions.", + "description": "This message gets sent if an user don't hase enough permissions", "type": "string", "allowEmbed": true }, { "name": "helpembed", - "humanName": { - "en": "Help-Message", - "de": "Hilfe-Nachricht" - }, + "humanName": "Help-Message", "default": { - "en": { - "title": "Help", - "description": "You can find every command here", - "module_translation": "%name% by %author%: %description%", - "build_in": "Build-In-Commands" - }, - "de": { - "title": "Help", - "description": "Alle Commands findest du hier", - "module_translation": "%name% by %author%: %description%", - "build_in": "Build-In-Commands" - } - }, - "description": { - "en": "Strings for help command" + "title": "Help", + "description": "You can find every command here", + "module_translation": "%name% by %author%: %description%", + "build_in": "Build-In-Commands" }, + "description": "Strings for help command", "type": "keyed", "content": { "key": "string", @@ -207,48 +108,24 @@ }, { "name": "disableHelpEmbedStats", - "humanName": { - "en": "Disable Stats in Help-Embed", - "de": "Deaktiviere Stats im Hilfe-Embed" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, the stats-field in the Help-Embed will get hidden", - "de": "Wenn aktiviert, wird der Stats-Bereich im Help-Embed verborgen" - }, + "humanName": "Disable Stats in Help-Embed", + "default": false, + "description": "If enabled, the stats-field in the Help-Embed will get hidden", "type": "boolean", "pro": true }, { "name": "disableFooterTimestamp", - "humanName": { - "en": "Disable default Timestamp in footer", - "de": "Standard-Timestamp im Footer deaktivieren" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, the current time will not be displayed in the embed footer", - "de": "Wenn aktiviert, wird die Aktuelle Uhrzeit nicht im Footer angezeigt" - }, + "humanName": "Disable default Timestamp in footer", + "default": false, + "description": "If enabled, the current time will not be displayed in the embed footer", "type": "boolean" }, { "name": "putBotInfoOnLastSite", - "humanName": { - "en": "Hides the Bot-Info in the Help-Embed", - "de": "Verbergt die Bot-Info Sektion im Hilfe-Embed" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, the Bot-Info-Section of the Help-Embed will be hidden.", - "de": "Wenn aktiviert, wird der Bot-Info-Bereich im Help-Embed verborgen." - }, + "humanName": "Hides the Bot-Info in the Help-Embed", + "default": false, + "description": "If enabled, the Bot-Info-Section of the Help-Embed will be hidden.", "type": "boolean", "pro": true } diff --git a/config-localizations/convert-configs.js b/config-localizations/convert-configs.js new file mode 100644 index 00000000..f6669345 --- /dev/null +++ b/config-localizations/convert-configs.js @@ -0,0 +1,253 @@ +/** + * Converts all config JSON files from inline localization format to English-only format. + * + * Reads module.json config-example-files to discover ALL config files per module. + * + * Before: { "description": { "en": "Configure here", "de": "Konfigurieren" } } + * After: { "description": "Configure here" } + * + * For default values, the {en: value} wrapper is removed for ALL types: + * { "default": { "en": false } } → { "default": false } + * { "default": { "en": "Hello" } } → { "default": "Hello" } + * + * Usage: node config-localizations/convert-configs.js [--dry-run] + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const DRY_RUN = process.argv.includes('--dry-run'); + +let filesModified = 0; +let fieldsConverted = 0; + +/** + * Check if a value is a localized object ({en: ..., de: ...}). + */ +function isLocalizedObject(value) { + if (value === null || value === undefined) return false; + if (typeof value !== 'object' || Array.isArray(value)) return false; + if (!('en' in value)) return false; + const keys = Object.keys(value); + return keys.length > 0 && keys.every(k => /^[a-z]{2,3}$/.test(k)); +} + +/** + * Unwrap a localized object to its English value. + */ +function unwrap(value) { + if (isLocalizedObject(value)) { + fieldsConverted++; + return value.en; + } + return value; +} + +/** + * Recursively unwrap all localized objects within a nested structure. + */ +function recursiveUnwrap(obj) { + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return; + for (const key of Object.keys(obj)) { + if (isLocalizedObject(obj[key])) { + obj[key] = unwrap(obj[key]); + } else if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { + recursiveUnwrap(obj[key]); + } + } +} + +/** + * Process a single config file, converting all localized objects to English-only. + */ +function convertConfig(configData) { + // Top-level localized properties + for (const key of ['description', 'humanName', 'warningBanner', 'informationBanner']) { + if (isLocalizedObject(configData[key])) { + configData[key] = unwrap(configData[key]); + } + } + + // informationBanner may have nested localized objects (e.g. button.text) + if (configData.informationBanner && typeof configData.informationBanner === 'object' && !isLocalizedObject(configData.informationBanner)) { + recursiveUnwrap(configData.informationBanner); + } + + // configElementName: {en: {one: ..., more: ...}, de: {...}} → {one: ..., more: ...} + if (isLocalizedObject(configData.configElementName)) { + configData.configElementName = unwrap(configData.configElementName); + } + + // commandsWarnings.special[].info + if (configData.commandsWarnings && Array.isArray(configData.commandsWarnings.special)) { + for (const warning of configData.commandsWarnings.special) { + if (isLocalizedObject(warning.info)) { + warning.info = unwrap(warning.info); + } + } + } + + // categories[].displayName + if (Array.isArray(configData.categories)) { + for (const cat of configData.categories) { + if (isLocalizedObject(cat.displayName)) { + cat.displayName = unwrap(cat.displayName); + } + } + } + + // content fields + if (Array.isArray(configData.content)) { + for (const field of configData.content) { + convertField(field); + } + } + + return configData; +} + +/** + * Convert a single content field. + */ +function convertField(field) { + // humanName, description — always localized + for (const key of ['humanName', 'description']) { + if (isLocalizedObject(field[key])) { + field[key] = unwrap(field[key]); + } + } + + // default — unwrap {en: value} for ALL types + if (isLocalizedObject(field.default)) { + field.default = unwrap(field.default); + } + + // params[].description + if (Array.isArray(field.params)) { + for (const param of field.params) { + if (isLocalizedObject(param.description)) { + param.description = unwrap(param.description); + } + } + } + + // select content[].displayName (when content is array of objects) + if (Array.isArray(field.content) && field.content.length > 0 && typeof field.content[0] === 'object' && field.content[0] !== null) { + for (const option of field.content) { + if (option && isLocalizedObject(option.displayName)) { + option.displayName = unwrap(option.displayName); + } + } + } + + // links[].label + if (Array.isArray(field.links)) { + for (const link of field.links) { + if (isLocalizedObject(link.label)) { + link.label = unwrap(link.label); + } + } + } +} + +/** + * Process a config file at the given path. + */ +function processFile(filePath) { + let raw; + try { + raw = fs.readFileSync(filePath, 'utf-8'); + } catch (e) { + console.warn(` Skipping ${filePath}: ${e.message}`); + return; + } + + let configData; + try { + configData = JSON.parse(raw); + } catch (e) { + console.warn(` Skipping ${filePath}: invalid JSON`); + return; + } + + // Skip non-config files + if (Array.isArray(configData) && !configData.content) return; + if (!configData.content && !configData.description && !configData.humanName) return; + + const beforeCount = fieldsConverted; + convertConfig(configData); + const changed = fieldsConverted - beforeCount; + + if (changed > 0) { + const output = JSON.stringify(configData, null, 2); + if (DRY_RUN) { + console.log(` [DRY RUN] Would modify ${filePath} (${changed} fields)`); + } else { + fs.writeFileSync(filePath, output); + console.log(` Modified ${filePath} (${changed} fields)`); + } + filesModified++; + } +} + +// Process config-generator files +console.log('Converting config-generator/...'); +const coreDir = path.join(ROOT, 'config-generator'); +if (fs.existsSync(coreDir)) { + for (const file of fs.readdirSync(coreDir).sort()) { + if (!file.endsWith('.json')) continue; + processFile(path.join(coreDir, file)); + } +} + +// Process module config files using module.json +console.log('Converting modules/...'); +const modulesDir = path.join(ROOT, 'modules'); +for (const moduleName of fs.readdirSync(modulesDir).sort()) { + const moduleDir = path.join(modulesDir, moduleName); + if (!fs.statSync(moduleDir).isDirectory()) continue; + + const moduleJsonPath = path.join(moduleDir, 'module.json'); + if (!fs.existsSync(moduleJsonPath)) continue; + + let moduleJson; + try { + moduleJson = JSON.parse(fs.readFileSync(moduleJsonPath, 'utf-8')); + } catch (e) { + console.warn(` Skipping ${moduleName}: invalid module.json`); + continue; + } + + // Convert module.json humanReadableName, description, legalDisclaimer + let mjChanged = false; + for (const key of ['humanReadableName', 'description', 'legalDisclaimer']) { + if (isLocalizedObject(moduleJson[key])) { + moduleJson[key] = unwrap(moduleJson[key]); + mjChanged = true; + } + } + if (mjChanged) { + if (DRY_RUN) { + console.log(` [DRY RUN] Would modify ${moduleName}/module.json`); + } else { + fs.writeFileSync(moduleJsonPath, JSON.stringify(moduleJson, null, 2) + '\n'); + console.log(` Modified ${moduleName}/module.json`); + } + filesModified++; + } + + // Convert config files + const configFiles = moduleJson['config-example-files'] || []; + for (const configFile of configFiles) { + const filePath = path.join(moduleDir, configFile); + if (!fs.existsSync(filePath)) { + console.warn(` Warning: ${moduleName}/${configFile} listed in module.json but not found`); + continue; + } + processFile(filePath); + } +} + +console.log(`\n${DRY_RUN ? '[DRY RUN] ' : ''}Done! ${filesModified} files modified, ${fieldsConverted} fields converted.`); +if (DRY_RUN) console.log('Run without --dry-run to apply changes.'); diff --git a/config-localizations/en.json b/config-localizations/en.json new file mode 100644 index 00000000..67abb50a --- /dev/null +++ b/config-localizations/en.json @@ -0,0 +1,4907 @@ +{ + "_core": { + "config": { + "description": "Configure the basic features of the bot here", + "humanName": "Configuration", + "content": { + "token": { + "description": "Replace this with your token", + "default": "yourtokengoeshere" + }, + "dmAbuseButton": { + "description": "Used to allow mass dm reporting" + }, + "scnxToken": { + "description": "Replace this with your token", + "default": "yourtokengoeshere" + }, + "scnxHostOverwirde": { + "description": "Replace this with your token" + }, + "prefix": { + "humanName": "Prefix of your bot", + "description": "Set the prefix of your bot here", + "default": "!" + }, + "botOperators": { + "description": "Bot operators can reload the configuration and perform system relevant actions with this bot. Please only add users you really trust (and yourself of course)" + }, + "guildID": { + "description": "Replace this the id of the guild the bot should work in." + }, + "disableStatus": { + "humanName": "Disable Bot-Status", + "description": "If enabled, the bot won't have a status in discord" + }, + "user_presence": { + "humanName": "Bot-Status", + "description": "This will show up in Discord as \"Playing \"", + "default": "Change this in your Bot-Configuration on scnx.app: https://scootk.it/change-status" + }, + "logLevel": { + "humanName": "Logging-Level", + "description": "Log-Level of the bot. Leave it as it is, if you don't know what this means" + }, + "logChannelID": { + "humanName": "Log-Channel", + "description": "Default log-channel for most modules and used to log relevant information" + }, + "timezone": { + "humanName": "Timezone", + "description": "Timezone the bot runs in" + }, + "disableEveryoneProtection": { + "humanName": "Allow @everyone / @here pings", + "description": "Allows @everyone and @here pings for messages configurable in the dashboard" + }, + "syncCommandGlobally": { + "humanName": "Sync module commands as global commands", + "description": "If enabled, module-commands will be synced to discord as global commands. They will show up on other servers, but won't work. Syncing can take up to 2 hours, so changes may not be reflected immediately." + } + } + }, + "strings": { + "description": "Configure strings & messages of your bot here", + "humanName": "Messages", + "content": { + "addAtToUsernames": { + "humanName": "Add @ to usernames", + "description": "If enabled, every username will be prefixed by an \"@\". Example: \"scderox\" -> \"@scderox\"" + }, + "footer": { + "humanName": "Embed-Footer", + "description": "Footer of every embed", + "default": "Powered by scnx.xyz ⚡" + }, + "footerImgUrl": { + "humanName": "Embed-Footer-Image-URL", + "description": "Footer-Image of every embed", + "default": "https://scnx.xyz/favicon.png" + }, + "need_args": { + "humanName": "More arguments are needed", + "description": "This message gets sent if there are not enough arguments specified", + "default": "This command needs more arguments - you passed %count%, but you need to provide at least %neededCount%.", + "params": { + "count": { + "description": "Count of arguments provided" + }, + "neededCount": { + "description": "Count of arguments needed" + } + } + }, + "updated_roles": { + "humanName": "Roles updated", + "description": "This message gets sent after a user selects self-roles on a self-role-element.", + "default": "✅ Updated roles according to your settings" + }, + "added_role": { + "humanName": "Role added", + "description": "This message gets sent when a user adds a role to themselves.", + "default": "✅ Role %role% successfully added", + "params": { + "role": { + "description": "Name of the role" + } + } + }, + "removed_role": { + "humanName": "Role removed", + "description": "This message gets sent when a user removes a role from themselves.", + "default": "✅ Role %role% successfully removed", + "params": { + "role": { + "description": "Name of the role" + } + } + }, + "not_enough_permissions": { + "humanName": "Not enough permissions", + "description": "This message gets sent if an user don't hase enough permissions", + "default": "Seems like you don't have enough permissions." + }, + "helpembed": { + "humanName": "Help-Message", + "description": "Strings for help command" + }, + "disableHelpEmbedStats": { + "humanName": "Disable Stats in Help-Embed", + "description": "If enabled, the stats-field in the Help-Embed will get hidden" + }, + "disableFooterTimestamp": { + "humanName": "Disable default Timestamp in footer", + "description": "If enabled, the current time will not be displayed in the embed footer" + }, + "putBotInfoOnLastSite": { + "humanName": "Hides the Bot-Info in the Help-Embed", + "description": "If enabled, the Bot-Info-Section of the Help-Embed will be hidden." + } + } + } + }, + "admin-tools": { + "_module": { + "humanReadableName": "Admin-Tools", + "description": "Simple tools for admins - move channels and roles via commands, assign temporary roles, configure role bans or copy an emoji from another server to your server." + }, + "config": { + "description": "Configure the behaviour of the module here", + "humanName": "Configuration" + }, + "always-temporary-roles": { + "description": "Configure roles that are always temporary. When a user receives one of these roles (by any means), the role will automatically be removed after the configured duration.", + "humanName": "Always-Temporary Roles", + "configElementName": { + "one": "Always-Temporary Role", + "more": "Always-Temporary Roles" + }, + "content": { + "roleID": { + "humanName": "Role", + "description": "The role that should always be temporary. When a user receives this role, it will be automatically removed after the configured duration." + }, + "duration": { + "humanName": "Duration", + "description": "How long the role should last before being automatically removed. Examples: 1h, 12h, 1d, 7d, 30m", + "default": "24h", + "links": { + "https://scootk.it/custombot-durations": { + "label": "Duration format" + } + } + } + } + }, + "role-bans": { + "description": "Configure roles that automatically ban users when assigned. When a user receives one of these roles, they will be immediately banned from the server. Users with the \"Manage Roles\" permission are exempt.", + "humanName": "Role Bans", + "configElementName": { + "one": "Role Ban", + "more": "Role Bans" + }, + "content": { + "roleID": { + "humanName": "Role", + "description": "When a user receives this role, they will be immediately banned from the server. Users with the \"Manage Roles\" permission are exempt." + }, + "reason": { + "humanName": "Ban Reason", + "description": "The reason shown in the audit log when a user is banned for receiving this role.", + "default": "Received a banned role" + }, + "deleteMessageDays": { + "humanName": "Delete Message Days", + "description": "Number of days of messages to delete when banning the user (0-7)." + } + } + } + }, + "afk-system": { + "_module": { + "humanReadableName": "AFK-System", + "description": "Allow users to set their AFK-Status and notify other users if they try to reach them" + }, + "config": { + "description": "Configure the behaviour of the module here", + "humanName": "Configuration", + "content": { + "sessionEndedSuccessfully": { + "humanName": "AFK-Session ended successfully", + "description": "This message gets send if a user ended their AFK-session successfully.", + "default": "✅ Your AFK status has been removed. Welcome back!" + }, + "sessionStartedSuccessfully": { + "humanName": "AFK-Session started successfully", + "description": "This message gets send if a user started their session successfully.", + "default": "✅ Your status has been updated to AFK. If another member mentions you while your AFK, we're going to notify them about your status." + }, + "afkUserWithReason": { + "humanName": "User is AFK with reason", + "description": "This message gets send if a pinged user is currently AFK with a previously specified reason.", + "default": "ℹ %user% is currently AFK and specified the following reason: \"%reason%\".", + "params": { + "reason": { + "description": "Reason for their absence" + }, + "user": { + "description": "Mention of the user who is AFK" + } + } + }, + "afkUserWithoutReason": { + "humanName": "User is AFK without reason", + "description": "This message gets send if a pinged user is currently AFK without a previously specified reason.", + "default": "ℹ %user% is currently AFK.", + "params": { + "user": { + "description": "Mention of the user who is AFK" + } + } + }, + "autoEndMessage": { + "humanName": "AFK Session ended automatically", + "description": "This message gets send if a user who is AFK and hasn't disabled auto-ending their sessions posts a message on the server.", + "default": "Welcome back 👋!\nYou are no longer AFK because you wrote a message. You can start a new session with `/afk start` and disable `auto-end` if you don't want your sessions to be ended automatically.", + "params": { + "user": { + "description": "Mention of the user who was AFK" + } + } + } + } + } + }, + "anti-ghostping": { + "_module": { + "humanReadableName": "Anti-Ghostping", + "description": "This module detects ghost-pings and sends a message if one occurs" + }, + "config": { + "description": "Configure the behaviour of the module here", + "humanName": "Configuration", + "content": { + "awaitBotMessages": { + "humanName": "Wait for Bot-Messages", + "description": "If enabled, the bot will wait ~2 Seconds to make sure no bot like NQN deleted the messages and answered afterwards" + }, + "ignoredChannels": { + "humanName": "Ignored Channels", + "description": "If a ghost ping gets send in one of these configured channels, the bot will not run anti-ghost-ping" + }, + "youJustGotGhostPinged": { + "humanName": "Ghostping-Message", + "description": "This message gets send if a member pings another user and deletes the message afterwards", + "default": "%mentions%,\nYou just got ghost-pinged by %authorMention% with the following message: \"%msgContent%\"", + "params": { + "mentions": { + "description": "Mentions of every user that got pinged in the original message" + }, + "authorMention": { + "description": "Mention of the original message-author." + }, + "msgContent": { + "description": "Content of the original message" + } + } + } + } + } + }, + "auto-delete": { + "_module": { + "humanReadableName": "Auto-Message-Delete", + "description": "This module allows you to delete messages from a channel after a specified timeout to keep your channel clean" + }, + "channels": { + "description": "Set up channels to delete text-messages from", + "humanName": "Text-Channels", + "content": { + "channelID": { + "humanName": "Channel", + "description": "The Channel you want messages to be deleted from." + }, + "timeout": { + "humanName": "Timeout", + "description": "Timeout (in minutes) after which the messages in a channel will be deleted." + }, + "keepMessageCount": { + "humanName": "Amount of messages to keep", + "description": "Set up a number here to always have x messages in your channel left (newest messages are kept). The number has to below 50." + } + } + }, + "voice-channels": { + "description": "Set up voice-channels to delete messages from", + "humanName": "Voice-Channels", + "content": { + "channelID": { + "humanName": "Voice-Channel", + "description": "The Voice-Channel you want the auto-deleter to clear if there are no channel members left." + }, + "timeout": { + "humanName": "Timeout", + "description": "Timeout (in minutes) after which the messages in a Voice-Channel are deleted after the last member left the channel. Entering '0' will result in an instant deletion." + } + } + } + }, + "auto-messager": { + "_module": { + "humanReadableName": "Automatic Messages", + "description": "You can - with this module - send automatic messages" + }, + "hourly": { + "description": "You can send messages on an hourly basic here - this can be once or 24 times a day", + "humanName": "Hourly basic", + "configElementName": { + "one": "Automatic message", + "more": "Automatic messages" + }, + "content": { + "channelID": { + "humanName": "Channel", + "description": "ID of the channel in which the message should be send" + }, + "message": { + "humanName": "Message", + "description": "Message that should be send", + "default": "" + }, + "limitHoursTo": { + "humanName": "Limit hours to", + "description": "If one or more values are set, the message will only get send when the current hour is included in this field" + } + } + }, + "daily": { + "description": "You can send on a daily basic here - this can be once a week or month", + "humanName": "Daily Basic", + "configElementName": { + "one": "Automatic message", + "more": "Automatic messages" + }, + "content": { + "channelID": { + "humanName": "Channel", + "description": "ID of the channel in which the message should be send" + }, + "message": { + "humanName": "Message", + "description": "Message that should be send", + "default": "" + }, + "limitWeekDaysTo": { + "humanName": "Limit Week-Days to", + "description": "If one or more values are set, the message will only get send when the current week-day is included in this field" + }, + "limitDaysTo": { + "humanName": "Limit days to", + "description": "If one or more values are set, the message will only get send when the current day (of the month) is included in this field" + } + } + }, + "cronjob": { + "description": "Advanced users can unleash the full potential of automatic message with cronejobs", + "humanName": "Cronjob (advanced)", + "configElementName": { + "one": "Automatic message", + "more": "Automatic messages" + }, + "content": { + "channelID": { + "humanName": "Channel", + "description": "ID of the channel in which the message should be send" + }, + "message": { + "humanName": "Message", + "description": "Message that should be send", + "default": "" + }, + "expression": { + "humanName": "Expression", + "description": "The message gets scheduled for this expression", + "default": "1 6 1-31 * *" + } + } + } + }, + "auto-publisher": { + "_module": { + "humanReadableName": "Automatic Publishing", + "description": "Publishes messages in announcement channels" + }, + "config": { + "description": "Configure the behaviour of the module here", + "humanName": "Configuration", + "content": { + "mode": { + "humanName": "Message-Publishing-Mode", + "description": "Modus in which this module should operate" + }, + "blacklist": { + "humanName": "Blacklist", + "description": "Channel to be ignored (only if Message-Publishing-Mode = \"blacklist\")" + }, + "whitelist": { + "humanName": "Whitelist", + "description": "Channel in which messages should get published (only if Message-Publishing-Mode = \"whitelist\")" + }, + "ignoreBots": { + "humanName": "Ignore bots?", + "description": "Should bots get ignored when they post a message" + } + } + } + }, + "auto-thread": { + "_module": { + "humanReadableName": "Automatic Thread-Creation", + "description": "Automatically creates a thread under each message that gets posted in a selected channel" + }, + "config": { + "description": "Configure the behaviour of the module here", + "humanName": "Configuration", + "content": { + "channels": { + "humanName": "Channels", + "description": "Here you can add channels in which the bot should create a thread under every message" + }, + "threadName": { + "humanName": "Thread Name", + "description": "Name of every thread", + "default": "Comments" + }, + "threadArchiveDuration": { + "humanName": "Archive Duration", + "description": "Inactivity after which a thread is automatically archived (in minutes, some values are limited by guild boost level; select \"max\" for the longest possible duration)" + } + } + } + }, + "betterstatus": { + "_module": { + "humanReadableName": "Betterstatus", + "description": "Give you more features to make your status even better - change it when someone joins, change it every x seconds and more!" + }, + "config": { + "description": "Configure the bot status, activity type and interval settings here", + "humanName": "Configuration", + "content": { + "enableStatusCommand": { + "humanName": "Enable /status command?", + "description": "If enabled, administrators can change the bot status using the /status slash command" + }, + "enableInterval": { + "humanName": "Enable interval?", + "description": "If enabled the bot will change its status every x seconds" + }, + "intervalStatuses": { + "humanName": "Interval-Statuses", + "description": "Statuses from which the bot should randomly choose one", + "params": { + "onlineMemberCount": { + "description": "Count of online members on your guild (will not work if presence intent not enabled)" + }, + "memberCount": { + "description": "Count of members on your guild" + }, + "randomMemberTag": { + "description": "Tag of one random member on your guild" + }, + "randomOnlineMemberTag": { + "description": "Tag of one random member who is online on your guild" + }, + "channelCount": { + "description": "Count of channels on your guild" + }, + "roleCount": { + "description": "Count of roles on your guild" + } + } + }, + "activityType": { + "humanName": "Activity-Type", + "description": "Type of the user activity" + }, + "botStatus": { + "humanName": "Bot-Status", + "description": "Status of your bot" + }, + "interval": { + "humanName": "Status-Interval", + "description": "The interval in seconds (at least 10 seconds)" + }, + "changeOnUserJoin": { + "humanName": "Change status on user join?", + "description": "If the status should be changed if someone joins your guild" + }, + "userJoinStatus": { + "humanName": "User-Join-Status", + "description": "Status that will be set if a user joins", + "default": "Welcome %tag%!", + "params": { + "tag": { + "description": "Tag of the new user" + }, + "username": { + "description": "Username of the new user" + }, + "memberCount": { + "description": "New member count of your guild" + } + } + }, + "streamingLink": { + "humanName": "Streaming Link", + "description": "Will be shown, if the activity-typ is streaming and your link is supported by Discord", + "default": "" + } + } + } + }, + "channel-stats": { + "_module": { + "humanReadableName": "Channel-Stats", + "description": "Create channels containing stats about your server - updated automatically." + }, + "channels": { + "description": "Configure voice channels that display live server statistics", + "humanName": "Configuration", + "configElementName": { + "one": "Statistics-Channel", + "more": "Statistics-Channels" + }, + "content": { + "channelID": { + "humanName": "Channel", + "description": "ID of the voice channel" + }, + "channelName": { + "humanName": "Channel-Name", + "description": "Name of Channel", + "default": "", + "params": { + "userCount": { + "description": "Total count of users on your server" + }, + "memberCount": { + "description": "Total count of members (not bots) on your server" + }, + "onlineUserCount": { + "description": "Total count of online (dnd or online status) users on your server" + }, + "channelCount": { + "description": "Total count of channels on your server" + }, + "roleCount": { + "description": "Total count of roles on your server" + }, + "botCount": { + "description": "Count of Bots on your server" + }, + "dndCount": { + "description": "Count of members (not bots) with DND as status" + }, + "onlineMemberCount": { + "description": "Count of members (not bots) with online (and only online) as status" + }, + "awayCount": { + "description": "Count of members (not bots) with away status" + }, + "offlineCount": { + "description": "Count of members (not bots) with offline status" + }, + "guildBoosts": { + "description": "Show how often this guild was boosted" + }, + "boostLevel": { + "description": "Shows the current boost-level of this guild" + }, + "boosterCount": { + "description": "Count of boosters on this guild" + }, + "emojiCount": { + "description": "Count of emojis on this guild" + }, + "currentTime": { + "description": "Current time and date" + }, + "userWithRoleCount-": { + "description": "Count of members with a specific role (replace \"\" with an actual role-id)" + }, + "onlineUserWithRoleCount-": { + "description": "Count of members with a specific role who are online (replace \"\" with an actual role-id)" + } + } + }, + "updateInterval": { + "humanName": "Update-Interval", + "description": "You can set an interval here in which the bot should update the channels. Must be higher than seven; in minutes." + } + } + } + }, + "color-me": { + "_module": { + "humanReadableName": "Color me", + "description": "Simple module to reward users who have boosted your server with a custom role!" + }, + "config": { + "description": "Configure the function of the module here", + "humanName": "Configuration", + "content": { + "recreateRole": { + "humanName": "Recreate roles", + "description": "Should the role be created again if the user boosts again?" + }, + "listRoles": { + "humanName": "Separate roles in member-list", + "description": "Should the role be listed separately in the member-list?" + }, + "removeOnUnboost": { + "humanName": "Remove role on unboost", + "description": "Should the role be deleted automatically, if the user stops boosting your server? (disable, if also non-boosters should be able to use this command)" + }, + "updateCooldown": { + "humanName": "Role update cooldown", + "description": "The amount of time a user needs to wait util they can edit their role again (in hours)" + }, + "rolePosition": { + "humanName": "Role position", + "description": "The role, beneath which the custom-roles should be created" + } + } + }, + "strings": { + "description": "Edit the messages and strings of the module here", + "humanName": "Messages", + "content": { + "created": { + "humanName": "Role created", + "description": "This messages gets send when a booster sucessfully created their custom role", + "default": "Your role was created successfully." + }, + "createdNoIcon": { + "humanName": "Role created without icon", + "description": "This message gets send when a booster successfully created their custom role, but the guild has not enough boosts to use role icons", + "default": "Your role was created successfully, but your role icon was not used, as this requires the guild to be boost level 2 or higher." + }, + "updated": { + "humanName": "Role updated", + "description": "This messages gets send when a booster sucessfully updates their custom role", + "default": "Your role was updated successfully." + }, + "updatedNoIcon": { + "humanName": "Role updated without icon", + "description": "This messages gets send when a booster sucessfully updates their custom role, but the guild has not enough boosts to use role icons", + "default": "Your role was updated successfully, but your role icon was not used, as this requires the guild to be boost level 2 or higher." + }, + "removed": { + "humanName": "Role removed", + "description": "This messages gets send when a booster deleted their custom role", + "default": "Your role was removed successfully." + }, + "roleLimit": { + "humanName": "Role-limit reached", + "description": "This messages gets send when a booster-role couldn't be created", + "default": "Your role couldn't be created. This could be, because this server has reached the maximum of roles set by Discord. Ask the staff to delete an unnecessary role to make space for your role or try again later." + }, + "cooldown": { + "humanName": "Cooldown", + "description": "This messages gets send when a booster-role couldn't be edited, since the user is on cooldown", + "default": "Your role couldn't be edited, since you have to wait until %cooldown% for the cooldown to expire.", + "params": { + "cooldown": { + "description": "Timestamp the cooldown expires at" + } + } + }, + "invalidColor": { + "humanName": "Invalid Color", + "description": "This messages gets send when the user provides a wrong color code", + "default": "The color you provided is not a valid HEX-Code." + } + } + } + }, + "connect-four": { + "_module": { + "humanReadableName": "Connect Four", + "description": "Let your users play Connect Four against each other!" + } + }, + "counter": { + "_module": { + "humanReadableName": "Count-Game", + "description": "Allow your users to count together" + }, + "config": { + "description": "Configure counting channels, rules and moderation settings here", + "humanName": "Configuration", + "content": { + "channels": { + "humanName": "Channels", + "description": "Channels in which users can participate in the counting game" + }, + "channelDescription": { + "humanName": "Channel-Description", + "description": "Text which should be set after someone counted (leave blank to disable)", + "default": "Next number %x%", + "params": { + "x": { + "description": "Next number users should count" + } + } + }, + "success-reaction": { + "humanName": "Success-Reaction", + "description": "Reaction which the bot should give when someone counts successfully", + "default": "✅" + }, + "restartOnWrongCount": { + "humanName": "Restart game, if user miscounts", + "description": "If enabled, the game will restarts if a user sends a number that is not in order" + }, + "restartOnWrongCountMessage": { + "humanName": "Message when game gets restarted", + "description": "This message will be sent when the game gets restarted due to a miscount.", + "default": "Due to the incompetence of %mention%, the game had to restart - the next number is **%i%**.", + "params": { + "mention": { + "description": "Mention of the users" + }, + "i": { + "description": "Next number" + } + } + }, + "onlyOneMessagePerUser": { + "humanName": "Only one continuous message per user", + "description": "If enabled, users can not count more than one number continuously" + }, + "protectAgainstDeletion": { + "humanName": "Protect against users deleting the last counting message?", + "description": "If enabled, the bot will send a message when the last correct counting message gets deleted so that other counters can't be fooled into counting an already counted number again." + }, + "protectionMessage": { + "humanName": "Deletion protection message", + "description": "Message that gets send if a user deletes the last correct counting message.", + "default": "It seems like %mention% deleted their last message - the last counted number is **%number%**.", + "params": { + "mention": { + "description": "Mention of the user who's message got removed" + }, + "number": { + "description": "Last counted number in this the channel" + } + } + }, + "removeReactions": { + "humanName": "Remove reactions after 5 seconds?", + "description": "If enabled, the reactions the bot gives will be removed after 5 seconds. This will free up space in the counting channel" + }, + "wrong-input-message": { + "humanName": "Message on wrong input", + "description": "Message that gets send if a user provides an invalid input", + "default": "⚠️ %err%", + "params": { + "err": { + "description": "Description of what they did wrong" + } + } + }, + "strikeAmount": { + "humanName": "Amount of wrong messages to trigger action", + "description": "This is the amount of wrong messages a user has to send to trigger action. Once this amount is reached, the bot will either, depending on your configuration, give a role or disable the SEND_MESSAGES permission for a user (set to 0 to disable)" + }, + "giveRoleInsteadOfPermissionRemoval": { + "humanName": "Give role on action, instead of removing permission", + "description": "If enabled, a role will be given to the user (once their reach the configured action amount of wrong messages) instead of the removal of the \"Send Messages\"-permission in the counter channel" + }, + "strikeRole": { + "humanName": "Role given when amount is being reached", + "description": "This role will be given to users when they reach the configured amount of wrong messages" + }, + "strikeMessage": { + "humanName": "Message when user gets actioned", + "description": "This message will be sent when a user reach the configured amount of wrong messages and gets actioned", + "default": "%mention%, I had to restrict your access to this channel because you repeatedly used it improperly.", + "params": { + "mention": { + "description": "Mention of the users" + } + } + }, + "allowCharactersInMessage": { + "humanName": "Allow text characters in messages?", + "description": "If enabled, users may write additional content into their messages instead of forcing them to just write a number. Messages without a number will still lead to an error." + }, + "allowMaths": { + "humanName": "Allow users to use maths in their messages?", + "description": "If enabled, users can use maths in messages, as long as the result of their formula is the correct next number." + }, + "enableEasterEggs": { + "humanName": "Enable number easter eggs?", + "description": "If enabled, the bot will react with special emojis on certain numbers (e.g. 42, 67, 69, 100, 420)" + } + } + }, + "milestones": { + "description": "Reward your users, when they reach certain goals", + "humanName": "Milestones", + "configElementName": { + "one": "Milestone", + "more": "Milestones" + }, + "content": { + "userMessageCount": { + "humanName": "Message count", + "description": "Count of valid counter-messages the users has to achieve this goal" + }, + "giveRoles": { + "humanName": "Roles", + "description": "These roles are given to the user if they achieve this goal (optional)" + }, + "sendMessage": { + "humanName": "Message", + "description": "This message gets send when they achieve this goal", + "default": "Congrats %mention% for counting %milestone% times!", + "params": { + "mention": { + "description": "Mention the user who achieved the milestone" + }, + "milestone": { + "description": "The milestone (the number of message) that was reached" + } + } + } + } + } + }, + "duel": { + "_module": { + "humanReadableName": "Duel", + "description": "Let users play the game \"Duel\" on your discord" + } + }, + "economy-system": { + "_module": { + "humanReadableName": "Economy", + "description": "A simple economy-system, containing a shop system, message-drops and commands to earn money" + }, + "config": { + "description": "Configure here, how the module should behave", + "humanName": "Configuration", + "content": { + "admins": { + "humanName": "Administrators", + "description": "Users who can perform admin only actions e.g. manage the balance of users (Bot Operators always have this permission)" + }, + "allowCheats": { + "humanName": "Allow Cheats", + "description": "Allow admins to edit the balance of users (for a fair system not recommended!)" + }, + "selfBalance": { + "humanName": "Allow Self-Balance Editing", + "description": "Allow admins to edit their own balance (for a fair system not recommended! DON'T DO THIS!!!!!)" + }, + "shopManagers": { + "humanName": "shop-managers", + "description": "The Ids of the shop managers (Bot Operators have this permission always)" + }, + "startMoney": { + "humanName": "Start Money", + "description": "The amount of money that is given to a new user" + }, + "currencyName": { + "humanName": "currency name", + "description": "The name of the currency", + "default": "" + }, + "currencySymbol": { + "humanName": "Symbol of the currency", + "description": "The symbol of the currency", + "default": "💰" + }, + "maxWorkMoney": { + "humanName": "max work money", + "description": "The highest amount of money you can get for working" + }, + "minWorkMoney": { + "humanName": "min work money", + "description": "The lowest amount of money you can get for working" + }, + "workCooldown": { + "humanName": "work cooldown", + "description": "The amount of time a user needs to wait util they can use the work command again (in minutes)" + }, + "maxCrimeMoney": { + "humanName": "max crime money", + "description": "The highest amount of money you can get for crime" + }, + "minCrimeMoney": { + "humanName": "min crime money", + "description": "The lowest amount of money you can get for crime" + }, + "crimeCooldown": { + "humanName": "crime cooldown", + "description": "The amount of time a user needs to wait util they can use the crime command again (in minutes)" + }, + "maxRobAmount": { + "humanName": "max rob amount", + "description": "The highest amount of money that a user can rob" + }, + "robPercent": { + "humanName": "rob percent", + "description": "The amount that can get robed in percent" + }, + "robCooldown": { + "humanName": "rob cooldown", + "description": "The amount of time a user needs to wait util they can use the rob command again (in minutes)" + }, + "leaderboardChannel": { + "humanName": "leaderboard-channel", + "description": "The channel for the leaderboard. On this leaderboard everyone can see who has the most money." + }, + "shopChannel": { + "humanName": "shop channel", + "description": "The id of the channel for the shop-Message. This message shows the items of the shop" + }, + "msgDropsIgnoredChannels": { + "humanName": "message-drops ignored channels", + "description": "List of Channels where Users can't get message-drops" + }, + "messageDrops": { + "humanName": "Message Drop Chance", + "description": "Chance to get money for a message (Chance: 1/ This value). Set to 0 to disable message drops" + }, + "messageDropsMax": { + "humanName": "Max Message Drop Amount", + "description": "The max amount of money in a message Drop" + }, + "messageDropsMin": { + "humanName": "Min Message Drop Amount", + "description": "The min amount of money in a message Drop" + }, + "dailyReward": { + "humanName": "Daily Reward Amount", + "description": "The daily reward" + }, + "weeklyReward": { + "humanName": "Weekly Reward Amount", + "description": "The weekly reward" + }, + "publicCommandReplies": { + "humanName": "Public Command-Replies", + "description": "Should the Command-replies be displayed for everyone?" + } + } + }, + "strings": { + "description": "Configure messages of this module here", + "humanName": "Messages", + "content": { + "notFound": { + "humanName": "not found message", + "description": "The message that is send if the item wasn't found", + "default": "This item could not be found" + }, + "notEnoughMoney": { + "humanName": "not enough money", + "description": "The message that is send if the user haven't enough money to buy an item", + "default": "You haven't enough money to buy this Item" + }, + "shopMsg": { + "humanName": "shop message", + "description": "Message for the shop. The Items gets added at the end", + "default": { + "title": "Shop", + "description": "%shopItems%" + }, + "params": { + "shopItems": { + "description": "All items of the shop (format specified below)" + } + } + }, + "itemString": { + "humanName": "item string", + "description": "String for the items for the shop message", + "default": "**%id%** %itemName%, **price**: %price%, **sellcount**: %sellcount%", + "params": { + "id": { + "description": "Id of the item" + }, + "itemName": { + "description": "Name of the item" + }, + "price": { + "description": "Price of the item" + }, + "sellcount": { + "description": "Count of the sales of the item" + } + } + }, + "cooldown": { + "humanName": "cooldown", + "description": "This message gets send when a user is currently in cooldown", + "default": "Please wait before using this command again" + }, + "workSuccess": { + "humanName": "Work Success Messages", + "description": "Array of messages from which one random gets send when a user works successfully", + "params": { + "earned": { + "description": "Money that the user had earned" + } + } + }, + "crimeSuccess": { + "humanName": "Crime Success Messages", + "description": "Array of messages from which one random gets send when a user commits a crime successfully", + "params": { + "earned": { + "description": "Money that the user had earned" + } + } + }, + "crimeFail": { + "humanName": "Crime Fail Messages", + "description": "Array of messages from which one random gets send when a user fails to do some crime", + "params": { + "loose": { + "description": "Money that the user looses" + } + } + }, + "robSuccess": { + "humanName": "Rob Success Message", + "description": "This message gets send when a user robs another user successfully", + "default": "You robed %user% earned **%earned%**", + "params": { + "earned": { + "description": "Money that the user had earned" + }, + "user": { + "description": "The user that gets robed by you" + } + } + }, + "leaderboardEmbed": { + "humanName": "Leaderboard Embed", + "description": "Configure the leaderboard embed here" + }, + "dailyReward": { + "humanName": "Daily Reward Message", + "description": "Message that gets send after the user has claimed the daily reward", + "default": "You earned **%earned%** by collecting your daily reward", + "params": { + "earned": { + "description": "Money that the user had earned" + } + } + }, + "weeklyReward": { + "humanName": "Weekly Reward Message", + "description": "Message that gets send after the user has claimed the weekly reward", + "default": "You earned **%earned%** by collecting your weekly reward", + "params": { + "earned": { + "description": "Money that the user had earned" + } + } + }, + "balanceReply": { + "humanName": "Balance Reply", + "description": "Reply for the balance command", + "default": { + "title": "Balance of %user%", + "fields": [ + { + "name": "Balance:", + "value": "%balance%" + }, + { + "name": "Bank:", + "value": "%bank%" + }, + { + "name": "Total:", + "value": "%total%" + } + ] + }, + "params": { + "balance": { + "description": "Current balance of the user" + }, + "bank": { + "description": "Current value that the user has on the bank" + }, + "total": { + "description": "Total balance of the user" + }, + "user": { + "description": "Username and discriminator of the User" + } + } + }, + "userNotFound": { + "humanName": "User Not Found", + "description": "The message that gets sent when the bot can't find a user", + "default": "I can't find the user **%user%**", + "params": { + "user": { + "description": "User that can't been found" + } + } + }, + "buyMsg": { + "humanName": "Purchase Message", + "description": "Message that gets send when a user buys something in the shop", + "default": "You got the item **%item%**", + "params": { + "item": { + "description": "Name of the item" + } + } + }, + "itemCreate": { + "humanName": "Item Created Message", + "description": "Message that gets send when a new shop item gets created", + "default": "Successfully created the item %name% with the id %id%. It costs %price% and you get the role %role%", + "params": { + "name": { + "description": "Name of the created item" + }, + "id": { + "description": "Id of the created item" + }, + "price": { + "description": "Price of the created item" + }, + "role": { + "description": "Role that everyone gets who buys the item" + } + } + }, + "itemDelete": { + "humanName": "Item Deleted Message", + "description": "Message that gets send when a new shop item gets deleted", + "default": "Successfully deleted the item %name%.", + "params": { + "name": { + "description": "Name of the deleted item" + }, + "id": { + "description": "Id of the deleted item" + } + } + }, + "itemEdit": { + "humanName": "Item Edited Message", + "description": "Message that gets sent when a shop item gets edited", + "default": "Successfully edited the item %name%. Check it out using `/shop list`", + "params": { + "name": { + "description": "Name of the edited item" + }, + "id": { + "description": "Id of the edited item" + } + } + }, + "depositMsg": { + "humanName": "deposit message", + "description": "The reply when a user deposits money to the bank", + "default": "Successfully deposited **%amount%** to your bank", + "params": { + "amount": { + "description": "Amount deposited" + } + } + }, + "withdrawMsg": { + "humanName": "withdraw message", + "description": "The reply when a user withdraws money from the bank", + "default": "Successfully withdrew **%amount%** from your bank", + "params": { + "amount": { + "description": "Amount withdrawn" + } + } + }, + "msgDropMsg": { + "humanName": "message drop message", + "description": "The message that gets sent on a message-drop", + "params": { + "earned": { + "description": "Money earned from the drop" + } + } + }, + "NaN": { + "humanName": "not a number", + "description": "Message that gets send if the bot needs a number but gets something different", + "default": "**%input%** isn't a number", + "params": { + "input": { + "description": "The invalid input" + } + } + }, + "msgDropAlreadyEnabled": { + "humanName": "message-drop already enabled", + "description": "Message that gets send if a User trys to enable the Message-Drop message, but it's already enabled", + "default": "The Mesage-Drop message is already enabled!" + }, + "msgDropEnabled": { + "humanName": "message-drop enabled", + "description": "Message that gets send when a User enables the Message-Drop message", + "default": "Successfully enabled the Message-Drop message" + }, + "msgDropAlreadyDisabled": { + "humanName": "message-drop already disabled", + "description": "Message that gets send if a User trys to disable the Message-Drop message, but it's already disabled", + "default": "The Mesage-Drop message is already disabled!" + }, + "msgDropDisabled": { + "humanName": "message-drop disabled", + "description": "Message that gets send when a User disables the Message-Drop message", + "default": "Successfully disabled the Message-Drop message" + }, + "rebuyItem": { + "humanName": "rebuy message", + "description": "The message that is send when the user trys to buy an Item that he already own", + "default": "You already own this Item" + }, + "multipleMatches": { + "humanName": "multiple matches", + "description": "The message that gets send when multiple items match the query", + "default": "Multiple items match the query" + }, + "noMatches": { + "humanName": "no matches", + "description": "The message that gets send when the item can't be found", + "default": "The item with the id %id%/ the name %name% doesn't exists", + "params": { + "id": { + "description": "The specified ID" + }, + "name": { + "description": "The specified name" + } + } + }, + "itemDuplicate": { + "humanName": "item duplicate", + "description": "The message that gets send when an item with the specified id or name already exists", + "default": "There's already an item with the id %id% or the name %name%", + "params": { + "id": { + "description": "The specified ID" + }, + "name": { + "description": "The specified name" + } + } + } + } + } + }, + "fun": { + "_module": { + "humanReadableName": "Fun-Commands", + "description": "Some random fun commands like /hug or /random" + }, + "config": { + "description": "Customize the messages and images for fun commands here", + "humanName": "Configuration", + "content": { + "ikeaMessage": { + "humanName": "IKEA Message", + "description": "Message that gets send when someone uses /random ikea-name", + "default": "Here's a ikea-product-name: %name%", + "params": { + "name": { + "description": "Randomly generated name of an ikea product (probably not real)" + } + } + }, + "randomNumberMessage": { + "humanName": "Random numer message", + "description": "Message that gets send when someone uses /random number", + "default": "Here your random number between %min% and %max%: %number%", + "params": { + "min": { + "description": "Minimal value" + }, + "max": { + "description": "Maximal value" + }, + "number": { + "description": "Generated number" + } + } + }, + "diceRollMessage": { + "humanName": "Dice Roll message", + "description": "Message that gets send when someone uses /random dice", + "default": "🎲 %number%", + "params": { + "number": { + "description": "Generated number" + } + } + }, + "coinFlipMessage": { + "humanName": "Coin toss message", + "description": "Message that gets send when someone uses /random coinfilp", + "default": "🪙 %site%", + "params": { + "site": { + "description": "Site on which the coin landed" + } + } + }, + "hugMessage": { + "humanName": "Hug message", + "description": "Message that gets send when someone uses /hug", + "default": "<@%authorID%> hugs <@%userID%>", + "params": { + "authorID": { + "description": "ID of the user who ran this command" + }, + "userID": { + "description": "ID of the user that gets hugged" + } + } + }, + "hugImages": { + "humanName": "Hug images", + "description": "Images that one will be randomly selected from when someone uses /hug." + }, + "kissMessage": { + "humanName": "Kiss message", + "description": "Message that gets send when someone uses /kiss", + "default": "<@%authorID%> kissed <@%userID%>", + "params": { + "authorID": { + "description": "ID of the user who ran this command" + }, + "userID": { + "description": "ID of the user that gets kissed" + } + } + }, + "kissImages": { + "humanName": "Kiss images", + "description": "Images that one will be randomly selected from when someone uses /kiss." + }, + "slapMessage": { + "humanName": "Slap message", + "description": "Message that gets send when someone uses /slap", + "default": "<@%authorID%> slapped <@%userID%>", + "params": { + "authorID": { + "description": "ID of the user who ran this command" + }, + "userID": { + "description": "ID of the user that gets slapped" + } + } + }, + "slapImages": { + "humanName": "Slap images", + "description": "Images that one will be randomly selected from when someone uses /slap." + }, + "patMessage": { + "humanName": "Pat message", + "description": "Message that gets send when someone uses /pat", + "default": "<@%authorID%> patted <@%userID%>", + "params": { + "authorID": { + "description": "ID of the user who ran this command" + }, + "userID": { + "description": "ID of the user that gets patted" + } + } + }, + "patImages": { + "humanName": "Pat images", + "description": "Images that one will be randomly selected from when someone uses /pat." + }, + "8ballMessage": { + "humanName": "8ball Message", + "description": "Message that gets send when someone uses /random 8ball", + "default": "The oracle has spoken... %answer%", + "params": { + "answer": { + "description": "Answer to the question" + } + } + }, + "8BallMessages": { + "humanName": "8ball responses", + "description": "Possible answers for /random 8ball" + } + } + } + }, + "guess-the-number": { + "_module": { + "humanReadableName": "Guess the number", + "description": "Select a number and let your users guess" + }, + "config": { + "description": "Adjust messages and permissions here", + "humanName": "Configuration", + "commandsWarnings": { + "/guess-the-number": { + "info": "You need to first set the permissions in your server settings for this command and after that add them under \"adminRoles\" here." + } + }, + "content": { + "adminRoles": { + "humanName": "Admin-Roles", + "description": "Every role that can manage game sessions." + }, + "startMessage": { + "humanName": "Start-Message", + "description": "Message that gets send when a new round gets started", + "default": { + "title": "Guess the Number - Game started", + "description": "Guess a number between %min% and %max%. Good luck!" + }, + "params": { + "min": { + "description": "Minimal value to guess" + }, + "max": { + "description": "Maximal value to guess" + } + } + }, + "endMessage": { + "humanName": "End-Message", + "description": "Message that gets send when a round ends", + "default": { + "title": "Guess the Number - Game ended", + "description": "Good game everyone!\nThe winner is %winner%.\nThe number was **%number%**.\nThere were around **%guessCount% guesses** in total." + }, + "params": { + "min": { + "description": "Minimal value to guess" + }, + "max": { + "description": "Maximal value to guess" + }, + "winner": { + "description": "@-mention of the winner" + }, + "guessCount": { + "description": "Count of guesses in this game session" + }, + "number": { + "description": "Winning number" + } + } + }, + "higherLowerReactions": { + "humanName": "React with Lower / Higher reactions", + "description": "If enabled, the bot will react with ⬇ (if the guess is higher than the correct number) or with ⬆ (if the guess is lower than the correct number) on wrong guesses. If disabled, the bot will just react with ❌ on wrong guesses." + }, + "enableLeaderboard": { + "humanName": "Enable leaderboard?", + "description": "If enabled, a leaderboard button is shown on new game messages and user statistics (wins, guesses) are tracked." + } + } + }, + "channel": { + "description": "Enable the Gamechannel mode to automatically re-start games", + "humanName": "Gamechannel Mode", + "content": { + "enabled": { + "humanName": "Enable Gamechannel mode?", + "description": "If enabled, you can configure a game channel, in which a new guess the number game will be started if a number got guessed correctly. You still will be able to manually start games in other channels. Everyone, including admins, can guess in game channels." + }, + "channel": { + "humanName": "Gamechannel", + "description": "In this channel, games will be automatically started if a game ends or no game is currently running" + }, + "minInt": { + "humanName": "Minimum number", + "description": "A number between this and the highest number will be selected at random when a game starts." + }, + "maxInt": { + "humanName": "Highest number", + "description": "A number between this and the minimum number will be selected at random when a game starts." + } + } + } + }, + "info-commands": { + "_module": { + "humanReadableName": "Info-Commands", + "description": "Adds info-commands with information about specific parts of your server" + }, + "strings": { + "description": "Edit the messages and strings of the module here", + "humanName": "Messages", + "content": { + "serverinfo": { + "humanName": "Server Info", + "description": "You can change the parts of the serverinfo-command here" + }, + "userinfo": { + "humanName": "User Info", + "description": "You can change the parts of the userinfo-command here" + }, + "channelInfo": { + "humanName": "Channel Info", + "description": "You can change the parts of the channelinfo-command here" + }, + "roleInfo": { + "humanName": "Role Info", + "description": "You can change the parts of the roleinfo-command here" + }, + "user_not_found": { + "humanName": "User Not Found", + "description": "Message that gets send if the user provided an invalid userid", + "default": "I could not find this user - try using an ID or a mention" + }, + "channel_not_found": { + "humanName": "Channel Not Found", + "description": "Message that gets send if the user provided an invalid userid", + "default": "I could not find this channel - try using an ID or a mention" + }, + "role_not_found": { + "humanName": "Role Not Found", + "description": "Message that gets send if the user provided an invalid roleid", + "default": "I could not find this role - try using an ID or a mention" + }, + "avatarMsg": { + "humanName": "Avatar Message", + "description": "Message that gets send if the user requested an avatar", + "default": "Here is the avatar: (Please reminder that the image may be protected under copyright-law)", + "params": { + "avatarUrl": { + "description": "URL to the avatar" + }, + "tag": { + "description": "Tag of the requested user" + } + } + } + } + } + }, + "levels": { + "_module": { + "humanReadableName": "Level-System", + "description": "Easy to use levelsystem with a lot of customization!" + }, + "config": { + "description": "Configure the function of the module here", + "humanName": "Configuration", + "categories": { + "general": { + "displayName": "General Settings" + }, + "xp": { + "displayName": "XP Settings" + }, + "leaderboard": { + "displayName": "Leaderboard" + }, + "roles": { + "displayName": "Level Roles" + }, + "messages": { + "displayName": "Level-up Messages" + } + }, + "content": { + "min-xp": { + "humanName": "XP given at least for messages", + "description": "How much XP the user gets at least for each message" + }, + "max-xp": { + "humanName": "XP given at most for messages", + "description": "How much XP the user gets at most for each messages" + }, + "voiceXPPerMinute": { + "humanName": "XP given per Voice Minute", + "description": "How many XP will be given to users per minute when they are in a voice channel with other members. No XP will be given if they are alone in their channel or are muted or deafened. Numbers will be rounded and XP will be given every 15 minutes or when the user leaves the channel." + }, + "cooldown": { + "humanName": "Cooldown", + "description": "In ms. How much cooldown there is between each XP getting" + }, + "curveType": { + "humanName": "Type of the leveling curve", + "description": "Type of the leveling curve. The exponential curve is recommended, as archiving new levels gets harder the higher your level is. Leveling is always the same if you use the linear curve.", + "selectOptions": { + "EXPONENTIAL": { + "displayName": "Easy Linear" + }, + "LINEAR": { + "displayName": "Default Linear" + }, + "EXPONENTIATION": { + "displayName": "Exponentiation (softer start, harder leveling after level 14)" + }, + "CUSTOM": { + "displayName": "Custom formula (dangerous!)" + } + }, + "links": { + "https://scootk.it/level-calculator": { + "label": "Calculate how much XP is needed to level up" + } + } + }, + "customLevelCurve": { + "humanName": "Custom Level Formula (if enabled)", + "description": "Your custom leveling formula. Use the x variable (and no other variables). The result of the formula should be the required XP to reach level x (your variable). Example: \"x*750+((x-1)*500)\" (our default level curve)", + "default": "", + "links": { + "https://scootk.it/level-calculator": { + "label": "Calculate how much XP is needed to level up" + } + } + }, + "levelUpMessagesConditions": { + "humanName": "Which Level-Up-Messages should get sent?", + "description": "This settings changes in which cases a level up message should be sent. With the setting \"all\", level up messages will be sent at every level up. With the setting \"only-role-rewards\", level up messages will only be sent if the new level has a role reward. With the \"none\" setting, no level up messages will be sent." + }, + "level_up_channel_id": { + "humanName": "Level-Up-Channel", + "description": "Channel in which Level-Up-Messages should get send. (Leave empty to disable)" + }, + "sortLeaderboardBy": { + "humanName": "Leaderboard-Sort-Category", + "description": "How the leaderboard should be sorted" + }, + "blacklisted_channels": { + "humanName": "Blacklisted Channels", + "description": "Blacklisted-Channels in which users can not earn XP" + }, + "blacklistedRoles": { + "humanName": "Blacklisted roles", + "description": "These roles won't receive XP when writing messages" + }, + "reward_roles": { + "humanName": "Level Reward roles", + "description": "Level at which users should get roles. Parameter 1: Level, Parameter 2: Role-ID" + }, + "multiplication_roles": { + "humanName": "XP Multiplication Roles", + "description": "Allows you to configure roles that have a higher multiplication factor than normal (default value is 1). If a user has more than one of the configured roles, the multiplication factors get multiplied together before multiplying the result with the amount of XP the user receives for their message." + }, + "multiplication_channels": { + "humanName": "XP Multiplication Channels", + "description": "Allows you to configure channels that have a higher multiplication factor than normal (default value is 1). Messages sent in these channels will have their XP value multiplied by the multiplier configured here." + }, + "onlyTopLevelRole": { + "humanName": "Only keep highest Level-Role", + "description": "If enabled, all previous level roles a user had will get removed, when they advance to a new level." + }, + "reset-on-leave": { + "humanName": "Rest Level on leave", + "description": "If enabled, all levels and the XP of a user will be deleted, when they leave your server." + }, + "randomMessages": { + "humanName": "Random messages", + "description": "If enabled the module will randomly select a messages from random-levelup-messages and ignore the one set in strings" + }, + "leaderboard-channel": { + "humanName": "Live Leaderboard-Channel", + "description": "If set, the bot will send a messages in this channel with the current leaderboard and edit it every five minutes" + }, + "leaderboard-channel-max-amount": { + "humanName": "Maximum amount of users displayed in live leaderboard Channel", + "description": "This is the maximum amount of users displayed in the Live Leaderboard channel. /leaderboard will still show the full leaderboard." + }, + "maximumLevelEnabled": { + "humanName": "Enable maximum level?", + "description": "If enabled, users can only level until they reach the configured maximum level. After that, they can't level up and can't earn XP. Can be enabled retroactively." + }, + "maximumLevel": { + "humanName": "Maximum level", + "description": "Once a user reaches this level, they neither earn more XP nor level up anymore." + }, + "startFromZero": { + "humanName": "Start with Level 0?", + "description": "If enabled, the initial level of users will be displayed as zero. This doesn't affect leveling, this is a cosmetic setting and can be applied retroactively." + }, + "useTags": { + "humanName": "Use User's Tags instead of their Mention in the Leaderboard-Channel-Embed", + "description": "If enabled, the bot will use the tag of users in the Leaderboard-Channel-Embed instead of their mention." + }, + "allowCheats": { + "humanName": "Cheats", + "description": "If enabled admins can change the XP of other users (not recommended (please leave it off if you want to have a fair levelling system!!!))" + } + } + }, + "strings": { + "description": "Edit the messages and strings of the module here", + "humanName": "Messages", + "categories": { + "leaderboard": { + "displayName": "Leaderboard Messages" + }, + "general": { + "displayName": "General Messages" + } + }, + "content": { + "user_not_found": { + "humanName": "User not found", + "description": "This messages gets send if someone checks a profile of a user when the user never send a message", + "default": "⚠️ We do not have any records of this user" + }, + "embed": { + "humanName": "Profile Embed", + "description": "Embed which gets send if !profile gets executed" + }, + "leaderboardEmbed": { + "humanName": "Leaderboard Embed", + "description": "This embed gets send if !leaderboard (!lb) gets executed" + }, + "level_up_message": { + "humanName": "Level Up Message", + "description": "This messages gets send if a user levels up (gets overwritten if randomMessages is enabled)", + "default": "Level Up! Your new level is **%newLevel%**!", + "params": { + "mention": { + "description": "Mention of the user" + }, + "avatarURL": { + "description": "Avatar of the user" + }, + "username": { + "description": "Username of the user" + }, + "tag": { + "description": "Tag of the user" + }, + "newLevel": { + "description": "New level of the user" + } + } + }, + "level_up_message_with_reward": { + "humanName": "Level Up Message with Reward", + "description": "This messages gets send if a user levels up and gets a role (gets overwritten if randomMessages is enabled)", + "default": "Level Up! Your new level is **%newLevel%**! You received %role%.", + "params": { + "mention": { + "description": "Mention of the user" + }, + "avatarURL": { + "description": "Avatar of the user" + }, + "username": { + "description": "Username of the user" + }, + "tag": { + "description": "Tag of the user" + }, + "newLevel": { + "description": "New level of the user" + }, + "role": { + "description": "Mention of the role (No ping)" + } + } + }, + "liveLeaderBoardEmbed": { + "humanName": "Live Leaderboard", + "description": "Embed which gets send to the leaderboard-channel and gets updated" + }, + "leaderboard-button-answer": { + "humanName": "Leaderboard Button Response", + "description": "This messages gets send if a user clicks on the button below the live-leaderboard", + "default": "Hi, %name%, you are currently on **level %level%** with **%userXP%**/%nextLevelXP% **XP**. Learn more with `/profile`.", + "params": { + "name": { + "description": "Username of the user" + }, + "level": { + "description": "Level of the user" + }, + "userXP": { + "description": "XP of the user" + }, + "nextLevelXP": { + "description": "XP of the next level" + } + } + } + } + }, + "random-levelup-messages": { + "description": "If enabled, the bot will randomly select a message from here", + "humanName": "Random-Level-Up-Messages", + "content": { + "type": { + "humanName": "Message Type", + "description": "Type of this message" + }, + "message": { + "humanName": "Messages", + "description": "Messages which should be send", + "default": "", + "params": { + "mention": { + "description": "Mention of the user" + }, + "avatarURL": { + "description": "Avatar of the user" + }, + "username": { + "description": "Username of the user" + }, + "tag": { + "description": "Tag of the user" + }, + "newLevel": { + "description": "New level of the user" + }, + "role": { + "description": "Mention of the role (No ping, only if type = with-reward)" + } + } + } + } + }, + "special-levelup-messages": { + "description": "If enabled, the bot will randomly select a message from here", + "humanName": "Selected messages", + "content": { + "level": { + "humanName": "Level", + "description": "Level at which this messages should get send" + }, + "message": { + "humanName": "Message", + "description": "Messages which should be send", + "default": "", + "params": { + "mention": { + "description": "Mention of the user" + }, + "avatarURL": { + "description": "Avatar of the user" + }, + "username": { + "description": "Username of the user" + }, + "tag": { + "description": "Tag of the user" + }, + "newLevel": { + "description": "New level of the user" + }, + "role": { + "description": "Mention of the role (No ping, only if level has reward)" + } + } + } + } + } + }, + "massrole": { + "_module": { + "humanReadableName": "Massrole", + "description": "Simple module to manage the roles of many members at once!" + }, + "config": { + "description": "Configure the function of the module here", + "humanName": "Configuration", + "commandsWarnings": { + "/massrole": { + "info": "You need to first set the permissions in your server settings for this command and after that add them under \"adminRoles\" here." + } + }, + "content": { + "adminRoles": { + "humanName": "Admin Roles", + "description": "Every role that can use the massrole command" + } + } + }, + "strings": { + "description": "Edit the messages and strings of the module here", + "humanName": "Messages", + "content": { + "done": { + "humanName": "Action executed", + "description": "This messages gets send when a action was executed successfully", + "default": "The action was executed successfully." + }, + "notDone": { + "humanName": "Action not executed", + "description": "This messages gets send when a action was not executed successfully", + "default": "The Action couldn't be executed because the bot has not enough permissions." + } + } + } + }, + "moderation": { + "_module": { + "humanReadableName": "Moderation & Security", + "description": "Advanced security- and moderation-system with tons of features" + }, + "config": { + "description": "You can set up permissions and features of this module here", + "humanName": "Configuration", + "commandsWarnings": { + "/moderate": { + "info": "Each moderator needs to be able to execute the /moderate command, so set your permissions in your server-settings accordingly. Additionally, moderator need to be entered into their level below." + } + }, + "categories": { + "general": { + "displayName": "General Settings" + }, + "roles": { + "displayName": "Roles & Permissions" + }, + "reports": { + "displayName": "Reports" + }, + "automod": { + "displayName": "Auto-Moderation" + }, + "actions": { + "displayName": "Actions & Punishments" + }, + "nicknames": { + "displayName": "Nickname Management" + } + }, + "content": { + "logchannel-id": { + "humanName": "Log-Channel", + "description": "Moderative actions will get logged in this channel" + }, + "quarantine-role-id": { + "humanName": "Quarantine-Role", + "description": "When a user gets quarantined, all of their roles will get removed and this quarantine-role wil get assigned" + }, + "report-channel-id": { + "humanName": "Report-Channel", + "description": "Channel in which user-reports should get send. (optional, default: Log-Channel)" + }, + "remove-all-roles-on-quarantine": { + "humanName": "Remove all roles on quarantine", + "description": "If enabled all roles from a user get removed if they get quarantined (they get saved an can be restored with /unquarantine)" + }, + "moderator-roles_level1": { + "humanName": "Moderator-Level 1", + "description": "Moderator roles that can perform the following actions: Warn" + }, + "moderator-roles_level2": { + "humanName": "Moderator-Level 2", + "description": "Moderator roles that can perform the following actions: Warn, Mute, Unmute, Lock, Unlock, Channelmute, Remove-Channel-Mute" + }, + "moderator-roles_level3": { + "humanName": "Moderator-Level 3", + "description": "Moderator roles that can perform the following actions: Warn, Mute, Unmute, Kick, Clear" + }, + "moderator-roles_level4": { + "humanName": "Moderator-Level 4", + "description": "Moderator roles that can perform the following actions: Warn, Mute, Unmute, Kick, Clear, Ban, Unban" + }, + "roles-to-ping-on-report": { + "humanName": "Roles to ping on reports", + "description": "Roles that should get pinged in the log-channel when a user reports someone" + }, + "require_reason": { + "humanName": "Force moderators to set a reason", + "description": "Should moderators be required to set a reason?" + }, + "require_proof": { + "humanName": "Force moderators to upload proof", + "description": "Should moderators be required to upload proof for their actions?" + }, + "action_on_invite": { + "humanName": "Action on invite", + "description": "What should the bot do if someone posts an invite link?" + }, + "allowed_invite_guild_ids": { + "humanName": "Allowed invite guild IDs", + "description": "Guild IDs whose invites should be allowed (in addition to this server's invites which are always allowed)." + }, + "action_on_scam_link": { + "humanName": "Action on Scam-Link", + "description": "What should the bot do if someone posts an suspicious or confirmed scam link?" + }, + "scam_link_level": { + "humanName": "Level of Scam-Link-Detection", + "description": "Select the Level of Scam-Link-Filter. \"confirmed\" only contains verified Scam-Domains, while \"suspicious\" may contain not-harmful domains." + }, + "whitelisted_channels_for_invite_blocking": { + "humanName": "Whitelisted channels for invite-ban", + "description": "Channels or categories where invite blocking is disabled" + }, + "whitelisted_roles_for_invite_blocking": { + "humanName": "Whitelisted roles for invite-ban", + "description": "ID of Roles which are allowed to bypass invite blocking" + }, + "blacklisted_words": { + "humanName": "Blacklisted words", + "description": "Words that are blacklisted" + }, + "action_on_posting_blacklisted_word": { + "humanName": "Action on blacklisted Word", + "description": "What should the bot do if someone posts a blacklisted word?" + }, + "defaultMuteDuration": { + "humanName": "Default Mute-Duration", + "description": "Default mute duration when none was configured. Will also be used for automod features (e.g. when someone posts a blacklisted word). Maximum value of 28 days.", + "default": "14d" + }, + "changeNicknames": { + "humanName": "Change nicknames on Mute- / Quarantine", + "description": "If enabled, the user will get renamed when they get muted or quarantined" + }, + "changeNicknameOnMute": { + "humanName": "New nickname on mute", + "description": "The nickname in which the user should be renamed when they get muted", + "default": "%nickname%", + "params": { + "nickname": { + "description": "Original nickname of the user" + } + } + }, + "changeNicknameOnQuarantine": { + "humanName": "Nickname during quarantine", + "description": "The nickname in which the user should be renamed when they get quarantined", + "default": "%nickname%", + "params": { + "nickname": { + "description": "Original nickname of the user" + } + } + }, + "automod": { + "humanName": "Automod", + "description": "You can define here what should happen (options: mute, kick, ban, quarantine) when someone gets x warns. Specify duration by writing : after the action." + }, + "warnsExpire": { + "humanName": "Should warns be deleted automatically?", + "description": "If enabled, warns will be deleted automatically after a certain period of time. Warns expired this way will completely disappear and can not be viewed again after they expired." + }, + "warnExpiration": { + "humanName": "Time after which warns will be automatically removed", + "description": "Warns will be automatically deleted after this value after it's creation. Please note that this action will delete existing warns if they expired. Enter an english value, such as \"1y\" (= 1 year), \"3 Months\" (= 3 Months) oder \"2w\" (= 2 Weeks).", + "default": "3 months" + } + } + }, + "joinGate": { + "description": "This system can prevent suspicious accounts from getting access to your server", + "humanName": "Join-Gate-Configuration", + "categories": { + "general": { + "displayName": "General Settings" + }, + "roles": { + "displayName": "Roles" + } + }, + "content": { + "enabled": { + "humanName": "Enabled?", + "description": "Enable or disable the join gate" + }, + "allUsers": { + "humanName": "Filter all users", + "description": "If enabled all users action against all new users will be taken" + }, + "action": { + "humanName": "Action", + "description": "Select the action here that should get performed if the join gate gets triggered" + }, + "roleID": { + "humanName": "Role", + "description": "Only if action = give-role. Role that gets given to users who fail the join gate" + }, + "removeOtherRoles": { + "humanName": "Remove other roles", + "description": "Only if action = give-role. If enabled other roles that have been give to the user get removed after a short interval (and the giving of the role from \"roleID\" will be delayed)" + }, + "minAccountAge": { + "humanName": "Minimum account age", + "description": "Age of the account of a new user that is required to be set to pass the join gate (in days)" + }, + "requireProfilePicture": { + "humanName": "Require profile picture", + "description": "If enabled users are required to have a profile picture set to pass the join gate" + }, + "ignoreBots": { + "humanName": "Ignore bots", + "description": "If enabled bots are allowed to pass the join gate without any restrictions" + } + } + }, + "strings": { + "description": "Set up which messages your bot should send", + "humanName": "Messages", + "categories": { + "actions": { + "displayName": "Action Messages" + }, + "errors": { + "displayName": "Error Messages" + } + }, + "content": { + "no_permissions": { + "humanName": "No Permissions", + "description": "Message that gets send if the user doesn't has the required role and/or has not the required mod-level", + "default": "You can not do that. You need at least moderator level %required_level% to do this", + "params": { + "required_level": { + "description": "Required mod-level to do this." + } + } + }, + "user_not_found": { + "humanName": "User Not Found", + "description": "Message that gets send if the user provided an invalid userid", + "default": "I could not find this user - try using an ID or a mention" + }, + "missing_reason": { + "humanName": "Missing Reason", + "description": "Message that gets send if the user does not provide a reason and 'require reason' is activated", + "default": "Please specify an reason" + }, + "this_is_a_mod": { + "humanName": "Target Is a Moderator", + "description": "Message that gets send if the user tries to mute another moderator", + "default": "You can not perform this action on your college." + }, + "submitted-report-message": { + "humanName": "Report Submitted", + "description": "Message that gets send, if someone reports somebody.", + "default": "Thanks for reporting %user%. I notified our server team and transmitted them an [encrypted snapshot](<%mURL%>) of the current messages in this channel, so they can see what really happened. Please make sure that our bots and staff can message you, so we can ask you follow-up-questions, if needed.", + "params": { + "user": { + "description": "Tag of the user they reported" + }, + "mURL": { + "description": "URL to the message log" + } + } + }, + "mute_message": { + "humanName": "Mute Message", + "description": "Message that gets send to a user when they got muted", + "default": "You got muted for **%reason%** by %user%!", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the mute" + } + } + }, + "channel_mute": { + "humanName": "Channel Mute Message", + "description": "Message that gets send to a user when they got muted", + "default": "You got channel-muted from %channel% for **%reason%** by %user%!", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the mute" + }, + "channel": { + "description": "Channel from which the user got muted" + } + } + }, + "remove-channel_mute": { + "humanName": "Channel Unmute Message", + "description": "Message that gets send to a user when they got muted", + "default": "Your channel-mute from %channel% got removed because of **%reason%** by %user%!", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the mute" + }, + "channel": { + "description": "Channel from which the user got unmuted" + } + } + }, + "tmpmute_message": { + "humanName": "Temporary Mute Message", + "description": "Message that gets send to a user when they got temporarily muted", + "default": "You got temporarily muted for **%reason%** by %user%! This action is going to expire on %date%.", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the mute" + }, + "date": { + "description": "Timestamp when this action expires" + } + } + }, + "quarantine_message": { + "humanName": "Quarantine Message", + "description": "Message that gets send to a user when they get quarantined", + "default": "You got quarantined for **%reason%** by %user%!", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the mute" + } + } + }, + "tmpquarantine_message": { + "humanName": "Temporary Quarantine Message", + "description": "Message that gets send to a user when they get quarantined", + "default": "You got quarantined temporarily for **%reason%** by %user%! This action is going to expire on %date%", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the mute" + }, + "date": { + "description": "Date when the quarantine is going to be removed automatically" + } + } + }, + "unquarantine_message": { + "humanName": "Unquarantine Message", + "description": "Message that gets send to a user when they get unquarantined", + "default": "You got unquarantined for **%reason%** by %user%!", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the mute" + } + } + }, + "unmute_message": { + "humanName": "Unmute Message", + "description": "Message that gets send to a user when they got unmuted", + "default": "You got unmuted for **%reason%** by %user%!", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the unmute" + } + } + }, + "kick_message": { + "humanName": "Kick Message", + "description": "Message that gets send to a user when they got kicked", + "default": "You got kicked for **%reason%** by %user%!", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the kick" + } + } + }, + "ban_message": { + "humanName": "Ban Message", + "description": "Message that gets send to a user when they got banned", + "default": "You got banned for **%reason%** by %user%!", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the ban" + } + } + }, + "tmpban_message": { + "humanName": "Temporary Ban Message", + "description": "Message that gets send to a user when they got banned temporarily", + "default": "You got temporarily banned for **%reason%** by %user%! This action is going to expire on %date%", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the ban" + }, + "date": { + "description": "Date on which the ban expires" + } + } + }, + "warn_message": { + "humanName": "Warn Message", + "description": "Message that gets send to a user when they got warned", + "default": "You got warned for **%reason%** by %user%!", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the warn" + } + } + }, + "lock_channel_message": { + "humanName": "Channel Lock Message", + "description": "Message that gets send in a channel if it gets locked", + "default": "This channel got locked because %reason% by %user%", + "params": { + "user": { + "description": "Tag of the moderator" + }, + "reason": { + "description": "Reason of the lock" + } + } + }, + "unlock_channel_message": { + "humanName": "Channel Unlock Message", + "description": "Message that gets send in a channel if it gets unlocked", + "default": "This channel got unlocked by %user%", + "params": { + "user": { + "description": "Tag of the moderator" + } + } + } + } + }, + "antiSpam": { + "description": "You can configure here, how your bot should react to spam", + "humanName": "Anti-Spam-Configuration", + "categories": { + "settings": { + "displayName": "Detection Settings" + }, + "actions": { + "displayName": "Actions" + }, + "exemptions": { + "displayName": "Exemptions" + } + }, + "content": { + "enabled": { + "humanName": "Enabled?", + "description": "Enable or disable the anti spam system" + }, + "timeframe": { + "humanName": "Timeframe (in seconds)", + "description": "Timeframe in seconds after which message objects get deleted (and can not longer be used to detect spam)" + }, + "maxMessagesInTimeframe": { + "humanName": "Maximal count of messages in timeframe", + "description": "Count of messages that are allowed to be sent in the selected timeframe" + }, + "maxDuplicatedMessagesInTimeframe": { + "humanName": "Maximal count of duplicated messages in timeframe", + "description": "Count of identical messages that are allowed to be sent in the selected timeframe" + }, + "maxPingsInTimeframe": { + "humanName": "Maximal count of pings in timeframe", + "description": "Count of pings (also counts replies) that are allowed to be sent in the selected timeframe" + }, + "maxMassPings": { + "humanName": "Maximal count of mass-pings in timeframe", + "description": "Count of mass pings (= @everyone, @here and roles) that are allowed to be sent in the selected timeframe" + }, + "action": { + "humanName": "Action", + "description": "Select what should happen if someone spams" + }, + "sendChatMessage": { + "humanName": "Send Chat-Message", + "description": "If enabled the bot will send a chat message if it has to take action agains a bot" + }, + "message": { + "humanName": "Message", + "description": "This will get send in the channel the spam is occurring in when anti-spam gets triggered", + "default": "Anti-Spam: I took action against <@%userid%> because of **%reason%**", + "params": { + "userid": { + "description": "ID of the user" + }, + "reason": { + "description": "Reason of the action" + } + } + }, + "ignoredChannels": { + "humanName": "Whitelisted Channels", + "description": "You can set channels that get ignored here" + }, + "ignoredRoles": { + "humanName": "Whitelisted roles", + "description": "You can set roles that get ignored here" + } + } + }, + "antiGrief": { + "description": "This system can prevent moderation-tool-abuse by staff-members", + "humanName": "Anti-Grief-Configuration", + "warningBanner": "This feature is currently limited to actions run by the moderation-module. If you've given your moderators native discord-permissions, they can bypass this. We plan to support native actions (+ channel-deletes and other griefing actions) in future.", + "informationBanner": "This feature can automatically quarantine moderators that abuse their permissions (banning / warning / kicking more people than you set up). For this to work, place your bot above all other roles and make sure that the quarantine-role is right below it. This ensures that moderators / admins can not just give permissions to the quarantine-role or remove permissions from the bot.", + "categories": { + "settings": { + "displayName": "Detection Settings" + }, + "actions": { + "displayName": "Actions" + } + }, + "content": { + "enabled": { + "humanName": "Enabled?", + "description": "Enables or disables the anti-join-grief-system" + }, + "timeframe": { + "humanName": "Timeframe (in hours)", + "description": "Timeframe in hours in which the limits can not be overstepped" + }, + "max_warn": { + "humanName": "Maximal amount of warns in the timeframe", + "description": "Maximal amount of warns a moderator can give in the timeframe until they get quarantined" + }, + "max_mute": { + "humanName": "Maximal amount of mutes in the timeframe", + "description": "Maximal amount of mutes a moderator can give in the timeframe until they get quarantined" + }, + "max_kick": { + "humanName": "Maximal amount of kicks in the timeframe", + "description": "Maximal amount of kicks a moderator can give in the timeframe until they get quarantined" + }, + "max_ban": { + "humanName": "Maximal amount of bans in the timeframe", + "description": "Maximal amount of bans a moderator can give in the timeframe until they get quarantined" + } + } + }, + "antiJoinRaid": { + "description": "This system can prevent spammers from raiding your server", + "humanName": "Anti-Join-Raid-Configuration", + "categories": { + "settings": { + "displayName": "Detection Settings" + }, + "actions": { + "displayName": "Actions" + } + }, + "content": { + "enabled": { + "humanName": "Enabled?", + "description": "Enables or disables the anti-join-raid-system" + }, + "timeframe": { + "humanName": "Timeframe (in minutes)", + "description": "Timeframe in which join actions should be recorded (in minutes)" + }, + "maxJoinsInTimeframe": { + "humanName": "Maximal count of new users", + "description": "Count of joins that are allowed to happen in the selected timeframe" + }, + "action": { + "humanName": "Action", + "description": "Select the action here that should get performed if the anti-join-system gets triggered" + }, + "roleID": { + "humanName": "Role", + "description": "Only if action = give-role. Role that gets given to users who trigger the antiJoinRaid-System" + }, + "removeOtherRoles": { + "humanName": "Remove other roles", + "description": "Only if action = give-role. If enabled other roles that have been give to the user get removed after a short interval (and the giving of the role from \"roleID\" will be delayed)" + } + } + }, + "verification": { + "description": "Require accounts to verify that they are not a robot before accessing your server", + "humanName": "Verification-Configuration", + "categories": { + "general": { + "displayName": "General Settings" + }, + "messages": { + "displayName": "Messages" + }, + "roles": { + "displayName": "Roles" + } + }, + "content": { + "enabled": { + "humanName": "Enabled?", + "description": "If checked, verification on your server will be enabled" + }, + "verification-needed-role": { + "humanName": "Role for users with pending verification", + "description": "Role, which members should be given before they verify themselves" + }, + "verification-passed-role": { + "humanName": "Role for users that passed verification", + "description": "Role, which members should be given after they got verified successfully" + }, + "verification-log": { + "humanName": "Verification Log Channel", + "description": "Channel where all verification-actions should get logged" + }, + "type": { + "humanName": "Type of verification", + "description": "How should new members verify themselves on your server?", + "selectOptions": { + "captcha": { + "displayName": "Image Captcha: distorted image, solved in-channel" + }, + "captcha-dm": { + "displayName": "Image Captcha (DM): legacy, sent via direct message" + }, + "word": { + "displayName": "Word challenge: retype a displayed word" + }, + "math": { + "displayName": "Math challenge: solve an arithmetic problem" + }, + "manual": { + "displayName": "Manual: a moderator approves each new member" + }, + "button": { + "displayName": "Button click: one click, no challenge" + } + } + }, + "captchaLevel": { + "humanName": "Challenge difficulty", + "description": "Difficulty of the verification challenge. Applies to Image Captcha, Image Captcha (DM), Word and Math. Not used for Manual or Button.", + "selectOptions": { + "easy": { + "displayName": "Easy: short words / small numbers" + }, + "medium": { + "displayName": "Medium (default)" + }, + "hard": { + "displayName": "Hard: longer words / larger numbers & multiplication" + } + } + }, + "actionOnFail": { + "humanName": "Action on failure of verification", + "description": "What should happen if someone fails the verification?" + }, + "verification-channel": { + "humanName": "Verification Channel", + "description": "Channel where users can verify themselves by clicking the Verify Me button. For the legacy DM type, this serves as a fallback channel for users with DMs disabled." + }, + "maxRetries": { + "humanName": "Maximum verification attempts", + "description": "How many attempts a user gets before the failure action is applied. Applies to Image Captcha, Image Captcha (DM), Word and Math types." + }, + "retryCooldown": { + "humanName": "Cooldown between retries", + "description": "How long a user must wait between verification attempts (e.g. 5m, 10m, 1h).", + "default": "5m" + }, + "actionOnFailDuration": { + "humanName": "Punishment duration", + "description": "Duration for mute or quarantine punishment when a user exhausts all verification attempts (e.g. 1h, 1d). Only applies when action on fail is mute or quarantine.", + "default": "1h" + }, + "cooldown-message": { + "humanName": "Cooldown message", + "description": "Shown when a user needs to wait before verifying again.", + "default": "⏳ Please wait %t% before trying again.", + "params": { + "t": { + "description": "Discord timestamp showing when the user can try again" + } + } + }, + "captcha-message": { + "humanName": "Captcha-Message", + "description": "This message gets sent to users who need to complete a captcha", + "default": "Welcome! Please verify that you are a human. You have two minutes to complete this." + }, + "manual-verification-message": { + "humanName": "Manual-Verification-Message", + "description": "This message gets sent to users who need to get verified manually.", + "default": "Welcome! A human will be verifying your account shortly. I will update you if I have any news." + }, + "captcha-failed-message": { + "humanName": "Captcha failed-Message", + "description": "This message gets sent when a user fails the verification", + "default": "It seems like you failed the verification. This is bad, I will have to take moderative actions against you - sorry fellow bot." + }, + "captcha-succeeded-message": { + "humanName": "Captcha completed-Message", + "description": "This message gets sent to users when they complete the verification", + "default": "Thanks! We have verified that you are indeed not a bot, so I granted you access to the whole server! Have fun <3" + }, + "verify-channel-first-message": { + "humanName": "Verification-Channel-Info-Message", + "description": "This message is the introduction message in the verify-channel.", + "default": "Welcome! Please verify yourself by clicking the button below. This step is required to access this server." + } + } + }, + "lockdown": { + "description": "Configure the server-wide lockdown system. This is separate from per-channel lock/unlock commands.", + "humanName": "Lockdown Configuration", + "categories": { + "general": { + "displayName": "General Settings" + }, + "messages": { + "displayName": "Messages" + }, + "automation": { + "displayName": "Automation" + } + }, + "content": { + "enabled": { + "humanName": "Enable lockdown system?", + "description": "Enables the /moderate lockdown command and automatic lockdown triggers" + }, + "logChannel": { + "humanName": "Lockdown log channel", + "description": "Channel where detailed lockdown log entries are posted. Falls back to the moderation log channel if not set." + }, + "sendMessageInAffectedChannels": { + "humanName": "Send message in affected channels?", + "description": "If enabled, the lockdown/lift message will be sent in every affected channel" + }, + "lockdownMessageChannels": { + "humanName": "Channels for lockdown messages", + "description": "If set, lockdown/lift messages will only be sent in these channels instead of all affected channels. Leave empty to send in all affected channels." + }, + "lockdownMessage": { + "humanName": "Lockdown activation message", + "description": "Message sent in affected channels when lockdown is activated", + "default": "🔒 **Server Lockdown** - This server is currently in lockdown mode. Reason: %reason%", + "params": { + "reason": { + "description": "Reason for the lockdown" + }, + "user": { + "description": "User who activated the lockdown (or 'System' for automatic)" + } + } + }, + "liftMessage": { + "humanName": "Lockdown lifted message", + "description": "Message sent in affected channels when lockdown is lifted", + "default": "🔓 **Lockdown Lifted** - The server lockdown has been lifted. You can chat again.", + "params": { + "user": { + "description": "User who lifted the lockdown" + } + } + }, + "autoLiftAfter": { + "humanName": "Auto-lift lockdown after (minutes, 0 = manual only)", + "description": "Automatically lift the lockdown after this many minutes. Set to 0 to require manual lifting." + }, + "autoTriggerOnJoinRaid": { + "humanName": "Auto-lockdown on join raid?", + "description": "Automatically activate lockdown when the anti-join-raid system is triggered" + }, + "autoTriggerOnJoinGate": { + "humanName": "Auto-lockdown on join-gate violations?", + "description": "Automatically activate lockdown when the join-gate system is triggered. Thresholds are configured in the Join-Gate configuration." + }, + "autoTriggerOnSpam": { + "humanName": "Auto-lockdown on spam detection?", + "description": "Automatically activate lockdown when the anti-spam system is triggered. Thresholds are configured in the Anti-Spam configuration." + } + } + } + }, + "nicknames": { + "_module": { + "humanReadableName": "Role-Nicknames", + "description": "Simple module to edit user nicknames based on roles!" + }, + "config": { + "description": "Configure the function of the module here", + "humanName": "Configuration", + "content": { + "forceDisplayname": { + "humanName": "Force display name", + "description": "Use display names of users instead of custom nicknames." + } + } + }, + "strings": { + "description": "Set a prefixes and/or suffixes for roles.", + "humanName": "Roles", + "content": { + "roleID": { + "humanName": "Role", + "description": "The role you want to set a prefix/suffix for." + }, + "prefix": { + "humanName": "Prefix", + "description": "The Prefix to be set.", + "default": "" + }, + "suffix": { + "humanName": "Suffix", + "description": "The Suffix to be set.", + "default": "" + } + } + } + }, + "ping-on-vc-join": { + "_module": { + "humanReadableName": "Voice-Channel Actions", + "description": "Sends messages when someone joins a voicechat and assign roles to users in Voice-Channels" + }, + "config": { + "description": "Configure messages that should get send when a user joins a Voice-Channel", + "humanName": "Message on Voice Join", + "categories": { + "general": { + "displayName": "General Settings" + }, + "cooldown": { + "displayName": "Cooldown" + }, + "messages": { + "displayName": "Messages" + } + }, + "content": { + "channels": { + "humanName": "Channels", + "description": "Channel-ID in which this messages should get triggered" + }, + "message": { + "humanName": "Message", + "description": "Here you can set the message that should be send if someone joins a selected voicechat", + "default": "The user %tag% joined the voicechat %vc%", + "params": { + "tag": { + "description": "Tag of the user" + }, + "vc": { + "description": "Name of the voicechat" + }, + "mention": { + "description": "Mention of the user" + } + } + }, + "notify_channel_id": { + "humanName": "Notification-Channel", + "description": "Channel where the message should be send" + }, + "cooldownEnabled": { + "humanName": "Enable Cooldown?", + "description": "When enabled, messages will only be sent once per channel within the cooldown period" + }, + "cooldownMinutes": { + "humanName": "Cooldown Duration (Minutes)", + "description": "Duration in minutes to wait before sending another message for the same channel" + }, + "send_pn_to_member": { + "humanName": "Join-DM", + "description": "Should the bot send a PN to the member?" + }, + "pn_message": { + "humanName": "Join-DM-Message", + "description": "This message is sent to the user when they join a voice chat (if \"Join DM\" is enabled).", + "default": "Hi, I saw you joined the voice chat %vc%. Nice (;", + "params": { + "vc": { + "description": "Name of the voicechat" + } + } + } + } + }, + "actual-config": { + "description": "Configure messages that should get send when a user joins a Voice-Channel", + "humanName": "Configuration", + "categories": { + "roles": { + "displayName": "Voice Roles" + } + }, + "content": { + "assignRoleToUsersInVoiceChannels": { + "humanName": "Assign roles to members connected to voice channels?", + "description": "If enabled, users will receive a role when they join a voice channel. This role will be removed when they leave the voice channel (switching voice channels does not trigger a role removal)." + }, + "voiceRoles": { + "humanName": "Roles for users that are connected to voice channels", + "description": "Users that are currently connected to a voice channel will be assigned these roles." + } + } + } + }, + "ping-protection": { + "_module": { + "humanReadableName": "Ping-Protection", + "description": "Powerful and highly customizable ping-protection module to protect members/roles from unwanted mentions with moderation capabilities." + }, + "configuration": { + "description": "Configure protected users/roles, whitelisted roles/members, ignored channels and the notification message.", + "humanName": "General Configuration", + "categories": { + "protection": { + "displayName": "Protected" + }, + "whitelisted": { + "displayName": "Whitelists" + }, + "rules": { + "displayName": "Ping rules" + }, + "automod": { + "displayName": "AutoMod settings" + }, + "messages": { + "displayName": "Warning message" + } + }, + "content": { + "protectedRoles": { + "humanName": "Protected Roles", + "description": "Specific roles which are protected from pings." + }, + "protectAllUsersWithProtectedRole": { + "humanName": "Protect all users with a protected role", + "description": "if enabled, all users with at least one protected role will be protected from pings, even if they are not specifically listed as protected users." + }, + "protectedUsers": { + "humanName": "Protected Users", + "description": "Specific users who are protected from pings." + }, + "ignoredRoles": { + "humanName": "Whitelisted Roles", + "description": "Roles allowed to ping protected members or roles." + }, + "ignoredChannels": { + "humanName": "Whitelisted Channels", + "description": "Pings in these channels are ignored." + }, + "ignoredUsers": { + "humanName": "Whitelisted Users", + "description": "Pings from these users are ignored." + }, + "allowReplyPings": { + "humanName": "Allow Reply Pings", + "description": "If enabled, replying to a protected user (with mention ON) is allowed." + }, + "selfPingConfiguration": { + "humanName": "Self-Ping configuration", + "description": "Configure what happens when a protected user pings themselves. Note: Automod overrides this setting meaning this setting will not apply if Automod is enabled." + }, + "enableAutomod": { + "humanName": "Enable automod", + "description": "If enabled, the bot will utilise Discord's native AutoMod to block the message with a ping of a protected user/role." + }, + "autoModLogChannel": { + "humanName": "AutoMod Log Channel", + "description": "Channel where AutoMod alerts are sent." + }, + "autoModBlockMessage": { + "humanName": "AutoMod custom message for message block", + "description": "Custom text shown to the user when blocked (Max 150 characters).", + "default": "Your message was blocked because you are trying to ping a protected user/role. The message content might be logged depending on the configuration." + }, + "pingWarningMessage": { + "humanName": "Warning Message", + "description": "The message that gets sent to the user when they ping someone.", + "default": { + "title": "You are not allowed to ping %target-name%!", + "description": "<@%pinger-id%>, You are not allowed to ping %target-mention% due to your role. You can view which roles/members you are not allowed to ping by using the `/ping-protection list protected` command.\n\nIf you were replying, make sure to turn off the mention in the reply.", + "image": "https://scnx-cdn.scootkit.net/1769198862209-rJfCVKzAuo6uQLhPUe9o2P6ArJkDBSVUCEyUQM6bqt5WFKWK.gif", + "color": "#ed4245" + }, + "params": { + "target-name": { + "description": "Name of the pinged user/role" + }, + "target-mention": { + "description": "Mention of the pinged user/role" + }, + "target-id": { + "description": "ID of the pinged user/role" + }, + "pinger-id": { + "description": "ID of the user who pinged" + } + } + } + } + }, + "moderation": { + "description": "Define triggers for punishments.", + "humanName": "Moderation Actions", + "configElementName": { + "one": "punishment", + "more": "punishment" + }, + "content": { + "pingsCount": { + "humanName": "Pings to trigger moderation", + "description": "The amount of pings required to trigger a moderation action." + }, + "useCustomTimeframe": { + "humanName": "Use a custom timeframe", + "description": "If enabled, you can choose your own custom timeframe of days in which the pings must occur to trigger the moderation action." + }, + "timeframeDays": { + "humanName": "Timeframe (Days)", + "description": "In how many days must these pings occur?" + }, + "actionType": { + "humanName": "Action", + "description": "What punishment should be applied?" + }, + "muteDuration": { + "humanName": "Mute Duration (only if action type is MUTE)", + "description": "How long to mute the user? (in minutes)" + }, + "enableActionLogging": { + "humanName": "Enable action logging", + "description": "If enabled, moderation actions will be logged in the channel where a protected user/role got pinged." + }, + "actionLogMessage": { + "humanName": "Action log message", + "description": "The message that will be sent when a user is punished for pinging protected users/roles.", + "default": { + "title": "Moderation action taken against %pinger-name%", + "description": "I have taken action against %pinger-mention% for pinging protected users/roles %pings% times within %timeframe% days.\n **Action:** %action%\n**Duration:** %duration% minutes", + "color": "#ed4245" + }, + "params": { + "pinger-mention": { + "description": "Mention of the user who pinged" + }, + "pinger-name": { + "description": "Name of the user who pinged" + }, + "action": { + "description": "The action that was taken (muted/kicked)" + }, + "pings": { + "description": "Number of pings that triggered the action" + }, + "timeframe": { + "description": "The timeframe in days in which the pings occurred" + }, + "duration": { + "description": "Duration of the mute in minutes (only for the mute action)" + } + } + } + } + }, + "storage": { + "description": "Configure how long moderation logs and leaver data are kept.", + "humanName": "Data Storage", + "categories": { + "pings": { + "displayName": "Ping History" + }, + "moderation": { + "displayName": "Moderation Logs" + }, + "leavers": { + "displayName": "Leaver Data" + } + }, + "content": { + "enablePingHistory": { + "humanName": "Enable Ping History", + "description": "If enabled, the bot will keep a history of pings to enforce moderation actions." + }, + "pingHistoryRetention": { + "humanName": "Ping History Retention", + "description": "Decides on how long to keep ping logs. Minimum is 4 weeks (1 month) with a maximum of 96 weeks (2 years). This is the length factor of the 'Basic' punishment timeframe." + }, + "deleteAllPingHistoryAfterTimeframe": { + "humanName": "Delete all the pings in history after the timeframe?", + "description": "If enabled, the bot will delete ALL the pings history of an user after the timeframe instead of only the ping(s) exceeding the timeframe in the history." + }, + "modLogRetention": { + "humanName": "Moderation Log Retention (Months)", + "description": "How long to keep records of punishments (1 - 24 Months). This is applied when moderation actions are enabled." + }, + "enableLeaverDataRetention": { + "humanName": "Keep user logs after they leave", + "description": "If enabled, the bot will keep a history of the user after they leave." + }, + "leaverRetention": { + "humanName": "Leaver Data Retention (Days)", + "description": "How long to keep data after a user leaves (1-7 Days)." + } + } + } + }, + "polls": { + "_module": { + "humanReadableName": "Polls", + "description": "Simple module to create fresh polls on your server! Supports anonymous polls and more." + }, + "config": { + "description": "Configure the function of the module here", + "humanName": "Configuration", + "content": { + "reactions": { + "humanName": "Emojis", + "description": "You can set the different emojis to use" + } + } + }, + "strings": { + "description": "Edit the messages and strings of the module here", + "humanName": "Messages", + "content": { + "embed": { + "humanName": "Embed", + "description": "You can edit the settings of your embed here" + } + } + } + }, + "quiz": { + "_module": { + "humanReadableName": "Quiz Module", + "description": "Create quiz for your users and let them compete against each other." + }, + "config": { + "description": "Configure the function of the module here", + "humanName": "Configuration", + "content": { + "emojis": { + "humanName": "Emojis", + "description": "You can set the emojis to use" + }, + "dailyQuizLimit": { + "humanName": "Daily quiz limit", + "description": "How many quizzes can be played per day using /quiz play" + }, + "leaderboardChannel": { + "humanName": "Quiz leaderboard channel", + "description": "In which channel the quiz leaderboard is displayed" + }, + "createAllowedRole": { + "humanName": "Role needed to create quizzes", + "description": "Which role a user needs to have to be able to create quizzes with /quiz create/create-bool" + }, + "mode": { + "humanName": "Mode for quiz selection", + "description": "How a /quiz play quiz is selected for users" + }, + "livePreview": { + "humanName": "Live preview of results", + "description": "Whether the live preview of results is enabled" + } + } + }, + "strings": { + "description": "Edit the messages and strings of the module here", + "humanName": "Messages", + "content": { + "embed": { + "humanName": "Embed", + "description": "You can edit the settings of your embed here" + } + } + }, + "quizList": { + "description": "Create and edit the quizzes of the server", + "humanName": "Edit quiz", + "content": { + "description": { + "humanName": "Question or statement", + "description": "Title/Question of the quiz", + "default": "" + }, + "duration": { + "humanName": "Time limit", + "description": "How much time the user has to answer", + "default": "1m" + }, + "correctOptions": { + "humanName": "Correct answers", + "description": "Correct answers" + }, + "wrongOptions": { + "humanName": "Wrong answers", + "description": "Wrong answers" + } + } + } + }, + "reminders": { + "_module": { + "humanReadableName": "Reminders", + "description": "Let users set reminders for themselves - either via DMs or Channels" + }, + "config": { + "description": "Configure the behavior of this module here", + "humanName": "Configuration", + "content": { + "notificationMessage": { + "humanName": "Reminder-Message", + "description": "This message gets send when someone gets remaindered", + "default": { + "title": "🔔 Reminder", + "color": "#F1C40F", + "description": "%message%", + "message": "%mention%" + }, + "params": { + "mention": { + "description": "Mention of the user" + }, + "message": { + "description": "Reminder message set by the user" + }, + "userTag": { + "description": "Tag of the user" + }, + "userAvatarURL": { + "description": "Avatar-URL of the user" + } + } + } + } + } + }, + "rock-paper-scissors": { + "_module": { + "humanReadableName": "Rock Paper Scissors", + "description": "Let your users play Rock Paper Scissors against the bot and each other!" + } + }, + "staff-management-system": { + "_module": { + "humanReadableName": "Staff Management System", + "description": "A powerful, highly customizable staff management system designed to track activity, moderate personnel, and maintain detailed staff records seamlessly." + }, + "configuration": { + "description": "Configure the main staff roles and the default log channel.", + "humanName": "General Configuration", + "categories": { + "roles": { + "displayName": "Staff Roles" + }, + "logging": { + "displayName": "Logging" + } + }, + "content": { + "staffRoles": { + "humanName": "Staff Roles", + "description": "Roles that can use basic staff commands (Shifts, LoA Request and RA Request, reviews etc.)." + }, + "supervisorRoles": { + "humanName": "Supervisor Roles", + "description": "Roles that can manage other staff members (Approve/Deny/Manage LoA's and RA's, Manage Shifts, promote and infract users)." + }, + "managementRoles": { + "humanName": "Management Roles", + "description": "Roles with full access, including data deletion abilities." + }, + "generalLogChannel": { + "humanName": "General Log Channel", + "description": "The default channel where logs happen such as status changes/request and more. This can be overridden in some features." + } + } + }, + "infractions": { + "description": "Configure how staff infractions, strikes, and suspensions are handled.", + "humanName": "Infractions & Suspensions", + "categories": { + "logic": { + "displayName": "General Logic" + }, + "suspensions": { + "displayName": "Suspensions Logic" + }, + "messages": { + "displayName": "Messages & Embeds" + } + }, + "content": { + "enableInfractions": { + "humanName": "Enable Infractions System", + "description": "Enabling this will unlock features such as issuing infractions to staff members, suspensions and more." + }, + "infractionTypes": { + "humanName": "Infraction Types", + "description": "These are the types of infractions that can be issued to staff members. You can customize these to fit your infractions system." + }, + "enableSuspensions": { + "humanName": "Enable Suspensions System", + "description": "Suspensions temporarily strip a staff member of their roles." + }, + "suspensionHierarchyRole": { + "humanName": "Hierarchy Base Role", + "description": "When suspending, the bot will remove all roles above and including this one. This would usually be your lowest 'Staff' role." + }, + "suspensionRole": { + "humanName": "Suspended Role (Optional)", + "description": "A role to assign the user while they are suspended (e.g., 'Suspended Staff')." + }, + "suspensionMessage": { + "humanName": "Suspension Announcement Message", + "description": "The message sent to the log channel when a staff member is suspended.", + "default": { + "_schema": "v3", + "content": "%user%", + "embeds": [ + { + "author": { + "name": "Signed, %issuer-name% • Case #%case-id%", + "iconURL": "%issuer-avatar%" + }, + "title": "⛔ Staff Suspension", + "description": "**Staff Member:** %user%\n**Duration:** %duration%\n**Ends:** %end-date%\n**Reason:** %reason%", + "color": "#ed4245", + "thumbnailURL": "%user-avatar%" + } + ] + }, + "params": { + "user": { + "description": "Mention of the staff member" + }, + "user-avatar": { + "description": "Avatar of the staff member" + }, + "issuer-mention": { + "description": "Mention of the manager issuing it" + }, + "issuer-name": { + "description": "Name of the issuer" + }, + "issuer-avatar": { + "description": "Avatar of the issuer" + }, + "duration": { + "description": "Duration of the suspension" + }, + "end-date": { + "description": "Timestamp of when the suspension ends" + }, + "reason": { + "description": "Reason provided" + }, + "case-id": { + "description": "Database Case ID" + } + } + }, + "infractionLogChannel": { + "humanName": "Infraction Log Channel", + "description": "Where should infractions and suspensions be announced?" + }, + "infractionMessage": { + "humanName": "Infraction Announcement Message", + "description": "The message sent to the log channel for regular infractions.", + "default": { + "_schema": "v3", + "content": "%user%", + "embeds": [ + { + "author": { + "name": "Signed, %issuer-name% • Case #%case-id%", + "iconURL": "%issuer-avatar%" + }, + "title": "⚠️ New infraction", + "description": "**Staff Member:** %user%\n**Action Taken:** %type%\n**Expires:** %end-date%\n**Reason:** %reason%", + "color": "#e67e22", + "thumbnailURL": "%user-avatar%" + } + ] + }, + "params": { + "user": { + "description": "Mention of the staff member" + }, + "user-avatar": { + "description": "Avatar of the staff member" + }, + "issuer-mention": { + "description": "Mention of the manager issuing it" + }, + "issuer-name": { + "description": "Name of the issuer" + }, + "issuer-avatar": { + "description": "Avatar of the issuer" + }, + "type": { + "description": "Type of infraction (e.g., Warning, Strike)" + }, + "end-date": { + "description": "Timestamp of when this infraction expires" + }, + "reason": { + "description": "Reason provided" + }, + "case-id": { + "description": "Database Case ID" + } + } + }, + "dmInfractedUser": { + "humanName": "DM User on infraction?", + "description": "If enabled, the bot will DM the staff member when they receive an infraction or suspension." + }, + "infractionDmMessage": { + "humanName": "Infraction DM Message", + "description": "The message sent directly to the staff member.", + "default": { + "_schema": "v3", + "embeds": [ + { + "author": { + "name": "Signed, %issuer-name% • Case #%case-id%" + }, + "title": "⚠️ You have been infracted", + "description": "**Type:** %type%\n**Reason:** %reason%\n**Expires:** %end-date%", + "color": "#e67e22" + } + ] + }, + "params": { + "user": { + "description": "Mention of the staff member" + }, + "issuer-name": { + "description": "Name of the issuer" + }, + "type": { + "description": "Type of infraction (e.g., Warning, Strike)" + }, + "end-date": { + "description": "Timestamp of when this infraction expires" + }, + "reason": { + "description": "Reason provided" + }, + "case-id": { + "description": "Database Case ID" + } + } + }, + "suspensionDmMessage": { + "humanName": "Suspension DM Message1", + "description": "The message sent directly to the staff member when suspended.", + "default": { + "_schema": "v3", + "embeds": [ + { + "author": { + "name": "Signed, %issuer-name% • Case #%case-id%" + }, + "title": "⛔ Staff Suspension", + "description": "You have been temporarily suspended.\n\n**Duration:** %duration%\n**Returns:** %end-date%\n**Reason:** %reason%", + "color": "#ed4245" + } + ] + }, + "params": { + "user": { + "description": "Mention of the staff member" + }, + "issuer-name": { + "description": "Name of the issuer" + }, + "type": { + "description": "Type of infraction (e.g., Warning, Strike)" + }, + "duration": { + "description": "Duration of the suspension" + }, + "end-date": { + "description": "Timestamp of when this infraction expires" + }, + "reason": { + "description": "Reason provided" + }, + "case-id": { + "description": "Database Case ID" + } + } + } + } + }, + "promotions": { + "description": "Configure how staff promotions are handled and announced.", + "humanName": "Promotions", + "categories": { + "logic": { + "displayName": "General logic" + }, + "messages": { + "displayName": "Announcements" + } + }, + "content": { + "enablePromotions": { + "humanName": "Enable Promotions System", + "description": "If disabled, the /staff-management promote command will not work." + }, + "autoAddRole": { + "humanName": "Auto-Add New Role?", + "description": "If enabled, the bot will automatically give the user the new rank role. Note: This makes your server prone to raids by promoting someone to a role with more dangerous permissions which can be used to do malicious actions. It is recommended to keep this setting disabled." + }, + "promotionsChannel": { + "humanName": "Promotions Channel", + "description": "The channel where promotion announcements will be sent." + }, + "promotionMessage": { + "humanName": "Promotion Announcement Embed", + "description": "This will be the message sent when someone is promoted.", + "default": { + "_schema": "v3", + "content": "%user-mention%", + "embeds": [ + { + "author": { + "name": "Signed, %promoter-name%", + "imageURL": "%promoter-avatar%" + }, + "title": "🎉 New promotion!", + "description": "Congratulations, you have been promoted to **%new-role-name%**!\n\n**Promoted to:** %new-role-mention%\n**On behalf of:** %promoter-mention%\n**Reason:** %reason%", + "color": "#f1c40f", + "thumbnailURL": "%user-avatar%" + } + ] + }, + "params": { + "user-mention": { + "description": "Pings the promoted user." + }, + "new-role-name": { + "description": "The plain text name of the new role." + }, + "new-role-mention": { + "description": "The pingable mention of the new role." + }, + "promoter-mention": { + "description": "Pings the staff member who issued the promotion." + }, + "promoter-name": { + "description": "The username of the staff member who issued the promotion." + }, + "reason": { + "description": "The reason for the promotion." + }, + "user-avatar": { + "description": "The avatar URL of the promoted user." + }, + "promoter-avatar": { + "description": "The avatar URL of the promoter." + } + } + }, + "dmPromotedUser": { + "humanName": "DM Promoted User?", + "description": "If enabled, the user will receive a direct message when promoted." + }, + "promotionDmMessage": { + "humanName": "Promotion DM Embed", + "description": "The message sent directly to the user.", + "default": { + "_schema": "v3", + "content": "%user-mention%", + "embeds": [ + { + "author": { + "name": "Signed, %promoter-name%", + "imageURL": "%promoter-avatar%" + }, + "title": "🎉 New promotion!", + "description": "Congratulations, you have been promoted to **%new-role-name%**!\n\n**Promoted to:** %new-role-mention%\n**On behalf of:** %promoter-mention%\n**Reason:** %reason%", + "color": "#f1c40f", + "thumbnailURL": "%user-avatar%" + } + ] + }, + "params": { + "user-mention": { + "description": "Pings the promoted user." + }, + "new-role-name": { + "description": "The plain text name of the new role." + }, + "new-role-mention": { + "description": "The pingable mention of the new role." + }, + "promoter-mention": { + "description": "Pings the staff member who issued the promotion." + }, + "promoter-name": { + "description": "The username of the staff member who issued the promotion." + }, + "reason": { + "description": "The reason for the promotion." + }, + "user-avatar": { + "description": "The avatar URL of the promoted user." + }, + "promoter-avatar": { + "description": "The avatar URL of the promoter." + } + } + } + } + }, + "reviews": { + "description": "Configure the staff rating system and feedback channels.", + "humanName": "Staff Reviews", + "categories": { + "settings": { + "displayName": "Settings" + }, + "messages": { + "displayName": "Notifications" + } + }, + "content": { + "enableReviews": { + "humanName": "Enable Reviews System", + "description": "Enabling this unlocks the staff review system, allowing users to submit ratings and feedback for staff members." + }, + "reviewLogChannel": { + "humanName": "Reviews Log Channel", + "description": "Channel where new reviews are posted." + }, + "allowSelfRating": { + "humanName": "Allow Self-Rating?", + "description": "If enabled, staff can review themselves. This is not recommended to keep a fair ratings system." + }, + "onlyAllowStaffReview": { + "humanName": "Only let users review staff", + "description": "If enabled, only staff members can review other staff members." + }, + "ratingMessage": { + "humanName": "Review Message", + "description": "The message sent when a review is submitted.", + "default": { + "_schema": "v3", + "content": "%staff%", + "embeds": [ + { + "title": "🌟 New Staff Rating", + "description": "**Staff:** %staff-mention%\n**Rated by:** %reviewer-mention%\n\n**Rating:** %stars% (%rating%/5)\n**Comment:**\n%comment%", + "color": "#f1c40f", + "thumbnailURL": "%staff-avatar%" + } + ] + }, + "params": { + "staff-mention": { + "description": "Mention of the staff member" + }, + "reviewer-mention": { + "description": "Mention of the reviewer" + }, + "stars": { + "description": "Amount of stars rated in emoji's (⭐⭐⭐⭐⭐)" + }, + "rating": { + "description": "Amount of stars rated in text (1-5)" + }, + "comment": { + "description": "The review's text" + }, + "staff-avatar": { + "description": "The staff member's profile picture (URL)" + }, + "reviewer-avatar": { + "description": "The reviewer's profile picture (URL)" + } + } + } + } + }, + "shifts": { + "description": "Configure shift requirements, duty roles, leaderboards, and quotas.", + "humanName": "Shift Management", + "categories": { + "settings": { + "displayName": "Shift Settings" + }, + "leaderboard": { + "displayName": "Leaderboard" + }, + "quotas": { + "displayName": "Quotas" + }, + "logging": { + "displayName": "Logging" + } + }, + "content": { + "enableShifts": { + "humanName": "Enable Shifts", + "description": "This unlocks the ability for staff to use a shifts system, where they can get on-duty, off-duty, take a break and see their total duty time." + }, + "onDutyRole": { + "humanName": "On-Duty Role", + "description": "Role given to users when they are on-duty. This is optional, but recommended to easily identify who is on-duty." + }, + "dutyTypes": { + "humanName": "Duty Types", + "description": "The types of duty a staff member can select when going on-duty." + }, + "minShiftDuration": { + "humanName": "Minimum Shift Duration (minutes)", + "description": "A minimum shift duration for a shift to count towards their duty time. Default is 0, which means all shift time counts." + }, + "enableLeaderboard": { + "humanName": "Enable duty leaderboard", + "description": "If enabled, staff can see a leaderboard of who has the most duty time in the configured timeframe." + }, + "leaderboardLookback": { + "humanName": "Leaderboard Timeframe", + "description": "The timeframe of the duty time shown on the leaderboard." + }, + "enableQuotas": { + "humanName": "Enable Quota System", + "description": "If enabled, you can set a custom quota of hours for staff to meet in the configured timeframe." + }, + "quotaTimeframe": { + "humanName": "Quota Timeframe", + "description": "The timeframe in which the quota must be met." + }, + "quotas": { + "humanName": "Role Quotas", + "description": "Set required hours per role - the left side will be the role, and the right side is a number which is the hours for the quota. The user's highest role counts as their quota." + }, + "logShiftChanges": { + "humanName": "Log Shift Changes", + "description": "When enabled, shift changes (such as going on-duty, on break, or off-duty) will be logged in a custom channel." + }, + "logShiftChangesChannel": { + "humanName": "Channel for shift change logs", + "description": "The channel where shift changes will be logged. You can set this empty to use the general log channel." + } + } + }, + "status": { + "description": "Configure Leave of Absence (LoA) and Reduced Activity (RA) settings.", + "humanName": "LoA & RA Status", + "categories": { + "base": { + "displayName": "Base Settings" + }, + "loa": { + "displayName": "LoA Settings" + }, + "ra": { + "displayName": "RA Settings" + }, + "logging": { + "displayName": "Requests Log" + } + }, + "content": { + "enableStatusSystem": { + "humanName": "Enable Status System", + "description": "Enabling this unlocks the Leave of Absence (LoA) and Reduced Activity (RA) system, allowing staff to request these statuses and have them tracked." + }, + "enableLoa": { + "humanName": "Enable LoA System", + "description": "If enabled, staff can request a Leave of Absence (LoA)." + }, + "loaRole": { + "humanName": "LoA Role", + "description": "Role given to users when they are on a Leave of Absence. This is optional, but recommended to easily identify who is on LoA." + }, + "loaMaxDays": { + "humanName": "Maximum LoA Duration (days)", + "description": "The maximum duration for a Leave of Absence in days. This limits how long staff can request to be on LoA for." + }, + "requireLoaApproval": { + "humanName": "Require Approval for LoA?", + "description": "If enabled, LoA requests will require approval from staff who have supervisor permissions or higher." + }, + "enableRa": { + "humanName": "Enable RA System", + "description": "If enabled, staff can request Reduced Activity (RA) status for when they are still working but at a reduced load." + }, + "raRole": { + "humanName": "RA Role", + "description": "Role given to users when they are on Reduced Activity. This is optional, but recommended to easily identify who is on RA." + }, + "raMaxDays": { + "humanName": "Maximum RA Duration (days)", + "description": "The maximum duration for RA in days. This limits how long staff can request to be on RA for." + }, + "requireRaApproval": { + "humanName": "Require Approval for RA?", + "description": "If enabled, RA requests will require approval from staff who have supervisor permissions or higher." + }, + "statusLogChannel": { + "humanName": "Status Request Channel", + "description": "Channel where requests are sent for approval." + }, + "logStatusChanges": { + "humanName": "Log status changes", + "description": "If enabled, any changes in staff status (going on/off LoA or RA) will be logged in the configured channel." + }, + "statusChangeLogChannel": { + "humanName": "Status Change Log Channel", + "description": "Channel where status changes are logged. By default this uses your main log channel, but you can set a separate channel here." + } + } + }, + "profiles": { + "description": "Configure the staff profile system (Intros, custom nicknames, and stats).", + "humanName": "Staff Profiles", + "categories": { + "settings": { + "displayName": "Profile Settings" + } + }, + "content": { + "enableProfiles": { + "humanName": "Enable Staff Profiles", + "description": "Allows staff to have a profile tracking their shifts, reviews, and a custom introduction." + }, + "onlyAllowStaffProfile": { + "humanName": "Only allow staff and higher to have their own customizable profile", + "description": "If enabled, only staff members and higher will be able to set a custom profile nickname and introduction. If disabled, all members will be able to set a custom profile nickname and introduction." + }, + "managePermission": { + "humanName": "Profile Moderation Permission", + "description": "Which group is allowed to forcibly wipe another staff member's profile?" + }, + "profileEmbedMessage": { + "humanName": "Profile Embed", + "description": "Customize the embed shown when viewing a staff profile.", + "default": { + "_schema": "v3", + "embeds": [ + { + "title": "Staff Profile: %nickname%", + "description": "%intro%", + "color": "#2b2d31", + "thumbnailURL": "%avatar%", + "fields": [ + { + "name": "Status", + "value": "%status%", + "inline": true + }, + { + "name": "Average Rating", + "value": "%rating%", + "inline": true + } + ] + } + ] + }, + "params": { + "user-mention": { + "description": "The user's mention." + }, + "username": { + "description": "The user's standard Discord username." + }, + "nickname": { + "description": "The user's custom profile nickname (uses default username if not set)." + }, + "intro": { + "description": "The user's custom introduction." + }, + "status": { + "description": "The user's current status (LoA, RA, etc.)." + }, + "rating": { + "description": "The user's average review rating." + }, + "avatar": { + "description": "The user's avatar URL." + } + } + } + } + }, + "activity-checks": { + "description": "Configure automated staff activity checks and response logging.", + "humanName": "Activity Checks", + "categories": { + "general": { + "displayName": "General Settings" + }, + "exceptions": { + "displayName": "Exceptions" + }, + "automation": { + "displayName": "Automation" + }, + "results": { + "displayName": "Results & Logging" + } + }, + "content": { + "enableActivityChecks": { + "humanName": "Enable Activity Checks", + "description": "Allows admins to start an activity check to see who is active." + }, + "targetRoles": { + "humanName": "Roles to Check", + "description": "The roles required to respond to the activity check. Anyone with these roles will be expected to click the button. Leave empty to default to the General Staff Roles." + }, + "timeframe": { + "humanName": "Check Duration (Hours)", + "description": "How long staff have to respond to the activity check (Max 168 hours / 1 week)." + }, + "checkMessage": { + "humanName": "Activity Check Embed", + "description": "The message sent when an activity check starts.", + "default": { + "title": "📋 Staff Activity Check", + "description": "Please click the button below to confirm your activity before %endtime%.", + "color": "#3498db" + }, + "params": { + "end-time": { + "description": "The Discord timestamp when the check ends." + }, + "duration": { + "description": "The configured duration in hours." + } + } + }, + "sendingChannel": { + "humanName": "Default Sending Channel", + "description": "The default channel where the activity check message will be posted. This can manually be overridden with the command." + }, + "exceptionsType": { + "humanName": "Exceptions Rule", + "description": "Who are excused from the activity checks?" + }, + "customExceptionRoles": { + "humanName": "Custom Exception Roles", + "description": "Only applies if 'Custom role(s)' is selected above." + }, + "automatedChecks": { + "humanName": "Automated Checks", + "description": "If enabled, the bot will automatically start activity checks at configured intervals." + }, + "automatedCheckInterval": { + "humanName": "Automated Check Interval", + "description": "On which interval to start automatic checks. Choose cronjob for full customzation." + }, + "automatedCheckCronjob": { + "humanName": "Automated Check Cronjob", + "description": "The cronjob schedule for automatic checks. Only applies if 'Cronjob' is selected above.", + "default": "" + }, + "automatedCheckWeekDay": { + "humanName": "Automated Check Week Day", + "description": "The week day to start automatic checks." + }, + "automatedCheckMonthWeek": { + "humanName": "Automated Check Month Week", + "description": "The week of the month to start automatic checks. Only applies if 'Monthly' is selected above." + }, + "logChannel": { + "humanName": "Results Channel", + "description": "Where the final results are posted. Leave empty if you want to use the general log channel." + }, + "pingResults": { + "humanName": "Ping on Results", + "description": "Ping specific roles when the results are posted." + }, + "pingRoles": { + "humanName": "Roles to Ping", + "description": "The roles to ping with the results message." + } + } + } + }, + "starboard": { + "_module": { + "humanReadableName": "Starboard", + "description": "Let users highlight messages into a starboard channel by reacting." + }, + "config": { + "description": "Configure the starboard channel and reaction settings here", + "humanName": "Configuration", + "content": { + "channelId": { + "humanName": "Starboard channel", + "description": "In which channel starred messages are sent" + }, + "emoji": { + "humanName": "Emoji", + "description": "Which emoji should be used to star messages", + "default": "⭐" + }, + "message": { + "humanName": "Message", + "description": "This message gets send into the selected channel", + "default": { + "message": "**%stars%** %emoji% in %channelMention%", + "color": "#f5c91b", + "description": "%content%", + "image": "%image%", + "author": { + "name": "%displayName%", + "img": "%userAvatar%", + "url": "%link%" + } + }, + "params": { + "stars": { + "description": "Amount of reactions on the message" + }, + "content": { + "description": "The content of the starred message" + }, + "link": { + "description": "A link to the starred message" + }, + "userID": { + "description": "The user ID of the author of the starred message" + }, + "userName": { + "description": "The username of the author of the starred message" + }, + "displayName": { + "description": "The nickname of the author" + }, + "userTag": { + "description": "The tag of the author of the starred message" + }, + "userAvatar": { + "description": "The avatar URL of the message author" + }, + "channelName": { + "description": "The name of the channel the starred message was sent in" + }, + "channelMention": { + "description": "The channel mention of the channel the starred message was sent in" + }, + "emoji": { + "description": "The set starboard emoji for lazy users" + }, + "image": { + "description": "The first attachment or the first image url in the message" + } + } + }, + "excludedChannels": { + "humanName": "Excluded channels", + "description": "In which channels messages cannot be starred" + }, + "excludedRoles": { + "humanName": "Excluded roles", + "description": "Users with these roles cannot star messages" + }, + "minStars": { + "humanName": "Minimum stars", + "description": "How many star reactions are needed for a message to land on the starboard" + }, + "starsPerHour": { + "humanName": "Stars per user per hour", + "description": "How many messages a user can star per hour" + }, + "selfStar": { + "humanName": "Self-Star", + "description": "Whether users can star their own messages" + } + } + } + }, + "status-roles": { + "_module": { + "humanReadableName": "Status-roles", + "description": "Simple module to reward users who have an invite to your server in their status!" + }, + "config": { + "description": "Configure the function of the module here", + "humanName": "Configuration", + "content": { + "words": { + "humanName": "Words", + "description": "Words users should have in their status." + }, + "roles": { + "humanName": "Roles", + "description": "Roles to give to users with one of the words in their status" + }, + "remove": { + "humanName": "Remove all other roles", + "description": "Remove all other roles from users with one of the words in their status" + }, + "ignoreOfflineUsers": { + "humanName": "Do not remove roles from offline users", + "description": "When users are offline, they don't have a status, leading to the role being removed. If enabled, the status role won't be removed from offline users, only users that have a different status. Recommended on servers with more than 500 members." + } + } + } + }, + "sticky-messages": { + "_module": { + "humanReadableName": "Sticky messages", + "description": "Let a set message always appear at the end of a channel." + }, + "sticky-messages": { + "description": "Manage the sticky messages here", + "humanName": "Sticky messages", + "content": { + "channelId": { + "humanName": "Channel", + "description": "Channel-ID in which the message should get send" + }, + "message": { + "humanName": "Message", + "description": "Message that should get send", + "default": "" + }, + "respondBots": { + "humanName": "Respond to bots", + "description": "Whether your bot reacts to messages from other bots in the channel" + } + } + } + }, + "suggestions": { + "_module": { + "humanReadableName": "Suggestions", + "description": "Advanced module to manage suggestions on your guild" + }, + "config": { + "description": "Configure the function of the module here", + "humanName": "Configuration", + "content": { + "suggestionChannel": { + "humanName": "Suggestion-Channel", + "description": "Channel in which this module should operate" + }, + "createSuggestionFromMessagesInChannel": { + "humanName": "Create suggestions from messages in channel", + "description": "If enabled, the bot will create thread under each suggestion" + }, + "reactions": { + "humanName": "Reactions", + "description": "Emojis with which the bot should react to a new suggestion" + }, + "allowUserComment": { + "humanName": "User-Comments in Threads", + "description": "If enabled, the bot will create thread under each suggestion" + }, + "threadName": { + "humanName": "Thread-Name", + "description": "Name of the thread", + "default": "Comments" + }, + "successfullySubmitted": { + "humanName": "\"Successfully submitted\"-Message", + "description": "This message gets send if a suggestion is submitted successfully.", + "default": "Suggestion %id% submitted successfully.", + "params": { + "id": { + "description": "ID of the suggestion" + } + } + }, + "notifyRole": { + "humanName": "Notification-Role", + "description": "If set, this role gets pinged when a new suggestion gets created" + }, + "sendPNNotifications": { + "humanName": "Send DM-Notifications", + "description": "If enabled the creator and all commentators get a notification when something changes on a suggestion" + }, + "teamChange": { + "humanName": "DM-Status-Notification", + "description": "This message gets send to the creator and all commentators when a suggestion gets updated and sendPNNotifications is enabled", + "default": "Hi, a suggestion you are subscribed to got updated by a team member - read it here %url%", + "params": { + "url": { + "description": "URL to the suggestion" + }, + "title": { + "description": "Title of the suggestion" + } + } + }, + "unansweredSuggestion": { + "humanName": "Unanswered Suggestion-Message", + "description": "This will be the messages that will get send when the user creates their suggestion and no admin has responded yet", + "default": { + "title": "Suggestion #%id%", + "description": "%suggestion%", + "color": "#F1C40F", + "thumbnail": "%avatarURL%", + "author": { + "name": "%tag%", + "img": "%avatarURL%" + }, + "fields": [ + { + "name": "Suggestion-Status", + "value": "No admin answered to this suggestion yet" + } + ] + }, + "params": { + "id": { + "description": "ID of the suggestion" + }, + "suggestion": { + "description": "Content of the suggestion" + }, + "tag": { + "description": "Tag of the user who created this suggestion" + }, + "avatarURL": { + "description": "Avatar-URL of the user who created this suggestion" + } + } + }, + "deniedSuggestion": { + "humanName": "Denied Suggestion-Message", + "description": "The suggestion will be edited to this message, when an admin denies a suggestion", + "default": { + "title": "Suggestion #%id%", + "description": "%suggestion%", + "color": "#E74C3C", + "thumbnail": "%avatarURL%", + "author": { + "name": "%tag%", + "img": "%avatarURL%" + }, + "fields": [ + { + "name": "Suggestion-Status: DENIED", + "value": "Denied by %adminUser% with the following reason: \"%adminMessage%\"" + } + ] + }, + "params": { + "id": { + "description": "ID of the suggestion" + }, + "suggestion": { + "description": "Content of the suggestion" + }, + "tag": { + "description": "Tag of the user who created this suggestion" + }, + "avatarURL": { + "description": "Avatar-URL of the user who created this suggestion" + }, + "adminUser": { + "description": "Mention of the administrator who denied this suggestion" + }, + "adminMessage": { + "description": "Message by administrator who denied this suggestion" + } + } + }, + "approvedSuggestion": { + "humanName": "Approved Suggestion-Message", + "description": "The suggestion will be edited to this message, when an admin approves a suggestion", + "default": { + "title": "Suggestion #%id%", + "description": "%suggestion%", + "color": "#2ECC71", + "thumbnail": "%avatarURL%", + "author": { + "name": "%tag%", + "img": "%avatarURL%" + }, + "fields": [ + { + "name": "Suggestion-Status: APPROVED", + "value": "Approved by %adminUser% with the following reason: \"%adminMessage%\"" + } + ] + }, + "params": { + "id": { + "description": "ID of the suggestion" + }, + "suggestion": { + "description": "Content of the suggestion" + }, + "tag": { + "description": "Tag of the user who created this suggestion" + }, + "avatarURL": { + "description": "Avatar-URL of the user who created this suggestion" + }, + "adminUser": { + "description": "Mention of the administrator who approved this suggestion" + }, + "adminMessage": { + "description": "Message by administrator who approved this suggestion" + } + } + } + } + } + }, + "team-list": { + "_module": { + "humanReadableName": "Staff-List", + "description": "List all your staff members and explain team roles in always up-to-date embed" + }, + "config": { + "description": "Configure your team list embeds and displayed roles here", + "humanName": "Configuration", + "content": { + "channelID": { + "humanName": "Channel", + "description": "Channel-ID to run all operations in it" + }, + "roles": { + "humanName": "Listed Roles", + "description": "Roles that should be listed in the embed" + }, + "descriptions": { + "humanName": "Descriptions of roles", + "description": "Optional description of a listed role (Field 1: Role-ID, Field 2: Description)" + }, + "embed": { + "humanName": "Embed", + "description": "Configuration of the member-embed" + }, + "nameOverwrites": { + "humanName": "Name-Overwrites", + "description": "optional; Allows to overwrite the displayed name of roles (Field 1: Role-ID, Field 2: Displayed Name)" + }, + "includeStatus": { + "humanName": "Include Online-Status of Staff-Members", + "description": "If enabled, the current online status will be displayed in the staffmember-list" + }, + "onlineShowHighestRole": { + "humanName": "Only list the highest role of a user?", + "description": "If enabled, a staff member will only be listed under their highest role in the list." + } + } + } + }, + "temp-channels": { + "_module": { + "humanReadableName": "Temporary channels", + "description": "Allow users to quickly create voice channels by joining a voice channel" + }, + "config": { + "description": "Configure temporary voice channel creation settings here", + "humanName": "Configuration", + "categories": { + "general": { + "displayName": "General" + }, + "permissions": { + "displayName": "Permissions & Mode" + }, + "features": { + "displayName": "Features" + }, + "messages": { + "displayName": "Messages" + }, + "limits": { + "displayName": "Limits" + }, + "archiving": { + "displayName": "Archiving" + } + }, + "content": { + "channelID": { + "humanName": "Channel", + "description": "Set the channel here where users have to join to create their temp-channel" + }, + "category": { + "humanName": "Category", + "description": "You can set a category here in which the new channel should be created" + }, + "channelname_format": { + "humanName": "Channel name", + "description": "Change the format of the channel name here", + "default": "⏳ %username%", + "params": { + "username": { + "description": "Username of the user" + }, + "nickname": { + "description": "Nickname of the member" + }, + "number": { + "description": "The current number of the channel" + }, + "tag": { + "description": "Tag of the user" + } + } + }, + "timeout": { + "humanName": "Deletion timeout", + "description": "Set a timeout here in which the bot should wait before deleting the voice channel (in seconds)" + }, + "publicChannels": { + "humanName": "Default to public channels", + "description": "If enabled, new temp channels start public (synced with category). If disabled, channels start private (only the creator can join)." + }, + "allowUserToChangeMode": { + "humanName": "Allow change of channel mode", + "description": "If enabled the user has the permission to change the access-mode of the voice channel" + }, + "privateBypassRoles": { + "humanName": "Private Mode Bypass Roles", + "description": "Roles that can always join and see private temporary channels, regardless of who created them." + }, + "allowUserToChangeName": { + "humanName": "Allow editing the channel", + "description": "If enabled the user has the permission to change the name and settings of the voice channel via both, the Discord-integrated menus and the corresponding /-commands" + }, + "create_no_mic_channel": { + "humanName": "Create no-mic-channel", + "description": "If enabled the bot will create a separate text channel for each voice channel, visible only to users in the voice channel. Note: Discord now has built-in text-in-voice channels, so this is usually not needed." + }, + "noMicChannelMessage": { + "humanName": "No-Mic Channel Message", + "description": "You can set a message here that should be send in the no-mic-channel when created", + "default": "Welcome to your no-mic-channel - you can only see this channel if you are in the connected voicechat" + }, + "useNoMic": { + "humanName": "No-Mic Channel for Settings", + "description": "If enabled the settings menu will be sent into the no-mic-channel. If no-mic-channels aren't enabled, the menu will instead be sent to Discord's integrated text-in-voice channels" + }, + "settingsChannel": { + "humanName": "Settings channel", + "description": "You can set a channel here in which the settings menu should be created. Leave this field empty, if you don't want to use this feature." + }, + "send_dm": { + "humanName": "Send DM", + "description": "Should the bot send a direct message to a user when a new channel is created for them?" + }, + "dm": { + "humanName": "DM Message Content", + "description": "The direct message content sent to the user when their temporary channel is created.", + "default": "I have created and moved you to your new voice-channel - have fun ^^", + "params": { + "channelname": { + "description": "Name of the channel" + } + } + }, + "notInChannel": { + "humanName": "Not in Channel Message", + "description": "This message gets sent to a user who tries to edit their channel while not being in it.", + "default": "You have to be in your temp-channel to do this" + }, + "modeSwitched": { + "humanName": "Mode Switched Message", + "description": "This message gets sent to a user, after they changed the mode of their channel", + "default": "The access-mode of your channel has been switched to %mode%", + "params": { + "mode": { + "description": "Mode of the channel" + } + } + }, + "userAdded": { + "humanName": "User Added Message", + "description": "This message gets sent to a user, after they added an user to their channel", + "default": "the user %user% has been added to your channel. They can now access it whenever they like to", + "params": { + "user": { + "description": "The user, that was added" + } + } + }, + "userRemoved": { + "humanName": "User Removed Message", + "description": "This message gets sent to a user, after they removed an user from their channel", + "default": "the user %user% has been removed from your channel. They can no longer access it, while your channel is private", + "params": { + "user": { + "description": "The user, that was removed" + } + } + }, + "listUsers": { + "humanName": "List Users Message", + "description": "The message to be sent when a user requests a list of users with access to their channel.", + "default": "Here is a list of all the users that have access to your channel: %users%", + "params": { + "users": { + "description": "List of users with access" + } + } + }, + "channelEdited": { + "humanName": "Channel Edited Message", + "description": "The message to be sent when a user edits their channel.", + "default": "Your channel was edited" + }, + "edit-error": { + "humanName": "Edit Error Message", + "description": "The message sent when a channel edit fails.", + "default": "An error occurred while editing your channel. One or more of your settings could not be applied. This could be due to missing permissions or an invalid value" + }, + "settingsMessage": { + "humanName": "Settings Panel Message", + "description": "Set the message that should get send in the channel specified above to let the users change the settings of their temp-channels", + "default": "Change the Settings of your temporary channel here" + }, + "enableMaxActiveChannels": { + "humanName": "Enable channel limit", + "description": "If enabled, the bot will limit the number of temporary channels that can exist at the same time." + }, + "maxActiveChannels": { + "humanName": "Maximum active channels", + "description": "Maximum number of temp channels that can exist at the same time." + }, + "maxActiveChannelsMessage": { + "humanName": "Channel Limit Reached Message", + "description": "This message is sent via DM when a user tries to create a temp channel but the limit has been reached.", + "default": "⚠️ The maximum number of temporary channels has been reached. Please try again later." + }, + "enableArchiving": { + "humanName": "Enable channel archiving", + "description": "If enabled, empty temp channels will be moved to an archive category instead of being deleted. Channels are restored when the creator rejoins the trigger channel." + }, + "archiveCategory": { + "humanName": "Archive category", + "description": "Category where archived temp channels are moved to. Make this category hidden from regular users." + }, + "archiveDeleteAfterHours": { + "humanName": "Delete archived channels after (hours)", + "description": "Hours after which archived channels are permanently deleted. Set to 0 to never auto-delete. Default: 168 (7 days)." + } + } + } + }, + "tic-tak-toe": { + "_module": { + "humanReadableName": "Tic Tac Toe", + "description": "Let your users play Tick-Tac-Toe against each other!" + } + }, + "tickets": { + "_module": { + "humanReadableName": "Ticket-System", + "description": "Let users create tickets to message your staff" + }, + "config": { + "description": "Manage the basic settings of this module here", + "humanName": "Configuration", + "configElementName": { + "one": "Ticket-Category", + "more": "Ticket-Categories" + }, + "content": { + "name": { + "humanName": "Name", + "description": "Name of the Ticket type. This will be shown to users", + "default": "Support" + }, + "ticket-create-category": { + "humanName": "Ticket create category", + "description": "Category in which tickets should get created." + }, + "ticket-create-channel": { + "humanName": "Ticket creation channel", + "description": "Channel in which a message with a \"Create Ticket\" button should get send" + }, + "ticketRoles": { + "humanName": "Ticket Roles", + "description": "Users who get pinged in the tickets and who can see tickets" + }, + "logChannel": { + "humanName": "Log channel", + "description": "Channel in which ticket logs should get send" + }, + "ticket-create-message": { + "humanName": "Ticket created message", + "description": "Message that gets send/edited in the ticket-create-channel", + "default": "Click the big button below to contact our staff and create a ticket" + }, + "sendUserDMAfterTicketClose": { + "humanName": "Send user DM after ticket is closed", + "description": "If enabled users get a DM from the bot after someone closes the ticket" + }, + "userDM": { + "humanName": "User DM", + "description": "This message gets send to the user if sendUserDMAfterTicketClose is enabled", + "default": "Thanks for contacting our support for the ticket-category \"%type%\", here is your transcript: %transcriptURL%", + "params": { + "transcriptURL": { + "description": "URL to transcript" + }, + "type": { + "description": "Name of this ticket type" + } + } + }, + "creation-message": { + "humanName": "Ticket-Created Message", + "description": "This message will get sent in new tickets. The close buttons will be added.", + "default": { + "title": "📥 New ticket #%id%", + "color": "#2ECC71", + "message": "%rolePings%", + "fields": [ + { + "name": "👤 User", + "value": "%userMention%", + "inline": true + }, + { + "name": "☕ Ticket-Topic", + "value": "%ticketTopic%", + "inline": true + }, + { + "name": "ℹ️ Information", + "value": "Your issue got solved? Click the button below. You can always find this message pinned." + } + ] + }, + "params": { + "id": { + "description": "Unique identification number of the ticket" + }, + "userMention": { + "description": "Mention of the user who created this ticket" + }, + "rolePings": { + "description": "Mention of the roles you have selected in the \"Ticket roles\" field" + }, + "ticketTopic": { + "description": "Name of the Ticket-Topic" + }, + "userTag": { + "description": "Tag of the user who created this ticket" + } + } + }, + "ticket-create-button": { + "humanName": "Ticket create button", + "description": "Button for creating a ticket", + "default": "Create ticket 🎫" + }, + "ticket-close-button": { + "humanName": "Ticket close button", + "description": "Button for closing a ticket", + "default": "❎ Close ticket" + } + } + } + }, + "twitch-notifications": { + "_module": { + "humanReadableName": "Twitch-Notifications", + "description": "Module that sends a message to a channel, when a streamer goes live on Twitch" + }, + "streamers": { + "description": "Configure here, where for what streamer which message should get send", + "humanName": "Streamers", + "content": { + "liveMessage": { + "humanName": "Live-Messages", + "description": "Message that gets send if the streamer goes live", + "default": "Hey, %streamer% is live on Twitch streaming %game%! Check it out: %url%", + "params": { + "streamer": { + "description": "Name of the Streamer" + }, + "game": { + "description": "Game which is streamed" + }, + "url": { + "description": "Link to the stream" + }, + "title": { + "description": "Title of the Stream" + }, + "thumbnailUrl": { + "description": "The Link to the thumbnail of the Stream" + } + } + }, + "liveMessageChannel": { + "humanName": "Channel", + "description": "Channel in which live-message should get sent" + }, + "streamer": { + "humanName": "Streamer", + "description": "Streamer where a notification should send when they start streaming", + "default": "" + }, + "liveRole": { + "humanName": "Use Live-Role", + "description": "Should the Live-Role be activated?" + }, + "id": { + "humanName": "Discord-User ID", + "description": "ID of the Discord-Account of the Streamer" + }, + "role": { + "humanName": "Live Role", + "description": "ID of the Role that the Streamer should get, when live" + } + } + } + }, + "uno": { + "_module": { + "humanReadableName": "Uno", + "description": "Let your users play Uno against each other!" + } + }, + "welcomer": { + "_module": { + "humanReadableName": "Welcome and Boosts", + "description": "Simple module to say \"Hi\" to new members, give them roles automatically and say \"thanks\" to users who boosted" + }, + "channels": { + "description": "Configure here in which channel which message should get send", + "humanName": "Channel", + "content": { + "channelID": { + "humanName": "Channel", + "description": "Channel in which the message should get send" + }, + "type": { + "humanName": "Channel-Type", + "description": "This sets in which content the channel should get used" + }, + "randomMessages": { + "humanName": "Random messages?", + "description": "If enabled the bot will randomly pick a messages instead of using the message option below" + }, + "message": { + "humanName": "Message", + "description": "Message that should get send", + "default": "", + "params": { + "mention": { + "description": "Mention of the user who unboosted" + }, + "memberProfilePictureUrl": { + "description": "URL of the user's avatar" + }, + "servername": { + "description": "Name of the guild" + }, + "tag": { + "description": "Tag of the user" + }, + "createdAt": { + "description": "Date when account was created" + }, + "memberProfileBannerUrl": { + "description": "URL of the banner's avatar" + }, + "joinedAt": { + "description": "Date when user joined guild" + }, + "guildUserCount": { + "description": "Count of users on the guild" + }, + "guildMemberCount": { + "description": "Count of members (without bots) on the guild" + }, + "boostCount": { + "description": "Total count of boosts" + }, + "guildLevel": { + "description": "Boost-Level of the guild after the boost" + } + } + }, + "welcome-button": { + "humanName": "Welcome-Button (only if \"Channel-Type\" = \"join\")", + "description": "If enabled, a welcome-button will be attached to the welcome message. When a user clicks on it, the bot will send a welcome-ping in a configured channel. The button can be pressed once." + }, + "welcome-button-content": { + "humanName": "Welcome-Button-Content", + "description": "Content of the welcome button", + "default": "Say hi 👋" + }, + "welcome-button-channel": { + "humanName": "Channel in which the welcome-button should send a message", + "description": "The bot will send the configured message in this channel when a user presses the button" + }, + "welcome-button-message": { + "humanName": "Welcome-Button-Message", + "description": "This is the message the bot will send in the configured channel when a user presses the button", + "default": "%clickUserMention% welcomes %userMention% :wave:", + "params": { + "userMention": { + "description": "Mention of the user who joined the server" + }, + "userTag": { + "description": "Tag of the user who joined the server" + }, + "userAvatarURL": { + "description": "Avatar of the user who joined the server" + }, + "clickUserMention": { + "description": "Mention of the user who clicked the button" + }, + "clickUserTag": { + "description": "Tag of the user who clicked the button" + }, + "clickUserAvatarURL": { + "description": "Avatar of the user who clicked the button" + } + } + } + } + }, + "random-messages": { + "description": "Manage the randomly send messages here", + "humanName": "Random messages", + "content": { + "type": { + "humanName": "Message-Type", + "description": "This sets in which content the message should get send" + }, + "message": { + "humanName": "Message", + "description": "Message that should get send", + "default": "", + "params": { + "mention": { + "description": "Mention of the user who unboosted" + }, + "memberProfilePictureUrl": { + "description": "URL of the user's avatar" + }, + "servername": { + "description": "Name of the guild" + }, + "tag": { + "description": "Tag of the user" + }, + "createdAt": { + "description": "Date when account was created" + }, + "joinedAt": { + "description": "Date when user joined guild" + }, + "guildUserCount": { + "description": "Count of users on the guild" + }, + "guildMemberCount": { + "description": "Count of members (without bots) on the guild" + }, + "boostCount": { + "description": "Total count of boosts" + }, + "guildLevel": { + "description": "Boost-Level of the guild after the unboost" + } + } + } + } + }, + "config": { + "description": "Manage the basic settings of this module here", + "humanName": "Configuration", + "categories": { + "welcome": { + "displayName": "Welcome" + }, + "roles": { + "displayName": "Auto-Roles" + }, + "boost": { + "displayName": "Boosts" + } + }, + "content": { + "give-roles-on-join": { + "humanName": "Give roles on join", + "description": "Roles to give to a new member" + }, + "assign-roles-immediately": { + "humanName": "Immediately give roles, instead of waiting for rules acceptance?", + "description": "If enabled, roles will be granted immediately when a user joins your server. Otherwise, no roles will be assigned to users before they complete the Discord onboarding." + }, + "not-send-messages-if-member-is-bot": { + "humanName": "Ignore bots?", + "description": "Should bots get ignored when they join (or leave) the server" + }, + "give-roles-on-boost": { + "humanName": "Give additional roles to boosters", + "description": "Roles to give to members who boosts the server" + }, + "delete-welcome-message": { + "humanName": "Delete welcome message", + "description": "Should their welcome message be deleted, if a user leaves the server within 7 days" + }, + "sendDirectMessageOnJoin": { + "humanName": "Send DM on join? (often experienced by users as spam)", + "description": "If enabled, a DM will be sent to new users. This is often experienced by them as spam and can decrease your new user retention metrics. Please note that not all users will receive this DM, as a huge chunk has DMs disabled." + }, + "joinDM": { + "humanName": "Join DM Message", + "description": "Message that should get send to new users via DMs", + "default": "", + "params": { + "mention": { + "description": "Mention of the user who unboosted" + }, + "memberProfilePictureUrl": { + "description": "URL of the user's avatar" + }, + "servername": { + "description": "Name of the guild" + }, + "tag": { + "description": "Tag of the user" + }, + "createdAt": { + "description": "Date when account was created" + }, + "joinedAt": { + "description": "Date when user joined guild" + }, + "guildUserCount": { + "description": "Count of users on the guild" + }, + "guildMemberCount": { + "description": "Count of members (without bots) on the guild" + }, + "boostCount": { + "description": "Total count of boosts" + }, + "guildLevel": { + "description": "Boost-Level of the guild after the unboost" + } + } + } + } + } + } +} diff --git a/config-localizations/generate-files.js b/config-localizations/generate-files.js new file mode 100644 index 00000000..f06e5569 --- /dev/null +++ b/config-localizations/generate-files.js @@ -0,0 +1,322 @@ +/** + * Extracts English strings from all config JSON files and generates + * config-localizations/en.json for use as the Weblate reference file. + * + * Reads module.json config-example-files to discover ALL config files per module. + * Config files use inline English-only values (plain strings). This script + * extracts them into a structured JSON file that translators can work with. + * + * Also reports warnings for missing humanName/description fields and shows + * how many new strings were added compared to the previous en.json. + * + * Usage: node config-localizations/generate-files.js + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const OUTPUT_DIR = __dirname; +const OUTPUT_PATH = path.join(OUTPUT_DIR, 'en.json'); + +const extracted = {}; +const warnings = []; + +// Load previous en.json for comparison +let previousData = {}; +try { + previousData = JSON.parse(fs.readFileSync(OUTPUT_PATH, 'utf-8')); +} catch (e) { + // No previous file — everything will be new +} + +/** + * Extract English strings from a config file's top-level and content fields. + */ +function extractFromConfig(configData, filePath) { + const result = {}; + + // Top-level fields + for (const key of ['description', 'humanName', 'warningBanner']) { + if (typeof configData[key] === 'string' && configData[key].length > 0) { + result[key] = configData[key]; + } + } + + // Warn about missing top-level fields + if (!configData.humanName) { + warnings.push(`${filePath}: Missing top-level "humanName"`); + } + if (!configData.description) { + warnings.push(`${filePath}: Missing top-level "description"`); + } + + // informationBanner: can be a string or a complex object with nested strings + if (configData.informationBanner) { + if (typeof configData.informationBanner === 'string') { + result.informationBanner = configData.informationBanner; + } else if (typeof configData.informationBanner === 'object') { + result.informationBanner = configData.informationBanner; + } + } + + // configElementName: after conversion, this is {one: "...", more: "..."} or a string + if (configData.configElementName) { + if (typeof configData.configElementName === 'string') { + result.configElementName = configData.configElementName; + } else if (typeof configData.configElementName === 'object' && !Array.isArray(configData.configElementName)) { + result.configElementName = configData.configElementName; + } + } + + // commandsWarnings.special[].info + if (configData.commandsWarnings && Array.isArray(configData.commandsWarnings.special)) { + const cmdWarnings = {}; + for (const warning of configData.commandsWarnings.special) { + if (typeof warning.info === 'string' && warning.info.length > 0) { + cmdWarnings[warning.name] = {info: warning.info}; + } + } + if (Object.keys(cmdWarnings).length > 0) result.commandsWarnings = cmdWarnings; + } + + // categories[].displayName + if (Array.isArray(configData.categories)) { + const categories = {}; + for (const cat of configData.categories) { + if (typeof cat.displayName === 'string' && cat.displayName.length > 0) { + categories[cat.id] = {displayName: cat.displayName}; + } else if (!cat.displayName) { + warnings.push(`${filePath}: Category "${cat.id}" missing "displayName"`); + } + } + if (Object.keys(categories).length > 0) result.categories = categories; + } + + // content fields + if (Array.isArray(configData.content)) { + const contentResult = {}; + for (const field of configData.content) { + const fieldResult = extractFromField(field, filePath); + if (Object.keys(fieldResult).length > 0) { + contentResult[field.name] = fieldResult; + } + } + if (Object.keys(contentResult).length > 0) result.content = contentResult; + } + + return result; +} + +/** + * Extract English strings from a single content field. + */ +function extractFromField(field, filePath) { + const result = {}; + + // humanName and description + for (const key of ['humanName', 'description']) { + if (typeof field[key] === 'string' && field[key].length > 0) { + result[key] = field[key]; + } + } + + // Warn about missing required field properties + if (!field.humanName) { + warnings.push(`${filePath}: Field "${field.name}" missing "humanName"`); + } + if (!field.description) { + warnings.push(`${filePath}: Field "${field.name}" missing "description"`); + } + + // Only extract defaults for localizable types + if (['string', 'emoji', 'imgURL'].includes(field.type)) { + if (typeof field.default === 'string') { + result.default = field.default; + } else if (field.default && typeof field.default === 'object' && !Array.isArray(field.default)) { + // Embed default object (with title, description, etc.) + result.default = field.default; + } + } + + // params[].description + if (Array.isArray(field.params)) { + const params = {}; + for (const param of field.params) { + if (typeof param.description === 'string' && param.description.length > 0) { + params[param.name] = {description: param.description}; + } else if (!param.description) { + warnings.push(`${filePath}: Field "${field.name}" param "${param.name}" missing "description"`); + } + } + if (Object.keys(params).length > 0) result.params = params; + } + + // select content[].displayName (when content is array of objects) + if (Array.isArray(field.content) && field.content.length > 0 && typeof field.content[0] === 'object' && field.content[0] !== null) { + const selectOptions = {}; + for (const option of field.content) { + if (option && typeof option.displayName === 'string' && option.displayName.length > 0) { + selectOptions[option.value] = {displayName: option.displayName}; + } else if (option && !option.displayName) { + warnings.push(`${filePath}: Field "${field.name}" select option "${option.value}" missing "displayName"`); + } + } + if (Object.keys(selectOptions).length > 0) result.selectOptions = selectOptions; + } + + // links[].label + if (Array.isArray(field.links)) { + const links = {}; + for (let i = 0; i < field.links.length; i++) { + if (typeof field.links[i].label === 'string' && field.links[i].label.length > 0) { + links[field.links[i].url || i] = {label: field.links[i].label}; + } + } + if (Object.keys(links).length > 0) result.links = links; + } + + return result; +} + +/** + * Count all leaf string values in a nested object. + */ +function countStrings(obj) { + if (obj === null || obj === undefined) return 0; + if (typeof obj === 'string') return 1; + if (typeof obj !== 'object') return 0; + if (Array.isArray(obj)) return obj.reduce((sum, v) => sum + countStrings(v), 0); + return Object.values(obj).reduce((sum, v) => sum + countStrings(v), 0); +} + +/** + * Process a single config JSON file. + */ +function processFile(filePath, scope, fileName) { + let configData; + try { + configData = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch (e) { + console.warn(` Skipping ${filePath}: ${e.message}`); + return; + } + + // Skip non-config files + if (Array.isArray(configData) && !configData.content) return; + if (!configData.content && !configData.description && !configData.humanName) return; + + const result = extractFromConfig(configData, `${scope}/${fileName}.json`); + if (Object.keys(result).length === 0) return; + + if (!extracted[scope]) extracted[scope] = {}; + extracted[scope][fileName] = result; +} + +// Process config-generator files +console.log('Scanning config-generator/...'); +const coreDir = path.join(ROOT, 'config-generator'); +if (fs.existsSync(coreDir)) { + for (const file of fs.readdirSync(coreDir).sort()) { + if (!file.endsWith('.json')) continue; + const filePath = path.join(coreDir, file); + const fileName = file.replace('.json', ''); + console.log(` ${file}`); + processFile(filePath, '_core', fileName); + } +} + +// Process module config files using module.json +console.log('Scanning modules/...'); +const modulesDir = path.join(ROOT, 'modules'); +for (const moduleName of fs.readdirSync(modulesDir).sort()) { + const moduleDir = path.join(modulesDir, moduleName); + if (!fs.statSync(moduleDir).isDirectory()) continue; + + const moduleJsonPath = path.join(moduleDir, 'module.json'); + if (!fs.existsSync(moduleJsonPath)) continue; + + let moduleJson; + try { + moduleJson = JSON.parse(fs.readFileSync(moduleJsonPath, 'utf-8')); + } catch (e) { + console.warn(` Skipping ${moduleName}: invalid module.json`); + continue; + } + + // Extract module.json metadata (humanReadableName, description) + const moduleMetadata = {}; + if (typeof moduleJson.humanReadableName === 'string' && moduleJson.humanReadableName.length > 0) { + moduleMetadata.humanReadableName = moduleJson.humanReadableName; + } else if (!moduleJson.humanReadableName) { + warnings.push(`${moduleName}/module.json: Missing "humanReadableName"`); + } + if (typeof moduleJson.description === 'string' && moduleJson.description.length > 0) { + moduleMetadata.description = moduleJson.description; + } else if (!moduleJson.description) { + warnings.push(`${moduleName}/module.json: Missing "description"`); + } + if (typeof moduleJson.legalDisclaimer === 'string' && moduleJson.legalDisclaimer.length > 0) { + moduleMetadata.legalDisclaimer = moduleJson.legalDisclaimer; + } + if (typeof moduleJson.enableWarning === 'string' && moduleJson.enableWarning.length > 0) { + moduleMetadata.enableWarning = moduleJson.enableWarning; + } + if (Object.keys(moduleMetadata).length > 0) { + if (!extracted[moduleName]) extracted[moduleName] = {}; + extracted[moduleName]['_module'] = moduleMetadata; + } + + // Extract config files + const configFiles = moduleJson['config-example-files'] || []; + for (const configFile of configFiles) { + const filePath = path.join(moduleDir, configFile); + if (!fs.existsSync(filePath)) { + console.warn(` Warning: ${moduleName}/${configFile} listed in module.json but not found`); + continue; + } + const fileName = path.basename(configFile, '.json'); + console.log(` ${moduleName}/${configFile}`); + processFile(filePath, moduleName, fileName); + } +} + +// Count strings +const totalStrings = countStrings(extracted); +const previousStrings = countStrings(previousData); + +// Write en.json +fs.writeFileSync(OUTPUT_PATH, JSON.stringify(extracted, null, 2) + '\n'); +const scopeCount = Object.keys(extracted).length; +let fieldCount = 0; +for (const scope of Object.values(extracted)) { + for (const file of Object.values(scope)) { + if (file.content) fieldCount += Object.keys(file.content).length; + } +} + +console.log(`\nWritten ${OUTPUT_PATH}`); +console.log(` ${scopeCount} scopes, ${fieldCount} content fields`); +console.log(` ${totalStrings} total strings`); +if (previousStrings > 0) { + const newStrings = totalStrings - previousStrings; + if (newStrings > 0) { + console.log(` ${newStrings} new strings added since last generation`); + } else if (newStrings < 0) { + console.log(` ${Math.abs(newStrings)} strings removed since last generation`); + } else { + console.log(` No change in string count`); + } +} else { + console.log(` (first generation — all strings are new)`); +} + +// Report warnings +if (warnings.length > 0) { + console.log(`\n${warnings.length} warning(s):`); + for (const w of warnings) { + console.log(` - ${w}`); + } +} + +console.log('\nDone!'); diff --git a/config-localizations/getLocale.js b/config-localizations/getLocale.js new file mode 100644 index 00000000..f38442ad --- /dev/null +++ b/config-localizations/getLocale.js @@ -0,0 +1,449 @@ +/** + * Locale utilities for config-localizations JSON files. + * + * Exports: + * localize(stringName, locale, dir) + * Look up a single localized value by dot-path. + * + * getLocalizedConfig(configName, moduleName, locale, rootCustomBotDir) + * Return a full config file with all values localized. + * + * Usage: + * const { localize, getLocalizedConfig } = require('./config-localizations/getLocale'); + * + * localize('moderation.strings.content.ban_message.default', 'de', '/path/to/branch/config-localizations'); + * + * getLocalizedConfig('configs/config.json', 'moderation', 'de', '/path/to/bot'); + * getLocalizedConfig('config.json', null, 'de', '/path/to/bot'); // core config + */ + +const fs = require('fs'); +const path = require('path'); + +/** Cache TTL in ms (5 minutes). */ +const CACHE_TTL = 5 * 60 * 1000; + +// Keyed by "dir\0locale" to keep per-directory caches separate. +const cache = {}; + +/** + * Load and cache a locale file from a given directory. + * Re-reads from disk if the cache entry is older than CACHE_TTL. + */ +function loadLocale(dir, locale) { + const key = dir + '\0' + locale; + const entry = cache[key]; + if (entry && (Date.now() - entry.ts) < CACHE_TTL) return entry.data; + const filePath = path.join(dir, `${locale}.json`); + let data = null; + try { + data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { /* missing/unreadable file → null */ + } + cache[key] = { + data, + ts: Date.now() + }; + return data; +} + +/** + * Walk an object by a dot-separated path. Returns undefined on miss. + */ +function resolve(obj, dotPath) { + const keys = dotPath.split('.'); + let current = obj; + for (const key of keys) { + if (current == null || typeof current !== 'object') return undefined; + current = current[key]; + } + return current; +} + +/** + * Look up a localized string by dot-path. + * + * @param {string} stringName Dot-separated path, e.g. "moderation.strings.content.ban_message.default" + * @param {string} [locale] BCP-47 language code (e.g. "de"). Falls back to "en". + * @param {string} [dir] Directory containing the locale JSON files. Defaults to this file's directory. + * @returns {*} The resolved value, or undefined if not found. + */ +function localize(stringName, locale, dir) { + const configDir = dir || __dirname; + if (locale && locale !== 'en') { + const locData = loadLocale(configDir, locale); + if (locData) { + const value = resolve(locData, stringName); + if (value !== undefined) return value; + } + } + const enData = loadLocale(configDir, 'en'); + if (!enData) return undefined; + return resolve(enData, stringName); +} + +/** + * Return a full config example file with all values replaced by their + * localized equivalents. Falls back to English for missing translations. + * + * @param {string} configName Path to the config file relative to the module dir + * (e.g. "configs/config.json"). For core configs, relative + * to config-generator/ (e.g. "config.json"). + * @param {string|null} moduleName Module name (e.g. "moderation"), or null for core configs. + * @param {string} locale BCP-47 language code (e.g. "de"). Falls back to "en". + * @param {string} rootCustomBotDir Root directory of the custom bot installation. + * @returns {object|null} The localized config object, or null if the file doesn't exist. + */ +function getLocalizedConfig(configName, moduleName, locale, rootCustomBotDir) { + const configPath = moduleName + ? path.join(rootCustomBotDir, 'modules', moduleName, configName) + : path.join(rootCustomBotDir, 'config-generator', configName); + + let config; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + return null; + } + config = JSON.parse(JSON.stringify(config)); + + if (!locale || locale === 'en') return config; + + const locDir = path.join(rootCustomBotDir, 'config-localizations'); + const locData = loadLocale(locDir, locale); + const enData = loadLocale(locDir, 'en'); + + const scope = moduleName || '_core'; + const fileKey = path.basename(configName, '.json'); + const fileLoc = locData && locData[scope] && locData[scope][fileKey]; + + if (!fileLoc) return config; + + const enFile = enData && enData[scope] && enData[scope][fileKey]; + + function pick(locObj, enObj, key, original) { + if (locObj && locObj[key] !== undefined) return locObj[key]; + if (enObj && enObj[key] !== undefined) return enObj[key]; + return original; + } + + // Top-level metadata + for (const key of ['humanName', 'description', 'informationBanner']) { + if (fileLoc[key] !== undefined) config[key] = fileLoc[key]; + } + + // configElementName (e.g. { one: "punishment", more: "punishments" }) + if (fileLoc.configElementName && config.configElementName) { + const locCE = fileLoc.configElementName; + const enCE = enFile && enFile.configElementName; + for (const k of Object.keys(config.configElementName)) { + config.configElementName[k] = pick(locCE, enCE, k, config.configElementName[k]); + } + } + + // Categories — config: [{id, displayName, ...}], locale: {id: {displayName}} + if (fileLoc.categories && Array.isArray(config.categories)) { + const enCats = enFile && enFile.categories; + for (const cat of config.categories) { + const catLoc = fileLoc.categories[cat.id]; + const catEn = enCats && enCats[cat.id]; + if (catLoc || catEn) { + cat.displayName = pick(catLoc, catEn, 'displayName', cat.displayName); + } + } + } + + // Content fields — config: [{name, humanName, ...}], locale: {name: {humanName, ...}} + if (fileLoc.content && Array.isArray(config.content)) { + const enContent = enFile && enFile.content; + for (const field of config.content) { + const fLoc = fileLoc.content[field.name]; + const fEn = enContent && enContent[field.name]; + if (!fLoc && !fEn) continue; + + for (const key of ['humanName', 'description', 'default']) { + const val = pick(fLoc, fEn, key, undefined); + if (val !== undefined) field[key] = val; + } + + // Params — config: [{name, description}], locale: {name: {description}} + if (Array.isArray(field.params) && (fLoc && fLoc.params || fEn && fEn.params)) { + const pLoc = fLoc && fLoc.params; + const pEn = fEn && fEn.params; + for (const param of field.params) { + const paramLoc = pLoc && pLoc[param.name]; + const paramEn = pEn && pEn[param.name]; + if (paramLoc || paramEn) { + param.description = pick(paramLoc, paramEn, 'description', param.description); + } + } + } + } + } + + return config; +} + +/** + * List config files for a module with localized metadata. + * + * @param {string} locale BCP-47 language code (e.g. "de"). Falls back to "en". + * @param {string} moduleName Module directory name (e.g. "moderation"). + * @param {string} rootCustomBotDir Root directory of the custom bot installation. + * @returns {Array<{filename: string, humanName: string, description: string, fieldCount: number}>|null} + * Array of config summaries, or null if the module doesn't exist. + */ +function listLocalizedConfigs(locale, moduleName, rootCustomBotDir) { + const mjPath = path.join(rootCustomBotDir, 'modules', moduleName, 'module.json'); + let mj; + try { + mj = JSON.parse(fs.readFileSync(mjPath, 'utf-8')); + } catch { + return null; + } + + const configFiles = mj['config-example-files']; + if (!Array.isArray(configFiles) || configFiles.length === 0) return []; + + const locDir = path.join(rootCustomBotDir, 'config-localizations'); + const locData = locale && locale !== 'en' ? loadLocale(locDir, locale) : null; + const enData = loadLocale(locDir, 'en'); + + function pickVal(locObj, enObj, key, fallback) { + if (locObj && locObj[key] !== undefined) return locObj[key]; + if (enObj && enObj[key] !== undefined) return enObj[key]; + return fallback; + } + + const result = []; + for (const cfgPath of configFiles) { + const fullPath = path.join(rootCustomBotDir, 'modules', moduleName, cfgPath); + let cfg; + try { + cfg = JSON.parse(fs.readFileSync(fullPath, 'utf-8')); + } catch { + continue; + } + + const fileKey = path.basename(cfgPath, '.json'); + const fileLoc = locData && locData[moduleName] && locData[moduleName][fileKey]; + const fileEn = enData && enData[moduleName] && enData[moduleName][fileKey]; + + result.push({ + filename: cfgPath, + humanName: pickVal(fileLoc, fileEn, 'humanName', cfg.humanName || fileKey), + description: pickVal(fileLoc, fileEn, 'description', cfg.description || ''), + fieldCount: Array.isArray(cfg.content) ? cfg.content.length : 0 + }); + } + + return result; +} + +/** + * List all config files for every module with localized metadata. + * + * @param {string} locale BCP-47 language code (e.g. "de"). Falls back to "en". + * @param {string} rootCustomBotDir Root directory of the custom bot installation. + * @returns {Array<{moduleName: string, humanReadableName: string, moduleDescription: string, configs: Array<{filename: string, humanName: string, description: string, fieldCount: number}>}>} + */ +function listAllLocalizedConfigs(locale, rootCustomBotDir) { + const modulesDir = path.join(rootCustomBotDir, 'modules'); + let moduleDirs; + try { + moduleDirs = fs.readdirSync(modulesDir).sort(); + } catch { + return []; + } + + const locDir = path.join(rootCustomBotDir, 'config-localizations'); + const locData = loadLocale(locDir, locale && locale !== 'en' ? locale : null); + const enData = loadLocale(locDir, 'en'); + + function pickVal(locScope, enScope, key, fallback) { + if (locScope && locScope[key] !== undefined) return locScope[key]; + if (enScope && enScope[key] !== undefined) return enScope[key]; + return fallback; + } + + const result = []; + + for (const mod of moduleDirs) { + const mjPath = path.join(modulesDir, mod, 'module.json'); + let mj; + try { + mj = JSON.parse(fs.readFileSync(mjPath, 'utf-8')); + } catch { + continue; + } + + const configFiles = mj['config-example-files']; + if (!Array.isArray(configFiles) || configFiles.length === 0) continue; + + // Localized module metadata + const modLoc = locData && locData[mod] && locData[mod]._module; + const modEn = enData && enData[mod] && enData[mod]._module; + + const entry = { + moduleName: mod, + humanReadableName: pickVal(modLoc, modEn, 'humanReadableName', mj.humanReadableName || mod), + moduleDescription: pickVal(modLoc, modEn, 'description', mj.description || ''), + configs: [] + }; + + for (const cfgPath of configFiles) { + const fullPath = path.join(modulesDir, mod, cfgPath); + let cfg; + try { + cfg = JSON.parse(fs.readFileSync(fullPath, 'utf-8')); + } catch { + continue; + } + + const fileKey = path.basename(cfgPath, '.json'); + const fileLoc = locData && locData[mod] && locData[mod][fileKey]; + const fileEn = enData && enData[mod] && enData[mod][fileKey]; + + entry.configs.push({ + name: cfgPath.replaceAll('.json', ''), + filename: cfgPath.replaceAll('.json', '').replaceAll('configs/', ''), + humanName: pickVal(fileLoc, fileEn, 'humanName', cfg.humanName || fileKey), + description: pickVal(fileLoc, fileEn, 'description', cfg.description || ''), + fieldCount: Array.isArray(cfg.content) ? cfg.content.length : 0 + }); + } + + result.push(entry); + } + + return result; +} + +/** + * Return all modules with localized humanReadableName and description, + * plus static metadata from module.json. The author field is redacted to + * only { scnxOrgID } when a scnxOrgID is present. + * + * @param {string} rootCustomBotDir Root directory of the custom bot installation. + * @returns {Array} Array of module summary objects. + */ +function localizedModules(rootCustomBotDir) { + const modulesDir = path.join(rootCustomBotDir, 'modules'); + let moduleDirs; + try { + moduleDirs = fs.readdirSync(modulesDir).sort(); + } catch { + return []; + } + + const locDir = path.join(rootCustomBotDir, 'config-localizations'); + const enData = loadLocale(locDir, 'en'); + + // Collect all available locales + const locales = {}; + try { + for (const file of fs.readdirSync(locDir)) { + if (file.endsWith('.json')) { + const loc = file.replace('.json', ''); + locales[loc] = loadLocale(locDir, loc); + } + } + } catch { /* no localization dir */ + } + + const result = []; + + for (const mod of moduleDirs) { + const mjPath = path.join(modulesDir, mod, 'module.json'); + let mj; + try { + mj = JSON.parse(fs.readFileSync(mjPath, 'utf-8')); + } catch { + continue; + } + + if (mj.hidden) continue; + + // Build localized humanReadableName and description across all locales + const humanReadableName = {}; + const description = {}; + const legalDisclaimer = {}; + + for (const [loc, data] of Object.entries(locales)) { + const modLoc = data && data[mod] && data[mod]._module; + if (modLoc && modLoc.humanReadableName !== undefined) { + humanReadableName[loc] = modLoc.humanReadableName; + } + if (modLoc && modLoc.description !== undefined) { + description[loc] = modLoc.description; + } + if (modLoc && modLoc.legalDisclaimer !== undefined) { + legalDisclaimer[loc] = modLoc.legalDisclaimer; + } + } + + // English fallback from the file itself + if (!humanReadableName.en) humanReadableName.en = mj.humanReadableName || mod; + if (!description.en) description.en = mj.description || ''; + if (!legalDisclaimer.en && mj.legalDisclaimer) legalDisclaimer.en = mj.legalDisclaimer; + + // Author: redact to just scnxOrgID when it's set + let author = mj.author; + if (author && author.scnxOrgID) { + author = {scnxOrgID: author.scnxOrgID}; + } + + // Config file count + const configFiles = mj['config-example-files']; + const configFileCount = Array.isArray(configFiles) ? configFiles.length : 0; + + // Command count: count .js files in commands-dir + let commandCount = 0; + if (mj['commands-dir']) { + const cmdDir = path.join(modulesDir, mod, mj['commands-dir']); + try { + commandCount = fs.readdirSync(cmdDir).filter(f => f.endsWith('.js')).length; + } catch { /* no commands dir */ + } + } + + // Has database models + let hasDB = false; + if (mj['models-dir']) { + const modelsDir = path.join(modulesDir, mod, mj['models-dir']); + try { + hasDB = fs.readdirSync(modelsDir).some(f => f.endsWith('.js')); + } catch { /* no models dir */ + } + } + + const entry = { + name: mj.name || mod, + humanReadableName, + description, + tags: mj.tags || [], + 'fa-icon': mj['fa-icon'] || '', + author, + openSourceURL: mj.openSourceURL || null, + usesAICredits: mj.usesAICredits || false, + earlyAccess: mj.earlyAccess || false, + commandsCount: commandCount, + configFileCount, + hasDB + }; + + if (Object.keys(legalDisclaimer).length > 0) entry.legalDisclaimer = legalDisclaimer; + + result.push(entry); + } + + return result; +} + +module.exports = { + localize, + getLocalizedConfig, + listAllLocalizedConfigs, + listLocalizedConfigs, + localizedModules +}; \ No newline at end of file diff --git a/developer-docs/README.md b/developer-docs/README.md new file mode 100644 index 00000000..c0ed6228 --- /dev/null +++ b/developer-docs/README.md @@ -0,0 +1,42 @@ +# Developer Documentation + +Guides for people writing modules or contributing to the bot core. + +## Module authors + +Start here if you want to add a new feature as a module: + +- [**Writing a module**](./writing-a-module.md) - file layout, `module.json`, lifecycle, end-to-end example. +- [**Events**](./events.md) - event handler shape, lifecycle gates (`botReadyAt`, `allowPartial`, + `ignoreBotReadyCheck`), Discord and custom events you can listen to. +- [**Slash commands**](./commands.md) - `config` / `run` / `subcommands` / `autocomplete`, registration, options. +- [**Database models**](./database-models.md) - Sequelize `Model.init` pattern, `models-dir`, accessing models from + events. +- [**Localization**](./localization.md) - adding strings to `locales/en.json` and using `localize()`. + +## Configuration schema + +For module config files (`config.json`, `streamers.json`, etc.): + +- [**Configuration files**](./configuration.md) - schema reference: field types, defaults, `dependsOn`, `elementToggle`, + validation. +- [**Country localization**](./config-localization.md) - how user-facing strings in config files are extracted and + translated. + +## Message schemas + +The string + embed format used in `allowEmbed` config fields. Canonical reference (v2 / v3 / v4): + +- [V2 schema](https://docs.scnx.xyz/docs/scnx-api/reference/message-schema-v2/) - legacy, still parsed when `_schema` is + absent. +- [V3 schema](https://docs.scnx.xyz/docs/scnx-api/reference/message-schema-v3/) - tag with `"_schema": "v3"`. +- [V4 schema](https://docs.scnx.xyz/docs/scnx-api/reference/message-schema-v4/) - tag with `"_schema": "v4"`. + +## Migration + +- [**Migration**](./migration.md) - upgrading between major bot versions. + +## Validation + +Run `npm run verify-configs` to validate every module's config schema. CI runs this on every PR via +`.github/workflows/verify-configs.yml`. \ No newline at end of file diff --git a/developer-docs/commands.md b/developer-docs/commands.md new file mode 100644 index 00000000..eaf3f946 --- /dev/null +++ b/developer-docs/commands.md @@ -0,0 +1,184 @@ +# Slash Commands + +Commands live in a module's `commands-dir` (typically `commands/`). Each `.js` file is one slash command. The bot +collects all command files and syncs them with Discord at startup. + +## Minimum command + +```js +// modules/example/commands/ping.js +module.exports.config = { + name: 'ping', + description: 'Replies with pong.' +}; + +module.exports.run = async (interaction) => { + await interaction.reply({content: 'Pong!', ephemeral: true}); +}; +``` + +Two exports: + +- **`config`** - the slash command definition Discord registers. `name`, `description`, optional `options`, optional + `defaultMemberPermissions`. +- **`run`** - async function called when a user invokes the command. Receives the `ChatInputCommandInteraction`. + +## Options + +```js +const {ChannelType} = require('discord.js'); + +module.exports.config = { + name: 'archive', + description: 'Archive a channel.', + options: [ + { + type: 'CHANNEL', + name: 'channel', + description: 'Channel to archive.', + required: true, + channelTypes: [ChannelType.GuildText, ChannelType.GuildAnnouncement] + }, + { + type: 'STRING', + name: 'reason', + description: 'Why are you archiving it?', + required: false + } + ] +}; +``` + +Supported `type` strings: `STRING`, `INTEGER`, `BOOLEAN`, `USER`, `CHANNEL`, `ROLE`, `MENTIONABLE`, `NUMBER`, +`ATTACHMENT`, `SUB_COMMAND`, `SUB_COMMAND_GROUP`. (These are mapped to `ApplicationCommandOptionType` internally.) + +Read option values inside `run` with `interaction.options.getString('reason')`, `getChannel('channel', true)`, +`getInteger(...)`, etc. + +## Subcommands + +Use `SUB_COMMAND` options and export a `subcommands` map keyed by subcommand name: + +```js +module.exports.subcommands = { + 'add': async (interaction) => { /* ... */ }, + 'remove': async (interaction) => { /* ... */ }, + 'list': async (interaction) => { /* ... */ } +}; + +module.exports.config = { + name: 'role', + description: 'Manage self-assignable roles.', + options: [ + { + type: 'SUB_COMMAND', + name: 'add', + description: 'Add a role.', + options: [{type: 'ROLE', name: 'role', description: 'Role to add.', required: true}] + }, + { + type: 'SUB_COMMAND', + name: 'remove', + description: 'Remove a role.', + options: [{type: 'ROLE', name: 'role', description: 'Role to remove.', required: true}] + }, + { + type: 'SUB_COMMAND', + name: 'list', + description: 'List configured roles.' + } + ] +}; +``` + +When `subcommands` is exported, the loader dispatches to the matching key automatically - you don't need a top-level +`run`. (You may still export `run` as a fallback for commands that have both subcommands and a no-subcommand +invocation.) + +## Autocomplete + +For `STRING` / `INTEGER` / `NUMBER` options with `autocomplete: true`, export an `autocomplete` function: + +```js +module.exports.config = { + name: 'play', + description: 'Play a sound.', + options: [ + { + type: 'STRING', + name: 'sound', + description: 'Which sound to play.', + required: true, + autocomplete: true + } + ] +}; + +module.exports.autocomplete = async (interaction) => { + const focused = interaction.options.getFocused(); + const sounds = client.configurations['sounds']['catalog'] + .filter(s => s.name.toLowerCase().includes(focused.toLowerCase())) + .slice(0, 25); + await interaction.respond(sounds.map(s => ({name: s.name, value: s.id}))); +}; +``` + +## Permissions + +Restrict who can use a command at the Discord level with `defaultMemberPermissions`: + +```js +const {PermissionFlagsBits} = require('discord.js'); + +module.exports.config = { + name: 'kick', + description: 'Kick a member.', + defaultMemberPermissions: PermissionFlagsBits.KickMembers.toString(), + options: [/* ... */] +}; +``` + +For finer-grained checks (role-based, configurable per-server), do the check inside `run`: + +```js +module.exports.run = async (interaction) => { + const staffRoles = interaction.client.configurations['my-module']['config']['staffRoles']; + if (!interaction.member.roles.cache.some(r => staffRoles.includes(r.id))) { + return interaction.reply({content: '⚠️ Staff only.', ephemeral: true}); + } + // ... +}; +``` + +## Localization + +Use `localize()` for both descriptions and replies - see [localization.md](./localization.md). Descriptions are +evaluated at command registration time, so they always render in `client.locale`: + +```js +const {localize} = require('../../../src/functions/localize'); + +module.exports.config = { + name: 'help', + description: localize('help', 'command-description') +}; +``` + +## Defer when slow + +Discord requires a response within 3 seconds. If your command does anything slow (database lookups, API calls, file +I/O), defer immediately: + +```js +module.exports.run = async (interaction) => { + await interaction.deferReply({ephemeral: true}); + const result = await someSlowThing(); + await interaction.editReply({content: result}); +}; +``` + +## Where commands are registered + +Commands are registered as **guild commands** for the guild configured in `config/config.json`. Global registration is +not supported - this bot is single-guild by design. Reloading happens automatically at startup; new commands appear +within seconds. To force a re-sync without restart, run `/reload`. \ No newline at end of file diff --git a/developer-docs/config-localization.md b/developer-docs/config-localization.md new file mode 100644 index 00000000..44a69906 --- /dev/null +++ b/developer-docs/config-localization.md @@ -0,0 +1,274 @@ +# Config Localization System + +## Overview + +Configuration files (`config.json`) currently embed all translations inline as localized objects: + +```json +{ + "description": { + "en": "Configure settings", + "de": "Einstellungen konfigurieren" + }, + "humanName": { + "en": "Configuration", + "de": "Konfiguration" + } +} +``` + +The new system moves all non-English translations to external files in `config-localizations/.json`, keeping only +the English value inline as a plain string: + +```json +{ + "description": "Configure settings", + "humanName": "Configuration" +} +``` + +German (and any other language) lives in `config-localizations/de.json`: + +```json +{ + "module-name": { + "config": { + "description": "Einstellungen konfigurieren", + "humanName": "Konfiguration" + } + } +} +``` + +## What gets localized + +| Property | Where it appears | Localized? | +|-----------------------------------|-----------------------------------------------------|-------------------------------| +| `description` | Top-level, fields, params | Yes | +| `humanName` | Top-level, fields | Yes | +| `default` (string/embed types) | Fields with `type: "string"`, `"emoji"`, `"imgURL"` | Yes | +| `default` (all other types) | Booleans, integers, IDs, arrays, selects, keyed | **No** - values are universal | +| `displayName` | Categories, select options with object content | Yes | +| `configElementName` | Top-level (configElements files) | Yes | +| `warningBanner` | Top-level | Yes | +| `commandsWarnings.special[].info` | Top-level | Yes | +| `params[].description` | Inside field params | Yes | +| `links[].label` | Inside field links | Yes | + +### Why some defaults are not localized + +- **Booleans**: `true`/`false` - universal +- **Integers/Floats**: Numbers - universal +- **Colors**: Color names like `"GREEN"`, `"ORANGE"` or hex codes - universal +- **Channel/Role/User IDs**: Discord snowflakes - universal +- **Select values**: The stored value is a code (`"daily"`, `"none"`) - universal. The _display name_ of select options + IS localized separately +- **Arrays of IDs**: Lists of snowflakes - universal +- **Keyed maps**: Key-value maps where keys/values are IDs or numbers - universal +- **Timezones**: Timezone strings like `"Europe/Berlin"` - universal + +## Localization file structure + +``` +config-localizations/ + en.json # English (reference/fallback) + de.json # German + generate-files.js # Extraction script +``` + +Each language file follows this structure: + +```json +{ + "_core": { + "": { + "description": "...", + "humanName": "...", + "content": { + "": { + "humanName": "...", + "description": "...", + "default": "..." + } + } + } + }, + "": { + "": { + "description": "...", + "humanName": "...", + "categories": { + "": { + "displayName": "..." + } + }, + "content": { + "": { + "humanName": "...", + "description": "...", + "default": "...", + "params": { + "": { + "description": "..." + } + }, + "selectOptions": { + "": { + "displayName": "..." + } + } + } + } + } + } +} +``` + +- `_core` contains config-generator files (bot-level config, strings) +- Module names match directory names (`birthday`, `moderation`, `activity-streak`, etc.) +- File keys are filenames without `.json` (`config`, `lockdown`, `strings`, etc.) +- Only keys that have a translation are present - missing keys fall back to English + +## Extraction script + +`config-localizations/generate-files.js` scans all config files and extracts localized objects into per-language files: + +```bash +node config-localizations/generate-files.js +``` + +This regenerates ALL language files from the current config sources. Run it after modifying any config file. + +## Implementation plan + +### Phase 1: Generate localization files (done) + +The `generate-files.js` script extracts all existing translations into `en.json` and `de.json`. + +### Phase 2: Modify configuration loader + +Update `src/functions/configuration.js` to resolve translations from the external files. + +The `checkConfigFile` function needs to be updated so that when it reads a config schema, it checks if a field value is +a plain string (new format) or a localized object (old format for backwards compatibility). If it's a plain string, it +looks up the translation from `config-localizations/.json`. + +Specifically, a new function `resolveLocalization(scope, fileName, fieldPath, value, locale)` should: + +1. If `value` is already a localized object (`{en: ..., de: ...}`), use the old behavior (backwards compatible) +2. If `value` is a plain string/value (new format), look up the translation: + - Load `config-localizations/.json` (cache it) + - Navigate to `[scope][fileName][fieldPath]` + - Return the translated value if found, otherwise return the English value + +This must handle: + +- Top-level `description`, `humanName` +- Field-level `humanName`, `description`, `default` +- `params[].description` +- `categories[].displayName` +- `commandsWarnings.special[].info` +- Select option `displayName` +- `configElementName` +- `warningBanner` +- `links[].label` + +### Phase 3: Convert config files to new format + +Write a second script (`config-localizations/convert-configs.js`) that: + +1. Reads each config JSON file +2. For every localized object (`{en: ..., de: ...}`), replaces it with just the English value +3. Skips `default` on non-string types (they already aren't localized objects for boolean/integer/etc, but some may + have `{en: false}` which should become just `false`) +4. Writes the simplified config file back + +This converts: + +```json +{ + "description": { + "en": "Configure here", + "de": "Hier konfigurieren" + }, + "content": [ + { + "name": "enabled", + "type": "boolean", + "default": { + "en": false + }, + "description": { + "en": "Enable?", + "de": "Aktivieren?" + } + } + ] +} +``` + +To: + +```json +{ + "description": "Configure here", + "content": [ + { + "name": "enabled", + "type": "boolean", + "default": false, + "description": "Enable?" + } + ] +} +``` + +Note: `default: { "en": false }` becomes `default: false` - the `{en: ...}` wrapper is removed for ALL defaults, not +just strings. The localization files only store string defaults, but the config files should be cleaned up uniformly. + +### Phase 4: Update SCNX dashboard integration + +The SCNX dashboard reads config schemas directly. It needs to be updated to: + +1. Load the localization files +2. Apply translations when rendering field labels, descriptions, and defaults +3. Fall back to the inline English value when no translation exists + +### Phase 5: Add translation workflow + +- Add `config-localizations/` to the Weblate translation project +- Translators edit the language JSON files directly +- Running `generate-files.js` is only needed to bootstrap new configs or verify the structure +- New languages are added by creating a new `.json` file following the same structure + +## For module developers + +When writing a new config file, use plain English strings everywhere: + +```json +{ + "description": "Configure the example module", + "humanName": "Configuration", + "filename": "config.json", + "content": [ + { + "name": "logChannel", + "type": "channelID", + "humanName": "Log Channel", + "description": "Channel for log messages.", + "default": "" + }, + { + "name": "welcomeMessage", + "type": "string", + "allowEmbed": true, + "humanName": "Welcome Message", + "description": "Message sent when a user joins.", + "default": "Welcome %user%!" + } + ] +} +``` + +Translations are handled externally. After adding your config, run `node config-localizations/generate-files.js` to add +English entries to `en.json`. Translators will add the other languages. \ No newline at end of file diff --git a/developer-docs/configuration.md b/developer-docs/configuration.md new file mode 100644 index 00000000..bf00cbb3 --- /dev/null +++ b/developer-docs/configuration.md @@ -0,0 +1,566 @@ +# Module Configuration Files + +This guide explains how to write `config.json`, `streamers.json`, etc. - the JSON files in `modules//configs/`that +define a module's settings. The bot reads these to render config editors, validate values, and provide defaults. + +> **Format change.** As of bot v3, config files use **plain English strings** for `humanName`, `description`, defaults, +> etc. The old `{en: "...", de: "..."}` inline-localization format is no longer supported and `npm run verify-configs`will +> reject it. Translations now live in `config-localizations/.json` and are extracted by a separate script. +> See [config-localization.md](./config-localization.md). + +Selected developers can preview how their configuration files render in the SCNX dashboard +at https://scnx.app/developers/configuration after approval. The OSS bot reads the same files - dashboard preview is +optional. + +## File structure + +Every config file has the same top-level shape: + +```json +{ + "filename": "config.json", + "humanName": "Configuration", + "description": "Adjust messages and permissions here.", + "content": [] +} +``` + +| Field | Required | Description | +|---------------|----------|--------------------------------------------------------------------| +| `filename` | Yes | The generated config filename (must match the file's actual name). | +| `humanName` | Yes | Display name shown in the dashboard. | +| `description` | Yes | One-line description shown in the dashboard. | +| `content` | Yes | Array of field definitions (see below). | + +Optional top-level keys: `categories`, `commandsWarnings`, `configElements`, `configElementName`, `warningBanner`, +`hidden`, `skipContentCheck`. Each is documented in its own section below. + +## Field definitions + +Each entry in the `content` array defines one configuration field: + +```json +{ + "name": "staffRoles", + "humanName": "Staff Roles", + "description": "Roles that can manage this module.", + "type": "array", + "content": "roleID", + "default": [] +} +``` + +### Required field properties + +| Property | Description | +|---------------|-------------------------------------------------------------------| +| `name` | Internal key used in code (`moduleConfig.staffRoles`). camelCase. | +| `type` | Data type. See [Field types](#field-types) for the full list. | +| `humanName` | Display name shown in the dashboard. | +| `description` | Sentence explaining what the field does. | +| `default` | Default value. Must match the declared `type`. | + +### Optional field properties + +| Property | Applies to | Description | +|-------------------|--------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `category` | All types | Groups the field under a UI tab (see [Categories](#categories)). | +| `dependsOn` | All types | Only show this field when another named field is truthy. | +| `dependsOnNot` | All types | Only show this field when another named field is falsy. (Opposite of `dependsOn`.) | +| `allowNull` | `channelID`, `roleID`, `userID`, `guildID`, `integer`, `float`, `string` | Allow the field to be empty (`""` or `null`) without failing validation. | +| `allowEmbed` | `string` | Allow the user to configure an embed object instead of plain text. | +| `params` | `string` (with `allowEmbed`) | Document available `%placeholder%` variables (see [Parameters](#parameters)). | +| `content` | `array`, `keyed`, `select`, `channelID` | Sub-type, options, or allowed channel types (meaning depends on parent type). For `channelID`, an array of channel-type identifiers (see `channelID` below). | +| `maxValue` | `integer`, `float` | Maximum allowed numeric value. | +| `minValue` | `integer`, `float` | Minimum allowed numeric value. | +| `maxLength` | `array`, `string` | Maximum number of items (array) or characters (string). | +| `disableKeyEdits` | `keyed` | Prevent users from adding/removing keys; only existing values are editable. | +| `optional` | `string` | Field can be skipped without being explicitly null. | +| `links` | All types | Help links shown next to the field. Format: `[{"label": "...", "url": "..."}]`. | +| `hidden` | All types | Hide the field from the dashboard UI. The value is still loaded - useful for migration shims. | +| `elementToggle` | `boolean` (inside `configElements: true`) | Marks this field as the per-element enable toggle. **Only one allowed per file.** | + +## Field types + +The verifier accepts these `type` values: + +`string`, `emoji`, `imgURL`, `timezone`, `boolean`, `integer`, `float`, `channelID`, `roleID`, `userID`, `guildID`, +`array`, `keyed`, `select`. + +### `string` + +A text field. Set `allowEmbed: true` to also accept an embed object. + +```json +{ + "name": "welcomeMessage", + "humanName": "Welcome message", + "description": "Sent in the welcome channel when someone joins.", + "type": "string", + "allowEmbed": true, + "default": { + "title": "Welcome!", + "description": "Hello %user%" + }, + "params": [ + {"name": "user", "description": "Mention of the new member."} + ] +} +``` + +When `allowEmbed` is true, the value can be a plain string or an embed object. Embed schemas v2/v3/v4 are all +supported - tag v3/v4 explicitly with `"_schema": "v3"` (or `"v4"`). +Reference: [v2](https://docs.scnx.xyz/docs/scnx-api/reference/message-schema-v2/), [v3](https://docs.scnx.xyz/docs/scnx-api/reference/message-schema-v3/), [v4](https://docs.scnx.xyz/docs/scnx-api/reference/message-schema-v4/). + +### `emoji` + +Unicode or custom Discord emoji. + +```json +{ + "name": "starEmoji", + "humanName": "Star emoji", + "description": "Emoji used for the starboard reaction.", + "type": "emoji", + "default": "⭐" +} +``` + +### `imgURL` + +A URL pointing at an image. Treated as a string at runtime, but the dashboard renders an image picker. + +```json +{ + "name": "logo", + "humanName": "Logo", + "description": "URL of the server logo (used in welcome embeds).", + "type": "imgURL", + "default": "" +} +``` + +### `timezone` + +A timezone name like `Europe/Berlin`. Stored as a string; validate with a library (e.g. `Intl.DateTimeFormat`) before +using. + +```json +{ + "name": "guildTimezone", + "humanName": "Server timezone", + "description": "Used for daily reset jobs and date formatting.", + "type": "timezone", + "default": "UTC" +} +``` + +### `boolean` + +```json +{ + "name": "enabled", + "humanName": "Enabled", + "description": "Toggle the module on or off.", + "type": "boolean", + "default": false +} +``` + +### `integer` / `float` + +Numeric fields. Use `minValue` and `maxValue` to constrain the range. + +```json +{ + "name": "cooldownSeconds", + "humanName": "Cooldown (seconds)", + "description": "Minimum time between uses.", + "type": "integer", + "default": 60, + "minValue": 0, + "maxValue": 3600 +} +``` + +### `channelID` + +A channel picker. Use `content` to restrict to specific channel kinds. Without `content`, all common types are accepted. + +```json +{ + "name": "logChannel", + "humanName": "Log channel", + "description": "Channel for log messages.", + "type": "channelID", + "content": ["GUILD_TEXT", "GUILD_NEWS"], + "default": "", + "allowNull": true +} +``` + +Valid channel-type identifiers: `GUILD_TEXT`, `GUILD_VOICE`, `GUILD_CATEGORY`, `GUILD_NEWS` (announcement channels), +`GUILD_STAGE_VOICE`, `GUILD_FORUM`, `GUILD_MEDIA`, `GUILD_NEWS_THREAD`, `GUILD_PUBLIC_THREAD`, `GUILD_PRIVATE_THREAD`. + +### `roleID` + +A role picker. + +```json +{ + "name": "moderatorRole", + "humanName": "Moderator role", + "description": "Role granted access to moderation commands.", + "type": "roleID", + "default": "" +} +``` + +### `userID` + +A user picker. + +```json +{ + "name": "owner", + "humanName": "Bot owner", + "description": "User who receives critical alerts.", + "type": "userID", + "default": "" +} +``` + +### `guildID` + +A Discord guild ID. Use this for cross-guild references (e.g. emoji from another server). + +```json +{ + "name": "emojiGuild", + "humanName": "Emoji guild", + "description": "Server where custom emojis are stored.", + "type": "guildID", + "default": "" +} +``` + +### `array` + +A list of values. The `content` property defines the type of each item. + +```json +{ + "name": "adminRoles", + "humanName": "Admin roles", + "description": "Roles allowed to use admin commands.", + "type": "array", + "content": "roleID", + "default": [] +} +``` + +Valid `content` values: any scalar type (`roleID`, `channelID`, `userID`, `guildID`, `string`, `integer`, `emoji`, ...). +Use `maxLength` to limit the number of items. + +### `select` + +A dropdown. The `content` property defines the options. + +**Simple string options** (the stored value equals the displayed label): + +```json +{ + "name": "streakPeriod", + "humanName": "Streak period", + "description": "How often streak progress resets.", + "type": "select", + "content": ["daily", "weekly", "monthly"], + "default": "daily" +} +``` + +**Labeled options** (stored value differs from the label): + +```json +{ + "name": "curveType", + "humanName": "XP curve", + "description": "Formula used to calculate level requirements.", + "type": "select", + "content": [ + {"value": "LINEAR", "displayName": "Linear (default)"}, + {"value": "EXPONENTIAL", "displayName": "Exponential"}, + {"value": "CUSTOM", "displayName": "Custom formula"} + ], + "default": "LINEAR" +} +``` + +### `keyed` + +A key/value map. The `content` property defines the key and value types. + +```json +{ + "name": "rewardRoles", + "humanName": "Level reward roles", + "description": "Roles granted at specific levels.", + "type": "keyed", + "content": {"key": "integer", "value": "roleID"}, + "default": {} +} +``` + +Common combinations: + +| Key type | Value type | Use case | +|-------------|------------|--------------------------------------| +| `integer` | `roleID` | Level reward roles, milestone roles. | +| `roleID` | `float` | XP multiplier per role. | +| `channelID` | `float` | XP multiplier per channel. | +| `channelID` | `string` | Auto-react emojis per channel. | +| `roleID` | `string` | Descriptions per role. | + +Use `disableKeyEdits: true` when the keys are fixed and users should only edit values. + +## Categories + +Categories group fields into tabs in the dashboard. Without categories, all fields appear in a single list. + +```json +{ + "categories": [ + {"id": "general", "icon": "fas fa-gears", "displayName": "General"}, + {"id": "messages", "icon": "fas fa-comment", "displayName": "Messages"}, + {"id": "roles", "icon": "fas fa-user-shield", "displayName": "Roles & Permissions"} + ], + "content": [ + { + "name": "staffRoles", + "humanName": "Staff roles", + "description": "Roles that can manage this module.", + "type": "array", + "content": "roleID", + "category": "roles", + "default": [] + } + ] +} +``` + +| Property | Description | +|---------------|-----------------------------------------------------------------------------------| +| `id` | Internal identifier referenced by fields via `category: ""`. | +| `icon` | FontAwesome class. Browse and request icons at https://scnx.app/developers/icons. | +| `displayName` | Tab label. | + +Fields without a `category` appear in an uncategorized section. Use categories when your config has 7+ fields or +distinct logical groups; below that, a flat list is cleaner. + +## Conditional fields + +Use `dependsOn` to show a field only when another field is truthy: + +```json +[ + {"name": "enableCooldown", "humanName": "Enable cooldown", "description": "...", "type": "boolean", "default": false}, + {"name": "cooldownDuration", "humanName": "Cooldown (seconds)", "description": "...", "type": "integer", "default": 60, "dependsOn": "enableCooldown"} +] +``` + +`dependsOn` works with: + +- **Boolean fields** - shown when the boolean is `true`. +- **Select fields** - shown when the select is not `""` or `"none"`. + +`dependsOnNot` is the inverse - show the field when the named field is falsy. + +You can chain dependencies: A enables B which enables C. + +## Parameters + +For `string` fields with `allowEmbed: true`, document available `%placeholder%` variables with `params`: + +```json +{ + "name": "endMessage", + "humanName": "End message", + "description": "Posted when the game ends.", + "type": "string", + "allowEmbed": true, + "default": "Congrats %winner%, the number was %number%!", + "params": [ + {"name": "winner", "description": "Mention of the winner."}, + {"name": "number", "description": "The winning number."} + ] +} +``` + +In code, use `embedType()` from `src/functions/helpers.js` to substitute placeholders: + +```js +const {embedType} = require('../../../src/functions/helpers'); + +channel.send(embedType(moduleConfig.endMessage, { + '%winner%': member.toString(), + '%number%': game.number +})); +``` + +Param entries can also have: + +- `isImage: true` - the user can route this param into an embed `image`, `thumbnail`, `author.img`, or `footerImgUrl` + slot. +- `fieldValue: ""` - on a parent `select` field, the param is only available when the select equals this + value. + +## Config elements + +For configs where users create multiple instances of the same schema (ticket categories, team list entries, streamer +entries, ...), set `configElements: true` at the top level: + +```json +{ + "filename": "categories.json", + "humanName": "Ticket categories", + "description": "One entry per ticket category.", + "configElements": true, + "configElementName": {"one": "Ticket Category", "more": "Ticket Categories"}, + "content": [ + {"name": "channelID", "humanName": "Channel", "description": "Where new tickets are opened.", "type": "channelID", "default": ""}, + {"name": "enabled", "humanName": "Enabled", "description": "Toggle this category.", "type": "boolean", "default": true, "elementToggle": true}, + {"name": "message", "humanName": "Initial message", "description": "Sent when a ticket is created.", "type": "string", "allowEmbed": true, "default": "Hello!"} + ] +} +``` + +| Property | Description | +|---------------------|----------------------------------------------------------------------------------------| +| `configElements` | `true` to enable multi-element mode. The stored value is an array of objects. | +| `configElementName` | Singular/plural labels for the dashboard. `{one: "...", more: "..."}`. | +| `elementToggle` | On a single boolean field inside `content`, marks it as the per-element on/off toggle. | + +Add a new element from the CLI: `node add-config-element-object.js `. + +## Commands warnings + +Use `commandsWarnings` to tell users which slash commands need manual permission setup in their server settings: + +```json +{ + "commandsWarnings": { + "normal": ["/manage-levels"], + "special": [ + {"name": "/moderate", "info": "Each moderator needs explicit permission for this command in server settings."} + ] + } +} +``` + +- `normal` - simple list of command names that need permission configuration. +- `special` - commands that need additional explanation beyond just setting permissions. + +## Other top-level properties + +| Property | Description | +|--------------------|--------------------------------------------------------------------------------------------| +| `warningBanner` | Warning banner shown prominently at the top of the dashboard config page. | +| `hidden` | `true` to hide the entire file from the dashboard UI. Useful for credentials-only configs. | +| `skipContentCheck` | `true` to skip default-value normalization for this file. Use when the schema is dynamic. | + +## Validating + +Run `npm run verify-configs` to check every config file in the repo against this schema. CI runs the same script on +every PR via `.github/workflows/verify-configs.yml`. The script catches: + +- Missing required properties (`name`, `type`, `default`). +- Type mismatches between `type` and `default`. +- Unknown `type` values. +- `dependsOn` / `dependsOnNot` referencing non-existent fields. +- Multiple `elementToggle` fields in the same file. +- Duplicate field names. +- Defaults still using the deprecated localized format. +- Embed defaults that look like v3 messages but are missing `"_schema": "v3"`. + +## Full example + +```json +{ + "filename": "config.json", + "humanName": "Configuration", + "description": "Configure the example module.", + "commandsWarnings": { + "normal": [ + "/example" + ] + }, + "categories": [ + { + "id": "general", + "icon": "fas fa-gears", + "displayName": "General" + }, + { + "id": "messages", + "icon": "fas fa-comment", + "displayName": "Messages" + } + ], + "content": [ + { + "name": "enabled", + "humanName": "Enable module?", + "description": "Toggle this module on or off.", + "type": "boolean", + "category": "general", + "default": false + }, + { + "name": "logChannel", + "humanName": "Log channel", + "description": "Channel for log messages. Leave empty to disable.", + "type": "channelID", + "content": [ + "GUILD_TEXT" + ], + "category": "general", + "allowNull": true, + "dependsOn": "enabled", + "default": "" + }, + { + "name": "notificationMessage", + "humanName": "Notification message", + "description": "Sent when a user triggers the module.", + "type": "string", + "allowEmbed": true, + "category": "messages", + "dependsOn": "enabled", + "default": { + "title": "Notification", + "description": "Hello %user%!" + }, + "params": [ + { + "name": "user", + "description": "Mention of the user." + } + ] + } + ] +} +``` + +## Accessing config values in code + +Config values are available at runtime via `client.configurations`: + +```js +const moduleConfig = client.configurations['your-module']['config']; +const logChannel = moduleConfig.logChannel; +const isEnabled = moduleConfig.enabled; +``` + +The key under `client.configurations[moduleName]` is the config filename without `.json`. `configs/config.json` becomes +`client.configurations['your-module']['config']`; `configs/streamers.json` becomes +`client.configurations['your-module']['streamers']`. \ No newline at end of file diff --git a/developer-docs/database-models.md b/developer-docs/database-models.md new file mode 100644 index 00000000..5cde1376 --- /dev/null +++ b/developer-docs/database-models.md @@ -0,0 +1,101 @@ +# Database Models + +The bot uses [Sequelize](https://sequelize.org/) for persistence. The default driver is SQLite (`sqlite3` package), but +any Sequelize-supported database works. Each module declares its own models in `models-dir` (typically `models/`). + +## Defining a model + +A model file exports a class extending `Model` with a static `init(sequelize)` method: + +```js +// modules/welcomer/models/User.js +const {DataTypes, Model} = require('sequelize'); + +module.exports = class WelcomerUser extends Model { + static init(sequelize) { + return super.init({ + id: { + autoIncrement: true, + type: DataTypes.INTEGER, + primaryKey: true + }, + userID: DataTypes.STRING, + channelID: DataTypes.STRING, + messageID: DataTypes.STRING, + timestamp: DataTypes.DATE + }, { + tableName: 'welcomer_User', + timestamps: true, + sequelize + }); + } +}; +``` + +The loader calls `init(sequelize)` for you and registers the model under `client.models[][]`. The +filename without `.js` becomes the key - `User.js` → `client.models['welcomer']['User']`. + +### Conventions + +- **`tableName`**: prefix with the module name, e.g. `welcomer_User`, to avoid collisions across modules. +- **`timestamps: true`** adds `createdAt` and `updatedAt` automatically. Skip if you don't need them. +- **Primary key**: an auto-incrementing `id` is the simplest choice. Use a composite key only when you need it. +- **Class name**: doesn't have to match the filename, but matching keeps stack traces readable. Prefix with the module + if you have multiple modules with similarly-named models (e.g. `WelcomerUser` not just `User`). + +## Using models in handlers + +Models are available on `client.models` after the bot starts: + +```js +// modules/welcomer/events/guildMemberAdd.js +module.exports.run = async (client, member) => { + const User = client.models['welcomer']['User']; + await User.create({ + userID: member.id, + channelID: '...', + messageID: '...', + timestamp: new Date() + }); +}; +``` + +All standard Sequelize methods are available: `findOne`, `findAll`, `findOrCreate`, `update`, `destroy`, `count`, +`bulkCreate`, etc. + +## Migrations + +The bot calls `sequelize.sync()` at startup, which creates missing tables and adds missing columns automatically. **It +does not modify or remove existing columns.** If you change a column's type, rename it, or drop it, you have two +options: + +1. **Manual migration.** Use Sequelize's [umzug](https://github.com/sequelize/umzug) or write SQL by hand. Drop the + bot's table or run `ALTER TABLE` against your database. +2. **Bump the table name.** For breaking schema changes, rename `tableName` (e.g. `welcomer_User_v2`). The old table + stays in place for safety; you migrate data on the side. + +For non-trivial migrations across versions, the bot exposes `module.exports.migrationStart()` / `migrationEnd()` from +`main.js` - call these around long-running migration code so SIGTERM/SIGINT defers shutdown until the migration +finishes. + +## Associations + +Define associations from the module's `botReady` handler, after every model has been initialized: + +```js +// modules/example/events/botReady.js +module.exports.run = (client) => { + const A = client.models['example']['A']; + const B = client.models['example']['B']; + A.hasMany(B, {foreignKey: 'aId'}); + B.belongsTo(A, {foreignKey: 'aId'}); +}; +module.exports.ignoreBotReadyCheck = true; +``` + +## Performance notes + +- Use `attributes: ['col1', 'col2']` to limit returned columns on hot paths. +- Index columns you query on with `indexes: [{fields: ['userID']}]` in the second argument of `super.init`. +- Batch inserts with `bulkCreate` instead of looping `create`. +- For SQLite, write-heavy workloads benefit from `sequelize.transaction()` around batches. \ No newline at end of file diff --git a/developer-docs/events.md b/developer-docs/events.md new file mode 100644 index 00000000..69cc941f --- /dev/null +++ b/developer-docs/events.md @@ -0,0 +1,88 @@ +# Events + +Event handlers live in a module's `events-dir` (typically `events/`). The filename - without the `.js` extension - is +the event name. Discord.js events, custom client events, and submodule events are all handled the same way. + +## Handler shape + +```js +// modules/example/events/messageCreate.js +module.exports.run = async (client, message) => { + if (message.author.bot) return; + // ... +}; +``` + +A handler exports `run`. The bot calls it with `(client, ...args)` where `args` are whatever the underlying event emits. +For `messageCreate` that's a `Message`; for `guildMemberAdd` that's a `GuildMember`; for `voiceStateUpdate` that's +`(oldState, newState)`. + +The filename `messageCreate.js` registers a listener for the `messageCreate` event. You can have one file per event per +module - multiple modules can listen to the same event, and they will all run. + +## Lifecycle flags + +Three optional exports control when your handler runs: + +```js +module.exports.run = async (client, ...args) => { /* ... */ }; +module.exports.ignoreBotReadyCheck = true; // run before bot is fully ready (rare - usually leave false) +module.exports.allowPartial = true; // accept partial Discord structures (e.g. uncached messages) +``` + +Default behavior: + +- **`botReadyAt` gate**: handlers are skipped silently until `client.botReadyAt` is set (i.e. until config is loaded and + the guild is fetched). This prevents your code from running against half-initialized state. Set + `ignoreBotReadyCheck = true` only if you need to react to events during startup itself. +- **Partial gate**: if any argument is a partial structure (for example, a `messageDelete` for an uncached message), the + handler is skipped unless `allowPartial = true`. Set this when you can handle partials gracefully - for example, by + checking `if (message.partial) return;` early. + +## Errors + +Handler errors are caught by the loader and logged via `client.logger.error`. If Sentry is configured (SCNX builds), the +error is also reported. You don't need a top-level try/catch for safety - but you should still catch errors at +meaningful boundaries to log useful context. + +## Custom client events + +The bot emits its own events. Listen to them like any Discord event by naming your file accordingly: + +| Event | When it fires | File name | +|----------------|----------------------------------------------------------------------------------------------------------------------------------------------|-------------------| +| `botReady` | After config and commands have loaded, the guild has been fetched, and the bot is fully online. | `botReady.js` | +| `configReload` | After `config.json` and module configs have been (re-)loaded - including via `/reload`. Use this to invalidate caches that depend on config. | `configReload.js` | + +Example: invalidate a cached compiled formula when the user edits the formula in their config: + +```js +// modules/levels/events/configReload.js +module.exports.run = (client) => { + client.cache = client.cache || {}; + delete client.cache.levelFormula; +}; +module.exports.ignoreBotReadyCheck = true; +``` + +## Common Discord events used in this codebase + +| Event | Args | Typical use | +|---------------------|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------| +| `messageCreate` | `(message)` | Reactions, counter modules, AFK pings. | +| `messageDelete` | `(message)` (often partial - set `allowPartial`) | Anti-ghostping, sticky messages. | +| `messageUpdate` | `(oldMessage, newMessage)` | Edit logging, anti-ghostping. | +| `guildMemberAdd` | `(member)` | Welcomers, auto-roles, captcha. | +| `guildMemberRemove` | `(member)` | Goodbye messages, cleanup. | +| `guildMemberUpdate` | `(oldMember, newMember)` | Boost detection, role-driven side effects. | +| `interactionCreate` | `(interaction)` | Button/select-menu/modal handlers within a module. (Slash commands are handled separately - see [commands.md](./commands.md).) | +| `voiceStateUpdate` | `(oldState, newState)` | VC pings, temp channels, channel-stats. | +| `channelDelete` | `(channel)` | Cleanup of channel-bound config. | + +For the full list of Discord.js events, see +the [discord.js docs](https://discord.js.org/docs/packages/discord.js/14.26.2/Client:Class). + +## Module-disabled handling + +Handlers from a disabled module are not registered. If your handler depends on shared state from another module, check +`client.modules[''].enabled` defensively rather than assuming the model exists. \ No newline at end of file diff --git a/developer-docs/localization.md b/developer-docs/localization.md new file mode 100644 index 00000000..c4cf8c74 --- /dev/null +++ b/developer-docs/localization.md @@ -0,0 +1,64 @@ +# Localization + +The bot has two separate localization systems. Don't confuse them: + +| System | Purpose | Lives in | Authored where | +|--------------------|----------------------------------------------------------------------------------------------|--------------------------------------------|-----------------------------------------------------------------------------------------| +| **Code strings** | User-facing strings emitted by event handlers and slash commands (`localize()` calls in JS). | `locales/en.json`, `locales/de.json`, etc. | Hand-edited by developers. | +| **Config strings** | Field names and descriptions inside config files (`humanName`, `description`). | `config-localizations/en.json`, etc. | Generated from inline strings - see [config-localization.md](./config-localization.md). | + +This guide covers **code strings**. For config strings, see [config-localization.md](./config-localization.md). + +## Adding a string + +Strings are namespaced by module. Open `locales/en.json` and add a top-level key matching your module name (or extend an +existing one): + +```json +{ + "hello-world": { + "welcome": "Welcome %u to the server!", + "channel-not-found": "Configured welcome channel %c does not exist." + } +} +``` + +Then call `localize(namespace, key, params?)`: + +```js +const {localize} = require('../../../src/functions/localize'); + +await channel.send(localize('hello-world', 'welcome', {u: member.toString()})); +client.logger.error(localize('hello-world', 'channel-not-found', {c: channelID})); +``` + +`%u` and `%c` are placeholders - `localize()` substitutes them from the third argument (`{u: ..., c: ...}`). +Placeholders are arbitrary single-letter or short identifiers; pick whatever reads well in the source string. + +## Other languages + +This repository ships only `en.json` actively maintained. Translations for German, French, etc. exist in +`locales/.json` and are managed externally via Weblate. **Do not edit non-English locale files in this repository. +** Add new keys only to `en.json`; translations will follow. + +## Behavior at runtime + +`client.locale` is set from `--lang=` on the command line, defaulting to `en`. `localize()` looks up +`client.locale` first; if the key is missing, it falls back to `en`; if still missing, it returns the key itself so +missing translations are visible rather than silently empty. + +## Common mistakes + +- **Don't hard-code English strings in code.** Even one-off log messages should go through `localize()` so + other-language operators get readable logs. +- **Don't reuse a key across namespaces.** `localize('moderation', 'banned')` and `localize('admin-tools', 'banned')` + are independent - translators see them in separate contexts. +- **Don't dynamically build the namespace or key from user input.** That breaks translation tooling and creates + security/typo footguns. +- **Don't add keys for modules other than your own.** Each module owns its namespace. + +## Validation + +`npm run verify-configs` validates config schemas but does not currently lint `locales/*.json` for missing keys. If you +reference a key that doesn't exist, `localize()` returns the literal `.` string at runtime - easy to +spot in logs, but won't fail CI. \ No newline at end of file diff --git a/developer-docs/migration.md b/developer-docs/migration.md new file mode 100644 index 00000000..f9e49295 --- /dev/null +++ b/developer-docs/migration.md @@ -0,0 +1,351 @@ +# Database Migrations + +This guide explains how to write safe database migrations for CustomDCBot modules. + +## Why migrations are needed + +Sequelize's `db.sync()` (called in `main.js` at startup) creates tables that don't exist, but it **does not** add new +columns to existing tables. If you add a new field to a model, existing databases will be missing that column and +queries will fail. + +Migrations solve this by reading existing data, recreating the table with the new schema, and re-inserting the data. + +## Where migrations run + +Migrations go in your module's `events/botReady.js`, at the top of the `run` function - before any other logic. + +## The DatabaseSchemeVersion table + +Every migration is tracked using the shared `DatabaseSchemeVersion` model. Before running a migration, check if it has +already been applied: + +```js +const dbVersion = await client.models['DatabaseSchemeVersion'].findOne({ + where: { + model: 'your-module_YourModel', + version: 'V1' + } +}); +if (!dbVersion) { + // Run migration +} +``` + +After the migration completes, mark it as done: + +```js +await client.models['DatabaseSchemeVersion'].create({ + model: 'your-module_YourModel', + version: 'V1' +}); +``` + +The naming convention for `model` is `moduleName_ModelName` (e.g. `birthday_User`, `activity-streak_StreakUser`). + +## Migration pattern + +```js +const { + migrationStart, + migrationEnd +} = require('../../../main'); + +module.exports.run = async function (client) { + const dbVersion = await client.models['DatabaseSchemeVersion'].findOne({ + where: {model: 'your-module_YourModel'} + }); + if (!dbVersion) { + migrationStart(); + try { + client.logger.info('[your-module] Running V1 migration (adding newField)...'); + + // 1. Read existing data with EXPLICIT attributes (only columns that exist pre-migration) + const data = await client.models['your-module']['YourModel'].findAll({ + attributes: ['id', 'existingField1', 'existingField2'] + }); + + // 2. Drop and recreate the table with the new schema + await client.models['your-module']['YourModel'].sync({force: true}); + + // 3. Re-insert all data with the new field's default value + for (const row of data) { + await client.models['your-module']['YourModel'].create({ + id: row.id, + existingField1: row.existingField1, + existingField2: row.existingField2, + newField: false // default value for the new column + }); + } + + client.logger.info('[your-module] V1 migration complete.'); + await client.models['DatabaseSchemeVersion'].create({ + model: 'your-module_YourModel', + version: 'V1' + }); + } finally { + migrationEnd(); + } + } + + // ... rest of your botReady logic +}; +``` + +## Critical rules + +### Always use explicit attributes in findAll + +```js +// WRONG - will try to SELECT the new column that doesn't exist yet +const data = await client.models['your-module']['YourModel'].findAll(); + +// CORRECT - only selects columns that exist in the pre-migration table +const data = await client.models['your-module']['YourModel'].findAll({ + attributes: ['id', 'existingField1', 'existingField2'] +}); +``` + +Your model already defines the new field, so Sequelize will include it in the `SELECT` statement by default. Since the +column doesn't exist in the database yet, the query will crash. Always list only the columns that exist **before** your +migration. + +### Always wrap migrations in migrationStart/migrationEnd + +```js +const { + migrationStart, + migrationEnd +} = require('../../../main'); +``` + +Call `migrationStart()` before the migration begins and `migrationEnd()` when it finishes. **Always** use `try/finally` +to ensure `migrationEnd()` runs even if the migration throws an error. This prevents the bot from shutting down +mid-migration (which would cause data loss since `sync({force: true})` drops the table before recreating it). + +### Always re-insert with explicit field mapping + +```js +// WRONG - may carry over unexpected fields or miss the new default +await client.models['your-module']['YourModel'].create(row); + +// CORRECT - explicit mapping with new field default +await client.models['your-module']['YourModel'].create({ + id: row.id, + existingField1: row.existingField1, + newField: false +}); +``` + +### Mark the migration version after all data is re-inserted + +The `DatabaseSchemeVersion` entry should be created **after** all data has been successfully migrated. If the migration +fails halfway, it will re-run on next startup (which is safe since it checks the version first). + +## Multiple migrations + +Migrations stack sequentially. Each one runs in order and assumes all previous migrations have already been applied. +This matters for which columns you list in `attributes`. + +### Adding a second migration later + +When a new release needs another schema change, add a new migration block **after** the existing one: + +```js +// V1 migration (existing - added "hidden" field) +const dbVersion = await client.models['DatabaseSchemeVersion'].findOne({ + where: {model: 'your-module_YourModel'} +}); +if (!dbVersion) { + migrationStart(); + try { + const data = await client.models['your-module']['YourModel'].findAll({ + attributes: ['id', 'existingField1', 'existingField2'] + }); + await client.models['your-module']['YourModel'].sync({force: true}); + for (const row of data) { + await client.models['your-module']['YourModel'].create({ + id: row.id, + existingField1: row.existingField1, + existingField2: row.existingField2, + hidden: false + }); + } + await client.models['DatabaseSchemeVersion'].create({ + model: 'your-module_YourModel', + version: 'V1' + }); + } finally { + migrationEnd(); + } +} + +// V2 migration (new - added "priority" field) +const dbVersionV2 = await client.models['DatabaseSchemeVersion'].findOne({ + where: { + model: 'your-module_YourModel', + version: 'V2' + } +}); +if (!dbVersionV2) { + migrationStart(); + try { + // V1 has already run, so "hidden" exists in the table now + const data = await client.models['your-module']['YourModel'].findAll({ + attributes: ['id', 'existingField1', 'existingField2', 'hidden'] + }); + await client.models['your-module']['YourModel'].sync({force: true}); + for (const row of data) { + await client.models['your-module']['YourModel'].create({ + id: row.id, + existingField1: row.existingField1, + existingField2: row.existingField2, + hidden: row.hidden, + priority: 0 + }); + } + await client.models['DatabaseSchemeVersion'].upsert({ + model: 'your-module_YourModel', + version: 'V2' + }); + } finally { + migrationEnd(); + } +} +``` + +V2's `attributes` includes `hidden` because V1 has already added it by the time V2 runs. + +### Adding multiple fields in a single release + +If you're adding multiple new fields at the same time (e.g. both `hidden` and `priority` in the same release), you only +need **one** migration. Don't create separate migrations for each field - just handle them all in one version bump: + +```js +const dbVersion = await client.models['DatabaseSchemeVersion'].findOne({ + where: {model: 'your-module_YourModel'} +}); +if (!dbVersion) { + migrationStart(); + try { + const data = await client.models['your-module']['YourModel'].findAll({ + attributes: ['id', 'existingField1', 'existingField2'] + }); + await client.models['your-module']['YourModel'].sync({force: true}); + for (const row of data) { + await client.models['your-module']['YourModel'].create({ + id: row.id, + existingField1: row.existingField1, + existingField2: row.existingField2, + hidden: false, // new field 1 + priority: 0 // new field 2 + }); + } + await client.models['DatabaseSchemeVersion'].create({ + model: 'your-module_YourModel', + version: 'V1' + }); + } finally { + migrationEnd(); + } +} +``` + +### Fresh installs vs. existing databases + +On a fresh install (no existing database), `db.sync()` in `main.js` creates all tables with all columns from the model +definition. The migration check finds no existing rows and no `DatabaseSchemeVersion` entry. The migration runs but +`findAll` returns an empty array, so it effectively just creates the version entry. This is fine - the migration is a +no-op on empty tables. + +### Removing or renaming fields + +If you need to **remove** a column, the same pattern works - just don't include the removed field in the re-insert step. +The `sync({force: true})` recreates the table from the model definition (which no longer has the field), so the column +disappears. + +If you need to **rename** a column, read the old column name in `attributes` and write to the new column name during +re-insert: + +```js +const data = await client.models['your-module']['YourModel'].findAll({ + attributes: ['id', 'oldFieldName'] +}); +await client.models['your-module']['YourModel'].sync({force: true}); +for (const row of data) { + await client.models['your-module']['YourModel'].create({ + id: row.id, + newFieldName: row.oldFieldName // renamed + }); +} +``` + +### Changing a field's type + +Same approach - read the old data, recreate the table, convert during re-insert: + +```js +const data = await client.models['your-module']['YourModel'].findAll({ + attributes: ['id', 'count'] // was STRING, now INTEGER +}); +await client.models['your-module']['YourModel'].sync({force: true}); +for (const row of data) { + await client.models['your-module']['YourModel'].create({ + id: row.id, + count: parseInt(row.count, 10) || 0 + }); +} +``` + +### Multiple models in one module + +If your module has multiple models that both need migrations, run them independently with separate version keys: + +```js +// Model A migration +const dbVersionA = await client.models['DatabaseSchemeVersion'].findOne({ + where: {model: 'your-module_ModelA'} +}); +if (!dbVersionA) { + migrationStart(); + try { + // ... migrate ModelA ... + await client.models['DatabaseSchemeVersion'].create({ + model: 'your-module_ModelA', + version: 'V1' + }); + } finally { + migrationEnd(); + } +} + +// Model B migration +const dbVersionB = await client.models['DatabaseSchemeVersion'].findOne({ + where: {model: 'your-module_ModelB'} +}); +if (!dbVersionB) { + migrationStart(); + try { + // ... migrate ModelB ... + await client.models['DatabaseSchemeVersion'].create({ + model: 'your-module_ModelB', + version: 'V1' + }); + } finally { + migrationEnd(); + } +} +``` + +Each model tracks its own version independently. They don't need to share version numbers. + +## Checklist + +Before submitting a migration: + +- [ ] `findAll` uses explicit `attributes` listing only pre-migration columns +- [ ] Migration is wrapped in `migrationStart()` / `migrationEnd()` with `try/finally` +- [ ] New fields are explicitly set with their default value during re-insert +- [ ] `DatabaseSchemeVersion` entry is created **after** all data is re-inserted +- [ ] Version string follows the pattern `V1`, `V2`, etc. +- [ ] Model name follows the pattern `moduleName_ModelName` +- [ ] Migration runs at the top of `botReady.js` before any other module logic \ No newline at end of file diff --git a/developer-docs/writing-a-module.md b/developer-docs/writing-a-module.md new file mode 100644 index 00000000..bf1b857b --- /dev/null +++ b/developer-docs/writing-a-module.md @@ -0,0 +1,173 @@ +# Writing a Module + +A module is a self-contained folder under `modules/` that bundles together event handlers, slash commands, database +models, and configuration. The bot discovers and loads modules at startup based on each folder's `module.json`. + +## Minimum file layout + +``` +modules/ + hello-world/ + module.json # required - describes the module + events/ # optional - Discord & custom event handlers + messageCreate.js + commands/ # optional - slash commands + hello.js + models/ # optional - Sequelize models + Greeting.js + configs/ # optional - user-editable config files + config.json +``` + +Only `module.json` is mandatory. Everything else is opt-in via the matching `module.json` field. + +## `module.json` reference + +```json +{ + "name": "hello-world", + "humanReadableName": "Hello World", + "description": "Greets new members.", + "fa-icon": "fas fa-hand-wave", + "author": { + "name": "Your Name", + "link": "https://github.com/your-handle" + }, + "openSourceURL": "https://github.com/ScootKit/CustomDCBot/tree/main/modules/hello-world", + "tags": [ + "fun" + ], + "events-dir": "/events", + "commands-dir": "/commands", + "models-dir": "/models", + "config-example-files": [ + "configs/config.json" + ] +} +``` + +| Field | Required | Purpose | +|------------------------|----------|--------------------------------------------------------------------------------------------------------------| +| `name` | Yes | Internal id. Must match the folder name. Used as the namespace for `localize()` and `client.configurations`. | +| `humanReadableName` | Yes | Display name shown in dashboards and `/help`. | +| `description` | Yes | One-line summary. | +| `fa-icon` | No | FontAwesome class. Browse the supported set at https://scnx.app/developers/icons. | +| `author` | No | `{name, link}` shown in `/help`. `scnxOrgID` is dashboard-specific and ignored otherwise. | +| `openSourceURL` | No | Link to source in `/help`. | +| `tags` | No | Used by the dashboard to group modules. Free-form strings. | +| `events-dir` | No | Folder (relative to the module) scanned for event handlers. Convention: `/events`. | +| `commands-dir` | No | Folder scanned for slash commands. Convention: `/commands`. | +| `models-dir` | No | Folder scanned for Sequelize models. Convention: `/models`. | +| `config-example-files` | No | Paths (relative to the module) of config schema files. See [configuration.md](./configuration.md). | + +If you omit a `*-dir` key, that subsystem is skipped - there's no default. A module with only events doesn't need +`commands-dir`. + +## Lifecycle + +Bot startup, in order: + +1. Read `config/config.json` (the user's main config). +2. Discover modules - read each `module.json`, mark enabled/disabled. +3. Load core models, then each module's models (`models-dir`). +4. Load and validate each module's `config-example-files` against the user's actual config files in + `config//`. +5. Fire `client.emit('configReload')`. +6. Load core events, then each module's events (`events-dir`). +7. Connect to Discord, fetch the configured guild. +8. Load core commands, then each module's commands (`commands-dir`); sync slash commands with Discord. +9. Set `client.botReadyAt = new Date()` and fire `client.emit('botReady')`. + +After `botReadyAt` is set, queued events start firing. Until then, handlers without `ignoreBotReadyCheck = true` are +silently skipped - see [events.md](./events.md). + +## Accessing module state at runtime + +Inside any handler, the `client` object exposes everything the loader registered: + +```js +client.configurations['hello-world']['config'] // parsed configs/config.json +client.models['hello-world']['Greeting'] // Sequelize model class +client.modules['hello-world'] // {enabled, events: [...], ...} +client.guild // the configured guild (set after botReady) +client.logger // log4js logger - use this, not console +``` + +`client.configurations[][]` is keyed by the config filename without `.json`. +`configs/config.json` becomes `client.configurations['hello-world']['config']`; `configs/streamers.json` becomes +`client.configurations['hello-world']['streamers']`. + +## A complete minimal module + +``` +modules/hello-world/ +├── module.json +├── configs/config.json +└── events/guildMemberAdd.js +``` + +`module.json`: + +```json +{ + "name": "hello-world", + "humanReadableName": "Hello World", + "description": "Welcome message in a configured channel.", + "events-dir": "/events", + "config-example-files": [ + "configs/config.json" + ] +} +``` + +`configs/config.json`: + +```json +{ + "filename": "config.json", + "humanName": "Configuration", + "description": "Where to send the welcome message.", + "content": [ + { + "name": "channel", + "humanName": "Welcome channel", + "description": "Channel new members are greeted in.", + "type": "channelID", + "default": "" + } + ] +} +``` + +`events/guildMemberAdd.js`: + +```js +const {localize} = require('../../../src/functions/localize'); + +module.exports.run = async (client, member) => { + const {channel: channelID} = client.configurations['hello-world']['config']; + if (!channelID) return; + const channel = await client.channels.fetch(channelID).catch(() => null); + if (!channel) return; + await channel.send(localize('hello-world', 'welcome', {u: member.toString()})); +}; +``` + +`locales/en.json` (add a top-level key): + +```json +"hello-world": { +"welcome": "Welcome %u to the server!" +} +``` + +That's a working module. Run `npm run verify-configs` to confirm the config schema is valid, then start the bot with +`npm start`. + +## What to read next + +- [Events](./events.md) for handler patterns and the lifecycle gates that decide when your code runs. +- [Slash commands](./commands.md) when your module needs user-invokable commands. +- [Database models](./database-models.md) for persistent state. +- [Localization](./localization.md) for adding user-facing strings. +- [Configuration files](./configuration.md) for the full config schema reference. \ No newline at end of file diff --git a/generate-config.js b/generate-config.js index 656141ca..38dbaf30 100644 --- a/generate-config.js +++ b/generate-config.js @@ -14,12 +14,12 @@ if (args[0]) { try { require(`${confDir}/config.json`); console.error('Seems like you already have an config file! You can start the bot now with "npm start"!'); - process.exit(1); + process.exit(0); } catch (e) { console.log('[INFO] Starting generation...'); exampleFile.content.forEach(async field => { if (!field.name) return; - config[field.name] = field.default.en; + config[field.name] = (typeof field.default === 'object' && field.default !== null && 'en' in field.default) ? field.default.en : field.default; }); if (!fs.existsSync(`${confDir}`)) { diff --git a/locales/en.json b/locales/en.json index a0637a9b..b71ae7f8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,997 +1,1439 @@ { - "main": { - "startup-info": "SCNX-CustomBot v2 - Log-Level: %l", - "missing-moduleconf": "Missing moduleConfig-file. Automatically disabling all modules and overwriting modules.json later", - "sync-db": "Synced database", - "login-error": "Bot could not log in. Error: %e", - "login-error-token": "Bot could not log in because the provided token is invalid. Please update your token.", - "login-error-intents": "Bot could not log in because the intents were not enabled correctly. Please enable \"PRESENCE INTENT\", \"SERVER MEMBERS INTENT\" and \"MESSAGE CONTENT INTENT\" in your Discord-Developer-Dashboard: %url", - "not-invited": "Please invite the bot to your Discord server before continuing: %inv", - "require-code-grant-active": "You might be unable to invite your bot to your server as you have enabled the \"Require public code grant\" option in your Discord Developer Dashboard. Please disable this option: %d", - "interactions-endpoint-active": "You bot will be unable to respond to interactions, because the field \"Interactions Endpoint URL\" has a value in your Discord Developer Dashboard. Please remove any content from this field and restart your bot: %d", - "logged-in": "Bot logged in as %tag and is now online.", - "logchannel-wrong-type": "There is no Log-Channel set or it has the wrong type (only text-channels are supported).", - "config-check-failed": "Configuration-Check failed. You can find more information in your log. The bot exited.", - "bot-ready": "The bot initiated successfully and is now listening to commands", - "no-command-permissions": "Could not update server commands. Please give us permissions to performe this critical action: %inv", - "perm-sync": "Synced permissions for /%c", - "perm-sync-failed": "Failed to synced permissions for /%c: %e", - "loading-module": "Loading module %m", - "hidden-module": "Module %m is hidden, meaning that it is not available. Skipping…", - "module-disabled": "Module %m is disabled", - "command-loaded": "Loaded command %d/%f", - "command-dir": "Loading commands in %d/%f", - "global-command-sync": "Synced global application commands", - "guild-command-sync": "Synced server application commands", - "guild-command-no-sync-required": "Server application commands are up to date - no syncing required", - "global-command-no-sync-required": "Global application commands are up to date - no syncing required", - "event-loaded": "Loaded events %d/%f", - "event-dir": "Loading events in %d/%f", - "model-loaded": "Loaded database model %d/%f", - "model-dir": "Loading database model in %d/%f", - "loaded-cli": "Loaded API-Action %c in %p", - "channel-lock": "Locked channel", - "channel-unlock": "Unlocked channel", - "channel-unlock-data-not-found": "Unlocking channel with ID %c failed because it was never locked (which is weird to begin with).", - "module-disable": "Module %m got disabled because %r", - "migrate-success": "Migration from %o to %m finished successfully.", - "migrate-start": "Migration from %o to %m started... Please do not stop the bot" - }, - "reload": { - "reloading-config": "Reloading configuration…", - "reloading-config-with-name": "User %tag is reloading the configuration…", - "reloaded-config": "Configuration reloaded successfully.\nOut of %totalModules modules, %enabled were enabled, %configDisabled were disabled because their configuration was wrong.", - "reload-failed": "Configuration reloaded failed. Bot shutting down.", - "reload-successful-syncing-commands": "Configuration reloaded successfully, syncing commands, to make sure permissions are up-to-date…", - "reload-failed-message": "**FAILED**\n```%r```\n**Please read your log to find more information**\nThe bot will kill itself now, bye :wave:", - "command-description": "Reloads the configuration" - }, - "config": { - "checking-config": "Checking configurations...", - "done-with-checking": "Done with checking. Out of %totalModules modules, %enabled were enabled, %configDisabled were disabled because their configuration was wrong.", - "creating-file": "Config %m/%f does not exist - I'm going to create it, please stand by...", - "checking-of-field-failed": "An error occurred while checking the content of field \"%fieldName\" in %m/%f", - "saved-file": "Configuration-File %f in %m was saved successfully.", - "moduleconf-regeneration": "Regenerating module configuration, no settings will be overwritten, don't worry.", - "moduleconf-regeneration-success": "Module configuration regeneration successfully finished.", - "channel-not-found": "Channel with ID \"%id\" could not be found", - "user-not-found": "User with ID \"%id\" could not be found", - "channel-not-on-guild": "Channel with ID \"%id\" is not on your server", - "channel-invalid-type": "Channel with ID \"%id\" has a type that can not be used for this field", - "role-not-found": "Role with ID \"%id\" could not be found on your server", - "config-reload": "Reloading all configuration..." - }, - "helpers": { - "timestamp": "%dd.%mm.%yyyy at %hh:%min", - "you-did-not-run-this-command": "You did not run this command. If you want to use the buttons, try running the command yourself.", - "next": "Next", - "back": "Back", - "toggle-data-fetch-error": "SC Network Release: Toggle-Data could not be fetched", - "toggle-data-fetch": "SC Network Release: Toggle-Data fetched successfully" - }, - "command": { - "startup": "The bot is currently starting up. Please try again in a few minutes.", - "not-found": "Command not found", - "used": "%tag (%id) used command /%c", - "message-used": "%tag (%id) used command %p%c", - "execution-failed": "Execution of command /%c %g %s failed (Tracing: %t): %e", - "message-execution-failed": "Execution of command %p%c failed (Tracing: %t): %e", - "wrong-guild": "This command is only available on the server **%g**.", - "autcomplete-execution-failed": "Execution of auto-complete on command /%c %g %s with option %f failed: %e", - "execution-failed-message": "## 🔴 Command execution failed 🔴\nThis usually happens either due to misconfiguration or due to an error on our site. Please send a screenshot of this message in #support on the [ScootKit Discord](https://scootk.it/dc-en), to resolve this.\n\n### Internal Tracing ID\n`%t`\n### Debugging-Information\n```%e```", - "error-giving-role": "An error occurred when trying to give you your roles ):\nPlease ask the server administrators to confirm that the highest role of the bot is above the role that the bot is supposed to assign.", - "description-too-long": "The following command description of %c was too long to sync: %s", - "module-disabled": "This command is part of the \"%m\" which is disabled. This can either be intended by the server-admins (and slash-commands haven't synced yet) or this could be caused by a configuration error. Please check (or ask the admins) to check the bot's configuration and logs for details.", - "command-disabled": "This command is currently disabled by the server configuration. If you believe this is an error, please contact a server administrator." - }, - "help": { - "bot-info-titel": "ℹ️ Bot-Info", - "bot-info-description": "This bot is part of [SCNX](https://scnx.xyz/de?ref=custombot_help_embed), a plattform from [ScootKit](https://scootkit.net) allowing the creation of fully customizable for Discord communities, and is being hosted for \"%g\".", - "stats-title": "📊 Stats", - "stats-content": "Active modules: %am\nRegistered commands: %rc\nBot-Version: %v\nRunning on server %si\n[Server-Plan](https://scnx.xyz/plan): %pl\nLast restart: %lr\nLast reload: %lR", - "command-description": "Show every commands", - "slash-commands-title": "Slash-Commands", - "select-module-placeholder": "Select a module to view its commands", - "select-module-hint": "👇 Use the dropdown below to browse commands by module.", - "back-to-overview": "Back to overview", - "modules-overview": "📋 Modules & Commands", - "built-in-description": "Core commands built into the bot" - }, - "bot-feedback": { - "command-description": "Send feedback about the bot to the bot developer", - "submitted-successfully": "Thanks so much for your feedback! It has been carefully recorded and our team will review it soon. If we have any questions, we may contact you via DM (or if you are on our [Support Server]() we'll open a ticket). Thank you for making [CustomBots]() better for everyone <3\n\nYour feedback is subject to our [Terms of service]() and [Privacy Policy]().", - "failed-to-submit": "Sorry, but I couldn't send your feedback to our staff. This could be, because you got blocked or because of some server issue we are having. You can always report bugs and submit feedback in our [Feature-Board](https://features.sc-network.net). Thank you.", - "feedback-description": "Your feedback. Make sure it's neutral, constructive and helpful" - }, - "admin-tools": { - "position": "%i has the position %p.", - "position-changed": "Changed %i's position to %p.", - "category-can-not-have-category": "A Category can not have a category", - "not-category": "Can not change category of channel to a not category channel", - "changed-category": "%c's category got set to %cat", - "command-description": "Execute some actions for admins via commands", - "new-position-description": "New position", - "movechannel-description": "See the position of a channel or change the position of a channel", - "moverole-description": "See the position of a role or change the position of a role", - "setcategory-description": "Sets the category of a channel", - "channel-description": "Channel on which this action should be executed", - "role-description": "Role on which this action should be executed", - "category-description": "New category of the channel", - "emoji-too-much-data": "Please **only** enter one emoji and nothing else", - "emoji-import": "Imported \"%e\" successfully.", - "stealemote-description": "Steals a emote from another server", - "emote-description": "Emote to steal", - "role-command-description": "Assign or remove roles permanently or temporarily", - "role-give-description": "Assign someone a role permanently or temporarily", - "role-user-add-description": "Member that you want to assign the role to", - "role-add-role-description": "Role you want to assign to the member", - "role-add-duration-description": "If you set this parameter, the role will be removed from this user after this duration expires", - "role-user-status-description": "User you want to see temporary roles from", - "role-remove-description": "Remove a role from someone permanently or temporarily", - "role-user-remove-description": "Member that you want to remove the role from", - "role-remove-role-description": "Role you want to remove from the member", - "role-remove-duration-description": "If you set this parameter, the role will be added back to this user after this duration expires", - "role-status-description": "Shows which roles of a user are temporary and when they will be removed", - "role-not-high-enough": "The highest role of the bot is not above %e. The highest role of the bot needs to be above the role you want to remove or assign.", - "unable-to-change-roles": "Changing role %r to %u failed. Error message obtained by Discord:\n```%e```", - "user-not-found": "The user has not been found on your server.", - "duration-wrong": "The value of the duration argument is wrong. Learn more [in our docs]()", - "audit-log-add": "[admin-tools] %u added a role using a command.", - "audit-log-remove": "[admin-tools] %u removed a role using a command.", - "audit-log-add-duration": "[admin-tools] %u added a temporary role using a command that will be removed at %t.", - "audit-log-remove-duration": "[admin-tools] %u removed a temporary role using a command that will be added back at %t.", - "audit-log-temporary-remove": "[admin-tools] This role was added temporarily and has removed since the temporary timeframe expired.", - "audit-log-temporary-add": "[admin-tools] This role has been removed temporarily and has been added back since the temporary timeframe expired.", - "role-add": "%u has been given the role %r.", - "role-remove": "%u has removed the role %r.", - "role-add-duration": "%u has been given the role %r. It will be removed at %t.", - "role-remove-duration": "%r has been removed from %u. It will be given back at %t.", - "user-without-temporary-action": "%u has no roles that are temporary.", - "user-temporary-action-header": "Temporary roles of %u", - "status-remove": "%r will be removed on %t.", - "status-add": "%r will be added back on %t.", - "users-trying-to-manage-higher-role": "Your highest role, %t, is not below %e. To manage a user's role, you the role you are managing needs to be below your highest role." - }, - "welcomer": { - "channel-not-found": "[welcomer] Channel not found: %c", - "welcome-yourself-error": "Welcome, nice to meet you! This button is reversed for a special member of this server who want's to say \"Hi\" to you ^^" - }, - "birthdays": { - "channel-not-found": "[birthdays] Channel not found: %c", - "sync-error": "[birthdays] %u's state was set to \"sync\", but there was no syncing candidate, so I disabled the synchronization", - "age-hover": "%a years old", - "sync-enabled-hover": "Birthday synchronized", - "verified-hover": "Birthday verified", - "no-bd-this-month": "No birthdays this month ):", - "no-birthday-set": "You don't currently have a registered birthday on this server. Set a birthday with `/birthday set`.", - "birthday-status": "Your birthday is currently set to **%dd.%mm%yyyy**%age.", - "your-age": "which means that you are **%age** years old", - "sync-on": "Your birthday is being synced via your [SC Network Account](https://sc-network.net/dashboard).", - "sync-off": "Your birthday is set locally on this server and will not be synchronized", - "no-sync-account": "It seems like you either don't have an [SC Network Account]() or you haven't entered any information about your birthday in it yet.", - "auto-sync-on": "It seems that you have autoSync in your [SC Network Account]() enabled. This means that your birthday will be synchronized all the time on every server. [Learn more]().\nYour birthday isn't showing up? It can take up to 24 hours (usually it's less than two hours) for it to be synced, so stay calm and wait just a bit longer.", - "enabled-sync": "Successfully set. The synchronization is now enabled :+1:", - "disabled-sync": "Successfully set. The synchronization is disabled, you can now change or remove your birthday from this server.", - "delete-but-sync-is-on": "You currently have sync enabled. Please disable sync to delete your birthday.", - "deleted-successfully": "Birthday deleted successfully.", - "only-sync-allowed": "This server only allows synchronization of your birthday with a [SC Network Account]()", - "invalid-date": "Invalid date provided", - "against-tos": "You have to be at least 13 years old to use Discord. Please read Discord's [Terms of Service]() and if you are under the age of 13 please [delete your account]() to comply with Discord's [Terms of Service]() and wait %waitTime (or for the age for your country, listed [here]()) years before creating a new account.", - "too-old": "It seems like you are too old to be alive", - "command-description": "View, edit and delete your birthday", - "status-command-description": "Shows the current status of your birthday", - "sync-command-description": "Manage the synchronization on this server", - "sync-command-action-description": "Action which should be performed on your synchronization", - "sync-command-action-enable-description": "Enable synchronization", - "sync-command-action-disable-description": "Disable synchronization", - "set-command-description": "Sets your birthday", - "set-command-day-description": "Day of your birthday", - "set-command-month-description": "Month of your birthday", - "set-command-year-description": "Year of your birthday", - "delete-command-description": "Deletes your birthday from this server", - "migration-happening": "Database-Schema not up-to-date. Migration database... This could take a while. Do not restart your bot to avoid data loss.", - "migration-done": "Successfully migrated database to newest version." - }, - "months": { - "1": "January", - "2": "February", - "3": "March", - "4": "April", - "5": "May", - "6": "June", - "7": "July", - "8": "August", - "9": "September", - "10": "October", - "11": "November", - "12": "December" - }, - "levels": { - "leaderboard-channel-not-found": "Leaderboard-Channel not found or wrong type", - "leaderboard-notation": "%p. %u: Level %l - %xp XP", - "list-location": "[Level System] The live leaderboard is currently located here: %l. Delete the message and restart the bot, to re-send it.", - "leaderboard": "Leaderboard", - "no-user-on-leaderboard": "Can't generate a leaderboard, because no one has any XP which is odd, but that's how it is ¯\\_(ツ)_/¯", - "and-x-other-users": "and %uc other users", - "level": "Level %l", - "users": "Users", - "leaderboard-command-description": "Shows the leaderboard of this server", - "leaderboard-sortby-description": "How to sort the leaderboard (default: %d)", - "profile-command-description": "Shows the profile of you or an an user", - "profile-user-description": "User to see the profile from (default: you)", - "please-send-a-message": "Please send some messages before I can show you some data", - "no-role": "None", - "are-you-sure-you-want-to-delete-user-xp": "Okay, do you really want to screw with %u? If you hate them so much, feel free to run `/manage-levels reset-xp confirm:True user:%ut` to run this irreversible action.", - "are-you-sure-you-want-to-delete-server-xp": "Do you really want to delete all XP and Levels from this server? This action is irreversible and everyone on this server will hate you. Decided that it's worth it? Enter `/manage-levels reset-xp confirm:True`", - "user-not-found": "User not found", - "user-deleted-users-xp": "%t deleted the XP of the user with id %u", - "removed-xp-successfully": "`Removed %u's XP and level successfully.`", - "deleted-server-xp": "%u deleted the XP of all users", - "successfully-deleted-all-xp-of-users": "Successfully deleted all the XP of all users", - "cheat-no-profile": "This user doesn't have a profile (yet), please force them to write a message before trying to betrayal your community by manipulating level scores.", - "manipulated": "%u manipulated the XP of %m to %v (level %l)", - "successfully-changed": "Successfully edited the XP of %u - they are now **level %l** with **%x XP**.\nRemember, every change you make destroys the experience of other users on this server as the levelsystem isn't fair anymore.", - "edit-xp-command-description": "Manage the levels of your server", - "negative-xp": "This user would have a negative XP value which is not possible", - "negative-level": "This user would have a level below one which is not possible", - "reset-xp-description": "Reset the XP of a user or of the whole server", - "reset-xp-user-description": "User to reset the XP from (default: whole server)", - "reset-xp-confirm-description": "Do you really want to delete the data?", - "edit-xp-user-description": "User to edit", - "edit-xp-value-description": "New XP value of the user", - "edit-xp-description": "Betrays your community and edits a user's XP", - "no-custom-formula": "No valid custom formula was entered. Using default formula.", - "invalid-custom-formula": "Invalid custom formula was entered. Please either fix the syntax of your custom formula or remove the value of the custom formula field.", - "role-factors-total": "Multiplied together, the user receives **%f times more XP** for every message.", - "edit-level-description": "Betrays your community and edits a user's levels", - "random-messages-enabled-but-non-configured": "You have random messages enabled, but have non configured. Ignoring config.randomMessages configuration.", - "granted-rewards-audit-log": "Updated roles to make sure, they have the level role they need" - }, - "team-list": { - "channel-not-found": "Could not find channel with ID %c or the channel has a wrong type (only text-channels supported)", - "role-not-found": "Could not find role with ID %r", - "no-users-with-role": "No users on this server have the %r role yet.", - "no-roles-selected": "No roles listed yet.", - "offline": "Offline", - "dnd": "Do not disturb", - "idle": "Away", - "online": "Online" - }, - "ping-on-vc-join": { - "channel-not-found": "Notify channel %c not found", - "could-not-send-pn": "Could not send PN to %m" - }, - "suggestions": { - "suggestion-not-found": "Suggestion not found", - "updated-suggestion": "Successfully updated suggestion", - "suggest-description": "Create and comment on suggestions", - "suggest-content": "Content you want to suggest", - "loading": "A wild new suggestion appeared, loading..", - "manage-suggestion-command-description": "Manage suggestions as an admin", - "manage-suggestion-accept-description": "Accepts a suggestion", - "manage-suggestion-deny-description": "Denies a suggestion", - "manage-suggestion-id-description": "ID of the suggestion", - "manage-suggestion-comment-description": "Explain why you made this choice" - }, - "auto-delete": { - "could-not-fetch-channel": "Could not fetch channel with ID %c", - "could-not-fetch-messages": "Could not fetch messages from channel with ID %c" - }, - "auto-thread": { - "thread-create-reason": "This thread got created, because you configured auto-thread to do so" - }, - "auto-messager": { - "channel-not-found": "Channel with ID %id not found" - }, - "polls": { - "what-have-i-votet": "What have I voted?", - "vote": "Vote!", - "vote-this": "Click on this option to place your vote here", - "voted-successfully": "Successfully voted. Thanks for your participation.", - "not-voted-yet": "You have not voted yet, so I can't show you what you voted.", - "you-voted": "You have voted for **%o**.", - "remove-vote": "Remove my vote", - "removed-vote": "Your vote was removed successfully.", - "change-opinion": "You can change your opinion anytime by just selecting something else above the button you just clicked.", - "command-poll-description": "Create and end polls", - "command-poll-create-description": "Create a new poll", - "command-poll-end-description": "Ends an existing poll", - "command-poll-end-msgid-description": "ID of the poll", - "command-poll-create-description-description": "Topic / Description of this poll", - "command-poll-create-channel-description": "Channel in which the poll should get created", - "command-poll-create-option-description": "Option number %o", - "command-poll-create-endAt-description": "Duration of the poll (if not set the poll will not end automatically)", - "command-poll-create-public-description": "If enabled (disabled by default) the votes of users will be displayed publicly", - "created-poll": "Successfully created poll in %c.", - "not-found": "Poll could not be found", - "no-votes-for-this-option": "Nobody voted this option yet", - "ended-poll": "Poll ended successfully", - "view-public-votes": "View current voters", - "not-public": "This poll does not appear to be public, no results can be displayed.", - "poll-private": "\uD83D\uDD12 This poll is **anonymous**, meaning that no one can see what you voted (not even the admins).", - "poll-public": "\uD83D\uDD13 This poll is **public**, meaning that everyone can see what you voted.", - "not-text-channel": "You need to select a text-channel that is not an announcement-channel." - }, - "channel-stats": { - "audit-log-reason-interval": "Updated channel because of interval", - "audit-log-reason-startup": "Updated channel because of startup", - "not-voice-channel-info": "Channel \"%c\" (%id) is a %t and not a voice-channel as recommended" - }, - "info-commands": { - "info-command-description": "Find information about parts of this server", - "command-userinfo-description": "Find more information about a user on this server", - "argument-userinfo-user-description": "User you want to see information about (default: you)", - "command-roleinfo-description": "Find more information about a role on this server", - "argument-roleinfo-role-description": "Role you want to see information about", - "command-channelinfo-description": "Find more information about a channel on this server", - "argument-channelinfo-channel-description": "Channel you want to see information about", - "command-serverinfo-description": "Find more information about this server", - "information-about-role": "Information about the role %r", - "hoisted": "Hoisted", - "mentionable": "Mentionable", - "managed": "Managed", - "information-about-channel": "Information about the channel %c", - "information-about-user": "Information about the user %u", - "information-about-server": "Information about %s", - "boostLevel": "Level", - "boostCount": "Boosts", - "userCount": "Users", - "memberCount": "Members", - "onlineCount": "Online", - "textChannel": "Text", - "voiceChannel": "Voice", - "categoryChannel": "Categories", - "otherChannel": "Other", - "total-invites": "Total", - "active-invites": "Active", - "left-invites": "Left" - }, - "channelType": { - "GUILD_TEXT": "Text-Channel", - "GUILD_VOICE": "Voice-Channel", - "GUILD_CATEGORY": "Category", - "GUILD_NEWS": "News-Channel", - "GUILD_STORE": "Store-Channel", - "GUILD_NEWS_THREAD": "News-Channel-Thread", - "GUILD_PUBLIC_THREAD": "Public Thread", - "GUILD_PRIVATE_THREAD": "Private Thread", - "GUILD_STAGE_VOICE": "Stage-Channel", - "DM": "Direct-Message", - "GROUP_DM": "Group-Direct-Message", - "UNKNOWN": "Unknown" - }, - "stagePrivacy": { - "PUBLIC": "Publicly accessible", - "GUILD_ONLY": "Only server members can join" - }, - "guildVerification": { - "0": "None", - "1": "Low", - "2": "Medium", - "3": "High", - "4": "Very high" - }, - "boostTier": { - "0": "None", - "1": "Level 1", - "2": "Level 2", - "3": "Level 3" - }, - "temp-channels": { - "removed-audit-log-reason": "Removed temp channel, because no one was in it", - "permission-update-audit-log-reason": "Updated permissions, to make sure only people in the VC can see the no-mic-channel", - "created-audit-log-reason": "Created Temp-Channel for %u", - "move-audit-log-reason": "Moved user to their voice channel", - "no-mic-channel-topic": "Welcome to %u's no-mic-channel. You will see this channel as long as you are connected to this temp-channel.", - "disconnect-audit-log-reason": "The old channel of the user could not be found - disconnecting them - hopefully they join again", - "command-description": "Manage your temp-channel", - "mode-subcommand-description": "Change the mode of your channel", - "public-option-description": "If enabled, anyone can join your temp-channel", - "add-subcommand-description": "Add users, that will be able to join your channel, while it is private", - "remove-subcommand-description": "Remove users from you channel", - "add-user-option-description": "The user to be added", - "remove-user-option-description": "The user to be removed", - "list-subcommand-description": "List the users with access to your channel", - "edit-subcommand-description": "Edit various settings of your channel", - "user-limit-option-description": "Change the user-limit of your channel", - "bitrate-option-description": "Change the bitrate of your channel (min. 8000)", - "name-option-description": "Change the name of your channel", - "nsfw-option-description": "Change, whether your channel is age-restricted or not", - "no-added-user": "There are no users to be displayed here", - "nothing-changed": "Your channel already had these settings.", - "no-disconnect": "Couldn't disconnect the user from your channel. This could be due to missing permissions, or the user not being in your voice-channel", - "edit-error": "An error occurred while editing your channel. one or more of your settings couldn't be applied. This could be due to missing permissions or an invalid value.", - "add-user": "Add user", - "remove-user": "Remove user", - "list-users": "List users", - "private-channel": "Private", - "public-channel": "Public", - "edit-channel": "Edit channel", - "add-modal-title": "Add an user to your temp-channel", - "add-modal-prompt": "The user you want to add (tag or user-id)", - "remove-modal-title": "Remove an user from your temp-channel", - "remove-modal-prompt": "The user you want to remove (tag or user-id)", - "edit-modal-title": "Edit your temp-channel", - "edit-modal-nsfw-prompt": "Mark temp-channel as age-restricted?", - "edit-modal-nsfw-placeholder": "\"true\" (yes) or \"false\" (no)", - "edit-modal-bitrate-prompt": "Bitrate of your Temp-channel?", - "edit-modal-bitrate-placeholder": "A number over 8000", - "edit-modal-limit-prompt": "Limit of users in your temp-channel", - "edit-modal-limit-placeholder": "Number between 0 and 99; 0 = unlimited", - "edit-modal-name-prompt": "How should your channel be called?", - "edit-modal-name-placeholder": "A very creative channel name", - "edit-modal-username-placeholder": "Username of the user", - "user-not-found": "User not found" - }, - "massrole": { - "command-description": "Manage roles for all members", - "add-subcommand-description": "Add a role to all members", - "remove-subcommand-description": "Remove a role from all members", - "remove-all-subcommand-description": "Remove all roles from all members", - "role-option-add-description": "The role, that will be given to all members", - "role-option-remove-description": "The role, that will be removed from all members", - "target-option-description": "Determines whether bots should be included or not", - "all-users": "All Users", - "bots": "Bots", - "humans": "Humans", - "not-admin": "⚠ To use this command, you need to be added to the adminRoles option in the SCNX-Dashboard. If you are the owner of this bot please remember to create an override in the server settings to prevent abuse of this command.", - "add-reason": "Mass role addition by %u", - "remove-reason": "Mass role removal by %u" - }, - "twitch-notifications": { - "channel-not-found": "Channel with ID %c could not be found", - "user-not-on-twitch": "Could not find user %u on twitch" - }, - "hunt-the-code": { - "admin-command-description": "Manage the current Code-Hunt", - "create-code-description": "Create a new code for the current code-hunt", - "display-name-description": "Name of the code that will be displayed to user when they redeem the code", - "code-description": "Set the code that will be used to redeem it (default: randomly generated)", - "code-created": "Code \"%displayName\" successfully created: \"%code\"", - "error-creating-code": "Error creating code \"{{displayName}}\". Maybe the entered code is already in the database?", - "successful-reset": "Successfully ended the current Code-Hunt-Game - [here](%url)'s your report - save the URL if you want to access it later.", - "end-description": "Ends the current Code-Hunt (will clear users and codes and generates a report)", - "command-description": "Redeem or see data about the current Code-Hunt", - "redeem-description": "Redeem a code you found", - "code-redeem-description": "The code you want to redeem", - "leaderboard-description": "See the current leaderboard", - "profile-description": "See your current count of found codes", - "no-codes-found": "No codes redeemed yet ):", - "no-users": "No users redeemed codes yet ):", - "report-header": "Report for the Hunt-The-Code game on %s", - "user-header": "Participating users", - "code-header": "Codes", - "report-description": "Generates a report", - "report": "You can find the report [here](%url)." - }, - "fun": { - "slap-command-description": "Slap a user in the face", - "user-argument-description": "User to performe this action on", - "no-no-not-slapping-yourself": "You can not punch yourself lol (well technically you can, but our gifs do not support that, so deal with it ¯\\_(ツ)_/¯)", - "pat-command-description": "Pat someone nicely", - "no-no-not-patting-yourself": "Well, good try, but we don't do this here", - "no-no-not-kissing-yourself": "Uah, that's gross, you should try paying somebody to do that (well you should not, but better then kissing yourself)", - "kiss-command-description": "Kiss someone", - "hug-command-description": "Hug someone <3", - "no-no-not-hugging-yourself": "You are quite lonely aren't you? Try hugging a tree, that should work. Unless you live in a desert. Then hug a cactus. That's a bit more painful, but trust me.", - "random-command-description": "Helps you select random things", - "random-number-command-description": "Selects a random number", - "min-argument-description": "Minimal number (default: 1)", - "max-argument-description": "Maximal number (default: 42)", - "random-ikeaname-command-description": "Generates a random name for a IKEA-Name", - "syllable-count-argument-description": "Count of syllables to generate name from (default: random)", - "random-dice-command-description": "Roll a dice", - "random-coinflip-command-description": "Flip a coin", - "random-8ball-command-description": "Generates an answer to a yes/no question", - "dice-site-1": "Heads", - "dice-site-2": "Tails" - }, - "moderation": { - "moderate-command-description": "Moderate users on your server", - "moderate-notes-command-description": "Set or see moderator's notes of a user", - "moderate-notes-command-view": "View a user's notes", - "moderate-notes-command-create": "Create a new note about a user", - "moderate-notes-command-edit": "Edit one of your existing notes about a user", - "moderate-notes-command-delete": "Delete one of your existing notes about a user", - "moderate-ban-command-description": "Ban a user on your server", - "moderate-reason-description": "Reason for your action", - "moderate-proof-description": "Proof for your action", - "report-user-not-found-on-guild": "This user could not be found on \"%s\". You can only report users that are members of our server.", - "proof": "Proof", - "report-proof-description": "Attach an optional (image) proof to your report", - "file": "File uploaded", - "anti-grief-reason": "Too many actions of type \"%type\" in the last %h hours. Maximum amount allowed: %n", - "anti-grief-user-message": "Sorry, but it seems like you are abusing your moderative powers. We've taken actions to prevent this from happening.", - "moderate-duration-description": "Duration of the action (max: 28 days, default: 14 days)", - "mute-max-duration": "Discord limits the maximal duration of a timeout to 28 days. Please enter an amount equal or less than this", - "moderate-quarantine-command-description": "Quarantine a user on your server", - "moderate-unquarantine-command-description": "Removes a user from the quarantine", - "moderate-unban-command-description": "Revokes an existing ban", - "moderate-clear-command-description": "Clears messages in the current channel", - "moderate-clear-amount-description": "How many messages should get cleared?", - "moderate-kick-command-description": "Kick a user from your server", - "moderate-unwarn-command-description": "Revokes a warning", - "moderate-mute-command-description": "Mute a user on your server", - "moderate-unmute-command-description": "Unmutes a user on your server", - "moderate-warn-command-description": "Warn a user", - "moderate-channel-mute-description": "Mutes a user from the current channel", - "moderate-unchannel-mute-description": "Removes a channel-mute from this channel", - "moderate-lock-command-description": "Lock the current channel", - "moderate-unlock-command-description": "Unlock the current channel", - "moderate-lockdown-command-description": "Activate or lift server-wide lockdown", - "moderate-lockdown-enable-description": "True to activate lockdown, false to lift it", - "lockdown-not-enabled": "The lockdown system is not enabled. Enable it in the lockdown configuration.", - "lockdown-already-active": "A lockdown is already active.", - "lockdown-not-active": "No lockdown is currently active.", - "lockdown-activated": "Server Lockdown Activated", - "lockdown-lifted": "Server Lockdown Lifted", - "lockdown-activated-reply": "Lockdown activated. %c channels have been locked.", - "lockdown-lifted-reply": "Lockdown lifted. %c channels have been restored.", - "lockdown-log-description": "**Reason:** %r\n**Triggered by:** %u\n**Type:** %t\n**Affected channels:** %c", - "lockdown-lift-log-description": "**Reason:** %r\n**Lifted by:** %u\n**Restored channels:** %c", - "lockdown-automatic": "Automatic", - "lockdown-manual": "Manual", - "lockdown-system": "System", - "lockdown-auto-lift-reason": "Auto-lift timer expired", - "lockdown-restored": "Lockdown state restored from database after restart", - "lockdown-joinraid-trigger": "Join raid detected", - "lockdown-spam-trigger": "Excessive spam detected", - "lockdown-joingate-trigger": "Excessive join-gate violations detected", - "lockdown-restore-failed": "Failed to restore permissions for channel %c: %e", - "lockdown-users-kicked": "Users Kicked", - "lockdown-users-kicked-description": "%k non-moderator users were disconnected from voice channels.", - "moderate-user-description": "User on who the action should get performed", - "moderate-userid-description": "ID of a user", - "moderate-days-description": "Number of days of messages to delete", - "invalid-days": "Days can only be between 0 and 7 (inclusive)", - "moderate-notes-description": "Notes to set / update", - "moderate-note-id-description": "ID of one of your notes you want to edit (leave blank to create a new one)", - "moderate-warnid-description": "ID of a warn (run /moderate actions to get it)", - "moderate-actions-command-description": "Show all recorded actions against a user", - "report-command-description": "Reports a user and sends a snapshot of the chat to server staff", - "report-reason-description": "Please describe what the user did wrong", - "report-user-description": "User you want to report", - "no-reason": "Not set", - "muterole-not-found": "Could not find muterole. Can not perform this action", - "quarantinerole-not-found": "Could not find quarantinerole. Can not perform this action", - "mute-audit-log-reason": "Got muted by %u because of \"%r\"", - "unmute-audit-log-reason": "Got unmuted by %u because of \"%r\"", - "quarantine-audit-log-reason": "Got quarantined by %u because of \"%r\"", - "kicked-audit-log-reason": "Got kicked by %u because of \"%r\"", - "banned-audit-log-reason": "Got banned by %u because of \"%r\"", - "channelmute-audit-log-reason": "Got channel-mutet by %u because of \"%r\"", - "unchannelmute-audit-log-reason": "The Channel-Mute got removed by %u because of \"%r\"", - "unbanned-audit-log-reason": "Got unbanned by %u because of \"%r\"", - "unquarantine-audit-log-reason": "Got unquarantined by %u because of \"%r\"", - "action-expired": "Action expired", - "auto-mod": "Auto-Mod", - "batch-role-remove-failed": "Could not remove all roles from %i (trying to remove roles one by one): %e", - "batch-role-add-failed": "Could not add all roles to %i (trying to remove roles one by one): %e", - "could-not-remove-role": "Could not remove role %r from %i: %e", - "could-not-add-role": "Could not add role %r to %i: %e", - "reason": "Reason", - "join-gate": "Join-Gate", - "expires-at": "Action expires on", - "action": "Action", - "case": "Case", - "victim": "Victim", - "missing-logchannel": "LogChannel could not be found", - "reached-warns": "Reached %w warns", - "restored-punishment-audit-log-reason": "Restored punishment", - "anti-join-raid": "ANTI-JOIN-RAID", - "raid-detected": "Raid detected", - "joingate-for-everyone": "Join-Gate-Modus: Catch all users", - "account-age-to-low": "Account creation age of %a days is to low (required are more then %c)", - "no-profile-picture": "Account has no profile picture (required)", - "join-gate-fail": "Account failed Join-Gate (%r)", - "blacklisted-word": "Posted blacklisted word in %c", - "invite-sent": "Sent invite in %c", - "scam-url-sent": "Sent scam-url in %c", - "anti-spam": "Anti-Spam", - "reached-messages-in-timeframe": "Reached %m (normal) messages in less than %t seconds", - "reached-duplicated-content-messages": "Reached %m messages with the same content in less than %t", - "reached-ping-messages": "Reached %m messages with (user) pings in less then %t seconds", - "reached-massping-messages": "Reached %m messages with mass pings in less than %t seconds", - "action-done": "Executed action successfully. Action-ID: #%i", - "expiring-action-done": "Done. Action will expire on %d. Action-ID: #%i", - "cleared-channel": "Cleared channel successfully.\nNote: Messages older than 14 days can not be deleted using this method.", - "clear-failed": "An error occurred. You can only delete 100 messages at once.", - "no-quarantine-action-found": "Sorry, but I couldn't find any records of quarantining this users.", - "locked-channel-successfully": "Locked channel successfully. Only moderators (and admins) can write messages here now.", - "unlocked-channel-successfully": "Unlocked channel successfully. Permissions got restored to the permission-state before the lock occurred.", - "unlock-audit-log-reason": "User %u unlocked this channel by running /moderate unlock", - "warning-not-found": "I could not find this warning. Please make sure you are actually using a warning-id and not a userid.", - "can-not-report-mod": "You can not report moderators.", - "action-description-format": "%reason\nby %u on %t", - "no-actions-title": "None found", - "no-actions-value": "No actions against %u found.", - "actions-embed-title": "Mod-Actions against %u - Site %i", - "actions-embed-description": "You can find every action against %u here.", - "report-embed-title": "New report", - "report-embed-description": "A user reported another user. Please review the case and take actions if needed.", - "reported-user": "Reported user", - "report-reason": "Reason for the report", - "report-user": "User who submitted report", - "message-log": "Last 100 messages", - "message-log-description": "You can find an encrypted message-log [here](%u).", - "channel": "Channel", - "no-report-pings": "No pings configured. Check your configuration to ping your staff.", - "not-allowed-to-see-own-notes": "Sorry, but you are not allowed to see your own notes.", - "note-added": "Note added successfully", - "note-edited": "Edited note successfully", - "note-deleted": "Note deleted successfully", - "note-not-found-or-no-permissions": "Note not found or no permissions to edit this note.", - "notes-embed-title": "Notes about %u", - "info-field-title": "ℹ️ Information", - "no-notes-found": "No notes about this user. Create a new note with `/moderate notes create` and set the notes attribute.", - "more-notes": "%x other moderator also added notes about this user. Notes are sorted in reverse chronology, so you will see the newest notes first.", - "user-notes-field-title": "%t's notes", - "user-not-on-server": "I can't perform this action on this user, as they are not currently on your server.", - "verification": "VERIFICATION", - "verification-failed": "Verification failed", - "verification-started": "Verification got started", - "verification-completed": "Verification completed", - "user": "User", - "manual-verification-needed": "Manual verification needed", - "verification-deny": "Deny verification", - "verification-approve": "Approve verification", - "verification-skip": "Skip verification", - "captcha-verification-pending": "Captcha-Verification is pending. You can either wait for the user to complete it or skip it manually.", - "verification-update-proceeded": "Successfully update verification status", - "verify-channel-set-but-not-found-or-wrong-type": "The configured verify-channel could not be found or it's type is not supported.", - "generating-message": "We are preparing some stuff, this message should get edited shortly...", - "restart-verification-button": "Restart verification process", - "member-not-found": "This user could not be found, maybe they already left?", - "already-verified": "Seems like you are already verified... Why would you want to repeat this process?", - "restarted-verification": "I have sent you another DM about your verification process. Please read it carefully and follow the actions described in it. Please not that this action did not re-trigger the manual-verification (if enabled), so spamming this button is useless.", - "dms-still-disabled": "It seems like your DMs are still disabled. Please enable your DMs to start the verification. This is not optional, you need to do this in order to get access to %g.", - "dms-not-enabled-ping": "%p, it seems like you have your DMs disabled. Please enable them and hit the button below this message to verify yourself. You have two minutes to complete this process." - }, - "counter": { - "created-db-entry": "Initialized database entry for %i", - "not-a-number": "This is not a number. You can not chat here. Try creating a thread if your message is that important.", - "restriction-audit-log": "This user proceeded to abuse the counter channel after five warnings, so we locked them out.", - "only-one-message-per-person": "Users have to take turns counting: You can not count two times in a row.", - "not-the-next-number": "That's not the next number. The next number is **%n**, please make sure you are counting up one by one.", - "channel-topic-change-reason": "Someone counted, so we updated the description as required by the configuration" - }, - "tickets": { - "channel-not-found": "Ticket-Create-Channel could not be found", - "existing-ticket": "You already have a ticket open: %c", - "ticket-created-audit-log": "%u created a new ticket by clicking the button", - "ticket-created": "Successfully created ticket and notified staff. Head over to it: %c", - "no-admin-pings": "No pings configured. Check your configuration to ping your staff.", - "ticket-closed-successfully": "Closed ticket successfully. This channel will be deleted in a few seconds, thanks for reaching out to our support.", - "ticket-closed-audit-log": "%u closed ticket", - "closing-ticket": "Closing ticket as requested by %u...", - "ticket-with-user": "👤 Ticket-User", - "could-not-dm": "Could not DM %u: %r", - "no-log-channel": "Log-Channel not found", - "ticket-log-embed-title": "📎 Ticket %i closed", - "ticket-log": "Ticket-Log", - "ticket-type": "☕ Ticket-Topic", - "ticket-log-value": "Transcript with %n messages can be found [here](%u).", - "closed-by": "👷 Ticket closed by" - }, - "reminders": { - "command-description": "Set a reminder for yourself", - "in-description": "After what time should we remind you? (eg. \"2h 30m\")", - "what-description": "What should we remind you about?", - "dm-description": "Should we send you a DM instead of reminding your in this channel?", - "one-minute-in-future": "Your reminder needs to be at least one minute in the future", - "reminder-set": "Reminder set. We'll remind you at %d." - }, - "afk-system": { - "command-description": "Manage your AFK-Status on this server", - "end-command-description": "End your current AFK-Session", - "start-command-description": "Start a new AFK-Session", - "reason-option-description": "Explain why you started this session", - "autoend-option-description": "If enabled, the bot will auto-end your AFK Session when your write a message (default: enabled)", - "no-running-session": "You don't have any session running.", - "already-running-session": "You already have an AFK-Session running, try ending it with `/afk-system end`.", - "afk-nickname-change-audit-log": "Updated user nickname because they started an AFK-Session", - "can-not-edit-nickname": "Can not edit nickname of %u: %e" - }, - "tic-tac-toe": { - "command-description": "Play tic-tac-toe against someone in the chat", - "user-description": "User to play against", - "challenge-message": "%t, %u challenged you to a game of tic-tac-toe! Hit the button below to join the battle! This invitation will expire in about 2 minutes, so don't hesitate to much.", - "accept-invite": "Join game", - "deny-invite": "No thanks", - "self-invite-not-possible": "Are you really that lonely? Even Simon, a complete introvert with no friends and developer of this bot, can find another user to play tic-tac-toe with... You should be able to do that too, try inviting %r for example, maybe they want to play a round?", - "invite-expired": "Sorry, %u, %i didn't accept your request to play tic-tac-toe in time ):", - "invite-denied": "Sorry, %u, but %i denied your request to play a round of tic-tac-toe ):", - "you-are-not-the-invited-one": "Sorry, but this invite doesn't belong to you. You can start your own game with `/tic-tac-toe`.", - "playing-header": "**TIC-TAC-TOE GAME IS RUNNING**\n\n%u (🟢) VS %i (🟡)\nCurrently on turn: %t\n\n%t, click a button with a white circle below to place your marker", - "win-header": "**TIC-TOE-GAME ENDED**\n\n%u (🟢) VS %i (🟡)\n\n%w won the game - GG!\n\n*You can start a new round by using `/tic-tac-toe`*", - "draw-header": "**TIC-TOE-GAME ENDED**\n\n%u (🟢) VS %i (🟡)\n\nDraw - no one won this game.", - "not-your-turn": "It's not your turn, take a coffee and return later" - }, - "duel": { - "command-description": "Play duel against someone in the chat", - "user-description": "User to play against", - "challenge-message": "%t, %u challenged you to a game of duel! Hit the button below to join the battle! This invitation will expire in about 2 minutes, so don't hesitate to much.", - "accept-invite": "Join game", - "deny-invite": "No thanks", - "self-invite-not-possible": "Are you really that lonely? Even Simon, a complete introvert with no friends and developer of this bot, can find another user to play duel with... You should be able to do that too, try inviting %r for example, maybe they want to play a round?", - "invite-expired": "Sorry, %u, %i didn't accept your request to play duel in time ):", - "invite-denied": "Sorry, %u, but %i denied your request to play a round of duel ):", - "you-are-not-the-invited-one": "Sorry, but this invite doesn't belong to you. You can start your own game with `/duel`.", - "game-running-header": "🎮 Game running", - "what-do-you-want-to-do": "**Select your action!**", - "pending": "⏳ Waiting for selection…", - "ready": "✅ Ready", - "continues-info": "The game continues once both parties have selected their next action.", - "how-does-this-game-work": "Wondering how this game works? Read our short explanation [here]().", - "use-gun": "Use gun", - "guard": "Guard", - "reload": "Load gun", - "game-ended": "🎮 Game ended", - "no-bullets": "Sorry, but you haven't loaded any bullets yet, so you can't use your gun yet.", - "bullets-full": "Sorry, but your gun only has place for 5 bullets at a time.", - "gun-gun": "Both %g1 and %g1 draw their guns. They stare each other and their eyes and slowly lower their weapons. No, the duell won't be resolved if both die - there can only be one winner.", - "guard-gun": "%g1 draws their gun and shoot - %d1 dodged the bullet successfully.", - "guard-guard": "Both %d1 and %d2 wait for each other to fire the shot - but nothing happens.", - "reload-gun": "While %r1 starts reloading their gun, %g1 draws their weapon and shoots - it's a head-shot. %r1 drops to the ground. %g1 should celebrate because they won, but they are left feeling bad for murdering their old friend.", - "guard-over-reload-gun": "As this is %r1's fifth guard in a row, they are tired and are to slow - %g1 shoots them directly into their head and %r1 drops to the ground. It's a win for %g1 - but at what price?", - "reload-reload": "Both %r1 and %r2 stare each other in the eyes while taking a short break to load one bullet each in their chamber.", - "reload-guard": "%d1 prepares to doge a bullet - but %r1 uses the time to load their weapon - no shots get fired.", - "ended-state": "This game ended. You can start a new duel with `/duel`.", - "not-your-game": "You are not one of players - you can start a new game with `/duel`." - }, - "economy-system": { - "work-earned-money": "The user %u gained %m %c by working", - "crime-earned-money": "The user %u gained %m %c by committing a crime", - "message-drop-earned-money": "The user %u gained %m %c by getting a message drop", - "rob-earned-money": "The user %u gained %m %c by robbing from %v", - "weekly-earned-money": "The user %u gained %m %c by cashing in their weekly reward", - "daily-earned-money": "The user %u gained %m %c by cashing in their daily reward", - "admin-self-abuse": "The admin %a wanted to abuse their permissions by giving them self even more money! This can't and should not be ignored!", - "admin-self-abuse-answer": "What a bad admin you are, %u. I'm disappointed with you! I need to report this. If I wish I could ban you!", - "added-money": "%i %c has been added to the balance of %u", - "removed-money": "%i %c has been removed from the balance of %u", - "set-money": "The balance of %u has been set to %i.", - "added-money-log": "The user %u added %i %c to the balance of %v", - "removed-money-log": "The user %u removed %i %c from the balance of %v", - "set-money-log": "The user %u set %v's balance to %i %c", - "command-description-main": "Use the economy-system", - "command-description-work": "Earn some cash by working", - "command-description-crime": "Earn some cash by committing a crime", - "command-description-rob": "Rob some cash from another user", - "option-description-rob-user": "User to rob from", - "crime-loose-money": "The user %u lost %m %c by committing a crime", - "command-description-daily": "Cash in your daily rewards", - "command-description-weekly": "Cash in your weekly rewards", - "command-description-balance": "Show the balance of a user", - "option-description-user": "User to execute action upon", - "command-description-add": "Add some cash to a user", - "command-description-remove": "Remove some cash from a user", - "option-description-amount": "Amount to manipulate", - "command-description-set": "Set a user's balance", - "option-description-balance": "Balance to set user to", - "message-drop": "Message-Drop: You earned %m %c simply by chatting!", - "created-item": "The user %u has created a new shop item: %i", - "item-duplicate": "The item already exist", - "role-to-high": "The specified role is higher than the highest role of the bot. Therefore the bot can't give the role to users. The item was **not** created.", - "delete-item": "The user %u has deleted the shop item %i", - "edit-item": "The user %u has edited the item %i. Possible changes are:\nNew name: %n\nNew price: %p\nNew role: %r", - "user-purchase": "The user %u has purchased the shop item %i for %p.", - "shop-command-description": "Use the shop-system", - "shop-command-description-add": "Create a new item in the shop (admins only)", - "shop-option-description-itemName": "Name of the item", - "shop-option-description-newItemName": "New name of the Item", - "shop-option-description-itemID": "ID of the Item", - "shop-option-description-price": "Price of the item", - "shop-option-description-role": "Role to give to users who buy the item", - "shop-command-description-buy": "Buy an item", - "shop-command-description-list": "List all items in the shop", - "shop-command-description-delete": "Remove an item from the shop", - "shop-command-description-edit": "Edit an item", - "channel-not-found": "Can't find the leaderboard channel with the ID %c", - "command-description-deposit": "Deposit xyz to your bank", - "option-description-amount-deposit": "Amount to deposit", - "command-description-withdraw": "Withdraw xyz from your Bank", - "option-description-amount-withdraw": "Amount to withdraw", - "command-group-description-msg-drop-msg": "Enable/ Disable the Message-Drop-Message", - "command-description-msg-drop-msg-enable": "Enable the Message-Drop-Message", - "command-description-msg-drop-msg-disable": "Disable the Message-Drop-Message", - "command-description-destroy": "Destroy the whole economy (deletes all Database-Entries)", - "option-description-confirm": "Confirm, that you really want to destroy the whole economy", - "destroy-cancel-reply": "You're lucky. You stopped me in the last moment before I destroyed the economy", - "destroy-reply": "Ok... I'll destroy the whole economy", - "destroy": "%u destroyed the economy", - "migration-happening": "Database not up-to-date. Migrating database...", - "migration-done": "Migrated database successfully.", - "nothing-selected": "Select an item to buy it", - "select-menu-price": "Price: %p", - "price-less-than-zero": "The price can't be less or equal to zero" - }, - "status-role": { - "fulfilled": "Status-role condition is fulfilled", - "not-fulfilled": "Status-role condition is no longer fulfilled" - }, - "color-me": { - "create-log-reason": "%user redeemed their boosting-rewards by requesting the creation of this role", - "edit-log-reason": "%user edited their boosting-reward-role", - "delete-unboost-log-reason": "%user stopped boosting, so their role got deleted", - "delete-manual-log-reason": "%user deleted their role manually", - "command-description": "Request a Custom role as a reward for boosting. This has a cooldown of 24 hours", - "manage-subcommand-description": "Create or edit your custom role", - "name-option-description": "The name of your custom role", - "color-option-description": "The color of your custom role", - "remove-subcommand-description": "Remove your custom role", - "icon-option-description": "Your role-icon", - "confirm-option-remove-description": "Do you really want to delete your custom role? This will not reset any running cooldowns" - }, - "rock-paper-scissors": { - "stone": "Stone", - "paper": "Paper", - "scissors": "Scissors", - "won": "won", - "lost": "lost", - "tie": "tie", - "play-again": "Play again", - "challenge-message": "%t, %u challenged you to a game of rock-paper-scissors! Hit the button below to join the game! This invitation will expire in about 2 minutes, so don't hesitate to much.", - "invite-expired": "Sorry, %u, %i didn't accept your request to play rock-paper-scissors in time ):", - "invite-denied": "Sorry, %u, but %i denied your request to play a round of rock-paper-scissors ):", - "rps-title": "Rock Paper Scissors", - "rps-description": "Choose your weapon!", - "its-a-tie-try-again": "It's a tie! Try again!", - "command-description": "Play rock-paper-scissors against the bot or someone in the chat" - }, - "connect-four": { - "tie": "It's a tie!", - "win": "%u has won the game!", - "not-turn": "Sorry, but it's not your turn!", - "game-message": "Connect Four game of %u1 and %u2\nCurrent turn: %c %t.\n\n%g", - "challenge-message": "%t, %u challenged you to a game of Connect Four! Hit the button below to join the game! This invitation will expire in about 2 minutes, so don't hesitate to much.", - "invite-expired": "Sorry, %u, %i didn't accept your request to play Connect Four in time ):", - "invite-denied": "Sorry, %u, but %i denied your request to play a round of Connect Four ):", - "command-description": "Play Connect Four against someone in the chat", - "field-size-description": "The size of the playfield (default: 7)", - "challenge-yourself": "You cannot challenge yourself!", - "challenge-bot": "You cannot challenge bots!" - }, - "uno": { - "command-description": "Play Uno against users in the chat", - "challenge-message": "%u invites to a round of Uno! Click the button below this message to join! The game starts %timestamp with %count players.", - "not-enough-players": "Not enough players joined for a round of Uno!", - "user-cards": "%u: %cards cards", - "already-joined": "You're already in!", - "view-deck": "View deck", - "draw": "Draw card", - "uno": "Uno!", - "turn": "It's %u turn!", - "update-button": "Update", - "use-drawn": "Do you want to use the drawn card?", - "dont-use-drawn": "Dont use", - "win": "%u won the game! %turns cards were played.", - "win-you": "You've won the game!", - "missing-uno": "⚠️ You must use the Uno! button before you use your second last card!", - "choose-color": "Select a color:", - "pending-draws": "Use a Draw 2/4 card, otherwise you have to draw %count cards!", - "not-ingame": "You're not in this game!", - "skip": "Skip", - "reverse": "Reverse", - "color": "Color choice", - "draw2": "Draw 2", - "colordraw4": "Color choice and draw 4", - "cant-uno": "You cannot use Uno currently.", - "done-uno": "You've called Uno!", - "auto-drawn-skip": "Your turn was skipped because you would have had to draw the cards anyway.", - "start-game": "Start game now", - "not-host": "You're not the host of the game!", - "max-players": "The game is full!", - "previous-cards": "Previous cards: ", - "used-card": "You've already used the card %c! Use the Update button and play a valid card.", - "invalid-card": "You cannot play the card %c right now! Please select a valid card.", - "inactive-warn": "%u, it's your turn in the uno game!", - "inactive-win": "The uno game has ended. %u won as all others have been eliminated!" - }, - "quiz": { - "what-have-i-voted": "What have I voted?", - "vote": "Vote!", - "vote-this": "Select this option if you think it's correct.", - "voted-successfully": "Selected successfully.", - "not-voted-yet": "You have not selected an option yet, so I can't show you what you selected.", - "you-voted": "You've selected **%o** as correct answer.", - "change-opinion": "You can change your opinion at any time by selecting another option above the button you just clicked.", - "cannot-change-opinion": "You cannot change your selection as the creator of this quiz disabled it.", - "select-correct": "Select all correct answers", - "this-correct": "Mark this answer as correct", - "cmd-description": "Create or play server quiz", - "cmd-create-normal-description": "Create a quiz with up to 10 answers", - "cmd-create-bool-description": "Create a quiz with true or false answers", - "cmd-play-description": "Play a server quiz", - "cmd-leaderboard-description": "Shows the quiz leaderboard of the server", - "cmd-create-description-description": "Title / description of the quiz", - "cmd-create-channel-description": "Channel in which the quiz should be created", - "cmd-create-endAt-description": "How long the quiz will last", - "cmd-create-option-description": "Option number %o", - "cmd-create-canchange-description": "If the players can change their opinion after voting (default: no)", - "daily-quiz-limit": "You've reached the limit of **%l** daily playable quizzes. You can play again %timestamp.", - "created": "Quiz created successfully in %c.", - "correct-highlighted": "All correct answers were highlighted.", - "answer-correct": "✅ Your answer was correct and you've received one point for the leaderboard!", - "answer-wrong": "❌ Your answer was wrong!", - "bool-true": "Statement is correct", - "bool-false": "Statement is wrong", - "leaderboard-channel-not-found": "The leaderboard channel couldn't be found or it's type is invalid.", - "leaderboard-notation": "**%p. %u**: %xp XP", - "your-rank": "You've collected **%xp** points in quiz!", - "no-rank": "You've never finished a quiz successfully!", - "no-quiz": "No quizzes have been created for this server. Trusted admins can create them on https://scnx.app/glink?page=bot/configuration?query=quiz&file=quiz%7Cconfigs%2FquizList .", - "no-permission": "You don't have enough permissions to create quiz using the command." - }, - "starboard": { - "invalid-minstars": "Invalid minimum stars %stars", - "star-limit": "You've reached the hourly starboard limit of %limitEmoji on the server which is why you cannot react on the message %msgUrl .\nTry again %time!" - }, - "nicknames": { - "owner-cannot-be-renamed": "The owner of the server (%u) cannot be renamed.", - "nickname-error": "An error occurred while trying to change the nickname of %u: %e" - }, - "ping-protection": { - "log-not-a-member": "[Ping Protection] Punishment failed: The pinger is not a member.", - "log-punish-role-error": "[Ping Protection] Punishment failed: I cannot punish %tag because their role is higher than or equal to my highest role.", - "log-mute-error": "[Ping Protection] Punishment failed: I cannot mute %tag: %e", - "log-kick-error": "[Ping Protection] Punishment failed: I cannot kick %tag: %e", - "log-action-log-failed": "[Ping Protection] Punishment logging failed: %e", - "log-data-deletion": "[Ping Protection] All data for the user with ID %u has been deleted successfully.", - "log-automod-keyword-limit": "[Ping Protection] Automod keywords exceed 1000 characters limit. Keywords were truncated.", - "punish-log-failed-title": "Punishment failed for user %u", - "punish-log-failed-desc": "An error occured while trying to punish the user %m. Please check the bot's permissions and role hierarchy. See the message below for the error.", - "punish-log-error": "Error: ```%e```", - "punish-role-error": "I cannot punish %tag because their role is higher than or equal to my highest role.", - "reason-basic": "User reached %c pings in the last %w weeks.", - "reason-advanced": "User reached %c pings in the last %d days (Custom timeframe).", - "cmd-desc-module": "Ping protection related commands", - "cmd-desc-group-user": "Every command related to the users", - "cmd-desc-history": "View the ping history of a user", - "cmd-opt-user": "The user to check", - "cmd-desc-actions": "View the moderation action history of a user", - "cmd-desc-panel": "Admin: Open the user management panel", - "cmd-desc-group-list": "Lists protected or whitelisted entities", - "cmd-desc-list-protected": "List of all the protected users and roles", - "cmd-desc-list-wl": "List of all the whitelisted roles, channels and users", - "embed-history-title": "Ping history of %u", - "no-data-found": "No logs found for this user.", - "embed-actions-title": "Moderation history of %u", - "label-reason": "Reason", - "actions-retention-note": "Note: Moderation actions are retained for 1 - 12 months based on the configuration.", - "no-permission": "You don't have sufficient permissions to use this command.", - "panel-title": "User Panel: %u", - "panel-description": "Manage and view data for %u (%i). View a quick recap of their ping and moderation history, or delete all data stored for this user (Risky).", - "btn-history": "Ping history", - "btn-actions": "Actions history", - "btn-delete": "Delete all data (Risky)", - "list-protected-title": "Protected Users and Roles", - "list-protected-desc": "View all protected users and roles here. When someone pings one of these protected user(s)/role(s), a warning will be sent. Exceptions are when pinged by someone with a whitelisted role/as a whitelisted user or when it's sent in a whitelisted channel.", - "field-protected-users": "Protected Users", - "field-protected-roles": "Protected Roles", - "list-whitelist-title": "Whitelisted Roles, Users and Channels", - "list-whitelist-desc": "View all whitelisted roles, users and channels here. Whitelisted roles and users will not get a warning for pinging a protected entity, and pings from them or in whitelisted channels will be ignored.", - "field-wl-roles": "Whitelisted Roles", - "field-wl-channels": "Whitelisted Channels", - "field-wl-users": "Whitelisted Users", - "list-none": "None are configured.", - "modal-title": "Confirm data deletion for this user", - "modal-label": "Confirm data deletion by typing this phrase:", - "modal-phrase": "I understand that all data of this user will be deleted and that this action cannot be undone.", - "modal-failed": "The phrase you entered is incorrect. Data deletion cancelled.", - "modal-success-data-deletion": "All data for the user <@%u> (%u) has been deleted successfully", - "field-quick-history": "Quick history view (Last %w weeks)", - "field-quick-desc": "Pings history amount: %p\nModeration actions amount: %m", - "history-disabled": "History logging has been disabled by a bot-configurator.\nAre you (one of) the bot-configurators? You can enable history logging in the \"Data Storage\" tab in the 'ping-protection' module ^^", - "leaver-warning-long": "This user left the server at %d. These logs will stay until automatic deletion.", - "leaver-warning-short": "This user left the server at %d.", - "meme-why": "😐 [Why are you the way that you are?]() - You just pinged yourself..", - "meme-played": "🔑 [Congratulations, you played yourself.]()", - "meme-spider": "🕷️ [Is this you?]() - You just pinged yourself.", - "meme-rick": "🎵 [Never gonna give you up, never gonna let you down...]() You just Rick Rolled yourself. Also congrats you unlocked the secret easter egg that only has a 1% chance of appearing!!1!1!!", - "meme-grind": "Why are you even pinging yourself 5 times in a row? Anyways continue some more to possibly get the secret meme\n-# (good luck grinding, only a 1% chance of getting it and during testing I had it once after 83 pings)", - "label-jump": "Jump to Message", - "no-message-link": "This ping was blocked by AutoMod", - "list-entry-text": "%index. **Pinged %target** at %time\n%link" - } + "main": { + "startup-info": "SCNX-CustomBot v2 - Log-Level: %l", + "missing-moduleconf": "Missing moduleConfig-file. Automatically disabling all modules and overwriting modules.json later", + "sync-db": "Synced database", + "login-error": "Bot could not log in. Error: %e", + "login-error-token": "Bot could not log in because the provided token is invalid. Please update your token.", + "login-error-intents": "Bot could not log in because the intents were not enabled correctly. Please enable \"PRESENCE INTENT\", \"SERVER MEMBERS INTENT\" and \"MESSAGE CONTENT INTENT\" in your Discord-Developer-Dashboard: %url", + "not-invited": "Please invite the bot to your Discord server before continuing: %inv", + "require-code-grant-active": "You might be unable to invite your bot to your server as you have enabled the \"Require public code grant\" option in your Discord Developer Dashboard. Please disable this option: %d", + "interactions-endpoint-active": "You bot will be unable to respond to interactions, because the field \"Interactions Endpoint URL\" has a value in your Discord Developer Dashboard. Please remove any content from this field and restart your bot: %d", + "logged-in": "Bot logged in as %tag and is now online.", + "logchannel-wrong-type": "There is no Log-Channel set or it has the wrong type (only text-channels are supported).", + "config-check-failed": "Configuration-Check failed. You can find more information in your log. The bot exited.", + "bot-ready": "The bot initiated successfully and is now listening to commands", + "no-command-permissions": "Could not update server commands. Please give us permissions to performe this critical action: %inv", + "perm-sync": "Synced permissions for /%c", + "perm-sync-failed": "Failed to synced permissions for /%c: %e", + "loading-module": "Loading module %m", + "hidden-module": "Module %m is hidden, meaning that it is not available. Skipping…", + "module-disabled": "Module %m is disabled", + "command-loaded": "Loaded command %d/%f", + "command-dir": "Loading commands in %d/%f", + "global-command-sync": "Synced global application commands", + "guild-command-sync": "Synced server application commands", + "guild-command-no-sync-required": "Server application commands are up to date - no syncing required", + "global-command-no-sync-required": "Global application commands are up to date - no syncing required", + "event-loaded": "Loaded events %d/%f", + "event-dir": "Loading events in %d/%f", + "model-loaded": "Loaded database model %d/%f", + "model-dir": "Loading database model in %d/%f", + "loaded-cli": "Loaded API-Action %c in %p", + "channel-lock": "Locked channel", + "channel-unlock": "Unlocked channel", + "channel-unlock-data-not-found": "Unlocking channel with ID %c failed because it was never locked (which is weird to begin with).", + "module-disable": "Module %m got disabled because %r", + "migrate-success": "Migration from %o to %m finished successfully.", + "migrate-start": "Migration from %o to %m started... Please do not stop the bot", + "shutdown-deferred": "Shutdown requested but a database migration is in progress. Will shut down after migration completes.", + "shutdown-after-migration": "Migration complete, proceeding with shutdown.", + "uncaught-exception": "Uncaught exception: %e — continuing execution.", + "unhandled-rejection": "Unhandled promise rejection: %e — continuing execution.", + "discord-error": "Discord.js error: %e", + "shard-error": "Discord shard error: %e", + "shard-disconnect": "Disconnected from Discord (close event code: %c). Reconnection will be attempted automatically.", + "shard-reconnecting": "Reconnecting to Discord…", + "db-connect-error": "Could not connect to the database: %e — the bot will now exit.", + "cli-command-error": "CLI command error: %e", + "discord-api-error": "Could not reach the Discord API during startup: %e — some checks were skipped." + }, + "reload": { + "reloading-config": "Reloading configuration…", + "reloading-config-with-name": "User %tag is reloading the configuration…", + "reloaded-config": "Configuration reloaded successfully.\nOut of %totalModules modules, %enabled were enabled, %configDisabled were disabled because their configuration was wrong.", + "reload-failed": "Configuration reloaded failed. Bot shutting down.", + "reload-successful-syncing-commands": "Configuration reloaded successfully, syncing commands, to make sure permissions are up-to-date…", + "reload-failed-message": "**FAILED**\n```%r```\n**Please read your log to find more information**\nThe bot will kill itself now, bye :wave:", + "command-description": "Reloads the configuration" + }, + "config": { + "checking-config": "Checking configurations...", + "done-with-checking": "Done with checking. Out of %totalModules modules, %enabled were enabled, %configDisabled were disabled because their configuration was wrong.", + "creating-file": "Config %m/%f does not exist - I'm going to create it, please stand by...", + "checking-of-field-failed": "An error occurred while checking the content of field \"%fieldName\" in %m/%f", + "saved-file": "Configuration-File %f in %m was saved successfully.", + "moduleconf-regeneration": "Regenerating module configuration, no settings will be overwritten, don't worry.", + "moduleconf-regeneration-success": "Module configuration regeneration successfully finished.", + "channel-not-found": "Channel with ID \"%id\" could not be found", + "user-not-found": "User with ID \"%id\" could not be found", + "channel-not-on-guild": "Channel with ID \"%id\" is not on your server", + "channel-invalid-type": "Channel with ID \"%id\" has a type that can not be used for this field", + "role-not-found": "Role with ID \"%id\" could not be found on your server", + "config-reload": "Reloading all configuration..." + }, + "helpers": { + "timestamp": "%dd.%mm.%yyyy at %hh:%min", + "you-did-not-run-this-command": "You did not run this command. If you want to use the buttons, try running the command yourself.", + "next": "Next", + "back": "Back", + "toggle-data-fetch-error": "SC Network Release: Toggle-Data could not be fetched", + "toggle-data-fetch": "SC Network Release: Toggle-Data fetched successfully", + "duration-just-now": "just now", + "duration-minute": "%i minute", + "duration-minutes": "%i minutes", + "duration-hour": "%i hour", + "duration-hours": "%i hours", + "duration-day": "%i day", + "duration-days": "%i days", + "duration-month": "%i month", + "duration-months": "%i months", + "duration-year": "%i year", + "duration-years": "%i years" + }, + "command": { + "startup": "The bot is currently starting up. Please try again in a few minutes.", + "not-found": "Command not found", + "used": "%tag (%id) used command /%c", + "message-used": "%tag (%id) used command %p%c", + "execution-failed": "Execution of command /%c %g %s failed (Tracing: %t): %e", + "message-execution-failed": "Execution of command %p%c failed (Tracing: %t): %e", + "wrong-guild": "This command is only available on the server **%g**.", + "autcomplete-execution-failed": "Execution of auto-complete on command /%c %g %s with option %f failed: %e", + "execution-failed-message": "## 🔴 Command execution failed 🔴\nThis usually happens either due to misconfiguration or due to an error on our site. Please send a screenshot of this message in #support on the [ScootKit Discord](https://scootk.it/dc-en), to resolve this.\n\n### Internal Tracing ID\n`%t`\n### Debugging-Information\n```%e```", + "error-giving-role": "An error occurred when trying to give you your roles ):\nPlease ask the server administrators to confirm that the highest role of the bot is above the role that the bot is supposed to assign.", + "description-too-long": "The following command description of %c was too long to sync: %s", + "module-disabled": "This command is part of the \"%m\" which is disabled. This can either be intended by the server-admins (and slash-commands haven't synced yet) or this could be caused by a configuration error. Please check (or ask the admins) to check the bot's configuration and logs for details.", + "command-disabled": "This command is currently disabled by the server configuration. If you believe this is an error, please contact a server administrator." + }, + "help": { + "bot-info-titel": "ℹ️ Bot-Info", + "bot-info-description": "This bot is part of [SCNX](https://scnx.xyz/de?ref=custombot_help_embed), a plattform from [ScootKit](https://scootkit.net) allowing the creation of fully customizable for Discord communities, and is being hosted for \"%g\".", + "stats-title": "📊 Stats", + "stats-content": "Active modules: %am\nRegistered commands: %rc\nBot-Version: %v\nRunning on server %si\n[Server-Plan](https://scnx.xyz/plan): %pl\nLast restart: %lr\nLast reload: %lR", + "command-description": "Show every commands", + "slash-commands-title": "Slash-Commands", + "select-module-placeholder": "Select a module to view its commands", + "select-module-hint": "👇 Use the dropdown below to browse commands by module.", + "back-to-overview": "Back to overview", + "modules-overview": "📋 Modules & Commands", + "built-in-description": "Core commands built into the bot", + "custom-commands-label": "Custom Commands", + "custom-commands-description": "User-created custom slash commands" + }, + "bot-feedback": { + "command-description": "Send feedback about the bot to the bot developer", + "submitted-successfully": "Thanks so much for your feedback! It has been carefully recorded and our team will review it soon. If we have any questions, we may contact you via DM (or if you are on our [Support Server]() we'll open a ticket). Thank you for making [CustomBots]() better for everyone <3\n\nYour feedback is subject to our [Terms of service]() and [Privacy Policy]().", + "failed-to-submit": "Sorry, but I couldn't send your feedback to our staff. This could be, because you got blocked or because of some server issue we are having. You can always report bugs and submit feedback in our [Feature-Board](https://features.sc-network.net). Thank you.", + "feedback-description": "Your feedback. Make sure it's neutral, constructive and helpful" + }, + "admin-tools": { + "position": "%i has the position %p.", + "position-changed": "Changed %i's position to %p.", + "category-can-not-have-category": "A Category can not have a category", + "not-category": "Can not change category of channel to a not category channel", + "changed-category": "%c's category got set to %cat", + "command-description": "Execute some actions for admins via commands", + "new-position-description": "New position", + "movechannel-description": "See the position of a channel or change the position of a channel", + "moverole-description": "See the position of a role or change the position of a role", + "setcategory-description": "Sets the category of a channel", + "channel-description": "Channel on which this action should be executed", + "role-description": "Role on which this action should be executed", + "category-description": "New category of the channel", + "emoji-too-much-data": "Please **only** enter one emoji and nothing else", + "emoji-import": "Imported \"%e\" successfully.", + "stealemote-description": "Steals a emote from another server", + "emote-description": "Emote to steal", + "role-command-description": "Assign or remove roles permanently or temporarily", + "role-give-description": "Assign someone a role permanently or temporarily", + "role-user-add-description": "Member that you want to assign the role to", + "role-add-role-description": "Role you want to assign to the member", + "role-add-duration-description": "If you set this parameter, the role will be removed from this user after this duration expires", + "role-user-status-description": "User you want to see temporary roles from", + "role-remove-description": "Remove a role from someone permanently or temporarily", + "role-user-remove-description": "Member that you want to remove the role from", + "role-remove-role-description": "Role you want to remove from the member", + "role-remove-duration-description": "If you set this parameter, the role will be added back to this user after this duration expires", + "role-status-description": "Shows which roles of a user are temporary and when they will be removed", + "role-not-high-enough": "The highest role of the bot is not above %e. The highest role of the bot needs to be above the role you want to remove or assign.", + "unable-to-change-roles": "Changing role %r to %u failed. Error message obtained by Discord:\n```%e```", + "user-not-found": "The user has not been found on your server.", + "duration-wrong": "The value of the duration argument is wrong. Learn more [in our docs]()", + "audit-log-add": "[admin-tools] %u added a role using a command.", + "audit-log-remove": "[admin-tools] %u removed a role using a command.", + "audit-log-add-duration": "[admin-tools] %u added a temporary role using a command that will be removed at %t.", + "audit-log-remove-duration": "[admin-tools] %u removed a temporary role using a command that will be added back at %t.", + "audit-log-temporary-remove": "[admin-tools] This role was added temporarily and has removed since the temporary timeframe expired.", + "audit-log-temporary-add": "[admin-tools] This role has been removed temporarily and has been added back since the temporary timeframe expired.", + "role-add": "%u has been given the role %r.", + "role-remove": "%u has removed the role %r.", + "role-add-duration": "%u has been given the role %r. It will be removed at %t.", + "role-remove-duration": "%r has been removed from %u. It will be given back at %t.", + "user-without-temporary-action": "%u has no roles that are temporary.", + "user-temporary-action-header": "Temporary roles of %u", + "status-remove": "%r will be removed on %t.", + "status-add": "%r will be added back on %t.", + "users-trying-to-manage-higher-role": "Your highest role, %t, is not below %e. To manage a user's role, you the role you are managing needs to be below your highest role.", + "audit-log-role-ban": "[admin-tools] User banned for receiving the \"%r\" role. Reason: %reason" + }, + "welcomer": { + "channel-not-found": "[welcomer] Channel not found: %c", + "welcome-yourself-error": "Welcome, nice to meet you! This button is reversed for a special member of this server who want's to say \"Hi\" to you ^^" + }, + "months": { + "1": "January", + "2": "February", + "3": "March", + "4": "April", + "5": "May", + "6": "June", + "7": "July", + "8": "August", + "9": "September", + "10": "October", + "11": "November", + "12": "December" + }, + "levels": { + "leaderboard-channel-not-found": "Leaderboard-Channel not found or wrong type", + "leaderboard-notation": "%p. %u: Level %l - %xp XP", + "list-location": "[Level System] The live leaderboard is currently located here: %l. Delete the message and restart the bot, to re-send it.", + "leaderboard": "Leaderboard", + "no-user-on-leaderboard": "Can't generate a leaderboard, because no one has any XP which is odd, but that's how it is ¯\\_(ツ)_/¯", + "and-x-other-users": "and %uc other users", + "level": "Level %l", + "users": "Users", + "leaderboard-command-description": "Shows the leaderboard of this server", + "leaderboard-sortby-description": "How to sort the leaderboard (default: %d)", + "profile-command-description": "Shows the profile of you or an an user", + "profile-user-description": "User to see the profile from (default: you)", + "please-send-a-message": "Please send some messages before I can show you some data", + "no-role": "None", + "are-you-sure-you-want-to-delete-user-xp": "Okay, do you really want to screw with %u? If you hate them so much, feel free to run `/manage-levels reset-xp confirm:True user:%ut` to run this irreversible action.", + "are-you-sure-you-want-to-delete-server-xp": "Do you really want to delete all XP and Levels from this server? This action is irreversible and everyone on this server will hate you. Decided that it's worth it? Enter `/manage-levels reset-xp confirm:True`", + "user-not-found": "User not found", + "user-deleted-users-xp": "%t deleted the XP of the user with id %u", + "removed-xp-successfully": "`Removed %u's XP and level successfully.`", + "deleted-server-xp": "%u deleted the XP of all users", + "successfully-deleted-all-xp-of-users": "Successfully deleted all the XP of all users", + "cheat-no-profile": "This user doesn't have a profile (yet), please force them to write a message before trying to betrayal your community by manipulating level scores.", + "manipulated": "%u manipulated the XP of %m to %v (level %l)", + "successfully-changed": "Successfully edited the XP of %u - they are now **level %l** with **%x XP**.\nRemember, every change you make destroys the experience of other users on this server as the levelsystem isn't fair anymore.", + "edit-xp-command-description": "Manage the levels of your server", + "negative-xp": "This user would have a negative XP value which is not possible", + "negative-level": "This user would have a level below one which is not possible", + "xp-out-of-range": "This XP value is too large. Please choose a value below 1,000,000,000,000.", + "level-out-of-range": "This level value is too large. Please choose a value below 1,000,000.", + "reset-xp-description": "Reset the XP of a user or of the whole server", + "reset-xp-user-description": "User to reset the XP from (default: whole server)", + "reset-xp-confirm-description": "Do you really want to delete the data?", + "edit-xp-user-description": "User to edit", + "edit-xp-value-description": "New XP value of the user", + "edit-xp-description": "Betrays your community and edits a user's XP", + "no-custom-formula": "No valid custom formula was entered. Using default formula.", + "invalid-custom-formula": "Invalid custom formula was entered. Please either fix the syntax of your custom formula or remove the value of the custom formula field.", + "role-factors-total": "Multiplied together, the user receives **%f times more XP** for every message.", + "edit-level-description": "Betrays your community and edits a user's levels", + "random-messages-enabled-but-non-configured": "You have random messages enabled, but have non configured. Ignoring config.randomMessages configuration.", + "granted-rewards-audit-log": "Updated roles to make sure, they have the level role they need" + }, + "team-list": { + "channel-not-found": "Could not find channel with ID %c or the channel has a wrong type (only text-channels supported)", + "role-not-found": "Could not find role with ID %r", + "no-users-with-role": "No users on this server have the %r role yet.", + "no-roles-selected": "No roles listed yet.", + "offline": "Offline", + "dnd": "Do not disturb", + "idle": "Away", + "online": "Online" + }, + "ping-on-vc-join": { + "channel-not-found": "Notify channel %c not found", + "could-not-send-pn": "Could not send PN to %m" + }, + "suggestions": { + "suggestion-not-found": "Suggestion not found", + "updated-suggestion": "Successfully updated suggestion", + "suggest-description": "Create and comment on suggestions", + "suggest-content": "Content you want to suggest", + "loading": "A wild new suggestion appeared, loading..", + "manage-suggestion-command-description": "Manage suggestions as an admin", + "manage-suggestion-accept-description": "Accepts a suggestion", + "manage-suggestion-deny-description": "Denies a suggestion", + "manage-suggestion-id-description": "ID of the suggestion", + "manage-suggestion-comment-description": "Explain why you made this choice" + }, + "auto-delete": { + "could-not-fetch-channel": "Could not fetch channel with ID %c", + "could-not-fetch-messages": "Could not fetch messages from channel with ID %c" + }, + "auto-thread": { + "thread-create-reason": "This thread got created, because you configured auto-thread to do so" + }, + "auto-messager": { + "channel-not-found": "Channel with ID %id not found" + }, + "polls": { + "what-have-i-votet": "What have I voted?", + "vote": "Vote!", + "vote-this": "Click on this option to place your vote here", + "voted-successfully": "Successfully voted. Thanks for your participation.", + "not-voted-yet": "You have not voted yet, so I can't show you what you voted.", + "you-voted": "You have voted for **%o**.", + "remove-vote": "Remove my vote", + "removed-vote": "Your vote was removed successfully.", + "change-opinion": "You can change your opinion anytime by just selecting something else above the button you just clicked.", + "command-poll-description": "Create and end polls", + "command-poll-create-description": "Create a new poll", + "command-poll-end-description": "Ends an existing poll", + "command-poll-end-msgid-description": "ID of the poll", + "command-poll-create-description-description": "Topic / Description of this poll", + "command-poll-create-channel-description": "Channel in which the poll should get created", + "command-poll-create-option-description": "Option number %o", + "command-poll-create-endAt-description": "Duration of the poll (if not set the poll will not end automatically)", + "command-poll-create-public-description": "If enabled (disabled by default) the votes of users will be displayed publicly", + "created-poll": "Successfully created poll in %c.", + "not-found": "Poll could not be found", + "no-votes-for-this-option": "Nobody voted this option yet", + "ended-poll": "Poll ended successfully", + "view-public-votes": "View current voters", + "not-public": "This poll does not appear to be public, no results can be displayed.", + "poll-private": "🔒 This poll is **anonymous**, meaning that no one can see what you voted (not even the admins).", + "poll-public": "🔓 This poll is **public**, meaning that everyone can see what you voted.", + "not-text-channel": "You need to select a text-channel that is not an announcement-channel." + }, + "channel-stats": { + "audit-log-reason-interval": "Updated channel because of interval", + "audit-log-reason-startup": "Updated channel because of startup", + "not-voice-channel-info": "Channel \"%c\" (%id) is a %t and not a voice-channel as recommended" + }, + "info-commands": { + "info-command-description": "Find information about parts of this server", + "command-userinfo-description": "Find more information about a user on this server", + "argument-userinfo-user-description": "User you want to see information about (default: you)", + "command-roleinfo-description": "Find more information about a role on this server", + "argument-roleinfo-role-description": "Role you want to see information about", + "command-channelinfo-description": "Find more information about a channel on this server", + "argument-channelinfo-channel-description": "Channel you want to see information about", + "command-serverinfo-description": "Find more information about this server", + "information-about-role": "Information about the role %r", + "hoisted": "Hoisted", + "mentionable": "Mentionable", + "managed": "Managed", + "information-about-channel": "Information about the channel %c", + "information-about-user": "Information about the user %u", + "information-about-server": "Information about %s", + "boostLevel": "Level", + "boostCount": "Boosts", + "userCount": "Users", + "memberCount": "Members", + "onlineCount": "Online", + "textChannel": "Text", + "voiceChannel": "Voice", + "categoryChannel": "Categories", + "otherChannel": "Other", + "total-invites": "Total", + "active-invites": "Active", + "left-invites": "Left" + }, + "channelType": { + "GUILD_TEXT": "Text-Channel", + "GUILD_VOICE": "Voice-Channel", + "GUILD_CATEGORY": "Category", + "GUILD_NEWS": "News-Channel", + "GUILD_STORE": "Store-Channel", + "GUILD_NEWS_THREAD": "News-Channel-Thread", + "GUILD_PUBLIC_THREAD": "Public Thread", + "GUILD_PRIVATE_THREAD": "Private Thread", + "GUILD_STAGE_VOICE": "Stage-Channel", + "DM": "Direct-Message", + "GROUP_DM": "Group-Direct-Message", + "UNKNOWN": "Unknown" + }, + "stagePrivacy": { + "PUBLIC": "Publicly accessible", + "GUILD_ONLY": "Only server members can join" + }, + "guildVerification": { + "0": "None", + "1": "Low", + "2": "Medium", + "3": "High", + "4": "Very high" + }, + "boostTier": { + "0": "None", + "1": "Level 1", + "2": "Level 2", + "3": "Level 3" + }, + "temp-channels": { + "removed-audit-log-reason": "Removed temp channel, because no one was in it", + "permission-update-audit-log-reason": "Updated permissions, to make sure only people in the VC can see the no-mic-channel", + "created-audit-log-reason": "Created Temp-Channel for %u", + "move-audit-log-reason": "Moved user to their voice channel", + "no-mic-channel-topic": "Welcome to %u's no-mic-channel. You will see this channel as long as you are connected to this temp-channel.", + "disconnect-audit-log-reason": "The old channel of the user could not be found - disconnecting them - hopefully they join again", + "command-description": "Manage your temp-channel", + "mode-subcommand-description": "Change the mode of your channel", + "public-option-description": "If enabled, anyone can join your temp-channel", + "add-subcommand-description": "Add users, that will be able to join your channel, while it is private", + "remove-subcommand-description": "Remove users from you channel", + "add-user-option-description": "The user to be added", + "remove-user-option-description": "The user to be removed", + "list-subcommand-description": "List the users with access to your channel", + "edit-subcommand-description": "Edit various settings of your channel", + "user-limit-option-description": "Change the user-limit of your channel", + "bitrate-option-description": "Change the bitrate of your channel (min. 8000)", + "name-option-description": "Change the name of your channel", + "nsfw-option-description": "Change, whether your channel is age-restricted or not", + "no-added-user": "There are no users to be displayed here", + "nothing-changed": "Your channel already had these settings.", + "no-disconnect": "Couldn't disconnect the user from your channel. This could be due to missing permissions, or the user not being in your voice-channel", + "edit-error": "An error occurred while editing your channel. one or more of your settings couldn't be applied. This could be due to missing permissions or an invalid value.", + "add-user": "Add user", + "remove-user": "Remove user", + "list-users": "List users", + "private-channel": "Private", + "public-channel": "Public", + "edit-channel": "Edit channel", + "add-modal-title": "Add an user to your temp-channel", + "add-modal-prompt": "The user you want to add (tag or user-id)", + "remove-modal-title": "Remove an user from your temp-channel", + "remove-modal-prompt": "The user you want to remove (tag or user-id)", + "edit-modal-title": "Edit your temp-channel", + "edit-modal-nsfw-prompt": "Mark temp-channel as age-restricted?", + "edit-modal-nsfw-placeholder": "\"true\" (yes) or \"false\" (no)", + "edit-modal-nsfw-on": "Yes (age-restricted)", + "edit-modal-nsfw-off": "No (not age-restricted)", + "edit-modal-bitrate-prompt": "Bitrate of your Temp-channel?", + "edit-modal-bitrate-placeholder": "A number over 8000", + "edit-modal-limit-prompt": "Limit of users in your temp-channel", + "edit-modal-limit-placeholder": "Number between 0 and 99; 0 = unlimited", + "edit-modal-name-prompt": "How should your channel be called?", + "edit-modal-name-placeholder": "A very creative channel name", + "edit-modal-username-placeholder": "Username of the user", + "user-not-found": "User not found" + }, + "guess-the-number": { + "command-description": "Manage your guess-the-number-games", + "status-command-description": "Shows the current status of a guess-the-number-game in this channel", + "create-command-description": "Create a new guess-the-number-game in this channel", + "create-min-description": "Minimal value users can guess", + "create-max-description": "Maximal value users can guess", + "create-number-description": "Number users should guess to win", + "end-command-description": "Ends the current game", + "session-already-running": "There is a session already running in this channel. Please end it with /guess-the-number end", + "session-not-running": "There is currently no session running.", + "gamechannel-modus": "You can't use this command in a gamechannel. To end a game, disable the gamechannel modus and try using the end command.", + "session-ended-successfully": "Ended session successfully. Locked channel successfully.", + "current-session": "Current session", + "number": "Number", + "min-val": "Min-Value", + "max-val": "Max-Value", + "owner": "Owner", + "guess-count": "Count of guesses", + "min-max-discrepancy": "`min` can't be bigger or equal to `max`", + "max-discrepancy": "`number` can't be bigger than `max`.", + "min-discrepancy": "`number` can't be smaller than `min`.", + "emoji-guide-button": "What does the reaction under my guess mean?", + "guide-wrong-guess": "Your guess was wrong (but your entry was valid)", + "guide-win": "You guessed correctly - you win :tada:", + "guide-admin-guess": "Your guess was invalid, because you are an admin - admins can't participate because they can see the correct number", + "guide-invalid-guess": "Your guess was invalid (e.g. below the minimal / over the maximal number, not a number, …)", + "created-successfully": "Created game successfully. Users can now start guessing in this channel. The winning number is **%n**. You can always check the status by running `/guess-the-number-status`. Note that you as an admin can not guess.", + "game-ended": "Game ended", + "game-started": "Game started", + "leaderboard-button": "Leaderboard", + "leaderboard-title": "Guess-the-Number Leaderboard", + "leaderboard-empty": "No games have been won yet.", + "wins": "wins", + "guesses": "guesses" + }, + "massrole": { + "command-description": "Manage roles for all members", + "add-subcommand-description": "Add a role to all members", + "remove-subcommand-description": "Remove a role from all members", + "remove-all-subcommand-description": "Remove all roles from all members", + "role-option-add-description": "The role, that will be given to all members", + "role-option-remove-description": "The role, that will be removed from all members", + "target-option-description": "Determines whether bots should be included or not", + "all-users": "All Users", + "bots": "Bots", + "humans": "Humans", + "not-admin": "⚠ To use this command, you need to be added to the adminRoles option in the SCNX-Dashboard. If you are the owner of this bot please remember to create an override in the server settings to prevent abuse of this command.", + "add-reason": "Mass role addition by %u", + "remove-reason": "Mass role removal by %u" + }, + "twitch-notifications": { + "channel-not-found": "Channel with ID %c could not be found", + "user-not-on-twitch": "Could not find user %u on twitch", + "message-not-found": "No live message configured for streamer %s" + }, + "fun": { + "slap-command-description": "Slap a user in the face", + "user-argument-description": "User to performe this action on", + "no-no-not-slapping-yourself": "You can not punch yourself lol (well technically you can, but our gifs do not support that, so deal with it ¯\\_(ツ)_/¯)", + "pat-command-description": "Pat someone nicely", + "no-no-not-patting-yourself": "Well, good try, but we don't do this here", + "no-no-not-kissing-yourself": "Uah, that's gross, you should try paying somebody to do that (well you should not, but better then kissing yourself)", + "kiss-command-description": "Kiss someone", + "hug-command-description": "Hug someone <3", + "no-no-not-hugging-yourself": "You are quite lonely aren't you? Try hugging a tree, that should work. Unless you live in a desert. Then hug a cactus. That's a bit more painful, but trust me.", + "random-command-description": "Helps you select random things", + "random-number-command-description": "Selects a random number", + "min-argument-description": "Minimal number (default: 1)", + "max-argument-description": "Maximal number (default: 42)", + "random-ikeaname-command-description": "Generates a random name for a IKEA-Name", + "syllable-count-argument-description": "Count of syllables to generate name from (default: random)", + "random-dice-command-description": "Roll a dice", + "random-coinflip-command-description": "Flip a coin", + "random-8ball-command-description": "Generates an answer to a yes/no question", + "dice-site-1": "Heads", + "dice-site-2": "Tails" + }, + "moderation": { + "moderate-command-description": "Moderate users on your server", + "moderate-notes-command-description": "Set or see moderator's notes of a user", + "moderate-notes-command-view": "View a user's notes", + "moderate-notes-command-create": "Create a new note about a user", + "moderate-notes-command-edit": "Edit one of your existing notes about a user", + "moderate-notes-command-delete": "Delete one of your existing notes about a user", + "moderate-ban-command-description": "Ban a user on your server", + "moderate-reason-description": "Reason for your action", + "moderate-proof-description": "Proof for your action", + "report-user-not-found-on-guild": "This user could not be found on \"%s\". You can only report users that are members of our server.", + "proof": "Proof", + "report-proof-description": "Attach an optional (image) proof to your report", + "file": "File uploaded", + "anti-grief-reason": "Too many actions of type \"%type\" in the last %h hours. Maximum amount allowed: %n", + "anti-grief-user-message": "Sorry, but it seems like you are abusing your moderative powers. We've taken actions to prevent this from happening.", + "moderate-duration-description": "Duration of the action (max: 28 days, default: 14 days)", + "mute-max-duration": "Discord limits the maximal duration of a timeout to 28 days. Please enter an amount equal or less than this", + "moderate-quarantine-command-description": "Quarantine a user on your server", + "moderate-unquarantine-command-description": "Removes a user from the quarantine", + "moderate-unban-command-description": "Revokes an existing ban", + "moderate-clear-command-description": "Clears messages in the current channel", + "moderate-clear-amount-description": "How many messages should get cleared?", + "moderate-kick-command-description": "Kick a user from your server", + "moderate-unwarn-command-description": "Revokes a warning", + "moderate-mute-command-description": "Mute a user on your server", + "moderate-unmute-command-description": "Unmutes a user on your server", + "moderate-warn-command-description": "Warn a user", + "moderate-channel-mute-description": "Mutes a user from the current channel", + "moderate-unchannel-mute-description": "Removes a channel-mute from this channel", + "moderate-lock-command-description": "Lock the current channel", + "moderate-unlock-command-description": "Unlock the current channel", + "moderate-lockdown-command-description": "Activate or lift server-wide lockdown", + "moderate-lockdown-enable-description": "True to activate lockdown, false to lift it", + "lockdown-not-enabled": "The lockdown system is not enabled. Enable it in the lockdown configuration.", + "lockdown-already-active": "A lockdown is already active.", + "lockdown-not-active": "No lockdown is currently active.", + "lockdown-activated": "Server Lockdown Activated", + "lockdown-lifted": "Server Lockdown Lifted", + "lockdown-activated-reply": "Lockdown activated. %c channels have been locked.", + "lockdown-lifted-reply": "Lockdown lifted. %c channels have been restored.", + "lockdown-log-description": "**Reason:** %r\n**Triggered by:** %u\n**Type:** %t\n**Affected channels:** %c", + "lockdown-lift-log-description": "**Reason:** %r\n**Lifted by:** %u\n**Restored channels:** %c", + "lockdown-automatic": "Automatic", + "lockdown-manual": "Manual", + "lockdown-system": "System", + "lockdown-auto-lift-reason": "Auto-lift timer expired", + "lockdown-restored": "Lockdown state restored from database after restart", + "lockdown-joinraid-trigger": "Join raid detected", + "lockdown-spam-trigger": "Excessive spam detected", + "lockdown-joingate-trigger": "Excessive join-gate violations detected", + "lockdown-restore-failed": "Failed to restore permissions for channel %c: %e", + "lockdown-users-kicked": "Users Kicked", + "lockdown-users-kicked-description": "%k non-moderator users were disconnected from voice channels.", + "moderate-user-description": "User on who the action should get performed", + "moderate-userid-description": "ID of a user", + "moderate-days-description": "Number of days of messages to delete", + "invalid-days": "Days can only be between 0 and 7 (inclusive)", + "moderate-notes-description": "Notes to set / update", + "moderate-note-id-description": "ID of one of your notes you want to edit (leave blank to create a new one)", + "moderate-warnid-description": "ID of a warn (run /moderate actions to get it)", + "moderate-actions-command-description": "Show all recorded actions against a user", + "report-command-description": "Reports a user and sends a snapshot of the chat to server staff", + "report-reason-description": "Please describe what the user did wrong", + "report-user-description": "User you want to report", + "no-reason": "Not set", + "muterole-not-found": "Could not find muterole. Can not perform this action", + "quarantinerole-not-found": "Could not find quarantinerole. Can not perform this action", + "mute-audit-log-reason": "Got muted by %u because of \"%r\"", + "unmute-audit-log-reason": "Got unmuted by %u because of \"%r\"", + "quarantine-audit-log-reason": "Got quarantined by %u because of \"%r\"", + "kicked-audit-log-reason": "Got kicked by %u because of \"%r\"", + "banned-audit-log-reason": "Got banned by %u because of \"%r\"", + "channelmute-audit-log-reason": "Got channel-mutet by %u because of \"%r\"", + "unchannelmute-audit-log-reason": "The Channel-Mute got removed by %u because of \"%r\"", + "unbanned-audit-log-reason": "Got unbanned by %u because of \"%r\"", + "unquarantine-audit-log-reason": "Got unquarantined by %u because of \"%r\"", + "action-expired": "Action expired", + "auto-mod": "Auto-Mod", + "batch-role-remove-failed": "Could not remove all roles from %i (trying to remove roles one by one): %e", + "batch-role-add-failed": "Could not add all roles to %i (trying to remove roles one by one): %e", + "could-not-remove-role": "Could not remove role %r from %i: %e", + "could-not-add-role": "Could not add role %r to %i: %e", + "reason": "Reason", + "join-gate": "Join-Gate", + "expires-at": "Action expires on", + "action": "Action", + "case": "Case", + "victim": "Victim", + "missing-logchannel": "LogChannel could not be found", + "reached-warns": "Reached %w warns", + "restored-punishment-audit-log-reason": "Restored punishment", + "anti-join-raid": "ANTI-JOIN-RAID", + "raid-detected": "Raid detected", + "joingate-for-everyone": "Join-Gate-Modus: Catch all users", + "account-age-to-low": "Account creation age of %a days is to low (required are more then %c)", + "no-profile-picture": "Account has no profile picture (required)", + "join-gate-fail": "Account failed Join-Gate (%r)", + "blacklisted-word": "Posted blacklisted word in %c", + "invite-sent": "Sent invite in %c", + "scam-url-sent": "Sent scam-url in %c", + "anti-spam": "Anti-Spam", + "reached-messages-in-timeframe": "Reached %m (normal) messages in less than %t seconds", + "reached-duplicated-content-messages": "Reached %m messages with the same content in less than %t", + "reached-ping-messages": "Reached %m messages with (user) pings in less then %t seconds", + "reached-massping-messages": "Reached %m messages with mass pings in less than %t seconds", + "action-done": "Executed action successfully. Action-ID: #%i", + "expiring-action-done": "Done. Action will expire on %d. Action-ID: #%i", + "cleared-channel": "Cleared channel successfully.\nNote: Messages older than 14 days can not be deleted using this method.", + "clear-failed": "An error occurred. You can only delete 100 messages at once.", + "no-quarantine-action-found": "Sorry, but I couldn't find any records of quarantining this users.", + "locked-channel-successfully": "Locked channel successfully. Only moderators (and admins) can write messages here now.", + "unlocked-channel-successfully": "Unlocked channel successfully. Permissions got restored to the permission-state before the lock occurred.", + "unlock-audit-log-reason": "User %u unlocked this channel by running /moderate unlock", + "warning-not-found": "I could not find this warning. Please make sure you are actually using a warning-id and not a userid.", + "can-not-report-mod": "You can not report moderators.", + "action-description-format": "%reason\nby %u on %t", + "no-actions-title": "None found", + "no-actions-value": "No actions against %u found.", + "actions-embed-title": "Mod-Actions against %u - Site %i", + "actions-embed-description": "You can find every action against %u here.", + "report-embed-title": "New report", + "report-embed-description": "A user reported another user. Please review the case and take actions if needed.", + "reported-user": "Reported user", + "report-reason": "Reason for the report", + "report-user": "User who submitted report", + "message-log": "Last 100 messages", + "message-log-description": "You can find an encrypted message-log [here](%u).", + "channel": "Channel", + "no-report-pings": "No pings configured. Check your configuration to ping your staff.", + "not-allowed-to-see-own-notes": "Sorry, but you are not allowed to see your own notes.", + "note-added": "Note added successfully", + "note-edited": "Edited note successfully", + "note-deleted": "Note deleted successfully", + "note-not-found-or-no-permissions": "Note not found or no permissions to edit this note.", + "notes-embed-title": "Notes about %u", + "info-field-title": "ℹ️ Information", + "no-notes-found": "No notes about this user. Create a new note with `/moderate notes create` and set the notes attribute.", + "more-notes": "%x other moderator also added notes about this user. Notes are sorted in reverse chronology, so you will see the newest notes first.", + "user-notes-field-title": "%t's notes", + "user-not-on-server": "I can't perform this action on this user, as they are not currently on your server.", + "verification": "VERIFICATION", + "verification-failed": "Verification failed", + "verification-started": "Verification got started", + "verification-completed": "Verification completed", + "user": "User", + "manual-verification-needed": "Manual verification needed", + "verification-deny": "Deny verification", + "verification-approve": "Approve verification", + "verification-skip": "Skip verification", + "captcha-verification-pending": "Captcha-Verification is pending. You can either wait for the user to complete it or skip it manually.", + "verification-update-proceeded": "Successfully update verification status", + "verify-channel-set-but-not-found-or-wrong-type": "The configured verify-channel could not be found or it's type is not supported.", + "generating-message": "We are preparing some stuff, this message should get edited shortly...", + "restart-verification-button": "Restart verification process", + "member-not-found": "This user could not be found, maybe they already left?", + "already-verified": "Seems like you are already verified... Why would you want to repeat this process?", + "restarted-verification": "I have sent you another DM about your verification process. Please read it carefully and follow the actions described in it. Please not that this action did not re-trigger the manual-verification (if enabled), so spamming this button is useless.", + "dms-still-disabled": "It seems like your DMs are still disabled. Please enable your DMs to start the verification. This is not optional, you need to do this in order to get access to %g.", + "dms-not-enabled-ping": "%p, it seems like you have your DMs disabled. Please enable them and hit the button below this message to verify yourself. You have two minutes to complete this process.", + "verify-me-button": "Verify Me", + "enter-solution-button": "Enter Solution", + "verification-submitted": "Your verification request has been submitted. A moderator will review it shortly.", + "already-pending-review": "Your verification request is already being reviewed by a moderator.", + "captcha-expired": "Your captcha has expired. Please click Verify Me again.", + "retry-message": "Wrong answer. You can try again in %t. (Attempt %a/%m)", + "cooldown-message": "⏳ Please wait %t% before trying again.", + "retries-exhausted": "You have exhausted all verification attempts.", + "simple-math-challenge": "What is %a %op %b?", + "simple-word-challenge": "Type the following word: %w", + "captcha-solution-label": "Enter the captcha solution", + "simple-solution-label": "Enter your answer", + "verification-modal-title": "Verification" + }, + "counter": { + "created-db-entry": "Initialized database entry for %i", + "not-a-number": "This is not a number. You can not chat here. Try creating a thread if your message is that important.", + "restriction-audit-log": "This user proceeded to abuse the counter channel after five warnings, so we locked them out.", + "only-one-message-per-person": "Users have to take turns counting: You can not count two times in a row.", + "not-the-next-number": "That's not the next number. The next number is **%n**, please make sure you are counting up one by one.", + "channel-topic-change-reason": "Someone counted, so we updated the description as required by the configuration" + }, + "tickets": { + "channel-not-found": "Ticket-Create-Channel could not be found", + "existing-ticket": "You already have a ticket open: %c", + "ticket-created-audit-log": "%u created a new ticket by clicking the button", + "ticket-created": "Successfully created ticket and notified staff. Head over to it: %c", + "no-admin-pings": "No pings configured. Check your configuration to ping your staff.", + "ticket-closed-successfully": "Closed ticket successfully. This channel will be deleted in a few seconds, thanks for reaching out to our support.", + "ticket-closed-audit-log": "%u closed ticket", + "closing-ticket": "Closing ticket as requested by %u...", + "ticket-with-user": "👤 Ticket-User", + "could-not-dm": "Could not DM %u: %r", + "no-log-channel": "Log-Channel not found", + "ticket-log-embed-title": "📎 Ticket %i closed", + "ticket-log": "Ticket-Log", + "ticket-type": "☕ Ticket-Topic", + "ticket-log-value": "Transcript with %n messages can be found [here](%u).", + "closed-by": "👷 Ticket closed by" + }, + "reminders": { + "command-description": "Set a reminder for yourself", + "in-description": "After what time should we remind you? (eg. \"2h 30m\")", + "what-description": "What should we remind you about?", + "dm-description": "Should we send you a DM instead of reminding your in this channel?", + "one-minute-in-future": "Your reminder needs to be at least one minute in the future", + "reminder-set": "Reminder set. We'll remind you at %d.", + "snooze-10m": "10 min", + "snooze-30m": "30 min", + "snooze-1h": "1 hour", + "snooze-1d": "1 day", + "snoozed": "Reminder snoozed. We'll remind you again at %d.", + "snooze-not-allowed": "You can only snooze your own reminders." + }, + "afk-system": { + "command-description": "Manage your AFK-Status on this server", + "end-command-description": "End your current AFK-Session", + "start-command-description": "Start a new AFK-Session", + "reason-option-description": "Explain why you started this session", + "autoend-option-description": "If enabled, the bot will auto-end your AFK Session when your write a message (default: enabled)", + "no-running-session": "You don't have any session running.", + "already-running-session": "You already have an AFK-Session running, try ending it with `/afk-system end`.", + "afk-nickname-change-audit-log": "Updated user nickname because they started an AFK-Session", + "can-not-edit-nickname": "Can not edit nickname of %u: %e" + }, + "tic-tac-toe": { + "command-description": "Play tic-tac-toe against someone in the chat", + "user-description": "User to play against", + "challenge-message": "%t, %u challenged you to a game of tic-tac-toe! Hit the button below to join the battle! This invitation will expire in about 2 minutes, so don't hesitate to much.", + "accept-invite": "Join game", + "deny-invite": "No thanks", + "self-invite-not-possible": "Are you really that lonely? Even Simon, a complete introvert with no friends and developer of this bot, can find another user to play tic-tac-toe with... You should be able to do that too, try inviting %r for example, maybe they want to play a round?", + "invite-expired": "Sorry, %u, %i didn't accept your request to play tic-tac-toe in time ):", + "invite-denied": "Sorry, %u, but %i denied your request to play a round of tic-tac-toe ):", + "you-are-not-the-invited-one": "Sorry, but this invite doesn't belong to you. You can start your own game with `/tic-tac-toe`.", + "playing-header": "**TIC-TAC-TOE GAME IS RUNNING**\n\n%u (🟢) VS %i (🟡)\nCurrently on turn: %t\n\n%t, click a button with a white circle below to place your marker", + "win-header": "**TIC-TOE-GAME ENDED**\n\n%u (🟢) VS %i (🟡)\n\n%w won the game - GG!\n\n*You can start a new round by using `/tic-tac-toe`*", + "draw-header": "**TIC-TOE-GAME ENDED**\n\n%u (🟢) VS %i (🟡)\n\nDraw - no one won this game.", + "not-your-turn": "It's not your turn, take a coffee and return later" + }, + "duel": { + "command-description": "Play duel against someone in the chat", + "user-description": "User to play against", + "challenge-message": "%t, %u challenged you to a game of duel! Hit the button below to join the battle! This invitation will expire in about 2 minutes, so don't hesitate to much.", + "accept-invite": "Join game", + "deny-invite": "No thanks", + "self-invite-not-possible": "Are you really that lonely? Even Simon, a complete introvert with no friends and developer of this bot, can find another user to play duel with... You should be able to do that too, try inviting %r for example, maybe they want to play a round?", + "invite-expired": "Sorry, %u, %i didn't accept your request to play duel in time ):", + "invite-denied": "Sorry, %u, but %i denied your request to play a round of duel ):", + "you-are-not-the-invited-one": "Sorry, but this invite doesn't belong to you. You can start your own game with `/duel`.", + "game-running-header": "🎮 Game running", + "what-do-you-want-to-do": "**Select your action!**", + "pending": "⏳ Waiting for selection…", + "ready": "✅ Ready", + "continues-info": "The game continues once both parties have selected their next action.", + "how-does-this-game-work": "Wondering how this game works? Read our short explanation [here]().", + "use-gun": "Use gun", + "guard": "Guard", + "reload": "Load gun", + "game-ended": "🎮 Game ended", + "no-bullets": "Sorry, but you haven't loaded any bullets yet, so you can't use your gun yet.", + "bullets-full": "Sorry, but your gun only has place for 5 bullets at a time.", + "gun-gun": "Both %g1 and %g1 draw their guns. They stare each other and their eyes and slowly lower their weapons. No, the duell won't be resolved if both die - there can only be one winner.", + "guard-gun": "%g1 draws their gun and shoot - %d1 dodged the bullet successfully.", + "guard-guard": "Both %d1 and %d2 wait for each other to fire the shot - but nothing happens.", + "reload-gun": "While %r1 starts reloading their gun, %g1 draws their weapon and shoots - it's a head-shot. %r1 drops to the ground. %g1 should celebrate because they won, but they are left feeling bad for murdering their old friend.", + "guard-over-reload-gun": "As this is %r1's fifth guard in a row, they are tired and are to slow - %g1 shoots them directly into their head and %r1 drops to the ground. It's a win for %g1 - but at what price?", + "reload-reload": "Both %r1 and %r2 stare each other in the eyes while taking a short break to load one bullet each in their chamber.", + "reload-guard": "%d1 prepares to doge a bullet - but %r1 uses the time to load their weapon - no shots get fired.", + "ended-state": "This game ended. You can start a new duel with `/duel`.", + "not-your-game": "You are not one of players - you can start a new game with `/duel`." + }, + "economy-system": { + "work-earned-money": "The user %u gained %m %c by working", + "crime-earned-money": "The user %u gained %m %c by committing a crime", + "message-drop-earned-money": "The user %u gained %m %c by getting a message drop", + "rob-earned-money": "The user %u gained %m %c by robbing from %v", + "weekly-earned-money": "The user %u gained %m %c by cashing in their weekly reward", + "daily-earned-money": "The user %u gained %m %c by cashing in their daily reward", + "admin-self-abuse": "The admin %a wanted to abuse their permissions by giving them self even more money! This can't and should not be ignored!", + "admin-self-abuse-answer": "What a bad admin you are, %u. I'm disappointed with you! I need to report this. If I wish I could ban you!", + "added-money": "%i %c has been added to the balance of %u", + "removed-money": "%i %c has been removed from the balance of %u", + "set-money": "The balance of %u has been set to %i.", + "added-money-log": "The user %u added %i %c to the balance of %v", + "removed-money-log": "The user %u removed %i %c from the balance of %v", + "set-money-log": "The user %u set %v's balance to %i %c", + "command-description-main": "Use the economy-system", + "command-description-work": "Earn some cash by working", + "command-description-crime": "Earn some cash by committing a crime", + "command-description-rob": "Rob some cash from another user", + "option-description-rob-user": "User to rob from", + "crime-loose-money": "The user %u lost %m %c by committing a crime", + "command-description-daily": "Cash in your daily rewards", + "command-description-weekly": "Cash in your weekly rewards", + "command-description-balance": "Show the balance of a user", + "option-description-user": "User to execute action upon", + "command-description-add": "Add some cash to a user", + "command-description-remove": "Remove some cash from a user", + "option-description-amount": "Amount to manipulate", + "command-description-set": "Set a user's balance", + "option-description-balance": "Balance to set user to", + "message-drop": "Message-Drop: You earned %m %c simply by chatting!", + "created-item": "The user %u has created a new shop item: %i", + "item-duplicate": "The item already exist", + "role-to-high": "The specified role is higher than the highest role of the bot. Therefore the bot can't give the role to users. The item was **not** created.", + "delete-item": "The user %u has deleted the shop item %i", + "edit-item": "The user %u has edited the item %i. Possible changes are:\nNew name: %n\nNew price: %p\nNew role: %r", + "user-purchase": "The user %u has purchased the shop item %i for %p.", + "shop-command-description": "Use the shop-system", + "shop-command-description-add": "Create a new item in the shop (admins only)", + "shop-option-description-itemName": "Name of the item", + "shop-option-description-newItemName": "New name of the Item", + "shop-option-description-itemID": "ID of the Item", + "shop-option-description-price": "Price of the item", + "shop-option-description-role": "Role to give to users who buy the item", + "shop-command-description-buy": "Buy an item", + "shop-command-description-list": "List all items in the shop", + "shop-command-description-delete": "Remove an item from the shop", + "shop-command-description-edit": "Edit an item", + "channel-not-found": "Can't find the leaderboard channel with the ID %c", + "command-description-deposit": "Deposit xyz to your bank", + "option-description-amount-deposit": "Amount to deposit", + "command-description-withdraw": "Withdraw xyz from your Bank", + "option-description-amount-withdraw": "Amount to withdraw", + "command-group-description-msg-drop-msg": "Enable/ Disable the Message-Drop-Message", + "command-description-msg-drop-msg-enable": "Enable the Message-Drop-Message", + "command-description-msg-drop-msg-disable": "Disable the Message-Drop-Message", + "command-description-destroy": "Destroy the whole economy (deletes all Database-Entries)", + "option-description-confirm": "Confirm, that you really want to destroy the whole economy", + "destroy-cancel-reply": "You're lucky. You stopped me in the last moment before I destroyed the economy", + "destroy-reply": "Ok... I'll destroy the whole economy", + "destroy": "%u destroyed the economy", + "migration-happening": "Database not up-to-date. Migrating database...", + "migration-done": "Migrated database successfully.", + "nothing-selected": "Select an item to buy it", + "select-menu-price": "Price: %p", + "price-less-than-zero": "The price can't be less or equal to zero" + }, + "status-role": { + "fulfilled": "Status-role condition is fulfilled", + "not-fulfilled": "Status-role condition is no longer fulfilled" + }, + "color-me": { + "create-log-reason": "%user redeemed their boosting-rewards by requesting the creation of this role", + "edit-log-reason": "%user edited their boosting-reward-role", + "delete-unboost-log-reason": "%user stopped boosting, so their role got deleted", + "delete-manual-log-reason": "%user deleted their role manually", + "command-description": "Request a Custom role as a reward for boosting. This has a cooldown of 24 hours", + "manage-subcommand-description": "Create or edit your custom role", + "name-option-description": "The name of your custom role", + "color-option-description": "The color of your custom role", + "remove-subcommand-description": "Remove your custom role", + "icon-option-description": "Your role-icon", + "confirm-option-remove-description": "Do you really want to delete your custom role? This will not reset any running cooldowns" + }, + "rock-paper-scissors": { + "stone": "Stone", + "paper": "Paper", + "scissors": "Scissors", + "won": "won", + "lost": "lost", + "tie": "tie", + "play-again": "Play again", + "challenge-message": "%t, %u challenged you to a game of rock-paper-scissors! Hit the button below to join the game! This invitation will expire in about 2 minutes, so don't hesitate to much.", + "invite-expired": "Sorry, %u, %i didn't accept your request to play rock-paper-scissors in time ):", + "invite-denied": "Sorry, %u, but %i denied your request to play a round of rock-paper-scissors ):", + "rps-title": "Rock Paper Scissors", + "rps-description": "Choose your weapon!", + "its-a-tie-try-again": "It's a tie! Try again!", + "command-description": "Play rock-paper-scissors against the bot or someone in the chat" + }, + "connect-four": { + "tie": "It's a tie!", + "win": "%u has won the game!", + "not-turn": "Sorry, but it's not your turn!", + "game-message": "Connect Four game of %u1 and %u2\nCurrent turn: %c %t.\n\n%g", + "challenge-message": "%t, %u challenged you to a game of Connect Four! Hit the button below to join the game! This invitation will expire in about 2 minutes, so don't hesitate to much.", + "invite-expired": "Sorry, %u, %i didn't accept your request to play Connect Four in time ):", + "invite-denied": "Sorry, %u, but %i denied your request to play a round of Connect Four ):", + "command-description": "Play Connect Four against someone in the chat", + "field-size-description": "The size of the playfield (default: 7)", + "challenge-yourself": "You cannot challenge yourself!", + "challenge-bot": "You cannot challenge bots!" + }, + "uno": { + "command-description": "Play Uno against users in the chat", + "challenge-message": "%u invites to a round of Uno! Click the button below this message to join! The game starts %timestamp with %count players.", + "not-enough-players": "Not enough players joined for a round of Uno!", + "user-cards": "%u: %cards cards", + "already-joined": "You're already in!", + "view-deck": "View deck", + "draw": "Draw card", + "uno": "Uno!", + "turn": "It's %u turn!", + "update-button": "Update", + "use-drawn": "Do you want to use the drawn card?", + "dont-use-drawn": "Dont use", + "win": "%u won the game! %turns cards were played.", + "win-you": "You've won the game!", + "missing-uno": "⚠️ You must use the Uno! button before you use your second last card!", + "choose-color": "Select a color:", + "pending-draws": "Use a Draw 2/4 card, otherwise you have to draw %count cards!", + "not-ingame": "You're not in this game!", + "skip": "Skip", + "reverse": "Reverse", + "color": "Color choice", + "draw2": "Draw 2", + "colordraw4": "Color choice and draw 4", + "cant-uno": "You cannot use Uno currently.", + "done-uno": "You've called Uno!", + "auto-drawn-skip": "Your turn was skipped because you would have had to draw the cards anyway.", + "start-game": "Start game now", + "not-host": "You're not the host of the game!", + "max-players": "The game is full!", + "previous-cards": "Previous cards: ", + "used-card": "You've already used the card %c! Use the Update button and play a valid card.", + "invalid-card": "You cannot play the card %c right now! Please select a valid card.", + "inactive-warn": "%u, it's your turn in the uno game!", + "inactive-win": "The uno game has ended. %u won as all others have been eliminated!" + }, + "quiz": { + "what-have-i-voted": "What have I voted?", + "vote": "Vote!", + "vote-this": "Select this option if you think it's correct.", + "voted-successfully": "Selected successfully.", + "not-voted-yet": "You have not selected an option yet, so I can't show you what you selected.", + "you-voted": "You've selected **%o** as correct answer.", + "change-opinion": "You can change your opinion at any time by selecting another option above the button you just clicked.", + "cannot-change-opinion": "You cannot change your selection as the creator of this quiz disabled it.", + "select-correct": "Select all correct answers", + "this-correct": "Mark this answer as correct", + "cmd-description": "Create or play server quiz", + "cmd-create-normal-description": "Create a quiz with up to 10 answers", + "cmd-create-bool-description": "Create a quiz with true or false answers", + "cmd-play-description": "Play a server quiz", + "cmd-leaderboard-description": "Shows the quiz leaderboard of the server", + "cmd-create-description-description": "Title / description of the quiz", + "cmd-create-channel-description": "Channel in which the quiz should be created", + "cmd-create-endAt-description": "How long the quiz will last", + "cmd-create-option-description": "Option number %o", + "cmd-create-canchange-description": "If the players can change their opinion after voting (default: no)", + "daily-quiz-limit": "You've reached the limit of **%l** daily playable quizzes. You can play again %timestamp.", + "created": "Quiz created successfully in %c.", + "correct-highlighted": "All correct answers were highlighted.", + "answer-correct": "✅ Your answer was correct and you've received one point for the leaderboard!", + "answer-wrong": "❌ Your answer was wrong!", + "bool-true": "Statement is correct", + "bool-false": "Statement is wrong", + "leaderboard-channel-not-found": "The leaderboard channel couldn't be found or it's type is invalid.", + "leaderboard-notation": "**%p. %u**: %xp XP", + "your-rank": "You've collected **%xp** points in quiz!", + "no-rank": "You've never finished a quiz successfully!", + "no-quiz": "No quizzes have been created for this server. Trusted admins can create them on https://scnx.app/glink?page=bot/configuration?query=quiz&file=quiz%7Cconfigs%2FquizList .", + "no-permission": "You don't have enough permissions to create quiz using the command." + }, + "topgg": { + "channel-not-found": "The configured channel with the ID \"%c\" was not found", + "testvote-header": "This was a test vote", + "voterole-reached": "Voted on top.gg and received Voter-Role", + "voterole-ended": "Vote on top.gg expired and got Voter-Role removed", + "opt-in": "Enable notifications when you can vote again", + "opt-out": "Disable notifications when you can vote again", + "opted-in": "Successfully opted in into receiving notifications when you can vote again", + "opted-out": "Successfully opted out of receiving notifications when you can vote again", + "already-opted-in": "You are already opted-in and will receive notifications when you can vote again", + "already-opted-out": "You are already opted-out and will **not** receive notifications when you can vote again", + "voteamount-reached": "The user reached %k votes which resulted in this role to be given.", + "testvote-description": "This vote was triggered in the top.gg dashboard and does not count towards any votecount of anyone and won't be used for reminders." + }, + "starboard": { + "invalid-minstars": "Invalid minimum stars %stars", + "star-limit": "You've reached the hourly starboard limit of %limitEmoji on the server which is why you cannot react on the message %msgUrl .\nTry again %time!" + }, + "nicknames": { + "owner-cannot-be-renamed": "The owner of the server (%u) cannot be renamed.", + "nickname-error": "An error occurred while trying to change the nickname of %u: %e" + }, + "ping-protection": { + "log-not-a-member": "[Ping Protection] Punishment failed: The pinger is not a member.", + "log-punish-role-error": "[Ping Protection] Punishment failed: I cannot punish %tag because their role is higher than or equal to my highest role.", + "log-mute-error": "[Ping Protection] Punishment failed: I cannot mute %tag: %e", + "log-kick-error": "[Ping Protection] Punishment failed: I cannot kick %tag: %e", + "log-action-log-failed": "[Ping Protection] Punishment logging failed: %e", + "log-data-deletion": "[Ping Protection] All data for the user with ID %u has been deleted successfully.", + "log-automod-keyword-limit": "[Ping Protection] Automod keywords exceed 1000 characters limit. Keywords were truncated.", + "punish-log-failed-title": "Punishment failed for user %u", + "punish-log-failed-desc": "An error occured while trying to punish the user %m. Please check the bot's permissions and role hierarchy. See the message below for the error.", + "punish-log-error": "Error: ```%e```", + "punish-role-error": "I cannot punish %tag because their role is higher than or equal to my highest role.", + "reason-basic": "User reached %c pings in the last %w weeks.", + "reason-advanced": "User reached %c pings in the last %d days (Custom timeframe).", + "cmd-desc-module": "Ping protection related commands", + "cmd-desc-group-user": "Every command related to the users", + "cmd-desc-history": "View the ping history of a user", + "cmd-opt-user": "The user to check", + "cmd-desc-actions": "View the moderation action history of a user", + "cmd-desc-panel": "Admin: Open the user management panel", + "cmd-desc-group-list": "Lists protected or whitelisted entities", + "cmd-desc-list-protected": "List of all the protected users and roles", + "cmd-desc-list-wl": "List of all the whitelisted roles, channels and users", + "embed-history-title": "Ping history of %u", + "no-data-found": "No logs found for this user.", + "embed-actions-title": "Moderation history of %u", + "label-reason": "Reason", + "actions-retention-note": "Note: Moderation actions are retained for 1 - 12 months based on the configuration.", + "no-permission": "You don't have sufficient permissions to use this command.", + "panel-title": "User Panel: %u", + "panel-description": "Manage and view data for %u (%i). View a quick recap of their ping and moderation history, or delete all data stored for this user (Risky).", + "btn-history": "Ping history", + "btn-actions": "Actions history", + "btn-delete": "Delete all data (Risky)", + "list-protected-title": "Protected Users and Roles", + "list-protected-desc": "View all protected users and roles here. When someone pings one of these protected user(s)/role(s), a warning will be sent. Exceptions are when pinged by someone with a whitelisted role/as a whitelisted user or when it's sent in a whitelisted channel.", + "field-protected-users": "Protected Users", + "field-protected-roles": "Protected Roles", + "list-whitelist-title": "Whitelisted Roles, Users and Channels", + "list-whitelist-desc": "View all whitelisted roles, users and channels here. Whitelisted roles and users will not get a warning for pinging a protected entity, and pings from them or in whitelisted channels will be ignored.", + "field-wl-roles": "Whitelisted Roles", + "field-wl-channels": "Whitelisted Channels", + "field-wl-users": "Whitelisted Users", + "list-none": "None are configured.", + "modal-title": "Confirm data deletion for this user", + "modal-label": "Confirm data deletion by typing this phrase:", + "modal-phrase": "I understand that all data of this user will be deleted and that this action cannot be undone.", + "modal-failed": "The phrase you entered is incorrect. Data deletion cancelled.", + "modal-success-data-deletion": "All data for the user <@%u> (%u) has been deleted successfully", + "field-quick-history": "Quick history view (Last %w weeks)", + "field-quick-desc": "Pings history amount: %p\nModeration actions amount: %m", + "history-disabled": "History logging has been disabled by a bot-configurator.\nAre you (one of) the bot-configurators? You can enable history logging in the \"Data Storage\" tab in the 'ping-protection' module ^^", + "leaver-warning-long": "This user left the server at %d. These logs will stay until automatic deletion.", + "leaver-warning-short": "This user left the server at %d.", + "meme-why": "😐 [Why are you the way that you are?]() - You just pinged yourself..", + "meme-played": "🔑 [Congratulations, you played yourself.]()", + "meme-spider": "🕷️ [Is this you?]() - You just pinged yourself.", + "meme-rick": "🎵 [Never gonna give you up, never gonna let you down...]() You just Rick Rolled yourself. Also congrats you unlocked the secret easter egg that only has a 1% chance of appearing!!1!1!!", + "meme-grind": "Why are you even pinging yourself 5 times in a row? Anyways continue some more to possibly get the secret meme\n-# (good luck grinding, only a 1% chance of getting it and during testing I had it once after 83 pings)", + "label-jump": "Jump to Message", + "no-message-link": "This ping was blocked by AutoMod", + "list-entry-text": "%index. **Pinged %target** at %time\n%link" + }, + "betterstatus": { + "command-description": "Change the bot's status", + "command-disabled": "The /status command is not enabled. An administrator needs to enable it in the betterstatus module configuration.", + "text-description": "The status text to display", + "activity-type-description": "The activity type (Playing, Watching, etc.)", + "bot-status-description": "The bot's online status (Online, Idle, DND)", + "streaming-link-description": "Streaming URL (only used when activity type is Streaming)", + "status-changed": "Bot status has been changed to: %s" + }, + "staff-management-system": { + "time-zero": "0 seconds", + "time-hours": "hours", + "time-hour": "hour", + "time-mins": "minutes", + "time-min": "minute", + "time-secs": "seconds", + "time-sec": "second", + "stat-brk": "🟡 On Break", + "stat-on": "🟢 On-Duty", + "stat-off": "🔴 Off-Duty", + "duty-panel-title": "Duty Panel - %type", + "duty-stats": "📊 Statistics", + "duty-stat-desc": "**Total Shift Duration:** %duration\n**Total Shifts:** %count\n**Average Shift Duration:** %average", + "btn-duty-on": "On-Duty", + "btn-duty-res": "Resume Duty", + "btn-duty-brk": "Toggle Break", + "btn-duty-off": "Off-Duty", + "duty-breakdown": "Shift Breakdown", + "duty-quota-str": "\n\n**Quota (%timeframe):** %duration / %hours hours\n*%result*", + "quota-met": "✅ Quota Met", + "quota-fail": "❌ Quota Not Met", + "duty-time-title": "Shift Time - %type", + "duty-time-desc": "**Total Shifts:** %count\n**Total Duration:** %duration", + "btn-hist": "View History", + "err-no-lb": "ℹ️ No shift data found for **%type**.", + "duty-lb-title": "Leaderboard - %type", + "duty-lb-desc": "**%lookback Top Shifts**\n\n%lines", + "page-count": "Page %page/%total", + "info-no-sh-hi": "ℹ️ No completed shifts found.", + "duty-hi-title": "Shift History - %type", + "duty-adm-title": "Admin Duty Panel - %user", + "btn-f-off": "Force Off-Duty", + "btn-v-act": "Void Active Shift", + "btn-add-t": "Add Time", + "btn-v-all": "Void All Shifts", + "err-not-yours": "❌ This panel is not yours.", + "err-alr-on": "❌ You are already on a shift.", + "err-not-on": "❌ You are not on a shift.", + "err-hist-oth": "❌ You can only view your own history.", + "mod-v-all-title": "Confirm: Void All Shifts", + "mod-v-all-lbl": "Type CONFIRM to delete all shift data", + "err-conf-fail": "❌ Data deletion confirmation failed. You must type the phrase exactly.", + "succ-v-all": "All shift data for <@%user> has been deleted successfully.", + "mod-add-t": "Add Duty Time", + "mod-add-min": "Minutes to add", + "mod-add-type": "Shift Type", + "err-inv-min": "❌ Invalid number of minutes.", + "err-inv-type": "❌ Invalid shift type. Available: %types", + "err-sh-dis": "❌ Shift tracking is disabled.", + "info-no-act-sh": "ℹ️ There are no active shifts right now.", + "duty-act-title": "Active Shifts", + "duty-act-desc": "**Total Shifts:** %count", + "err-no-perm": "❌ You do not have permission to do this.", + "err-no-mem": "❌ Could not find that member.", + "ph-sel-type": "Select a Shift Type", + "msg-sel-type": "👇 Please choose your shift type:", + "err-prof-dis": "❌ Staff Profiles are disabled.", + "err-prof-cfg": "❌ Configuration is missing. Please make sure the message is not empty.", + "err-prof-no-own": "❌ You do not have a staff profile.", + "err-prof-no-tgt": "❌ That user does not have a profile.", + "rev-dis-text": "*Reviews disabled*", + "rev-no-rate": "No ratings yet", + "stat-offl": "⚫ Offline", + "stat-onl": "🟢 Online", + "stat-idl": "🟡 Away", + "stat-dnd": "🔴 Do Not Disturb", + "stat-prof-ond": "⏱️ On duty", + "stat-prof-loa": "🌙 On LoA", + "stat-prof-ra": "⛱️ On RA", + "prof-no-intro": "*No introduction set.*", + "err-prof-empty": "❌ Profile embed is empty.", + "err-prof-perm": "❌ You must be a staff member to have a profile.", + "prof-edit-title": "Edit Profile", + "prof-edit-nick": "Custom Nickname", + "prof-edit-intro": "Introduction", + "succ-prof-wipe": "✅ Profile wiped for %u.", + "succ-prof-upd": "✅ Profile updated!", + "general-chan": "Channel", + "general-ends": "Ends", + "ac-tot-res": "Total Responded", + "err-ac-noact": "❌ There is no active activity check.", + "succ-ac-end": "✅ Activity check ended manually.", + "err-gen-no-user": "❌ Could not find that user.", + "del-conf-phrase": "I understand that this will delete the specified data for this user and it cannot be undone.", + "mod-del-title": "Confirm Data Deletion", + "mod-del-lbl": "Type confirmation phrase:", + "del-all-title": "Confirm total data deletion", + "del-all-desc": "You are about to delete ALL data for this user. Reminder that this ***cannot be undone***. This is the last chance to back out. If you are sure, click the button below.\nThis action will automatically cancel in 30 seconds.", + "btn-conf-del": "Confirm deletion", + "btn-cancel": "Cancel", + "succ-del-canc": "✅ Data deletion cancelled.", + "succ-del-all": "✅ ALL data has been permanently wiped.", + "err-del-time": "⏳ Data deletion timed out.", + "succ-del-tgt": "✅ Target data has been permanently wiped.", + "err-gen-no-perm": "❌ You do not have permission.", + "err-no-req": "❌ Request not found.", + "err-req-hndl": "❌ Request is already %status.", + "mod-deny-req": "Deny Request", + "general-rsn": "Reason", + "general-req-reason": "Reason for request", + "label-appr-by": "This was approved by", + "req-appr-by": "✅ Approved by %user", + "req-deny-by": "❌ Denied by %user", + "general-stat": "Status", + "err-ac-alr-end": "❌ This activity check has already ended.", + "info-ac-alr-conf": "ℹ️ You already confirmed your activity!", + "succ-ac-log": "✅ Activity logged successfully!", + "err-internal": "❌ An internal error occurred.", + "dm-appr-title": "Your %label request got approved!", + "dm-appr-desc": "Your %label request got approved by %approver!\nYou are now on LoA until %endFmt.\nYou can view your LoA status by using the %viewCmd command.", + "dm-deny-title": "Your %label request was denied", + "dm-deny-desc": "Your %label request was denied by %denier.\n**Reason:** %reason", + "dm-ext-title": "Your %label got extended", + "dm-ext-desc": "Your %label got extended by %extender.\nThis extension is for **%days day(s)** - your %label now ends at %endFmt.\n**Reason for extension:** %reason\nYou can view your updated %label status by using the %viewCmd command.", + "dm-early-title": "Your %label ended early", + "dm-early-desc": "Your %label got ended early by %ender - your %label is now over and your role has been removed.\n**Reason for early end:** %reason.", + "dm-end-title": "Your %label has ended", + "dm-end-desc": "Your %label has now ended and your role has been removed.", + "log-start-title": "%label started for %username", + "log-start-desc": "%label started for %mention.%apprText", + "log-info-hdr": "%label Information", + "general-start": "Start", + "general-end": "End", + "log-end-title": "%label ended for %username", + "log-end-desc": "%label ended for %mention.", + "general-started": "Started", + "general-ended": "Ended", + "log-adj-title": "%label adjusted for %username", + "log-adj-desc": "The %label of %mention was adjusted by <@%executor>.", + "log-changes": "Changes made:", + "err-feat-disabled": "❌ %feature disabled.", + "err-use-susp": "❌ Please use `/staff-management infraction suspend`.", + "err-inv-dur": "❌ Invalid duration format or value.", + "label-never": "Never", + "succ-infract": "✅ Issued **%type** (Case #%caseId) to %user.", + "label-days": "days", + "succ-susp": "✅ Issued Suspension (Case #%caseId) to %user for %duration.", + "err-no-case": "❌ Case #%caseId does not exist.", + "err-no-case-ref": "❌ No case found for %reference.", + "err-case-inact": "⚠️ Case #%caseId is inactive.", + "succ-void-fail": "✅ Case #%caseId voided, role restore failed.", + "succ-void": "✅ Voided Case #%caseId.", + "info-clean-rec": "ℹ️ %username has a clean record.", + "rec-title": "Record: %username", + "icon-voided": "⚪", + "label-exp": "Expires", + "label-case": "Case", + "label-date": "Date", + "label-iss": "Issuer", + "err-role-hier": "❌ I cannot assign a role higher than my highest role.", + "err-add-role": "❌ Failed to add role: %e", + "succ-promo": "✅ Promoted %user to %role.", + "info-no-promo": "ℹ️ No promotion history found for %username.", + "prom-hist-title": "Promotion History: %username", + "label-role": "Role", + "label-prom-by": "Promoted by", + "panel-title": "User Panel: %username", + "panel-desc": "Manage and view all data for the user %mention (%id).", + "panel-ph": "Select a category...", + "opt-over": "Overview", + "opt-act": "Activity Checks", + "opt-inf": "Infractions", + "opt-prom": "Promotions", + "opt-rev": "Reviews", + "opt-shi": "Shifts", + "opt-sta": "Status", + "opt-del": "Data Deletion", + "p-inf-title": "Infractions: %username", + "p-inf-desc": "Total: **%count**\n%types\n", + "info-none": "*None*", + "p-no-hist": "*No history on this page.*", + "p-prom-title": "Promotions: %username", + "p-prom-desc": "Total: **%count**\n", + "p-rev-title": "Reviews: %username", + "p-rev-desc": "Total: **%count**\nAverage rating: **%avg ⭐**\n", + "label-by": "by", + "p-sta-title": "Status: %username", + "p-sta-desc": "Total requests: **%count**\nActive: %active\n", + "p-act-title": "Activity Checks: %username", + "p-act-desc": "Responses: **%count**\n", + "label-chk": "Check on", + "label-end": "Ends", + "label-chan": "Channel", + "p-shi-title": "Shifts: %username", + "no-quota-configured": "No quota", + "duty-quota-met": "✅ Quota Met", + "duty-quota-failed": "❌ Quota Not Met", + "label-unranked": "Unranked", + "panel-shifts-desc": "**Total Shifts:** %totalShifts\n**Duration:** %totalSeconds\n**Rank:** %lbRank\n**Breakdown:**\n%breakdownStr\n\n%quotaStr", + "err-shift-data-unavailable": "Shift data unavailable: %error", + "btn-view-history": "View History", + "panel-deletion-title": "Data Deletion: %tag", + "panel-deletion-desc": "⚠️ DANGEROUS AREA ⚠️\nYou are now entering a dangerous zone. At this place, you are able to delete specific or all data for the selected user. These actions ***CANNOT BE UNDONE*** and should only be used if you are absolutely sure about what you are doing. If you only want to delete specific entries, please use the respective command for that entry instead.\nIf you are unsure, click 'Go Back' from the dropdown now.\n\nUse the dropdown below to choose which data you want to delete or delete all data. Choose wisely and gracefully.", + "panel-deletion-placeholder": "Select data to delete...", + "panel-opt-back": "Go Back", + "panel-opt-del-act": "Delete Activity Checks", + "panel-opt-del-inf": "Delete Infractions", + "panel-opt-del-prom": "Delete Promotions", + "panel-opt-del-rev": "Delete Reviews", + "panel-opt-del-shifts": "Delete Shifts", + "panel-opt-del-status": "Delete Status", + "panel-opt-del-all": "Delete ALL data", + "status-active-loa": "🟢 On LoA", + "status-active-ra": "🟠 On RA", + "status-hist-loa": "LoA History", + "status-hist-ra": "RA History", + "err-status-disabled": "❌ %type system disabled.", + "err-invalid-duration": "❌ Invalid duration.", + "err-duration-max": "❌ Max duration is %max days.", + "err-status-exists": "❌ You have an active %type request.", + "status-request-title": "New %type Request", + "status-req-user": "User", + "status-req-duration": "Duration", + "btn-approve": "Approve", + "btn-deny": "Deny", + "success-status-request": "✅ %type request created (%state).", + "state-pending": "Pending", + "state-auto": "Auto-Approved", + "no-active-status": "ℹ️ %user has no active %type.", + "label-stat": "Status", + "filter-active": " (Active)", + "filter-expired": " (Expired)", + "filter-history": " (History)", + "err-no-recs": "No records found.", + "manage-status-title": "Manage %label - %username", + "manage-stat-desc": "%status\nPrevious %label's: %count", + "no-act-stat": "⚫ No active %label", + "manage-active-details": "📋 Active %label Details", + "label-auto": "Auto", + "manage-no-active-user": "No active %label.", + "btn-end-early": "End %label Early", + "btn-extend": "Extend %label", + "err-no-active-end": "❌ No active %label to end.", + "modal-end-early-title": "End %label Early", + "modal-end-early-reason": "Reason for ending", + "err-stat-inact": "❌ This %label is inactive.", + "status-ended-embed-desc": "⚫ %label ended by %user\nReason: %reason", + "err-no-active-extend": "❌ No active %label.", + "modal-extend-title": "Extend %label", + "modal-extend-days": "Additional days, maximum of 180 days", + "modal-extend-reason": "Reason for extension", + "status-adjusted-log": "**%label extended** - the %label now ends at %newEnd.\n**Reason:** %reason", + "mod-stat-ext": "**Start:** %s\n**End:** %e (+%d days)\n**Status:** %t\n**Approved by:** %a\n**Reason:** %r", + "info-no-status-history": "ℹ️ No %label history.", + "status-history-desc": "Showing %count of %total %label records.", + "err-ac-act": "❌ Active check already running.", + "err-ac-norole": "❌ No target roles configured.", + "err-ac-invchan": "❌ Invalid channel.", + "ac-confirm-btn": "Confirm Activity", + "succ-ac-start": "✅ Check started in <#%channel> for %hours hours.", + "err-ac-perms": "❌ Missing permissions in <#%channel>.", + "ac-title-end": "📋 Activity Check (Ended)", + "ac-res-title": "📊 Activity Results", + "ac-f-res": "✅ Responded (%count)", + "ac-f-fail": "❌ Failed (%count)", + "ac-f-exc": "🛡️ Exceptions (%count)", + "log-ac-send-fail": "Failed to send activity check results message: %error", + "err-not-mem": "❌ That is not a member.", + "err-self-rate": "A good detective never investigates themselves. Neither do you.", + "err-staff-rate": "❌ You can only rate staff.", + "succ-review": "✅ Rated %tag %stars stars.", + "rev-title": "Reviews: %username", + "rev-desc": "**Average:** %avg ⭐ (%count reviews)", + "label-hist": "History", + "info-ac-none": "There are no active activity checks. Please check recent results in %c.", + "log-sched-fail": "[Staff Management] Failed to init expiry schedules: %error", + "log-susp-end": "[Staff Management] Automatically ended suspension for %tag", + "log-susp-err": "[Staff Management] Error expiring suspension: %error", + "log-leave-err": "[Staff Management] Error handling member leave: %error", + "log-del-all": "[Staff Management] Data deletion (ALL) executed for user %target by admin %admin.", + "log-del-type": "[Staff Management] Data deletion (%type) executed for user %target by admin %admin.", + "log-int-error": "[Staff Management] Interaction Error: %error", + "log-void-all": "[Staff management] All shift data for the user with ID %target has been deleted by admin %admin.", + "log-add-time": "[Staff Management] %admin added %min mins of %type duty time to %target.", + "log-stat-dm-error": "[Staff Management] Failed to send status DM to %u: %e", + "log-status-adj-error": "[Staff Management] Logging status adjustment failed: %e", + "log-promo-msg-error": "[Staff Management] Failed to send promotion announcement: %e", + "lbl-log-chan": "the configured log channel", + "ac-live-title": "Live Activity Check Status", + "err-ac-not-req": "❌ You are not required to respond to this activity check.", + "cmd-desc-status": "Manage Leave of Absence (LoA) and Reduced Activity (RA).", + "cmd-desc-loa": "Manage Leave of Absence (LoA).", + "cmd-desc-loa-request": "Request a Leave of Absence.", + "cmd-desc-loar-duration": "The duration for your LoA (e.g. 3d, 2w, 1m)", + "cmd-desc-loar-reason": "Reason for your LoA", + "cmd-desc-loa-view": "View your Leave of Absence status.", + "cmd-desc-loav-user": "The user to view the LoA status", + "cmd-desc-loa-list": "List of all Leave of Absences", + "cmd-desc-loal-filter": "Filter the LoA list on active, expired or all", + "cmd-desc-loa-admin": "Manage a user's Leave of Absence.", + "cmd-desc-loaa-user": "The user to manage their LoA", + "cmd-desc-ra": "Manage Reduced Activity (RA).", + "cmd-desc-ra-request": "Request Reduced Activity.", + "cmd-desc-rar-duration": "The duration for your RA (e.g. 3d, 2w, 1m)", + "cmd-desc-rar-reason": "Reason for your RA", + "cmd-desc-ra-view": "View your Reduced Activity status.", + "cmd-desc-rav-user": "The user to view the RA status", + "cmd-desc-ra-list": "List of all Reduced Activities", + "cmd-desc-ral-filter": "Filter the RA list on active, expired or all", + "cmd-desc-ra-admin": "Manage a user's Reduced Activity.", + "cmd-desc-raa-user": "The user to manage their RA", + "cmd-desc-duty": "Manage your duty status and view statistics.", + "cmd-desc-duty-manage": "Manage your duty status.", + "cmd-desc-duty-manage-type": "The duty type", + "cmd-desc-duty-active": "View all staff currently on duty.", + "cmd-desc-duty-lb": "View the duty time leaderboard.", + "cmd-desc-duty-lb-type": "The duty type for the leaderboard.", + "cmd-desc-duty-time": "View your total duty time.", + "cmd-desc-duty-time-type": "The duty type", + "cmd-desc-duty-admin": "Manage a user's shift.", + "cmd-desc-duty-admin-user": "The user to manage their shift", + "cmd-desc-smg": "Access the staff management system.", + "cmd-desc-panel": "Open the staff management panel for a user.", + "cmd-desc-panel-user": "The user to open the staff panel for.", + "cmd-desc-infractions": "Manage staff infractions.", + "cmd-desc-issue": "Issue an infraction to a staff member.", + "cmd-desc-issue-user": "The user receiving the infraction.", + "cmd-desc-issue-type": "The type of infraction to issue.", + "cmd-desc-issue-reason": "The reason for issuing this infraction.", + "cmd-desc-issue-expiry": "When the infraction should expire.", + "cmd-desc-suspend": "Suspend a staff member.", + "cmd-desc-suspend-user": "The user to suspend.", + "cmd-desc-suspend-duration": "How long the suspension should last.", + "cmd-desc-suspend-reason": "The reason for the suspension.", + "cmd-desc-history": "View a user's history.", + "cmd-desc-history-user": "The user whose history you want to view.", + "cmd-desc-void": "Void an infraction case.", + "cmd-desc-void-case-ref": "The case ID or message link of the infraction to void.", + "cmd-desc-promotion": "Manage staff promotions.", + "cmd-desc-promote": "Promote a staff member to a new rank.", + "cmd-desc-promote-user": "The user to promote.", + "cmd-desc-promote-rank": "The rank to promote the user to.", + "cmd-desc-promote-reason": "The reason for the promotion.", + "cmd-desc-promote-channel": "The channel to announce the promotion in.", + "cmd-desc-prom-history": "View the promotion history of a staff member.", + "cmd-desc-prom-history-user": "The user whose promotion history you want to view.", + "cmd-desc-ac": "Manage activity checks.", + "cmd-desc-ac-start": "Start a new activity check.", + "cmd-desc-ac-start-channel": "The channel where the activity check will be posted.", + "cmd-desc-ac-view": "View the current activity check status.", + "cmd-desc-ac-end": "End the current activity check.", + "cmd-desc-profile": "Manage staff profiles.", + "cmd-desc-profile-view": "View a staff member's profile.", + "cmd-desc-profile-view-user": "The user whose profile you want to view.", + "cmd-desc-profile-edit": "Edit your staff profile.", + "cmd-desc-profile-wipe": "Wipe a staff member's profile data.", + "cmd-desc-profile-wipe-user": "The user whose profile will be wiped.", + "cmd-desc-review": "Manage staff reviews.", + "cmd-desc-review-submit": "Submit a review for a staff member.", + "cmd-desc-review-submit-user": "The user you are reviewing.", + "cmd-desc-review-submit-stars": "The star rating for the review.", + "cmd-desc-review-submit-comment": "Your review comment.", + "cmd-desc-review-history": "View the review history of a staff member.", + "cmd-desc-review-history-user": "The user whose review history you want to view.", + "del-no-perm": "You do not have sufficient permissions to perform data deletion.", + "log-err-exp-susp": "Suspension check failed: %error", + "duty-admin-target-left": "The action was completed, but the user is no longer in the server.", + "err-shift-too-short": "Your shift was not counted because it was shorter than the minimum required duration of %min minute(s).", + "log-status-expiry-fail": "[Staff Management] Failed to process automatic status expiry: %error", + "none-provided": "No reason provided.", + "log-infract-dm-fail": "[Staff Management] Failed to send infraction DM to %user: %error", + "log-susp-dm-fail": "[Staff Management] Failed to send suspension DM to %user: %error", + "log-promo-dm-fail": "[Staff Management] Failed to send promotion DM to %user: %error", + "duty-started-title": "⏲️ Shift Started", + "duty-break-title": "⏸️ On Break", + "duty-ended-title": "↩️ Off-Duty", + "duty-shift-overview": "Shift Overview", + "duty-shift-report-title": "Shift Report", + "duty-shift-information": "Shift Information", + "label-started": "Started", + "label-ended": "Ended", + "label-elapsed-time": "Elapsed Time", + "label-shift-type": "Shift Type", + "log-duty-dm-fail": "[Staff Management] Failed to send shift report DM to %user: %error", + "label-breaks": "Breaks", + "log-duty-start-title": "%username went on-duty", + "log-duty-start-desc": "%mention has started a duty shift.", + "log-duty-break-title": "%username went on break", + "log-duty-break-desc": "%mention is now on break.", + "log-duty-resume-title": "%username resumed duty", + "log-duty-resume-desc": "%mention is back on duty.", + "log-duty-end-title": "%username went off-duty", + "log-duty-end-desc": "%mention has ended their duty shift.", + "log-duty-void-title": "%username's active shift was voided", + "log-duty-void-desc": "%mention's active shift was voided by %executor.", + "log-duty-info-hdr": "Information", + "label-ended-by": "Ended by", + "log-duty-log-fail": "[Staff Management] Failed to log duty change (%action): %error", + "err-self-infract": "That's not in the code... well, it's more of a guideline anyway. Still no.\n-# You cannot infract yourself", + "err-self-promo": "You can't promote yourself through a black hole of audacity and expect it to work.", + "status-expired-auto": "Ended automatically because the status expired." + } } diff --git a/main.js b/main.js index 6191a4a7..6f096984 100644 --- a/main.js +++ b/main.js @@ -11,11 +11,31 @@ const { const client = new Discord.Client({ partials: [Partials.Message, Partials.GuildMember, Partials.GuildScheduledEvent, Partials.Reaction, Partials.User, Partials.Channel], // Most of these are not needed, but enabling them does not increase CPU / RAM usage and does not introduce problems, as we handle them in the event emitter system allowedMentions: {parse: ['users', 'roles']}, // Disables @everyone mentions because everyone hates them - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildBans, GatewayIntentBits.DirectMessages, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, - GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildPresences, GatewayIntentBits.GuildInvites, GatewayIntentBits.GuildEmojisAndStickers, GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.GuildEmojisAndStickers, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildWebhooks, GatewayIntentBits.AutoModerationExecution] + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.DirectMessages, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildPresences, GatewayIntentBits.GuildInvites, GatewayIntentBits.GuildEmojisAndStickers, GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.GuildEmojisAndStickers, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildWebhooks, GatewayIntentBits.AutoModerationExecution, GatewayIntentBits.GuildModeration] +}); +client.on('error', (err) => { + const {localize: loc} = require('./src/functions/localize'); + const sentryId = client.captureException ? client.captureException(err, {source: 'discord-client-error'}) : null; + client.logger ? client.logger.error(client.sanitizePath(loc('main', 'discord-error', {e: err.stack || err})) + (sentryId ? ` [Sentry: ${sentryId}]` : '')) : console.error(err); +}); +client.on('shardError', (err) => { + const {localize: loc} = require('./src/functions/localize'); + const sentryId = client.captureException ? client.captureException(err, {source: 'shard-error'}) : null; + client.logger ? client.logger.error(client.sanitizePath(loc('main', 'shard-error', {e: err.stack || err})) + (sentryId ? ` [Sentry: ${sentryId}]` : '')) : console.error(err); +}); +client.on('shardDisconnect', (event) => { + const {localize: loc} = require('./src/functions/localize'); + client.logger ? client.logger.warn(loc('main', 'shard-disconnect', {c: event ? event.code : 'unknown'})) : console.warn('Disconnected from Discord'); +}); +client.on('shardReconnecting', () => { + const {localize: loc} = require('./src/functions/localize'); + client.logger ? client.logger.info(loc('main', 'shard-reconnecting')) : console.info('Reconnecting to Discord'); }); client.intervals = []; client.jobs = []; +client._migrationCount = 0; +client._shutdownRequested = false; const fs = require('fs'); const {Sequelize} = require('sequelize'); const log4js = require('log4js'); @@ -31,6 +51,16 @@ const args = process.argv.slice(2); let scnxSetup = false; // If enabled some other (closed-sourced) files get imported and executed if (process.argv.includes('--scnx-enabled')) scnxSetup = true; client.scnxSetup = scnxSetup; +if (scnxSetup) { + const instrument = require('./instrument'); + client.sentry = instrument; + client.sanitizePath = instrument.sanitizePath; + client.captureException = function (err, data) { + return instrument.captureException(err, {contexts: {'extra-data': data}}); + }; +} else { + client.sanitizePath = (s) => s; +} if (args[0] === '--help' || args[0] === '-h') { process.exit(); } @@ -144,13 +174,13 @@ async function startUp() { if (scnxSetup) await require('./src/functions/scnx-integration').beforeInit(client); if (!client.isReady()) { await client.login(config.token).catch(async (e) => { - if (e.code === 'TOKEN_INVALID') { + if (e.code === 'TokenInvalid' || e.message === 'Authentication failed') { if (scnxSetup) await require('./src/functions/scnx-integration').reportIssue(client, { type: 'CORE_FAILURE', errorDescription: 'invalid_token' }); logger.fatal(localize('main', 'login-error-token')); - } else if (e.code === 'DISALLOWED_INTENTS') { + } else if (e.code === 'DisallowedIntents' || e.message === 'Used disallowed intents') { if (scnxSetup) await require('./src/functions/scnx-integration').reportIssue(client, { type: 'CORE_FAILURE', errorDescription: 'disallowed_intents' @@ -160,7 +190,12 @@ async function startUp() { process.exit(); }); } - const app = JSON.parse((await centra(`https://discord.com/api/applications/@me`, 'GET').header('Authorization', `Bot ${client.token}`).send()).body.toString()); + let app = {}; + try { + app = JSON.parse((await centra(`https://discord.com/api/applications/@me`, 'GET').header('Authorization', `Bot ${client.token}`).send()).body.toString()); + } catch (e) { + logger.warn(localize('main', 'discord-api-error', {e: e.message || e})); + } if (app.bot_require_code_grant) { if (scnxSetup) await require('./src/functions/scnx-integration').reportIssue(client, { type: 'CORE_ISSUE', @@ -235,14 +270,60 @@ async function startUp() { client.strings = jsonfile.readFileSync(`${confDir}/strings.json`); client.botReadyAt = new Date(); client.emit('botReady'); + await client.guild.members.fetch({withPresences: true}).catch(() => { + }); if (scnxSetup) await require('./src/functions/scnx-integration').init(client); logger.info(localize('main', 'bot-ready')); if (client.logChannel) client.logChannel.send('🚀 ' + localize('main', 'bot-ready')); await checkForUpdates(client); } +// Prevent shutdown during database migrations +function handleShutdownSignal(signal) { + if (client._migrationCount > 0) { + client._shutdownRequested = true; + logger.warn(localize('main', 'shutdown-deferred')); + return; + } + process.exit(0); +} + +process.on('SIGINT', handleShutdownSignal); +process.on('SIGTERM', handleShutdownSignal); + +process.on('uncaughtException', (err) => { + const sentryId = client.captureException ? client.captureException(err, {source: 'uncaught-exception'}) : null; + logger.error(client.sanitizePath(localize('main', 'uncaught-exception', {e: err.stack || err})) + (sentryId ? ` [Sentry: ${sentryId}]` : '')); +}); + +process.on('unhandledRejection', (reason) => { + const sentryId = client.captureException ? client.captureException(reason instanceof Error ? reason : new Error(String(reason)), {source: 'unhandled-rejection'}) : null; + logger.error(client.sanitizePath(localize('main', 'unhandled-rejection', {e: reason instanceof Error ? reason.stack : reason})) + (sentryId ? ` [Sentry: ${sentryId}]` : '')); +}); + +/** + * Call before starting a migration to prevent shutdown + */ +module.exports.migrationStart = function () { + client._migrationCount++; +}; + +/** + * Call after a migration completes to allow shutdown again + */ +module.exports.migrationEnd = function () { + client._migrationCount--; + if (client._migrationCount <= 0 && client._shutdownRequested) { + logger.info(localize('main', 'shutdown-after-migration')); + process.exit(0); + } +}; + // Starting bot -db.authenticate().then(startUp); +db.authenticate().then(startUp).catch((e) => { + logger.fatal(localize('main', 'db-connect-error', {e: e.message || e})); + process.exit(1); +}); // CLI-COMMANDS const cliCommands = []; @@ -250,6 +331,10 @@ const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); +rl.on('error', (err) => { + const sentryId = client.captureException ? client.captureException(err, {source: 'readline-error'}) : null; + logger.error(client.sanitizePath(localize('main', 'cli-command-error', {e: err.message || err})) + (sentryId ? ` [Sentry: ${sentryId}]` : '')); +}); rl.on('line', (input) => { if (!client.botReadyAt) { return console.error('The bot is not ready yet. Please wait until the bot gets ready to use the cli.'); @@ -260,12 +345,17 @@ rl.on('line', (input) => { if (!command) return console.error('Command not found. Use "help" to see all available commands.'); console.log('\n'); - command.run({ - input, - args: input.split(' '), - client, - cliCommands - }); + try { + command.run({ + input, + args: input.split(' '), + client, + cliCommands + }); + } catch (e) { + const sentryId = client.captureException ? client.captureException(e, {source: 'cli-command'}) : null; + logger.error(client.sanitizePath(localize('main', 'cli-command-error', {e: e.stack || e})) + (sentryId ? ` [Sentry: ${sentryId}]` : '')); + } }); /** @@ -423,14 +513,17 @@ async function syncCommandsIfNeeded() { break; } - if (oldCommand.description !== command.description || (oldCommand.options || []).length !== (command.options || []).length) { + if (oldCommand.description !== command.description || oldCommand.type !== command.type || (oldCommand.options || []).length !== (command.options || []).length) { needSync = true; break; } const newPerms = new PermissionsBitField(command.defaultMemberPermissions || []).bitfield; const oldPerms = new PermissionsBitField(oldCommand.defaultMemberPermissions || []).bitfield; - if (newPerms !== oldPerms) needSync = true; + if (newPerms !== oldPerms) { + needSync = true; + break; + } for (const option of (command.options || [])) { const oldOptionOption = (oldCommand.options || []).find(o => o.name === option.name); @@ -454,6 +547,9 @@ async function syncCommandsIfNeeded() { function checkOption(oldOption, newOption) { if (oldOption.name !== newOption.name || oldOption.autocomplete !== newOption.autocomplete || oldOption.description !== newOption.description || oldOption.type !== newOption.type || (typeof oldOption.required === 'undefined' ? false : oldOption.required) !== (typeof newOption.required === 'undefined' ? false : newOption.required)) return true; if (!compareArrays(oldOption.choices || [], newOption.choices || [])) return true; + if (!compareArrays(oldOption.channelTypes || [], newOption.channelTypes || [])) return true; + if (oldOption.minValue !== newOption.minValue || oldOption.maxValue !== newOption.maxValue) return true; + if (oldOption.minLength !== newOption.minLength || oldOption.maxLength !== newOption.maxLength) return true; if ((oldOption.options || []).length !== (newOption.options || []).length) return true; for (const option of (newOption.options || [])) { const oldOptionOption = (oldOption.options || []).find(o => o.name === option.name); @@ -547,11 +643,11 @@ async function loadEventsInDir(dir, moduleName = null) { if (!eData.moduleName) eData.eventFunction.run(client, ...cArgs); else if (client.modules[eData.moduleName].enabled) eData.eventFunction.run(client, ...cArgs); } catch (e) { - if (client.captureException) client.captureException(e, { + const sentryId = client.captureException ? client.captureException(e, { module: eData.moduleName, event: eventName - }); - client.logger.error(`Error on event ${(eData.moduleName ? eData.moduleName + '/' : '') + eventName}: ${e}`); + }) : null; + client.logger.error(client.sanitizePath(`Error on event ${(eData.moduleName ? eData.moduleName + '/' : '') + eventName}: ${e}${sentryId ? ` [Sentry: ${sentryId}]` : ''}`)); } } }); diff --git a/modules/admin-tools/always-temporary-roles.json b/modules/admin-tools/always-temporary-roles.json new file mode 100644 index 00000000..6f6f91af --- /dev/null +++ b/modules/admin-tools/always-temporary-roles.json @@ -0,0 +1,32 @@ +{ + "filename": "always-temporary-roles.json", + "humanName": "Always-Temporary Roles", + "configElementName": { + "one": "Always-Temporary Role", + "more": "Always-Temporary Roles" + }, + "description": "Configure roles that are always temporary. When a user receives one of these roles (by any means), the role will automatically be removed after the configured duration.", + "configElements": true, + "content": [ + { + "type": "roleID", + "name": "roleID", + "default": "", + "humanName": "Role", + "description": "The role that should always be temporary. When a user receives this role, it will be automatically removed after the configured duration." + }, + { + "type": "string", + "name": "duration", + "default": "24h", + "humanName": "Duration", + "description": "How long the role should last before being automatically removed. Examples: 1h, 12h, 1d, 7d, 30m", + "links": [ + { + "label": "Duration format", + "url": "https://scootk.it/custombot-durations" + } + ] + } + ] +} diff --git a/modules/admin-tools/commands/admin.js b/modules/admin-tools/commands/admin.js index a0d6ecda..6fed5221 100644 --- a/modules/admin-tools/commands/admin.js +++ b/modules/admin-tools/commands/admin.js @@ -105,6 +105,7 @@ module.exports.config = { channel_types: [ChannelType.GuildCategory], required: true, name: 'category', + channelTypes: [ChannelType.GuildCategory], description: localize('admin-tools', 'category-description') } ] diff --git a/modules/admin-tools/config.json b/modules/admin-tools/config.json index c34fdcf6..03368a49 100644 --- a/modules/admin-tools/config.json +++ b/modules/admin-tools/config.json @@ -1,12 +1,6 @@ { - "description": { - "en": "Configure the behaviour of the module here", - "de": "Stelle hier die Funktionen des Modules ein" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the behaviour of the module here", + "humanName": "Configuration", "filename": "config.json", "commandsWarnings": { "normal": [ diff --git a/modules/admin-tools/events/guildMemberUpdate.js b/modules/admin-tools/events/guildMemberUpdate.js new file mode 100644 index 00000000..7f3dc950 --- /dev/null +++ b/modules/admin-tools/events/guildMemberUpdate.js @@ -0,0 +1,49 @@ +const {createTemporaryRoleChangeAction} = require('../temporaryRoles'); +const durationParser = require('parse-duration'); +const {localize} = require('../../../src/functions/localize'); + +module.exports.run = async function (client, oldMember, newMember) { + if (!client.botReadyAt) return; + if (newMember.guild.id !== client.guild.id) return; + + const addedRoles = newMember.roles.cache.filter(r => !oldMember.roles.cache.has(r.id)); + if (addedRoles.size === 0) return; + + await handleRoleBans(client, newMember); + await handleAlwaysTemporaryRoles(client, newMember, addedRoles); +}; + +async function handleRoleBans(client, newMember) { + const config = client.configurations['admin-tools']['role-bans']; + if (!config || !Array.isArray(config) || config.length === 0) return; + + if (newMember.permissions.has('ManageRoles')) return; + + for (const role of newMember.roles.cache.values()) { + const entry = config.find(c => c.roleID === role.id); + if (!entry) continue; + + const deleteMessageSeconds = Math.min(Math.max((entry.deleteMessageDays || 0), 0), 7) * 86400; + await newMember.ban({ + deleteMessageSeconds, + reason: localize('admin-tools', 'audit-log-role-ban', {r: role.name, reason: entry.reason || ''}) + }); + return; + } +} + +async function handleAlwaysTemporaryRoles(client, newMember, addedRoles) { + const config = client.configurations['admin-tools']['always-temporary-roles']; + if (!config || !Array.isArray(config) || config.length === 0) return; + + for (const role of addedRoles.values()) { + const entry = config.find(c => c.roleID === role.id); + if (!entry) continue; + + const ms = durationParser(entry.duration); + if (!ms || ms < 20000) continue; + + const removeDate = new Date(Date.now() + ms); + await createTemporaryRoleChangeAction(client, 'remove', removeDate, role.id, newMember.id); + } +} diff --git a/modules/admin-tools/module.json b/modules/admin-tools/module.json index 65542085..d4bdd144 100644 --- a/modules/admin-tools/module.json +++ b/modules/admin-tools/module.json @@ -10,16 +10,14 @@ "models-dir": "/models", "events-dir": "/events", "config-example-files": [ - "config.json" + "config.json", + "always-temporary-roles.json", + "role-bans.json" ], "tags": [ "administration" ], - "humanReadableName": { - "en": "Admin-Tools" - }, - "description": { - "en": "Simple tools for admins - move channels and roles via commands, assign temporary roles or copy an emoji from another server to your server.", - "de": "Einfache Tools für Admins, um Channel und Rollen per Command zu verschieben, temporäre Rollen zu vergeben und Emojis zu leihen." - } -} \ No newline at end of file + "fa-icon": "fas fa-screwdriver-wrench", + "humanReadableName": "Admin-Tools", + "description": "Simple tools for admins - move channels and roles via commands, assign temporary roles, configure role bans or copy an emoji from another server to your server." +} diff --git a/modules/admin-tools/role-bans.json b/modules/admin-tools/role-bans.json new file mode 100644 index 00000000..d7c56b79 --- /dev/null +++ b/modules/admin-tools/role-bans.json @@ -0,0 +1,33 @@ +{ + "filename": "role-bans.json", + "humanName": "Role Bans", + "configElementName": { + "one": "Role Ban", + "more": "Role Bans" + }, + "description": "Configure roles that automatically ban users when assigned. When a user receives one of these roles, they will be immediately banned from the server. Users with the \"Manage Roles\" permission are exempt.", + "configElements": true, + "content": [ + { + "type": "roleID", + "name": "roleID", + "default": "", + "humanName": "Role", + "description": "When a user receives this role, they will be immediately banned from the server. Users with the \"Manage Roles\" permission are exempt." + }, + { + "type": "string", + "name": "reason", + "default": "Received a banned role", + "humanName": "Ban Reason", + "description": "The reason shown in the audit log when a user is banned for receiving this role." + }, + { + "type": "integer", + "name": "deleteMessageDays", + "default": 0, + "humanName": "Delete Message Days", + "description": "Number of days of messages to delete when banning the user (0-7)." + } + ] +} diff --git a/modules/admin-tools/temporaryRoles.js b/modules/admin-tools/temporaryRoles.js index 04ee8b65..1d86e250 100644 --- a/modules/admin-tools/temporaryRoles.js +++ b/modules/admin-tools/temporaryRoles.js @@ -16,8 +16,8 @@ module.exports.createTemporaryRoleChangeAction = async function (client, type, c } }); if (duplicate) { - duplicate.destroy(); if (jobCache.has(duplicate.id)) jobCache.get(duplicate.id).cancel(); + await duplicate.destroy(); } const res = await client.models['admin-tools']['TemporaryRoleChange'].create({ userID, diff --git a/modules/afk-system/config.json b/modules/afk-system/config.json index d8447340..6107acd5 100644 --- a/modules/afk-system/config.json +++ b/modules/afk-system/config.json @@ -1,130 +1,67 @@ { - "description": { - "en": "Configure the behaviour of the module here", - "de": "Stelle hier die Funktionen des Modules ein" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the behaviour of the module here", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "sessionEndedSuccessfully", - "humanName": { - "en": "AFK-Session ended successfully", - "de": "AFK-Sitzung erfolgreich beendet" - }, - "default": { - "en": "✅ Your AFK status has been removed. Welcome back!", - "de": "✅ Dein Status ist jetzt nicht mehr \"AFK\". Willkommen zurück!" - }, - "description": { - "en": "This message gets send if a user ended their AFK-session successfully.", - "de": "Diese Nachricht wird gesendet, wenn der Nutzer seine AFK-Sitzung erfolgreich beendet." - }, + "humanName": "AFK-Session ended successfully", + "default": "✅ Your AFK status has been removed. Welcome back!", + "description": "This message gets send if a user ended their AFK-session successfully.", "type": "string", "allowEmbed": true }, { "name": "sessionStartedSuccessfully", - "humanName": { - "en": "AFK-Session started successfully", - "de": "AFK-Sitzung erfolgreich gestartet" - }, - "default": { - "en": "✅ Your status has been updated to AFK. If another member mentions you while your AFK, we're going to notify them about your status.", - "de": "✅ Dein Status wurde auf \"AFK\" aktualisiert. Wenn dich ein anderer Nutzer erwähnt, während du AFK bist, werden wir ihn über deinen Status informieren." - }, - "description": { - "en": "This message gets send if a user started their session successfully.", - "de": "Diese Nachricht wird Nutzern angezeigt, wenn sie ihren Status auf AFK wechseln." - }, + "humanName": "AFK-Session started successfully", + "default": "✅ Your status has been updated to AFK. If another member mentions you while your AFK, we're going to notify them about your status.", + "description": "This message gets send if a user started their session successfully.", "type": "string", "allowEmbed": true }, { "name": "afkUserWithReason", - "humanName": { - "en": "User is AFK with reason", - "de": "Nutzer ist mit Begründung AFK" - }, - "default": { - "en": "ℹ %user% is currently AFK and specified the following reason: \"%reason%\".", - "de": "ℹ %user% ist aktuell AFK und hat folgenden Grund angegeben: \"%reason%\"." - }, - "description": { - "en": "This message gets send if a pinged user is currently AFK with a previously specified reason.", - "de": "Diese Nachricht wird gesendet, wenn ein Nutzer erwähnt wird, der AFK ist und zuvor eine Begründung dafür angegeben hat." - }, + "humanName": "User is AFK with reason", + "default": "ℹ %user% is currently AFK and specified the following reason: \"%reason%\".", + "description": "This message gets send if a pinged user is currently AFK with a previously specified reason.", "type": "string", "allowEmbed": true, "params": [ { "name": "reason", - "description": { - "de": "Begründung für die Abwesenheit des Nutzers", - "en": "Reason for their absence" - } + "description": "Reason for their absence" }, { "name": "user", - "description": { - "de": "Erwähnung des AFK Nutzers", - "en": "Mention of the user who is AFK" - } + "description": "Mention of the user who is AFK" } ] }, { "name": "afkUserWithoutReason", - "humanName": { - "en": "User is AFK without reason", - "de": "Nutzer ist ohne Begründung AFK" - }, - "default": { - "en": "ℹ %user% is currently AFK.", - "de": "ℹ %user% ist aktuell AFK." - }, - "description": { - "en": "This message gets send if a pinged user is currently AFK without a previously specified reason.", - "de": "Diese Nachricht wird gesendet, wenn ein Nutzer erwähnt wird, der AFK ist und zuvor keine Begründung dafür angegeben hat." - }, + "humanName": "User is AFK without reason", + "default": "ℹ %user% is currently AFK.", + "description": "This message gets send if a pinged user is currently AFK without a previously specified reason.", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "de": "Erwähnung des AFK Nutzers", - "en": "Mention of the user who is AFK" - } + "description": "Mention of the user who is AFK" } ] }, { "name": "autoEndMessage", - "humanName": { - "en": "AFK Session ended automatically", - "de": "AFK Sitzung automatisch beendet" - }, - "default": { - "en": "Welcome back \uD83D\uDC4B!\nYou are not longer AFK because you wrote a message. You can start a new session with `/afk start` and disable `auto-end` if you don't want your sessions to be ended automatically.", - "de": "Willkommen zurück \uD83D\uDC4B!\nDu bist nun nicht mehr AFK, da du eine Nachricht geschrieben hast. Um eine neue Sitzung zu starten gebe bitte `/afk start` ein; solltest du dieses Verhalten deaktivieren wollen, setze außerdem den `auto-end` Parameter." - }, - "description": { - "en": "This message gets send if a user who is AFK and hasn't disabled auto-ending their sessions posts a message on the server.", - "de": "Diese Nachricht wird verschickt, wenn ein Nutzer, der aktuell AFK ist und automatisches Beenden von Sitzungen nicht deaktiviert hat, eine Nachricht auf dem Server sendet." - }, + "humanName": "AFK Session ended automatically", + "default": "Welcome back 👋!\nYou are no longer AFK because you wrote a message. You can start a new session with `/afk start` and disable `auto-end` if you don't want your sessions to be ended automatically.", + "description": "This message gets send if a user who is AFK and hasn't disabled auto-ending their sessions posts a message on the server.", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Mention of the user who was AFK", - "de": "Erwähnung des Nutzers, der AFK war" - } + "description": "Mention of the user who was AFK" } ] } diff --git a/modules/afk-system/module.json b/modules/afk-system/module.json index aa1a47d9..44d7b73c 100644 --- a/modules/afk-system/module.json +++ b/modules/afk-system/module.json @@ -14,12 +14,8 @@ "tags": [ "tools" ], + "fa-icon": "fas fa-moon-stars", "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/afk-system", - "humanReadableName": { - "en": "AFK-System" - }, - "description": { - "en": "Allow users to set their AFK-Status and notify other users if they try to reach them", - "de": "Erlaubt es deinen Nutzern, ihren AFK-Status zu setzen und benachrichtigt andere Nutzer, wenn sie diesen versuchen zu erreichen" - } -} \ No newline at end of file + "humanReadableName": "AFK-System", + "description": "Allow users to set their AFK-Status and notify other users if they try to reach them" +} diff --git a/modules/anti-ghostping/config.json b/modules/anti-ghostping/config.json index b06f072e..2cfcec58 100644 --- a/modules/anti-ghostping/config.json +++ b/modules/anti-ghostping/config.json @@ -1,81 +1,42 @@ { - "description": {}, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the behaviour of the module here", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "awaitBotMessages", - "humanName": { - "de": "Botnachrichten abwarten", - "en": "Wait for Bot-Messages" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "If enabled, the bot will wait ~2 Seconds to make sure no bot like NQN deleted the messages and answered afterwards", - "de": "Wenn diese Option aktiviert ist, wird der Bot ~2 Sekunden warten, um sicherzustellen, dass kein Bot wie NQN die Nachricht gelöscht und danach geantwortet hat" - }, + "humanName": "Wait for Bot-Messages", + "default": true, + "description": "If enabled, the bot will wait ~2 Seconds to make sure no bot like NQN deleted the messages and answered afterwards", "type": "boolean" }, { "name": "ignoredChannels", - "humanName": { - "en": "Ignored Channels", - "de": "Ignorierte Channel" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "If a ghost ping gets send in one of these configured channels, the bot will not run anti-ghost-ping", - "de": "Wenn ein Ghost-Ping in einem dieser konfigurierten Channel gesendet wird, wird der Bot nicht anti-ghost-ping ausführen" - }, + "humanName": "Ignored Channels", + "default": [], + "description": "If a ghost ping gets send in one of these configured channels, the bot will not run anti-ghost-ping", "type": "array", "content": "channelID" }, { "name": "youJustGotGhostPinged", - "humanName": { - "en": "Ghostping-Message", - "de": "Ghostping-Nachricht" - }, - "default": { - "en": "%mentions%,\nYou just got ghost-pinged by %authorMention% with the following message: \"%msgContent%\"", - "de": "%mentions%,\nDu wurdest gerade von %authorMention% mit folgender Nachricht geghost-pinged: \"%msgContent%\"" - }, - "description": { - "en": "This message gets send if a member pings another user and deletes the message afterwards", - "de": "Diese Nachricht wird gesendet, wenn ein Nutzer einen anderen Nutzer pingt und die Nachricht danach löscht" - }, + "humanName": "Ghostping-Message", + "default": "%mentions%,\nYou just got ghost-pinged by %authorMention% with the following message: \"%msgContent%\"", + "description": "This message gets send if a member pings another user and deletes the message afterwards", "type": "string", "allowEmbed": true, "params": [ { "name": "mentions", - "description": { - "en": "Mentions of every user that got pinged in the original message", - "de": "Erwähnung von jedem, in der Originalnachricht gepingten, Nutzer" - } + "description": "Mentions of every user that got pinged in the original message" }, { "name": "authorMention", - "description": { - "en": "Mention of the original message-author.", - "de": "Erwähnung des Autors der Originalnachricht." - } + "description": "Mention of the original message-author." }, { "name": "msgContent", - "description": { - "en": "Content of the original message", - "de": "Inhalt der Originalnachricht" - } + "description": "Content of the original message" } ] } diff --git a/modules/anti-ghostping/events/messageCreate.js b/modules/anti-ghostping/events/messageCreate.js index 271bdc1b..0c1763e0 100644 --- a/modules/anti-ghostping/events/messageCreate.js +++ b/modules/anti-ghostping/events/messageCreate.js @@ -7,7 +7,7 @@ module.exports.run = async function (client, msg) { if (moduleConfig.ignoredChannels.includes(msg.channel.id)) return; if (msg.mentions.members.filter(f => f.id !== msg.author.id && !f.user.bot).size !== 0) msgsWithMention[msg.id] = msg; setTimeout(() => { - msgsWithMention[msg.id] = null; + delete msgsWithMention[msg.id]; }, 60000); }; module.exports.messageWithMentions = msgsWithMention; \ No newline at end of file diff --git a/modules/anti-ghostping/events/messageDelete.js b/modules/anti-ghostping/events/messageDelete.js index d175233f..da81ac0d 100644 --- a/modules/anti-ghostping/events/messageDelete.js +++ b/modules/anti-ghostping/events/messageDelete.js @@ -11,6 +11,7 @@ module.exports.run = async function (client, msg) { if (messageWithMentions[msg.id].guild.id !== client.config.guildID) return; if (!moduleStrings.awaitBotMessages) return executeGhostPingMessage(); setTimeout(async () => { + if (!messageWithMentions[msg.id]) return; const messages = await msg.channel.messages.fetch({after: msg.id}); if (messages.filter(m => m.author.bot).size !== 0) return; await executeGhostPingMessage(); @@ -22,6 +23,7 @@ module.exports.run = async function (client, msg) { * @return {Promise} */ async function executeGhostPingMessage() { + if (!messageWithMentions[msg.id]) return; let mentionString = ''; messageWithMentions[msg.id].mentions.members.filter(f => f.id !== messageWithMentions[msg.id].author.id && !f.user.bot).forEach(m => { mentionString = mentionString + `<@${m.id}>, `; diff --git a/modules/anti-ghostping/module.json b/modules/anti-ghostping/module.json index 86ca6e5f..cae717b7 100644 --- a/modules/anti-ghostping/module.json +++ b/modules/anti-ghostping/module.json @@ -6,6 +6,7 @@ "link": "https://github.com/SCDerox" }, "events-dir": "/events", + "fa-icon": "fa fa-bell-exclamation", "config-example-files": [ "config.json" ], @@ -13,11 +14,6 @@ "moderation" ], "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/anti-ghostping", - "humanReadableName": { - "en": "Anti-Ghostping" - }, - "description": { - "en": "This module detects ghost-pings and sends a message if one occurs", - "de": "Dieses Modul erkennt automatisch Ghost-Pings und schickt eine Nachricht, wenn einer erkannt wird" - } -} \ No newline at end of file + "humanReadableName": "Anti-Ghostping", + "description": "This module detects ghost-pings and sends a message if one occurs" +} diff --git a/modules/auto-delete/channels.json b/modules/auto-delete/channels.json index 14888788..a7460382 100644 --- a/modules/auto-delete/channels.json +++ b/modules/auto-delete/channels.json @@ -1,28 +1,14 @@ { - "description": { - "en": "Set up channels to delete text-messages from", - "de": "Stelle hier Text-Kanäle ein, aus welchen gelöscht werden soll" - }, - "humanName": { - "en": "Text-Channels", - "de": "Text-Kanäle" - }, + "description": "Set up channels to delete text-messages from", + "humanName": "Text-Channels", "filename": "channels.json", "configElements": true, "content": [ { "name": "channelID", - "humanName": { - "en": "Channel", - "de": "Kanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "The Channel you want messages to be deleted from.", - "de": "Wähle den Kanal aus, aus welchen Nachrichten automatisch gelöscht werden sollen." - }, + "humanName": "Channel", + "default": "", + "description": "The Channel you want messages to be deleted from.", "type": "channelID", "content": [ "GUILD_TEXT", @@ -31,33 +17,17 @@ }, { "name": "timeout", - "humanName": { - "en": "Timeout", - "de": "Timeout" - }, - "default": { - "en": "5" - }, - "description": { - "en": "Timeout (in minutes) after which the messages in a channel will be deleted.", - "de": "Timeout (in Minuten), nachdem die Nachrichten in einem Kanal automatisch gelöscht werden sollen." - }, + "humanName": "Timeout", + "default": 5, + "description": "Timeout (in minutes) after which the messages in a channel will be deleted.", "type": "integer" }, { "name": "keepMessageCount", - "default": { - "en": 0 - }, - "humanName": { - "en": "Amount of messages to keep", - "de": "Anzahl von zu behaltenden Nachrichten" - }, + "default": 0, + "humanName": "Amount of messages to keep", "type": "integer", - "description": { - "en": "Set up a number here to always have x messages in your channel left (newest messages are kept). The number has to below 50.", - "de": "Stelle hier eine Anzahl an Nachrichten ein, die auch nach einer Löschung in dem Kanal behalten werden sollen (neuere Nachrichten werden behalten). Die Zahl muss unter 50 liegen." - } + "description": "Set up a number here to always have x messages in your channel left (newest messages are kept). The number has to below 50." } ] } \ No newline at end of file diff --git a/modules/auto-delete/module.json b/modules/auto-delete/module.json index 8a0b73ef..963de1a6 100644 --- a/modules/auto-delete/module.json +++ b/modules/auto-delete/module.json @@ -5,6 +5,7 @@ "name": "SCDerox (SC Network Team)", "link": "https://github.com/SCDerox" }, + "fa-icon": "fa-regular fa-trash-can", "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/auto-delete", "events-dir": "/events", "config-example-files": [ @@ -14,12 +15,6 @@ "tags": [ "administration" ], - "humanReadableName": { - "en": "Auto-Message-Delete", - "de": "Automatisches Löschen" - }, - "description": { - "en": "This module allows you to delete messages from a channel after a specified timeout to keep your channel clean", - "de": "Halte deine Channel sauber, in dem du alle Nachrichten nach einem bestimmten Intervall in einem Channel löschst" - } -} \ No newline at end of file + "humanReadableName": "Auto-Message-Delete", + "description": "This module allows you to delete messages from a channel after a specified timeout to keep your channel clean" +} diff --git a/modules/auto-delete/voice-channels.json b/modules/auto-delete/voice-channels.json index 688205a2..aee6516f 100644 --- a/modules/auto-delete/voice-channels.json +++ b/modules/auto-delete/voice-channels.json @@ -1,28 +1,14 @@ { - "description": { - "en": "Set up voice-channels to delete messages from", - "de": "Stelle hier Sprach-Kanäle ein, aus welchen gelöscht werden soll" - }, - "humanName": { - "en": "Voice-Channels", - "de": "Sprach-Kanäle" - }, + "description": "Set up voice-channels to delete messages from", + "humanName": "Voice-Channels", "filename": "voice-channels.json", "configElements": true, "content": [ { "name": "channelID", - "humanName": { - "en": "Voice-Channel", - "de": "Sprachkanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "The Voice-Channel you want the auto-deleter to clear if there are no channel members left.", - "de": "Wähle den Sprachkanal aus, den der Bot leeren soll, sobald keine Mitglieder mehr im Sprachkanal sind." - }, + "humanName": "Voice-Channel", + "default": "", + "description": "The Voice-Channel you want the auto-deleter to clear if there are no channel members left.", "type": "channelID", "content": [ "GUILD_VOICE" @@ -30,17 +16,9 @@ }, { "name": "timeout", - "humanName": { - "en": "Timeout", - "de": "Timeout" - }, - "default": { - "en": "5" - }, - "description": { - "en": "Timeout (in minutes) after which the messages in a Voice-Channel are deleted after the last member left the channel. Entering '0' will result in an instant deletion.", - "de": "Timeout (in Minuten), nachdem die Nachrichten gelöscht werden, wenn das letzte Mitglied den Sprachkanal verlassen hat. Wenn du eine '0' verwendest, werden die Nachrichten sofort gelöscht." - }, + "humanName": "Timeout", + "default": 5, + "description": "Timeout (in minutes) after which the messages in a Voice-Channel are deleted after the last member left the channel. Entering '0' will result in an instant deletion.", "type": "integer" } ] diff --git a/modules/auto-messager/cronjob.json b/modules/auto-messager/cronjob.json index 18e9373b..bd40ed91 100644 --- a/modules/auto-messager/cronjob.json +++ b/modules/auto-messager/cronjob.json @@ -1,78 +1,34 @@ { - "description": { - "en": "Advanced users can unleash the full potential of automatic message with cronejobs", - "de": "Nur für fortgeschrittene Nutzer - mit cronjob's kannst hast du die volle Kontrolle über die Nachrichten" - }, - "elementLimits": { - "STARTER": 2, - "ACTIVE_GUILD": 5, - "PRO": 15, - "UNLIMITED": 5, - "PROFESSIONAL": 15 - }, - "humanName": { - "en": "Cronjob (advanced)", - "de": "Cronjobs (fortgeschritten)" - }, + "description": "Advanced users can unleash the full potential of automatic message with cronejobs", + "humanName": "Cronjob (advanced)", "configElementName": { - "de": { - "one": "Automatische Nachricht", - "more": "Automatische Nachrichten" - }, - "en": { - "one": "Automatic message", - "more": "Automatic messages" - } + "one": "Automatic message", + "more": "Automatic messages" }, "filename": "cronjob.json", "configElements": true, "content": [ { "name": "channelID", - "humanName": { - "de": "Kanal", - "en": "Channel" - }, - "default": { - "en": "" - }, - "description": { - "en": "ID of the channel in which the message should be send", - "de": "ID des Kanals, in welchen die Nachricht gesendet werden soll" - }, + "humanName": "Channel", + "default": "", + "description": "ID of the channel in which the message should be send", "type": "channelID" }, { "name": "message", - "humanName": { - "de": "Nachricht", - "en": "Message" - }, - "default": { - "en": "" - }, - "description": { - "en": "Message that should be send", - "de": "Nachricht, die gesendet werden soll" - }, + "humanName": "Message", + "default": "", + "description": "Message that should be send", "type": "string", "allowEmbed": true }, { "name": "expression", - "humanName": { - "de": "Ausdruck", - "en": "Expression" - }, - "default": { - "en": "1 6 1-31 * *", - "de": "1 6 1-31 * *" - }, - "description": { - "en": "The message gets scheduled for this expression", - "de": "Die Nachricht wird für diesen Ausdruck geplant" - }, + "humanName": "Expression", + "default": "1 6 1-31 * *", + "description": "The message gets scheduled for this expression", "type": "string" } ] -} \ No newline at end of file +} diff --git a/modules/auto-messager/daily.json b/modules/auto-messager/daily.json index 4e32ae27..b52456cd 100644 --- a/modules/auto-messager/daily.json +++ b/modules/auto-messager/daily.json @@ -1,96 +1,43 @@ { - "description": { - "en": "You can send on a daily basic here - this can be once a week or month", - "de": "Hier kannst du Nachrichten auf täglicher Basis versenden lassen - das kann auch nur einmal pro Woche oder Monat sein" - }, - "elementLimits": { - "STARTER": 2, - "ACTIVE_GUILD": 5, - "PRO": 15, - "UNLIMITED": 5, - "PROFESSIONAL": 15 - }, + "description": "You can send on a daily basic here - this can be once a week or month", "configElementName": { - "de": { - "one": "Automatische Nachricht", - "more": "Automatische Nachrichten" - }, - "en": { - "one": "Automatic message", - "more": "Automatic messages" - } - }, - "humanName": { - "en": "Daily Basic", - "de": "Tägliche Basis" + "one": "Automatic message", + "more": "Automatic messages" }, + "humanName": "Daily Basic", "filename": "daily.json", "configElements": true, "content": [ { "name": "channelID", - "humanName": { - "de": "Kanal", - "en": "Channel" - }, - "default": { - "en": "" - }, - "description": { - "en": "ID of the channel in which the message should be send", - "de": "ID des Kanals, in welchen die Nachricht gesendet werden soll" - }, + "humanName": "Channel", + "default": "", + "description": "ID of the channel in which the message should be send", "type": "channelID" }, { "name": "message", - "humanName": { - "de": "Nachricht", - "en": "Message" - }, - "default": { - "en": "" - }, - "description": { - "en": "Message that should be send", - "de": "Nachricht, die gesendet werden soll" - }, + "humanName": "Message", + "default": "", + "description": "Message that should be send", "type": "string", "allowEmbed": true }, { "name": "limitWeekDaysTo", - "humanName": { - "de": "Wochentage begrenzen auf", - "en": "Limit Week-Days to" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "If one or more values are set, the message will only get send when the current week-day is included in this field", - "de": "Wenn ein oder mehrere Werte gesetzt sind, wird die Nachricht nur gesendet, wenn der aktuelle Wochentag hier enthalten ist" - }, + "humanName": "Limit Week-Days to", + "default": [], + "description": "If one or more values are set, the message will only get send when the current week-day is included in this field", "type": "array", "content": "integer" }, { "name": "limitDaysTo", - "humanName": { - "de": "Tage begrenzen auf", - "en": "Limit days to" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "If one or more values are set, the message will only get send when the current day (of the month) is included in this field", - "de": "Wenn ein oder mehrere Werte gesetzt sind, wird die Nachricht nur gesendet, wenn der aktuelle Tag (des Monats) hier enthalten ist" - }, + "humanName": "Limit days to", + "default": [], + "description": "If one or more values are set, the message will only get send when the current day (of the month) is included in this field", "type": "array", "content": "integer" } ] -} \ No newline at end of file +} diff --git a/modules/auto-messager/hourly.json b/modules/auto-messager/hourly.json index 9b9c2882..29b557cb 100644 --- a/modules/auto-messager/hourly.json +++ b/modules/auto-messager/hourly.json @@ -1,79 +1,35 @@ { - "description": { - "en": "You can send messages on an hourly basic here - this can be once or 24 times a day", - "de": "Hier kannst du Nachrichten auf stündlicher Basis schicken lassen - das kann alles von 1-24x pro Tag sein" - }, - "humanName": { - "en": "Hourly basic", - "de": "Stündliche Basis" - }, - "elementLimits": { - "STARTER": 1, - "ACTIVE_GUILD": 4, - "PRO": 14, - "UNLIMITED": 4, - "PROFESSIONAL": 14 - }, + "description": "You can send messages on an hourly basic here - this can be once or 24 times a day", + "humanName": "Hourly basic", "configElementName": { - "de": { - "one": "Automatische Nachricht", - "more": "Automatische Nachrichten" - }, - "en": { - "one": "Automatic message", - "more": "Automatic messages" - } + "one": "Automatic message", + "more": "Automatic messages" }, "filename": "hourly.json", "configElements": true, "content": [ { "name": "channelID", - "humanName": { - "de": "Kanal", - "en": "Channel" - }, - "default": { - "en": "" - }, - "description": { - "en": "ID of the channel in which the message should be send", - "de": "ID des Kanals, in welchen die Nachricht gesendet werden soll" - }, + "humanName": "Channel", + "default": "", + "description": "ID of the channel in which the message should be send", "type": "channelID" }, { "name": "message", - "humanName": { - "de": "Nachricht", - "en": "Message" - }, - "default": { - "en": "" - }, - "description": { - "en": "Message that should be send", - "de": "Nachricht, die gesendet werden soll" - }, + "humanName": "Message", + "default": "", + "description": "Message that should be send", "type": "string", "allowEmbed": true }, { "name": "limitHoursTo", - "humanName": { - "de": "Stunden begrenzen auf", - "en": "Limit hours to" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "If one or more values are set, the message will only get send when the current hour is included in this field", - "de": "Wenn ein oder mehrere Werte gesetzt sind, wird die Nachricht nur gesendet, wenn die aktuelle Stunde hier enthalten ist" - }, + "humanName": "Limit hours to", + "default": [], + "description": "If one or more values are set, the message will only get send when the current hour is included in this field", "type": "array", "content": "integer" } ] -} \ No newline at end of file +} diff --git a/modules/auto-messager/module.json b/modules/auto-messager/module.json index e332ccd6..3e073869 100644 --- a/modules/auto-messager/module.json +++ b/modules/auto-messager/module.json @@ -1,5 +1,6 @@ { "name": "auto-messager", + "fa-icon": "fas fa-comment-dots", "author": { "scnxOrgID": "1", "name": "SCDerox (SC Network Team)", @@ -15,12 +16,6 @@ "tags": [ "tools" ], - "humanReadableName": { - "en": "Automatic Messages", - "de": "Automatische Nachrichten" - }, - "description": { - "en": "You can - with this module - send automatic messages", - "de": "Dieses Modul erlaubt dir es dir, automatisch versenden zu lassen" - } -} \ No newline at end of file + "humanReadableName": "Automatic Messages", + "description": "You can - with this module - send automatic messages" +} diff --git a/modules/auto-publisher/config.json b/modules/auto-publisher/config.json index f067cfde..b5f631e1 100644 --- a/modules/auto-publisher/config.json +++ b/modules/auto-publisher/config.json @@ -1,27 +1,13 @@ { - "description": { - "en": "Configure the behaviour of the module here", - "de": "Stelle hier die Funktionen des Modules ein" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the behaviour of the module here", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "mode", - "humanName": { - "en": "Message-Publishing-Mode", - "de": "Nachrichten-Veröffentlichung-Modus" - }, - "default": { - "en": "all" - }, - "description": { - "en": "Modus in which this module should operate", - "de": "Modus in welchem dieses Modul arbeiten sollte" - }, + "humanName": "Message-Publishing-Mode", + "default": "all", + "description": "Modus in which this module should operate", "type": "select", "content": [ "all", @@ -31,47 +17,25 @@ }, { "name": "blacklist", - "humanName": { - "en": "Blacklist" - }, - "default": { - "en": [] - }, - "description": { - "en": "Channel to be ignored (only if Message-Publishing-Mode = \"blacklist\")", - "de": "Kanäle, die ignoriert werden sollen (nur wenn Nachrichten-Veröffentlichung-Modus = \"blacklist\")" - }, + "humanName": "Blacklist", + "default": [], + "description": "Channel to be ignored (only if Message-Publishing-Mode = \"blacklist\")", "type": "array", "content": "channelID" }, { "name": "whitelist", - "humanName": { - "en": "Whitelist" - }, - "default": { - "en": [] - }, - "description": { - "en": "Channel in which messages should get published (only if Message-Publishing-Mode = \"whitelist\")", - "de": "Kanäle, in denen Nachrichten veröffentlicht werden sollen (nur wenn Message-Publishing-Mode = \"whitelist\")" - }, + "humanName": "Whitelist", + "default": [], + "description": "Channel in which messages should get published (only if Message-Publishing-Mode = \"whitelist\")", "type": "array", "content": "channelID" }, { "name": "ignoreBots", - "humanName": { - "en": "Ignore bots?", - "de": "Bots ignorieren?" - }, - "default": { - "en": true - }, - "description": { - "en": "Should bots get ignored when they post a message", - "de": "Sollen Bots ignoriert werden, wenn sie eine Nachricht senden" - }, + "humanName": "Ignore bots?", + "default": true, + "description": "Should bots get ignored when they post a message", "type": "boolean" } ] diff --git a/modules/auto-publisher/events/messageCreate.js b/modules/auto-publisher/events/messageCreate.js index 77f1a4fe..1edec8e3 100644 --- a/modules/auto-publisher/events/messageCreate.js +++ b/modules/auto-publisher/events/messageCreate.js @@ -13,11 +13,12 @@ module.exports.run = async (client, msg) => { if (!config.mode) config.mode = 'all'; if (config.mode === 'blacklist' && config.blacklist.includes(msg.channel.id)) return; if (config.mode === 'whitelist' && !config.whitelist.includes(msg.channel.id)) return; - if (msg.crosspostable) await msg.crosspost(); + if (msg.crosspostable) await msg.crosspost().catch(() => { + }); await msg.react('✅').then((r) => { setTimeout(() => { r.remove(); }, 2500); }); } -}; +}; \ No newline at end of file diff --git a/modules/auto-publisher/module.json b/modules/auto-publisher/module.json index 958a7891..6be0fe8c 100644 --- a/modules/auto-publisher/module.json +++ b/modules/auto-publisher/module.json @@ -1,5 +1,6 @@ { "name": "auto-publisher", + "fa-icon": "fas fa-bullhorn", "author": { "scnxOrgID": "1", "name": "SCDerox (SC Network Team)", @@ -13,12 +14,6 @@ "tags": [ "tools" ], - "humanReadableName": { - "en": "Automatic Publishing", - "de": "Automatische Veröffentlichung" - }, - "description": { - "en": "Publishes messages in announcement channels", - "de": "Veröffentlicht Nachrichten in Ankündigungskanälen" - } -} \ No newline at end of file + "humanReadableName": "Automatic Publishing", + "description": "Publishes messages in announcement channels" +} diff --git a/modules/auto-thread/config.json b/modules/auto-thread/config.json index 1062e8b2..5c5ecef7 100644 --- a/modules/auto-thread/config.json +++ b/modules/auto-thread/config.json @@ -1,56 +1,28 @@ { - "description": {}, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the behaviour of the module here", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "channels", - "humanName": { - "en": "Channels", - "de": "Kanäle" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Here you can add channels in which the bot should create a thread under every message", - "de": "Hier kannst du Kanäle hinzufügen, in welchen der Bot automatisch unter jeder Nachricht einen Thread erstellen soll" - }, + "humanName": "Channels", + "default": [], + "description": "Here you can add channels in which the bot should create a thread under every message", "type": "array", "content": "channelID" }, { "name": "threadName", - "humanName": { - "de": "Threadname" - }, - "default": { - "en": "Comments", - "de": "Kommentare" - }, - "description": { - "en": "Name of every thread", - "de": "Name jedes Threads" - }, + "humanName": "Thread Name", + "default": "Comments", + "description": "Name of every thread", "type": "string" }, { "name": "threadArchiveDuration", - "humanName": { - "de": "Archivierungsdauer" - }, - "default": { - "en": "MAX", - "de": "MAX" - }, - "description": { - "en": "Inactivity after which a thread is automatically archived (in minutes, some values are limited by guild boost level; select \"max\" for the longest possible duration)", - "de": "Inaktivität nach welcher ein Thread automatisch archiviert wird (in Minutes, manche Werte sind durch das Boostlevel des Servers eingeschränkt; verwende \"max\" für die längstmögliche Dauer)" - }, + "humanName": "Archive Duration", + "default": "MAX", + "description": "Inactivity after which a thread is automatically archived (in minutes, some values are limited by guild boost level; select \"max\" for the longest possible duration)", "type": "select", "content": [ "MAX", diff --git a/modules/auto-thread/events/messageCreate.js b/modules/auto-thread/events/messageCreate.js index 276cf745..1cf58fc4 100644 --- a/modules/auto-thread/events/messageCreate.js +++ b/modules/auto-thread/events/messageCreate.js @@ -1,5 +1,15 @@ const {localize} = require('../../../src/functions/localize'); +const {ThreadAutoArchiveDuration} = require('discord.js'); + +const d = { + 'MAX': ThreadAutoArchiveDuration.OneWeek, + '60': ThreadAutoArchiveDuration.OneHour, + '1440': ThreadAutoArchiveDuration.OneDay, + '4320': ThreadAutoArchiveDuration.ThreeDays, + '10080': ThreadAutoArchiveDuration.OneWeek +}; + module.exports.run = async (client, msg) => { if (!client.botReadyAt) return; if (msg.interaction || msg.system) return; @@ -7,7 +17,8 @@ module.exports.run = async (client, msg) => { if (!(moduleConfig.channels || []).includes(msg.channel.id)) return; if (!msg.hasThread) await msg.startThread({ name: moduleConfig.threadName, - autoArchiveDuration: moduleConfig.threadArchiveDuration, + + autoArchiveDuration: d[moduleConfig.threadArchiveDuration], reason: `[auto-thread] ${localize('auto-thread', 'thread-create-reason')}` }); }; \ No newline at end of file diff --git a/modules/auto-thread/module.json b/modules/auto-thread/module.json index 8416f7f4..4d93ad0a 100644 --- a/modules/auto-thread/module.json +++ b/modules/auto-thread/module.json @@ -1,5 +1,6 @@ { "name": "auto-thread", + "fa-icon": "fa-regular fa-comment", "author": { "scnxOrgID": "1", "name": "SCDerox (SC Network Team)", @@ -13,12 +14,6 @@ "tools" ], "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/auto-thread", - "humanReadableName": { - "en": "Automatic Thread-Creation", - "de": "Automatisches Thread-Erstellen" - }, - "description": { - "en": "Automatically creates a thread under each message that gets posted in a selected channel", - "de": "Erstellt einen Thread unter jeder Nachricht, die in einem bestimmten Channel gesendet wird" - } -} \ No newline at end of file + "humanReadableName": "Automatic Thread-Creation", + "description": "Automatically creates a thread under each message that gets posted in a selected channel" +} diff --git a/modules/betterstatus/commands/status.js b/modules/betterstatus/commands/status.js new file mode 100644 index 00000000..dcc87b7d --- /dev/null +++ b/modules/betterstatus/commands/status.js @@ -0,0 +1,84 @@ +const {localize} = require('../../../src/functions/localize'); +const {ActivityType} = require('discord.js'); + +const activityTypes = { + 'PLAYING': ActivityType.Playing, + 'STREAMING': ActivityType.Streaming, + 'WATCHING': ActivityType.Watching, + 'COMPETING': ActivityType.Competing, + 'LISTENING': ActivityType.Listening, + 'CUSTOM': ActivityType.Custom +}; + +/** + * Handle /status command to change bot status + * @param {Interaction} interaction Discord interaction + */ +module.exports.run = async function (interaction) { + const activityType = interaction.options.getString('activity-type'); + const botStatus = interaction.options.getString('bot-status'); + const statusText = interaction.options.getString('text'); + const streamingLink = interaction.options.getString('streaming-link'); + + await interaction.client.user.setPresence({ + status: botStatus, + activities: [{ + name: statusText, + type: activityTypes[activityType], + url: (activityType === 'STREAMING' && streamingLink) ? streamingLink : null + }] + }); + + interaction.reply({ + ephemeral: true, + content: '✅ ' + localize('betterstatus', 'status-changed', {s: statusText}) + }); +}; + +module.exports.config = { + name: 'status', + description: localize('betterstatus', 'command-description'), + defaultMemberPermissions: ['ADMINISTRATOR'], + disabled: function (client) { + return !client.configurations['betterstatus']['config'].enableStatusCommand; + }, + options: [ + { + type: 'STRING', + name: 'text', + required: true, + description: localize('betterstatus', 'text-description') + }, + { + type: 'STRING', + name: 'activity-type', + required: true, + description: localize('betterstatus', 'activity-type-description'), + choices: [ + {name: 'Playing', value: 'PLAYING'}, + {name: 'Streaming', value: 'STREAMING'}, + {name: 'Watching', value: 'WATCHING'}, + {name: 'Competing', value: 'COMPETING'}, + {name: 'Listening', value: 'LISTENING'}, + {name: 'Custom', value: 'CUSTOM'} + ] + }, + { + type: 'STRING', + name: 'bot-status', + required: true, + description: localize('betterstatus', 'bot-status-description'), + choices: [ + {name: 'Online', value: 'online'}, + {name: 'Idle', value: 'idle'}, + {name: 'Do Not Disturb', value: 'dnd'} + ] + }, + { + type: 'STRING', + name: 'streaming-link', + required: false, + description: localize('betterstatus', 'streaming-link-description') + } + ] +}; \ No newline at end of file diff --git a/modules/betterstatus/config.json b/modules/betterstatus/config.json index 95e9da39..4cfeb18d 100644 --- a/modules/betterstatus/config.json +++ b/modules/betterstatus/config.json @@ -1,100 +1,62 @@ { - "description": {}, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the bot status, activity type and interval settings here", + "humanName": "Configuration", "filename": "config.json", "content": [ + { + "name": "enableStatusCommand", + "humanName": "Enable /status command?", + "default": false, + "description": "If enabled, administrators can change the bot status using the /status slash command", + "type": "boolean" + }, { "name": "enableInterval", - "humanName": { - "en": "Enable interval?", - "de": "Interval aktivieren?" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled the bot will change its status every x seconds", - "de": "Wenn aktiviert wird sich der Status des Bots alle x Sekunden ändern" - }, + "humanName": "Enable interval?", + "default": false, + "description": "If enabled the bot will change its status every x seconds", "type": "boolean" }, { "name": "intervalStatuses", "dependsOn": "enableInterval", - "humanName": { - "en": "Interval-Statuses", - "de": "Interval-Status" - }, - "default": { - "en": [] - }, - "description": { - "en": "Statuses from which the bot should randomly choose one", - "de": "Die Status von denen der Bot einen zufällig wählen soll" - }, + "humanName": "Interval-Statuses", + "default": [], + "description": "Statuses from which the bot should randomly choose one", "type": "array", "content": "string", "params": [ { "name": "onlineMemberCount", - "description": { - "en": "Count of online members on your guild (will not work if presence intent not enabled)", - "de": "Anzahl der Online-Mitglieder auf deinem Server" - } + "description": "Count of online members on your guild (will not work if presence intent not enabled)" }, { "name": "memberCount", - "description": { - "en": "Count of members on your guild", - "de": "Anzahl der Mitglieder auf deinem Server" - } + "description": "Count of members on your guild" }, { "name": "randomMemberTag", - "description": { - "en": "Tag of one random member on your guild", - "de": "Erwähnung eines zufälligen Nutzern auf deinem Server" - } + "description": "Tag of one random member on your guild" }, { "name": "randomOnlineMemberTag", - "description": { - "en": "Tag of one random member who is online on your guild", - "de": "Erwähnung eines zufälligen online Nutzern auf deinem Server" - } + "description": "Tag of one random member who is online on your guild" }, { "name": "channelCount", - "description": { - "en": "Count of channels on your guild", - "de": "Anzahl Channel auf deinem Server" - } + "description": "Count of channels on your guild" }, { "name": "roleCount", - "description": { - "en": "Count of roles on your guild", - "de": "Anzahl Rollen auf deinem Server" - } + "description": "Count of roles on your guild" } ] }, { "name": "activityType", - "humanName": { - "en": "Activity-Type", - "de": "Aktivität-Typ" - }, - "default": { - "en": "PLAYING" - }, - "description": { - "en": "Type of the user activity", - "de": "Type der Aktivität deines Bots" - }, + "humanName": "Activity-Type", + "default": "PLAYING", + "description": "Type of the user activity", "type": "select", "content": [ "CUSTOM", @@ -107,17 +69,9 @@ }, { "name": "botStatus", - "humanName": { - "en": "Bot-Status", - "de": "Bot-Status" - }, - "default": { - "en": "online" - }, - "description": { - "en": "Status of your bot", - "de": "Status deines Bots" - }, + "humanName": "Bot-Status", + "default": "online", + "description": "Status of your bot", "type": "select", "content": [ "idle", @@ -127,88 +81,47 @@ }, { "name": "interval", - "humanName": { - "en": "Status-Interval", - "de": "Statusänderung-Interval" - }, - "default": { - "en": 15 - }, - "description": { - "en": "The interval in seconds (at least 10 seconds)", - "de": "Das Intervall der Statusänderungen in Sekunden (mindestens 10 Sekunden)" - }, + "humanName": "Status-Interval", + "default": 15, + "description": "The interval in seconds (at least 10 seconds)", "minValue": 10, "type": "integer" }, { "name": "changeOnUserJoin", - "humanName": { - "en": "Change status on user join?", - "de": "Beim Beitreten Status ändern?" - }, - "default": { - "en": false - }, - "description": { - "en": "If the status should be changed if someone joins your guild", - "de": "Wenn aktiviert wird sich der Status des Bots ändern, wenn jemand deinem Server beitritt" - }, + "humanName": "Change status on user join?", + "default": false, + "description": "If the status should be changed if someone joins your guild", "type": "boolean" }, { "name": "userJoinStatus", "dependsOn": "changeOnUserJoin", - "humanName": { - "en": "User-Join-Status", - "de": "Nutzer-Join-Status" - }, - "default": { - "en": "Welcome %tag%!" - }, - "description": { - "en": "Status that will be set if a user joins", - "de": "Dieser Status wird gesetzt, wenn jemand deinem Server beitritt" - }, + "humanName": "User-Join-Status", + "default": "Welcome %tag%!", + "description": "Status that will be set if a user joins", "type": "string", "params": [ { "name": "tag", - "description": { - "en": "Tag of the new user", - "de": "Tag des Nutzers" - } + "description": "Tag of the new user" }, { "name": "username", - "description": { - "en": "Username of the new user", - "de": "Nutzername des Nutzers" - } + "description": "Username of the new user" }, { "name": "memberCount", - "description": { - "en": "New member count of your guild", - "de": "Anzahl der Mitglieder auf deinem Server" - } + "description": "New member count of your guild" } ] }, { "name": "streamingLink", "type": "string", - "humanName": { - "en": "Streaming Link", - "de": "Stream-Link" - }, - "default": { - "en": "" - }, - "description": { - "de": "Wird angezeigt, wenn der Aktivität-Typ auf streaming ist und der Link von Discord unterstützt wird", - "en": "Will be shown, if the activity-typ is streaming and your link is supported by Discord" - } + "humanName": "Streaming Link", + "default": "", + "description": "Will be shown, if the activity-typ is streaming and your link is supported by Discord" } ] } \ No newline at end of file diff --git a/modules/betterstatus/events/botReady.js b/modules/betterstatus/events/botReady.js index 2dab5e48..5773e573 100644 --- a/modules/betterstatus/events/botReady.js +++ b/modules/betterstatus/events/botReady.js @@ -24,7 +24,7 @@ module.exports.run = async function (client) { type: activityTypes[moduleConf['activityType']], url: (moduleConf['streamingLink'] && moduleConf.activityType === 'STREAMING') ? moduleConf['streamingLink'] : null }); - }, moduleConf.interval < 5 ? 5000 : moduleConf.interval * 1000); // At least 5 seconds to prevent rate limiting + }, Math.min(moduleConf.interval < 5 ? 5000 : moduleConf.interval * 1000, 0x7FFFFFFF)); // At least 5 seconds to prevent rate limiting client.intervals.push(interval); } diff --git a/modules/betterstatus/module.json b/modules/betterstatus/module.json index 5fe9f6fa..dd90089e 100644 --- a/modules/betterstatus/module.json +++ b/modules/betterstatus/module.json @@ -5,7 +5,9 @@ "link": "https://github.com/SCDerox", "scnxOrgID": "1" }, + "fa-icon": "far fa-user-circle", "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/betterstatus", + "commands-dir": "/commands", "events-dir": "/events", "config-example-files": [ "config.json" @@ -13,11 +15,6 @@ "tags": [ "bot" ], - "humanReadableName": { - "en": "Betterstatus" - }, - "description": { - "en": "Give you more features to make your status even better - change it when someone joins, change it every x seconds and more!", - "de": "Mache den Status deines Bots noch besser - Nutze Variablen, Intervalle und vieles mehr!" - } -} \ No newline at end of file + "humanReadableName": "Betterstatus", + "description": "Give you more features to make your status even better - change it when someone joins, change it every x seconds and more!" +} diff --git a/modules/channel-stats/channels.json b/modules/channel-stats/channels.json index 89f67b7b..6935dd95 100644 --- a/modules/channel-stats/channels.json +++ b/modules/channel-stats/channels.json @@ -1,187 +1,102 @@ { - "description": {}, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure voice channels that display live server statistics", + "humanName": "Configuration", "configElementName": { - "de": { - "one": "Statistik-Kanal", - "more": "Statistik-Kanäle" - }, - "en": { - "one": "Statistics-Channel", - "more": "Statistics-Channels" - } + "one": "Statistics-Channel", + "more": "Statistics-Channels" }, "filename": "channels.json", "configElements": true, "content": [ { "name": "channelID", - "humanName": { - "de": "Kanal", - "en": "Channel" - }, - "default": { - "en": "" - }, - "description": { - "en": "ID of the voice channel", - "de": "ID des Sprachkanals" - }, + "humanName": "Channel", + "default": "", + "description": "ID of the voice channel", "type": "channelID" }, { "name": "channelName", - "humanName": { - "de": "Kanalname", - "en": "Channel-Name" - }, - "default": { - "en": "" - }, - "description": { - "en": "Name of Channel", - "de": "Name des Kanals" - }, + "humanName": "Channel-Name", + "default": "", + "description": "Name of Channel", "type": "string", "params": [ { "name": "userCount", - "description": { - "en": "Total count of users on your server", - "de": "Anzahl an Nutzern auf dem Server" - } + "description": "Total count of users on your server" }, { "name": "memberCount", - "description": { - "en": "Total count of members (not bots) on your server", - "de": "Anzahl an Mitgliedern (keine Bots) auf dem Server" - } + "description": "Total count of members (not bots) on your server" }, { "name": "onlineUserCount", - "description": { - "en": "Total count of online (dnd or online status) users on your server", - "de": "Anzahl an Mitgliedern (keine Bots) auf dem Server, welche Online sind (Bitte nicht stören oder Online Status)" - } + "description": "Total count of online (dnd or online status) users on your server" }, { "name": "channelCount", - "description": { - "en": "Total count of channels on your server", - "de": "Anzahl der Kanäle auf dem Server" - } + "description": "Total count of channels on your server" }, { "name": "roleCount", - "description": { - "en": "Total count of roles on your server", - "de": "Anzahl der Rollen auf dem Server" - } + "description": "Total count of roles on your server" }, { "name": "botCount", - "description": { - "en": "Count of Bots on your server", - "de": "Anzahl der Bots auf dem Server" - } + "description": "Count of Bots on your server" }, { "name": "dndCount", - "description": { - "en": "Count of members (not bots) with DND as status", - "de": "Anzahl der Mitglieder (keine Bots) mit Bitte nicht stören als Status" - } + "description": "Count of members (not bots) with DND as status" }, { "name": "onlineMemberCount", - "description": { - "en": "Count of members (not bots) with online (and only online) as status", - "de": "Anzahl der Mitglieder (keine Bots) mit Online (und NUR Online) als Status" - } + "description": "Count of members (not bots) with online (and only online) as status" }, { "name": "awayCount", - "description": { - "en": "Count of members (not bots) with away status", - "de": "Anzahl der Mitglieder (keine Bots) mit \"Abwesend\" Status" - } + "description": "Count of members (not bots) with away status" }, { "name": "offlineCount", - "description": { - "en": "Count of members (not bots) with offline status", - "de": "Anzahl der Mitglieder (keine Bots) mit \"Offline\" status" - } + "description": "Count of members (not bots) with offline status" }, { "name": "guildBoosts", - "description": { - "en": "Show how often this guild was boosted", - "de": "Zeigt, wie oft der Server geboostet wurde" - } + "description": "Show how often this guild was boosted" }, { "name": "boostLevel", - "description": { - "en": "Shows the current boost-level of this guild", - "de": "Zeigt das aktuelle Boost-Level des Servers" - } + "description": "Shows the current boost-level of this guild" }, { "name": "boosterCount", - "description": { - "en": "Count of boosters on this guild", - "de": "Anzahl an Boostern auf dem Server" - } + "description": "Count of boosters on this guild" }, { "name": "emojiCount", - "description": { - "en": "Count of emojis on this guild", - "de": "Anzahl an Emojis auf dem Server" - } + "description": "Count of emojis on this guild" }, { "name": "currentTime", - "description": { - "en": "Current time and date", - "de": "Aktuelles Datum und Uhrzeit" - } + "description": "Current time and date" }, { "name": "userWithRoleCount-", - "description": { - "en": "Count of members with a specific role (replace \"\" with an actual role-id)", - "de": "Anzahl von Nutzern mit einer bestimmen Rolle (bitte \"\" mit einer echten Rollen-ID ersetzen)" - } + "description": "Count of members with a specific role (replace \"\" with an actual role-id)" }, { "name": "onlineUserWithRoleCount-", - "description": { - "en": "Count of members with a specific role who are online (replace \"\" with an actual role-id)", - "de": "Anzahl von Nutzern mit einer bestimmen Rolle, die online sind (bitte \"\" mit einer echten Rollen-ID ersetzen)" - } + "description": "Count of members with a specific role who are online (replace \"\" with an actual role-id)" } ] }, { "name": "updateInterval", - "humanName": { - "de": "Aktualisierungsintervall", - "en": "Update-Interval" - }, - "default": { - "en": 15, - "de": 15 - }, - "description": { - "en": "You can set an interval here in which the bot should update the channels. Must be higher than seven; in minutes.", - "de": "Du kannst hier ein Intervall einstellen, in welchem der Bot die Kanäle aktualisieren soll. Muss höher als sieben sein; in Minuten." - }, + "humanName": "Update-Interval", + "default": 15, + "description": "You can set an interval here in which the bot should update the channels. Must be higher than seven; in minutes.", "type": "integer" } ] diff --git a/modules/channel-stats/events/botReady.js b/modules/channel-stats/events/botReady.js index 4d5f8532..53814d74 100644 --- a/modules/channel-stats/events/botReady.js +++ b/modules/channel-stats/events/botReady.js @@ -14,11 +14,20 @@ module.exports.run = async (client) => { t: dcChannel.type })); const res = await channelNameReplacer(client, dcChannel, channel.channelName); - if (res !== dcChannel.name) dcChannel.setName(res, '[channel-stats] ' + localize('channel-stats', 'audit-log-reason-startup')); + if (res !== dcChannel.name) await dcChannel.setName(res, '[channel-stats] ' + localize('channel-stats', 'audit-log-reason-startup')).catch(() => { + }); + let updating = false; client.intervals.push(setInterval(async () => { - const repName = await channelNameReplacer(client, dcChannel, channel.channelName); - if (repName !== dcChannel.name) dcChannel.setName(repName, '[channel-stats] ' + localize('channel-stats', 'audit-log-reason-interval')); - }, (channel.updateInterval || 5) < 5 ? 5 * 60000 : (channel.updateInterval || 5) * 60000)); + if (updating) return; + updating = true; + try { + const repName = await channelNameReplacer(client, dcChannel, channel.channelName); + if (repName !== dcChannel.name) await dcChannel.setName(repName, '[channel-stats] ' + localize('channel-stats', 'audit-log-reason-interval')).catch(() => { + }); + } finally { + updating = false; + } + }, Math.min(((channel.updateInterval || 5) < 5 ? 5 : (channel.updateInterval || 5)) * 60000, 0x7FFFFFFF))); } }; @@ -71,4 +80,4 @@ async function channelNameReplacer(client, channel, input) { .split('%boosterCount%').join(members.filter(m => !!m.premiumSinceTimestamp).size) .split('%emojiCount%').join(channel.guild.emojis.cache.size) .split('%currentTime%').join(formatDate(new Date(), true)).trim(); -} \ No newline at end of file +} diff --git a/modules/channel-stats/module.json b/modules/channel-stats/module.json index e2a3bf61..13f9697c 100644 --- a/modules/channel-stats/module.json +++ b/modules/channel-stats/module.json @@ -1,5 +1,6 @@ { "name": "channel-stats", + "fa-icon": "fas fa-stream", "author": { "scnxOrgID": "1", "name": "SCDerox (SC Network Team)", @@ -13,12 +14,6 @@ "administration" ], "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/channel-stats", - "humanReadableName": { - "en": "Channel-Stats", - "de": "Channel-Statistiken" - }, - "description": { - "en": "Create channels containing stats about your server - updated automatically.", - "de": "Modul, um Channel mit automatisch aktualisierenden Namen mit Statistiken zu erstellen" - } -} \ No newline at end of file + "humanReadableName": "Channel-Stats", + "description": "Create channels containing stats about your server - updated automatically." +} diff --git a/modules/color-me/commands/color-me.js b/modules/color-me/commands/color-me.js index 168c7588..41b45cf1 100644 --- a/modules/color-me/commands/color-me.js +++ b/modules/color-me/commands/color-me.js @@ -1,12 +1,6 @@ const {localize} = require('../../../src/functions/localize'); const {client} = require('../../../main'); const {embedType, dateToDiscordTimestamp} = require('../../../src/functions/helpers'); -let roleColor; -let roleIcon; -let pos; -let cooldownModel; -let cancel = false; -let iconW = true; module.exports.beforeSubcommand = async function (interaction) { await interaction.deferReply({ephemeral: true}); @@ -14,6 +8,8 @@ module.exports.beforeSubcommand = async function (interaction) { module.exports.subcommands = { 'manage': async function (interaction) { + let roleIcon; + let iconW = true; if (interaction.options.getAttachment('icon') !== null) { if (client.guild.features.includes('ROLE_ICONS')) { roleIcon = interaction.options.getAttachment('icon').url; @@ -26,124 +22,129 @@ module.exports.subcommands = { const moduleStrings = interaction.client.configurations['color-me']['strings']; const moduleModel = interaction.client.models['color-me']['Role']; - if (moduleConf.rolePosition) { - pos = interaction.guild.roles.resolve(moduleConf.rolePosition).position; - } else { - pos = 0; + const pos = moduleConf.rolePosition + ? interaction.guild.roles.resolve(moduleConf.rolePosition).position + : 0; + + const { + allowed, + cooldownModel + } = await cooldown(moduleConf['updateCooldown'] * 3600000, interaction.user.id); + if (!allowed) { + await interaction.editReply(embedType(moduleStrings['cooldown'], { + '%cooldown%': dateToDiscordTimestamp(new Date(cooldownModel.timestamp.getTime() + moduleConf['updateCooldown'] * 3600000), 'R') + })); + return; } - if (await cooldown(moduleConf['updateCooldown'] * 3600000, interaction.user.id)) { - let role = await moduleModel.findOne({ - attributes: ['roleID'], - raw: true, - where: { - userID: interaction.user.id - } - }); - if (role) { - role = role.roleID; - await color(interaction, moduleStrings); - if (cancel) return; - if (interaction.guild.roles.cache.find(r => r.id === role)) { - role = interaction.guild.roles.resolve(role); - role.edit( - { - name: interaction.options.getString('name'), - color: roleColor, - icon: roleIcon, - reason: localize('color-me', 'edit-log-reason', { - user: interaction.user.username - }) - } - ); - if (iconW) { - await interaction.editReply(embedType(moduleStrings['updated'], {})); - } else { - await interaction.editReply(embedType(moduleStrings['updatedNoIcon'], {})); + + let role = await moduleModel.findOne({ + attributes: ['roleID'], + raw: true, + where: { + userID: interaction.user.id + } + }); + if (role) { + role = role.roleID; + const { + roleColor, + cancel + } = await color(interaction, moduleStrings); + if (cancel) return; + if (interaction.guild.roles.cache.find(r => r.id === role)) { + role = interaction.guild.roles.resolve(role); + role.edit( + { + name: interaction.options.getString('name'), + color: roleColor, + icon: roleIcon, + reason: localize('color-me', 'edit-log-reason', { + user: interaction.user.username + }) } + ); + if (iconW) { + await interaction.editReply(embedType(moduleStrings['updated'], {})); } else { - if (interaction.guild.roles.cache.size < 250) { - role = await interaction.guild.roles.create( - { - name: interaction.options.getString('name'), - color: roleColor, - icon: roleIcon, - hoist: moduleConf.listRoles, - permissions: '', - position: pos, - mentionable: false, - reason: localize('color-me', 'create-log-reason', { - user: interaction.user.username - }) - } - ); - } else { - await interaction.editReply(embedType(moduleStrings['roleLimit'], {})); - } - await moduleModel.update({ - userID: interaction.user.id, - roleID: role.id, - name: role.name, - color: role.hexColor, - timestamp: new Date() - }, { - where: { - userID: interaction.user.id - } - }); - if (!interaction.member.roles.cache.has(role)) { - await interaction.member.roles.add(role); - } - if (iconW) { - await interaction.editReply(embedType(moduleStrings['updated'], {})); - } else { - await interaction.editReply(embedType(moduleStrings['updatedNoIcon'], {})); - } + await interaction.editReply(embedType(moduleStrings['updatedNoIcon'], {})); } } else { - await color(interaction, moduleStrings); - if (cancel) return; - try { - role = await interaction.guild.roles.create( - { - name: interaction.options.getString('name'), - color: roleColor, - icon: roleIcon, - hoist: moduleConf.listRoles, - permissions: '', - position: pos, - mentionable: false, - reason: localize('color-me', 'create-log-reason', { - user: interaction.user.username - }) - } - ); - await moduleModel.create({ - userID: interaction.user.id, - roleID: role.id, - name: role.name, - color: role.hexColor, - timestamp: new Date() - }); - await interaction.member.roles.add(role); - if (iconW) { - await interaction.editReply(embedType(moduleStrings['created'], {})); - } else { - await interaction.editReply(embedType(moduleStrings['createdNoIcon'], {})); - } - } catch (e) { + if (interaction.guild.roles.cache.size >= 250) { await interaction.editReply(embedType(moduleStrings['roleLimit'], {})); + return; + } + role = await interaction.guild.roles.create( + { + name: interaction.options.getString('name'), + color: roleColor, + icon: roleIcon, + hoist: moduleConf.listRoles, + permissions: '', + position: pos, + mentionable: false, + reason: localize('color-me', 'create-log-reason', { + user: interaction.user.username + }) + } + ); + await moduleModel.update({ + userID: interaction.user.id, + roleID: role.id, + name: role.name, + color: role.hexColor, + timestamp: new Date() + }, { + where: { + userID: interaction.user.id + } + }); + if (!interaction.member.roles.cache.has(role)) { + await interaction.member.roles.add(role); + } + if (iconW) { + await interaction.editReply(embedType(moduleStrings['updated'], {})); + } else { + await interaction.editReply(embedType(moduleStrings['updatedNoIcon'], {})); } - } } else { - cooldownModel = await moduleModel.findOne({ - where: { - userId: interaction.member.id + const { + roleColor, + cancel + } = await color(interaction, moduleStrings); + if (cancel) return; + try { + role = await interaction.guild.roles.create( + { + name: interaction.options.getString('name'), + color: roleColor, + icon: roleIcon, + hoist: moduleConf.listRoles, + permissions: '', + position: pos, + mentionable: false, + reason: localize('color-me', 'create-log-reason', { + user: interaction.user.username + }) + } + ); + await moduleModel.create({ + userID: interaction.user.id, + roleID: role.id, + name: role.name, + color: role.hexColor, + timestamp: new Date() + }); + await interaction.member.roles.add(role); + if (iconW) { + await interaction.editReply(embedType(moduleStrings['created'], {})); + } else { + await interaction.editReply(embedType(moduleStrings['createdNoIcon'], {})); } - }); - await interaction.editReply((embedType(moduleStrings['cooldown'], { - '%cooldown%': dateToDiscordTimestamp(new Date(cooldownModel.timestamp.getTime() + moduleConf['updateCooldown'] * 3600000), 'R') - }))); + } catch (e) { + await interaction.editReply(embedType(moduleStrings['roleLimit'], {})); + } + } }, @@ -219,40 +220,54 @@ module.exports.config = { /** * Gets a color from the String of a command option + * @returns {Promise<{roleColor: string|number, cancel: boolean}>} */ async function color(interaction, moduleStrings) { if (interaction.options.getString('color')) { - roleColor = interaction.options.getString('color'); + let roleColor = interaction.options.getString('color'); if (!roleColor.startsWith('#')) { roleColor = '#' + roleColor; } if (!(/^#[0-9A-F]{6}$/i).test(roleColor)) { await interaction.editReply(embedType(moduleStrings['invalidColor'], {})); - cancel = true; + return { + roleColor, + cancel: true + }; } - } else { - roleColor = 0xF1C40F; + return { + roleColor, + cancel: false + }; } + return { + roleColor: 0xF1C40F, + cancel: false + }; } /** ** Function to handle the cooldown stuff * @private * @param {number} duration The duration of the cooldown (in ms) - * @param {userId} userId Id of the User - * @returns {Promise} + * @param {string} userId Id of the User + * @returns {Promise<{allowed: boolean, cooldownModel: object|null}>} */ async function cooldown(duration, userId) { const model = client.models['color-me']['Role']; - cooldownModel = await model.findOne({ + const cooldownModel = await model.findOne({ where: { - userId: userId + userID: userId } }); if (cooldownModel && cooldownModel.timestamp) { - // check cooldown duration - return cooldownModel.timestamp.getTime() + duration <= Date.now(); - } else { - return true; + return { + allowed: cooldownModel.timestamp.getTime() + duration <= Date.now(), + cooldownModel + }; } + return { + allowed: true, + cooldownModel: null + }; } \ No newline at end of file diff --git a/modules/color-me/configs/config.json b/modules/color-me/configs/config.json index c5668c2a..155bd206 100644 --- a/modules/color-me/configs/config.json +++ b/modules/color-me/configs/config.json @@ -1,87 +1,41 @@ { - "description": { - "en": "Configure the function of the module here", - "de": "Stelle hier die Funktionen des Modules ein" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the function of the module here", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "recreateRole", - "humanName": { - "en": "Recreate roles", - "de": "Rollen wiederherstellen" - }, - "default": { - "en": true - }, - "description": { - "en": "Should the role be created again if the user boosts again?", - "de": "Soll die Rolle wiederhergestellt werden, wenn ein Nutzer erneut boostet?" - }, + "humanName": "Recreate roles", + "default": true, + "description": "Should the role be created again if the user boosts again?", "type": "boolean" }, { "name": "listRoles", - "humanName": { - "en": "Separate roles in member-list", - "de": "Rollen in Mitgliederliste separieren" - }, - "default": { - "en": false - }, - "description": { - "en": "Should the role be listed separately in the member-list?", - "de": "Soll die Rolle in der Mitgliederliste separat gelistet werden?" - }, + "humanName": "Separate roles in member-list", + "default": false, + "description": "Should the role be listed separately in the member-list?", "type": "boolean" }, { "name": "removeOnUnboost", - "humanName": { - "en": "Remove role on unboost", - "de": "Rolle bei Unboost entfernen" - }, - "default": { - "en": false - }, - "description": { - "en": "Should the role be deleted automatically, if the user stops boosting your server? (disable, if also non-boosters should be able to use this command)", - "de": "Soll die Rolle automatisch gelöscht werden, wenn der Nutzer den Server nicht mehr boostet? (deaktivieren, wenn auch nicht-Booster den Befehl verwenden können sollen)" - }, + "humanName": "Remove role on unboost", + "default": false, + "description": "Should the role be deleted automatically, if the user stops boosting your server? (disable, if also non-boosters should be able to use this command)", "type": "boolean" }, { "name": "updateCooldown", - "humanName": { - "en": "Role update cooldown", - "de": "Rollenbearbeitungscooldown" - }, - "default": { - "en": 24 - }, - "description": { - "en": "The amount of time a user needs to wait util they can edit their role again (in hours)", - "de": "Die Dauer, die Benutzer*innen warten müssen, bevor ihre Rolle wieder bearbeitet werden kann (in Stunden)" - }, + "humanName": "Role update cooldown", + "default": 24, + "description": "The amount of time a user needs to wait util they can edit their role again (in hours)", "type": "integer" }, { "name": "rolePosition", - "humanName": { - "en": "Role position", - "de": "Rollenposition" - }, - "default": { - "en": "" - }, - "description": { - "en": "The role, beneath which the custom-roles should be created", - "de": "Die Rolle unter welcher die Custom-Rollen erstellt werden sollen" - }, + "humanName": "Role position", + "default": "", + "description": "The role, beneath which the custom-roles should be created", "type": "roleID" } ] diff --git a/modules/color-me/configs/strings.json b/modules/color-me/configs/strings.json index cbc205b2..b43014c8 100644 --- a/modules/color-me/configs/strings.json +++ b/modules/color-me/configs/strings.json @@ -1,158 +1,77 @@ { - "description": { - "en": "Edit the messages and strings of the module here", - "de": "Stelle hier die Nachrichten des Modules ein" - }, - "humanName": { - "en": "Messages", - "de": "Nachrichten" - }, + "description": "Edit the messages and strings of the module here", + "humanName": "Messages", "filename": "strings.json", "content": [ { "name": "created", - "humanName": { - "en": "Role created", - "de": "Rolle erstellt" - }, - "default": { - "en": "Your role was created successfully.", - "de": "Deine Rolle wurde erfolgreich erstellt." - }, - "description": { - "en": "This messages gets send when a booster sucessfully created their custom role", - "de": "Diese Nachricht wird verschickt, wenn ein Booster seine/ihre Custom-Rolle erstellt hat" - }, + "humanName": "Role created", + "default": "Your role was created successfully.", + "description": "This messages gets send when a booster sucessfully created their custom role", "type": "string", "allowEmbed": true }, { "name": "createdNoIcon", - "humanName": { - "en": "Role created without icon", - "de": "Rolle ohne Icon erstellt" - }, - "default": { - "en": "Your role was created successfully, but your role icon was not used, as this requires the guild to be boost level 2 or higher.", - "de": "Deine Rolle wurde erfolgreich erstellt, aber dein Rollen-Icon wurde nicht verwendet, da hierfür der Server mindestens Boostlevel 2 besitzen muss." - }, - "description": { - "en": "This message gets send when a booster successfully created their custom role, but the guild has not enough boosts to use role icons", - "de": "Diese Nachricht wird verschickt, wenn ein Booster seine/ihre Custom-Rolle erstellt hat, der Server aber nicht ausreichend Boosts hat, um Rollenicons zu verwenden" - }, + "humanName": "Role created without icon", + "default": "Your role was created successfully, but your role icon was not used, as this requires the guild to be boost level 2 or higher.", + "description": "This message gets send when a booster successfully created their custom role, but the guild has not enough boosts to use role icons", "type": "string", "allowEmbed": true }, { "name": "updated", - "humanName": { - "en": "Role updated", - "de": "Rolle aktualisiert" - }, - "default": { - "en": "Your role was updated successfully.", - "de": "Deine Rolle wurde erfolgreich aktualisiert." - }, - "description": { - "en": "This messages gets send when a booster sucessfully updates their custom role", - "de": "Diese Nachricht wird verschickt, wenn ein Booster seine/ihre Custom-Rolle aktualisiert hat" - }, + "humanName": "Role updated", + "default": "Your role was updated successfully.", + "description": "This messages gets send when a booster sucessfully updates their custom role", "type": "string", "allowEmbed": true }, { "name": "updatedNoIcon", - "humanName": { - "en": "Role updated without icon", - "de": "Rolle ohne Icon aktualisiert" - }, - "default": { - "en": "Your role was updated successfully, but your role icon was not used, as this requires the guild to be boost level 2 or higher.", - "de": "Deine Rolle wurde erfolgreich aktualisiert aber dein Rollen-Icon wurde nicht verwendet, da hierfür der Server mindestens Boostlevel 2 besitzen muss." - }, - "description": { - "en": "This messages gets send when a booster sucessfully updates their custom role, but the guild has not enough boosts to use role icons", - "de": "Diese Nachricht wird verschickt, wenn ein Booster seine/ihre Custom-Rolle aktualisiert hat, der Server aber nicht genug boosts hat um Rollenicons zu verwenden" - }, + "humanName": "Role updated without icon", + "default": "Your role was updated successfully, but your role icon was not used, as this requires the guild to be boost level 2 or higher.", + "description": "This messages gets send when a booster sucessfully updates their custom role, but the guild has not enough boosts to use role icons", "type": "string", "allowEmbed": true }, { "name": "removed", - "humanName": { - "en": "Role removed", - "de": "Rolle entfernt" - }, - "default": { - "en": "Your role was removed successfully.", - "de": "Deine Rolle wurde erfolgreich entfernt." - }, - "description": { - "en": "This messages gets send when a booster deleted their custom role", - "de": "Diese Nachricht wird verschickt, wenn ein Booster seine/ihre Custom-Rolle entfernt hat" - }, + "humanName": "Role removed", + "default": "Your role was removed successfully.", + "description": "This messages gets send when a booster deleted their custom role", "type": "string", "allowEmbed": true }, { "name": "roleLimit", - "humanName": { - "en": "Role-limit reached", - "de": "Rollenlimit erreicht" - }, - "default": { - "en": "Your role couldn't be created. This could be, because this server has reached the maximum of roles set by Discord. Ask the staff to delete an unnecessary role to make space for your role or try again later.", - "de": "Deine Rolle konnte nicht erstellt werden. Das kann daran liegen, dass dieser Server die von Discord vorgegebene maximale Rollenzahl erreicht hat. Frag das Team nach der Löschung einer überflüssigen Rolle um Platz zu machen, oder versuche es später erneut." - }, - "description": { - "en": "This messages gets send when a booster-role couldn't be created", - "de": "Diese Nachricht wird verschickt, wenn eine Booster-Rolle nicht erstellt werden konnte" - }, + "humanName": "Role-limit reached", + "default": "Your role couldn't be created. This could be, because this server has reached the maximum of roles set by Discord. Ask the staff to delete an unnecessary role to make space for your role or try again later.", + "description": "This messages gets send when a booster-role couldn't be created", "type": "string", "allowEmbed": true }, { "name": "cooldown", - "humanName": { - "en": "Cooldown", - "de": "Cooldown" - }, - "default": { - "en": "Your role couldn't be edited, since you have to wait until %cooldown% for the cooldown to expire.", - "de": "Deine Rolle konnte nicht bearbeitet werden, da du bis %cooldown% warten musst, dass der Cooldown abläuft." - }, - "description": { - "en": "This messages gets send when a booster-role couldn't be edited, since the user is on cooldown", - "de": "Diese Nachricht wird verschickt, wenn eine Booster-Rolle nicht bearbeitet werden konnte, da der Nutzer den Cooldown abwarten muss" - }, + "humanName": "Cooldown", + "default": "Your role couldn't be edited, since you have to wait until %cooldown% for the cooldown to expire.", + "description": "This messages gets send when a booster-role couldn't be edited, since the user is on cooldown", "type": "string", "allowEmbed": true, "params": [ { "name": "cooldown", - "description": { - "en": "Timestamp the cooldown expires at", - "de": "Zeitpunkt, an welchem der Cooldown abläuft" - } + "description": "Timestamp the cooldown expires at" } ] }, { "name": "invalidColor", - "humanName": { - "en": "Invalid Color", - "de": "Falsche Farbe" - }, - "default": { - "en": "The color you provided is not a valid HEX-Code.", - "de": "Die angegebene Farbe ist kein gültiger HEX-Code." - }, - "description": { - "en": "This messages gets send when the user provides a wrong color code", - "de": "Diese Nachricht wird verschickt, wenn der Nutzer einen falschen Farbcode angibt" - }, + "humanName": "Invalid Color", + "default": "The color you provided is not a valid HEX-Code.", + "description": "This messages gets send when the user provides a wrong color code", "type": "string", "allowEmbed": true } ] -} +} \ No newline at end of file diff --git a/modules/color-me/module.json b/modules/color-me/module.json index e64fa361..95eec666 100644 --- a/modules/color-me/module.json +++ b/modules/color-me/module.json @@ -1,8 +1,6 @@ { "name": "color-me", - "humanReadableName": { - "en": "Color me" - }, + "humanReadableName": "Color me", "author": { "name": "hfgd", "link": "https://github.com/hfgd123", @@ -11,6 +9,7 @@ "openSourceURL": "https://github.com/hfgd123/CustomDCBot/tree/main/modules/color-me", "commands-dir": "/commands", "events-dir": "/events", + "fa-icon": "fas fa-palette", "models-dir": "/models", "config-example-files": [ "configs/config.json", @@ -19,8 +18,5 @@ "tags": [ "community" ], - "description": { - "en": "Simple module to reward users who have boosted your server with a custom role!", - "de": "Einfaches Modul, um Nutzer mit einer eigenen Rolle zu belohnen, die deinen Server boosten!" - } -} \ No newline at end of file + "description": "Simple module to reward users who have boosted your server with a custom role!" +} diff --git a/modules/connect-four/commands/connect-four.js b/modules/connect-four/commands/connect-four.js index 79fdc43c..b302fbe1 100644 --- a/modules/connect-four/commands/connect-four.js +++ b/modules/connect-four/commands/connect-four.js @@ -229,7 +229,8 @@ module.exports.run = async function (interaction) { const collector = msg.createMessageComponentCollector({ componentType: ComponentType.Button, - filter: i => i.user.id === interaction.user.id || i.user.id === member.id + filter: i => i.user.id === interaction.user.id || i.user.id === member.id, + time: 600000 }); collector.on('collect', i => { if ((color === 'blue' && i.user.id !== interaction.user.id) || (color === 'red' && i.user.id !== member.id)) return i.reply({ @@ -264,6 +265,10 @@ module.exports.run = async function (interaction) { } } }); + collector.on('end', (_, reason) => { + if (reason === 'time') msg.edit({components: []}).catch(() => { + }); + }); }; diff --git a/modules/connect-four/module.json b/modules/connect-four/module.json index cbfbf594..80ae47f4 100644 --- a/modules/connect-four/module.json +++ b/modules/connect-four/module.json @@ -1,18 +1,13 @@ { "name": "connect-four", - "humanReadableName": { - "en": "Connect Four", - "de": "Vier gewinnt" - }, + "humanReadableName": "Connect Four", + "fa-icon": "fa-solid fa-table-cells", "author": { "scnxOrgID": "60", "name": "TomatoCake", "link": "https://github.com/DEVTomatoCake" }, - "description": { - "en": "Let your users play Connect Four against each other!", - "de": "Lasse Nutzer auf deinem Server Vier gewinnt gegeneinander spielen" - }, + "description": "Let your users play Connect Four against each other!", "commands-dir": "/commands", "noConfig": true, "releaseDate": "0", @@ -20,4 +15,4 @@ "fun" ], "openSourceURL": "https://github.com/DEVTomatoCake/ScootKit-CustomBot/tree/main/modules/connect-four" -} \ No newline at end of file +} diff --git a/modules/counter/config.json b/modules/counter/config.json index d70829d3..524bdd97 100644 --- a/modules/counter/config.json +++ b/modules/counter/config.json @@ -1,324 +1,173 @@ { - "description": {}, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure counting channels, rules and moderation settings here", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "channels", - "humanName": { - "de": "Kanäle", - "en": "Channels" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Channels in which users can participate in the counting game", - "de": "Kanäle, in welchem Nutzer am Zählspiel teilnehmen können." - }, + "humanName": "Channels", + "default": [], + "description": "Channels in which users can participate in the counting game", "type": "array", "content": "channelID" }, { "name": "channelDescription", - "humanName": { - "de": "Kanalbeschreibung", - "en": "Channel-Description" - }, - "default": { - "en": "Next number %x%", - "de": "Nächste Zahl: %x%" - }, - "description": { - "en": "Text which should be set after someone counted (leave blank to disable)", - "de": "Text, welcher gesetzt werden soll, nachdem jemand gezählt hat (leer lassen zum deaktivieren)" - }, + "humanName": "Channel-Description", + "default": "Next number %x%", + "description": "Text which should be set after someone counted (leave blank to disable)", "type": "string", "allowNull": true, "params": [ { "name": "x", - "description": { - "en": "Next number users should count", - "de": "Nächste Zahl, welche die Nutzer zählen sollen" - } + "description": "Next number users should count" } ] }, { "name": "success-reaction", - "humanName": { - "de": "Erfolgsreaktion", - "en": "Success-Reaction" - }, - "default": { - "en": "✅", - "de": "✅" - }, - "description": { - "en": "Reaction which the bot should give when someone counts successfully", - "de": "Reaktion welche der Bot geben soll, wenn jemand erfolgreich gezählt hat" - }, + "humanName": "Success-Reaction", + "default": "✅", + "description": "Reaction which the bot should give when someone counts successfully", "type": "emoji" }, { "name": "restartOnWrongCount", - "default": { - "en": false - }, - "humanName": { - "de": "Spiel neustarten, wenn sich jemand verzählt", - "en": "Restart game, if user miscounts" - }, - "description": { - "en": "If enabled, the game will restarts if a user sends a number that is not in order", - "de": "Wenn aktiviert, wird das Spiel neustarten, wenn ein Nutzer eine Zahl sendet, die nicht in die Reihenfolge passt" - }, + "default": false, + "humanName": "Restart game, if user miscounts", + "description": "If enabled, the game will restarts if a user sends a number that is not in order", "type": "boolean" }, { "name": "restartOnWrongCountMessage", "dependsOn": "restartOnWrongCount", - "default": { - "de": "Aufgrund der Inkompetenz von %mention% muss das Spiel neugestartet werden - die nächste Zahl ist **%i%**.", - "en": "Due to the incompetence of %mention%, the game had to restart - the next number is **%i%**." - }, - "humanName": { - "en": "Message when game gets restarted", - "de": "Nachricht, wenn das Spiel neugestartet werden" - }, + "default": "Due to the incompetence of %mention%, the game had to restart - the next number is **%i%**.", + "humanName": "Message when game gets restarted", "type": "string", "allowEmbed": true, - "description": { - "en": "This message will be sent when the game gets restarted due to a miscount.", - "de": "Diese Nachricht wird gesendet, wenn das Spiel aufgrund einer Verzählung neugestartet wird." - }, + "description": "This message will be sent when the game gets restarted due to a miscount.", "params": [ { "name": "mention", - "description": { - "de": "Erwähnung des Nutzers", - "en": "Mention of the users" - } + "description": "Mention of the users" }, { "name": "i", - "description": { - "de": "Nächste Nummer", - "en": "Next number" - } + "description": "Next number" } ] }, { "name": "onlyOneMessagePerUser", - "default": { - "en": true - }, - "humanName": { - "de": "Nutzer müssen abwechselnd zählen", - "en": "Only one continuous message per user" - }, - "description": { - "en": "If enabled, users can not count more than one number continuously", - "de": "Wenn aktiviert, können Nutzer nicht mehr als eine Nummer nacheinander zählen" - }, + "default": true, + "humanName": "Only one continuous message per user", + "description": "If enabled, users can not count more than one number continuously", "type": "boolean" }, { "name": "protectAgainstDeletion", - "default": { - "en": true - }, - "humanName": { - "de": "Verhindern, dass Nutzer die letzte Zählungsnachricht löschen?", - "en": "Protect against users deleting the last counting message?" - }, - "description": { - "en": "If enabled, the bot will send a message when the last correct counting message gets deleted so that other counters can't be fooled into counting an already counted number again.", - "de": "Wenn aktiviert, wird der Bot eine Nachricht in den Kanal schicken, wenn die letzte korrekte Zählnachricht gelöscht wird - das verhindert, dass andere Nutzer nicht dazu gebracht werden können, eine korrekte Nummer erneut zu zählen." - }, + "default": true, + "humanName": "Protect against users deleting the last counting message?", + "description": "If enabled, the bot will send a message when the last correct counting message gets deleted so that other counters can't be fooled into counting an already counted number again.", "type": "boolean" }, { "name": "protectionMessage", "dependsOn": "protectAgainstDeletion", - "humanName": { - "de": "Löschschutznachricht", - "en": "Deletion protection message" - }, - "default": { - "de": "Scheint als hätte %mention% seine letzte Nachricht gelöscht - die zuletzt gezählte Zahl ist **%number%**.", - "en": "It seems like %mention% deleted their last message - the last counted number is **%number%**." - }, - "description": { - "en": "Message that gets send if a user deletes the last correct counting message.", - "de": "Nachricht, welche verschickt wird, wenn die letzte korrekte Zahlnachricht gelöscht wird." - }, + "humanName": "Deletion protection message", + "default": "It seems like %mention% deleted their last message - the last counted number is **%number%**.", + "description": "Message that gets send if a user deletes the last correct counting message.", "type": "string", "allowEmbed": true, "params": [ { "name": "mention", - "description": { - "en": "Mention of the user who's message got removed", - "de": "Erwähnung des Nutzers, dessen Nachricht gelöscht wurde" - } + "description": "Mention of the user who's message got removed" }, { "name": "number", - "description": { - "en": "Last counted number in this the channel", - "de": "Zuletzt gezählte Nummer in diesem Kanal" - } + "description": "Last counted number in this the channel" } ] }, { "name": "removeReactions", - "default": { - "en": true - }, - "humanName": { - "de": "Reaktionen nach 5 Sekunden entfernen?", - "en": "Remove reactions after 5 seconds?" - }, - "description": { - "en": "If enabled, the reactions the bot gives will be removed after 5 seconds. This will free up space in the counting channel", - "de": "Wenn aktiviert, werden die Reaktionen des Bots nach 5 Sekunden entfernt. Das lässt mehr Platz im Kanal." - }, + "default": true, + "humanName": "Remove reactions after 5 seconds?", + "description": "If enabled, the reactions the bot gives will be removed after 5 seconds. This will free up space in the counting channel", "type": "boolean" }, { "name": "wrong-input-message", - "humanName": { - "de": "Nachricht bei falscher Eingabe", - "en": "Message on wrong input" - }, - "default": { - "en": "⚠️ %err%" - }, - "description": { - "en": "Message that gets send if a user provides an invalid input", - "de": "Nachricht, welche gesendet wird, wenn ein Nutzer eine ungültige Nachricht sendet" - }, + "humanName": "Message on wrong input", + "default": "⚠️ %err%", + "description": "Message that gets send if a user provides an invalid input", "type": "string", "allowEmbed": true, "params": [ { "name": "err", - "description": { - "en": "Description of what they did wrong", - "de": "Beschreibung, was der Nutzer falsch gemacht hat" - } + "description": "Description of what they did wrong" } ] }, { "name": "strikeAmount", - "default": { - "en": 5 - }, - "humanName": { - "en": "Amount of wrong messages to trigger action", - "de": "Anzahl von falschen Nachrichten, um eine Aktion auszulösen" - }, - "description": { - "en": "This is the amount of wrong messages a user has to send to trigger action. Once this amount is reached, the bot will either, depending on your configuration, give a role or disable the SEND_MESSAGES permission for a user (set to 0 to disable)", - "de": "Dies ist die Anzahl von falschen Nachrichten, die ein Nutzer senden muss, um eine Aktion auszulösen. Sobald diese Anzahl erreicht ist, wird der Bot, je nach Konfiguration, entweder dem Nutzer eine Rolle geben oder ihm die \"Nachrichten verfassen\"-Berechtigung entfernen (auf 0 setzen zum Deaktivieren)" - }, + "default": 5, + "humanName": "Amount of wrong messages to trigger action", + "description": "This is the amount of wrong messages a user has to send to trigger action. Once this amount is reached, the bot will either, depending on your configuration, give a role or disable the SEND_MESSAGES permission for a user (set to 0 to disable)", "type": "integer" }, { "name": "giveRoleInsteadOfPermissionRemoval", - "default": { - "en": false - }, - "humanName": { - "de": "Rolle bei Sperrung vergeben, anstatt Rechte zu entfernen", - "en": "Give role on action, instead of removing permission" - }, - "description": { - "en": "If enabled, a role will be given to the user (once their reach the configured action amount of wrong messages) instead of the removal of the \"Send Messages\"-permission in the counter channel", - "de": "Wenn aktiviert, wird dem Nutzer (sobald er die benötigte Anzahl von falschen Nachrichten erreicht hat) eine Rolle gegeben, anstatt die \"Nachrichten verfassen\"-Berechtigung im Kanal zu entfernen" - }, + "default": false, + "humanName": "Give role on action, instead of removing permission", + "description": "If enabled, a role will be given to the user (once their reach the configured action amount of wrong messages) instead of the removal of the \"Send Messages\"-permission in the counter channel", "type": "boolean" }, { "name": "strikeRole", "dependsOn": "giveRoleInsteadOfPermissionRemoval", - "default": { - "en": false - }, - "humanName": { - "de": "Rolle, die bei Sperrung vergeben wird", - "en": "Role given when amount is being reached" - }, - "description": { - "en": "This role will be given to users when they reach the configured amount of wrong messages", - "de": "Diese Rolle wird dem Nutzer gegeben, sobald die konfigurierte Anzahl von falschen Nachrichten erreicht wird" - }, + "default": "", + "humanName": "Role given when amount is being reached", + "description": "This role will be given to users when they reach the configured amount of wrong messages", "type": "roleID" }, { "name": "strikeMessage", - "default": { - "de": "%mention%, ich musste dir den Zugriff auf diesen Kanal verbieten, da du ihn mehrmals falsch verwendet hast.", - "en": "%mention%, I had to restrict your access to this channel because you repeatedly used it improperly." - }, - "humanName": { - "en": "Message when user gets actioned", - "de": "Nachricht, wenn ein Nutzer gesperrt wird" - }, + "default": "%mention%, I had to restrict your access to this channel because you repeatedly used it improperly.", + "humanName": "Message when user gets actioned", "type": "string", "allowEmbed": true, - "description": { - "en": "This message will be sent when a user reach the configured amount of wrong messages and gets actioned", - "de": "Diese Nachricht wird versendet, sobald die konfigurierte Anzahl von falschen Nachrichten erreicht wird und ein Nutzer bestraft wird" - }, + "description": "This message will be sent when a user reach the configured amount of wrong messages and gets actioned", "params": [ { "name": "mention", - "description": { - "de": "Erwähnung des Nutzers", - "en": "Mention of the users" - } + "description": "Mention of the users" } ] }, { "name": "allowCharactersInMessage", - "default": { - "en": false - }, + "default": false, "type": "boolean", - "humanName": { - "en": "Allow text characters in messages?", - "de": "Textcharaktere in der Nachricht erlauben?" - }, - "description": { - "en": "If enabled, users may write additional content into their messages instead of forcing them to just write a number. Messages without a number will still lead to an error.", - "de": "Wenn aktiviert, können Nutzer weitere Inhalte in ihre Nachrichten schreiben, statt sie zu zwingen, nur eine Nachricht zu posten. Nachrichten ohne Zahlen werden weiterhin zu einem Fehler führen." - } + "humanName": "Allow text characters in messages?", + "description": "If enabled, users may write additional content into their messages instead of forcing them to just write a number. Messages without a number will still lead to an error." }, { "name": "allowMaths", - "default": { - "en": true - }, + "default": true, "type": "boolean", - "humanName": { - "en": "Allow users to use maths in their messages?", - "de": "Nutzern erlauben, Mathematik in ihren Nachrichten zu verwenden?" - }, - "description": { - "en": "If enabled, users can use maths in messages, as long as the result of their formula is the correct next number.", - "de": "If enabled, können Nutzer Mathematik in ihren Nachrichten verwenden, solange das Ergebnis des Termes der korrekten nächsten Zahl entspricht." - } + "humanName": "Allow users to use maths in their messages?", + "description": "If enabled, users can use maths in messages, as long as the result of their formula is the correct next number." + }, + { + "name": "enableEasterEggs", + "default": false, + "type": "boolean", + "humanName": "Enable number easter eggs?", + "description": "If enabled, the bot will react with special emojis on certain numbers (e.g. 42, 67, 69, 100, 420)" } ] } \ No newline at end of file diff --git a/modules/counter/events/messageCreate.js b/modules/counter/events/messageCreate.js index f2915962..89c992d8 100644 --- a/modules/counter/events/messageCreate.js +++ b/modules/counter/events/messageCreate.js @@ -2,7 +2,7 @@ const {localize} = require('../../../src/functions/localize'); const {embedType} = require('../../../src/functions/helpers'); let Formula; -const invalidMessages = {}; +const invalidMessages = new Map(); module.exports.run = async function (client, msg) { if (!client.botReadyAt) return; @@ -29,7 +29,7 @@ module.exports.run = async function (client, msg) { object.lastCountedUser = null; object.userCounts = {}; await object.save(); - invalidMessages[msg.author.id]++; + invalidMessages.set(msg.author.id, (invalidMessages.get(msg.author.id) || 0) + 1); return msg.reply(embedType(moduleConfig.restartOnWrongCountMessage, { '%i%': 1, '%mention%': msg.author.toString() @@ -61,13 +61,18 @@ module.exports.run = async function (client, msg) { } let reactions; - if (msg.content === '42') reactions = [await msg.react('❓')]; - else if (msg.content === '420') reactions = [await msg.react('🚬')]; - else if (msg.content === '100') reactions = [await msg.react('💯')]; - else if (msg.content === '110') reactions = [await msg.react('🚓')]; - else if (msg.content === '112' || msg.content === '911') reactions = [await msg.react('🚑'), await msg.react('🚒')]; - else if (msg.content === '69') reactions = [await msg.react('🇳'), await msg.react('🇮'), await msg.react('🇨'), await msg.react('🇪')]; - else reactions = [await msg.react(moduleConfig['success-reaction'])]; + if (moduleConfig.enableEasterEggs) { + if (parsedNumber === 67) reactions = [await msg.react('🤲')]; + else if (parsedNumber === 42) reactions = [await msg.react('❓')]; + else if (parsedNumber === 420) reactions = [await msg.react('🚬')]; + else if (parsedNumber === 100) reactions = [await msg.react('💯')]; + else if (parsedNumber === 110) reactions = [await msg.react('🚓')]; + else if (parsedNumber === 112 || parsedNumber === 911) reactions = [await msg.react('🚑'), await msg.react('🚒')]; + else if (parsedNumber === 69) reactions = [await msg.react('🇳'), await msg.react('🇮'), await msg.react('🇨'), await msg.react('🇪')]; + else reactions = [await msg.react(moduleConfig['success-reaction'])]; + } else { + reactions = [await msg.react(moduleConfig['success-reaction'])]; + } if (moduleConfig.removeReactions) setTimeout(async () => { for (const reaction of reactions) await reaction.remove(); @@ -88,10 +93,8 @@ module.exports.run = async function (client, msg) { await msg.delete(); }, 8000); if (!skipStrike || parseInt(moduleConfig.strikeAmount) === 0) return; - console.log(invalidMessages); - if (!invalidMessages[msg.author.id]) invalidMessages[msg.author.id] = 0; - invalidMessages[msg.author.id]++; - if (invalidMessages[msg.author.id] >= parseInt(moduleConfig.strikeAmount)) { + invalidMessages.set(msg.author.id, (invalidMessages.get(msg.author.id) || 0) + 1); + if (invalidMessages.get(msg.author.id) >= parseInt(moduleConfig.strikeAmount)) { if (moduleConfig.giveRoleInsteadOfPermissionRemoval) await msg.member.roles.add(moduleConfig.strikeRole, '[counter] ' + localize('counter', 'restriction-audit-log')); else await msg.channel.permissionOverwrites.create(msg.author, { SEND_MESSAGES: false diff --git a/modules/counter/milestones.json b/modules/counter/milestones.json index 2ca1f83e..9ddfaaed 100644 --- a/modules/counter/milestones.json +++ b/modules/counter/milestones.json @@ -1,87 +1,43 @@ { - "description": { - "en": "Reward your users, when they reach certain goals", - "de": "Belohne deine Nutzer, wenn diese bestimmte Ziele erreichen" - }, - "humanName": { - "en": "Milestones", - "de": "Ziele" - }, + "description": "Reward your users, when they reach certain goals", + "humanName": "Milestones", "configElementName": { - "de": { - "one": "Ziel", - "more": "Ziele" - }, - "en": { - "one": "Milestone", - "more": "Milestones" - } + "one": "Milestone", + "more": "Milestones" }, "filename": "milestones.json", "configElements": true, "content": [ { "name": "userMessageCount", - "humanName": { - "de": "Nachrichtenzahl", - "en": "Message count" - }, - "default": { - "en": "" - }, - "description": { - "en": "Count of valid counter-messages the users has to achieve this goal", - "de": "Anzahl der gültigen Zähl-Nachrichten, die der Nutzer schreiben muss, um dieses Ziel zu erreichen" - }, + "humanName": "Message count", + "default": "", + "description": "Count of valid counter-messages the users has to achieve this goal", "type": "integer" }, { "name": "giveRoles", - "humanName": { - "de": "Rollen", - "en": "Roles" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "These roles are given to the user if they achieve this goal (optional)", - "de": "Diese Rollen werden an den Nutzer vergeben, wenn er dieses Ziel erreicht (optional)" - }, + "humanName": "Roles", + "default": [], + "description": "These roles are given to the user if they achieve this goal (optional)", "type": "array", "content": "roleID" }, { "name": "sendMessage", - "humanName": { - "de": "Nachricht", - "en": "Message" - }, - "default": { - "en": "Congrats %mention% for counting %milestone% times!", - "de": "Herzlichen Glückwunsch, %mention%, für %milestone%-mal zählen!!" - }, + "humanName": "Message", + "default": "Congrats %mention% for counting %milestone% times!", "params": [ { "name": "mention", - "description": { - "en": "Mention the user who achieved the milestone", - "de": "Erwähnt den Nutzer, der das Ziel erreicht hat" - } + "description": "Mention the user who achieved the milestone" }, { "name": "milestone", - "description": { - "en": "The milestone (the number of message) that was reached", - "de": "Das Ziel (also die Zahl der Nachrichten, die verschickt), das erreicht wurde" - } + "description": "The milestone (the number of message) that was reached" } ], - "description": { - "en": "This message gets send when they achieve this goal", - "de": "Diese Nachricht wird gesendet, wenn er dieses Ziel erreicht" - }, + "description": "This message gets send when they achieve this goal", "type": "string", "allowNull": true, "allowEmbed": true diff --git a/modules/counter/module.json b/modules/counter/module.json index 9d9b7f0e..0ebed82d 100644 --- a/modules/counter/module.json +++ b/modules/counter/module.json @@ -1,5 +1,6 @@ { "name": "counter", + "fa-icon": "fas fa-arrow-up-1-9", "author": { "scnxOrgID": "1", "name": "SCDerox (SC Network Team)", @@ -15,12 +16,6 @@ "fun" ], "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/counter", - "humanReadableName": { - "en": "Count-Game", - "de": "Zähl-Spiel" - }, - "description": { - "en": "Allow your users to count together", - "de": "Erlaubt es deinen Nutzern, zusammen zu zählen!" - } -} \ No newline at end of file + "humanReadableName": "Count-Game", + "description": "Allow your users to count together" +} diff --git a/modules/duel/commands/duel.js b/modules/duel/commands/duel.js index d7af202d..1d133d40 100644 --- a/modules/duel/commands/duel.js +++ b/modules/duel/commands/duel.js @@ -1,5 +1,6 @@ const {localize} = require('../../../src/functions/localize'); const {ComponentType, MessageEmbed} = require('discord.js'); +const {safeSetFooter} = require('../../../src/functions/helpers'); module.exports.run = async function (interaction) { const member = interaction.options.getMember('user', true); @@ -46,7 +47,7 @@ module.exports.run = async function (interaction) { bullets[member.user.id] = 0; guardAfterEachOther[interaction.user.id] = 0; guardAfterEachOther[member.user.id] = 0; - const a = rep.createMessageComponentCollector({componentType: ComponentType.Button}); + const a = rep.createMessageComponentCollector({componentType: ComponentType.Button, time: 600000}); setTimeout(() => { if (started || a.ended) return; endReason = localize('duel', 'invite-expired', {u: interaction.user.toString(), i: member.toString()}); @@ -128,8 +129,8 @@ module.exports.run = async function (interaction) { const embed = new MessageEmbed() .setTitle(localize('duel', ended ? 'game-ended' : 'game-running-header')) .setColor(ended ? 0x2ECC71 : (!mentions ? 0xD35400 : 0xE67E22)) - .setDescription(lastRoundString + (!ended ? stateString : '\n\n' + localize('duel', 'ended-state')) + '\n*' + localize('duel', 'how-does-this-game-work') + '*') - .setFooter({text: interaction.client.strings.footer, iconURL: interaction.client.strings.footerImgUrl}); + .setDescription(lastRoundString + (!ended ? stateString : '\n\n' + localize('duel', 'ended-state')) + '\n*' + localize('duel', 'how-does-this-game-work') + '*'); + safeSetFooter(embed, interaction.client); i.update({ content: ended ? 'GGs!' : `<@${member.user.id}> vs <@${interaction.user.id}>`, @@ -170,10 +171,11 @@ module.exports.run = async function (interaction) { }); }); a.on('end', () => { - rep.edit({ + if (!ended) rep.edit({ content: endReason, components: [] - }); + }).catch(() => { + }); } ); }; diff --git a/modules/duel/module.json b/modules/duel/module.json index 09eaf569..994f6318 100644 --- a/modules/duel/module.json +++ b/modules/duel/module.json @@ -1,23 +1,19 @@ { "name": "duel", - "humanReadableName": { - "en": "Duel" - }, + "humanReadableName": "Duel", "author": { "scnxOrgID": "1", "name": "SCDerox (SC Network Team)", "link": "https://github.com/SCDerox" }, - "description": { - "en": "Let users play the game \"Duel\" on your discord", - "de": "Erlaubt es deinen Nutzern, das Spiel \"Duel\" auf deinem Discord zu spielen" - }, + "description": "Let users play the game \"Duel\" on your discord", "commands-dir": "/commands", "noConfig": true, "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/duel", "tags": [ "fun" ], + "fa-icon": "fas fa-gun", "earlyAccess": false, "holidayGift": true -} \ No newline at end of file +} diff --git a/modules/economy-system/commands/shop.js b/modules/economy-system/commands/shop.js index 7dd5e64e..00120ec0 100644 --- a/modules/economy-system/commands/shop.js +++ b/modules/economy-system/commands/shop.js @@ -1,4 +1,11 @@ -const {createShopItem, createShopMsg, deleteShopItem, shopMsg, buyShopItem, updateShopItem} = require('../economy-system'); +const { + createShopItem, + createShopMsg, + deleteShopItem, + shopMsg, + buyShopItem, + updateShopItem +} = require('../economy-system'); const {localize} = require('../../../src/functions/localize'); /** @@ -8,9 +15,9 @@ const {localize} = require('../../../src/functions/localize'); async function checkPermsAndSendReplyOnFail(interaction) { const result = interaction.client.configurations['economy-system']['config']['shopManagers'].includes(interaction.user.id) || interaction.client.config['botOperators'].includes(interaction.user.id); if (!result) await interaction.reply({ - content: interaction.client.strings['not_enough_permissions'], - ephemeral: !interaction.client.configurations['economy-system']['config']['publicCommandReplies'] - }); + content: interaction.client.strings['not_enough_permissions'], + ephemeral: !interaction.client.configurations['economy-system']['config']['publicCommandReplies'] + }); return result; } @@ -154,6 +161,6 @@ module.exports.config = { description: localize('economy-system', 'shop-option-description-role') } ] - }, + } ] }; \ No newline at end of file diff --git a/modules/economy-system/configs/config.json b/modules/economy-system/configs/config.json index e37366e1..4165c8d0 100644 --- a/modules/economy-system/configs/config.json +++ b/modules/economy-system/configs/config.json @@ -1,365 +1,187 @@ { - "description": { - "en": "Configure here, how the module should behave", - "de": "Stelle hier ein, wie sich das Modul verhalten soll" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure here, how the module should behave", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "admins", - "humanName": { - "en": "Administrators", - "de": "Administratoren" - }, - "default": { - "en": [] - }, - "description": { - "en": "Users who can perform admin only actions e.g. manage the balance of users (Bot Operators always have this permission)", - "de": "Benutzer*innen die Admin only Aktionen ausführen können, wie z.B. die Balance von Benutzern ändern (Bot-Operatoren haben immer diese Berechtigung)" - }, + "humanName": "Administrators", + "default": [], + "description": "Users who can perform admin only actions e.g. manage the balance of users (Bot Operators always have this permission)", "type": "array", "content": "integer" }, { "name": "allowCheats", - "humanName": {}, - "default": { - "en": false - }, - "description": { - "en": "Allow admins to edit the balance of users (for a fair system not recommended!)" - }, + "humanName": "Allow Cheats", + "default": false, + "description": "Allow admins to edit the balance of users (for a fair system not recommended!)", "type": "boolean" }, { "name": "selfBalance", - "humanName": {}, - "default": { - "en": false - }, - "description": { - "en": "Allow admins to edit their own balance (for a fair system not recommended! DON'T DO THIS!!!!!)" - }, + "humanName": "Allow Self-Balance Editing", + "default": false, + "description": "Allow admins to edit their own balance (for a fair system not recommended! DON'T DO THIS!!!!!)", "type": "boolean" }, { "name": "shopManagers", - "humanName": { - "en": "shop-managers", - "de": "Shop-Verwaltung" - }, - "default": { - "en": [] - }, - "description": { - "en": "The Ids of the shop managers (Bot Operators have this permission always)", - "de": "Die Benutzer-IDs der Shopverwaltung (Bot-Operatoren haben immer diese Berechtigung)" - }, + "humanName": "shop-managers", + "default": [], + "description": "The Ids of the shop managers (Bot Operators have this permission always)", "type": "array", "content": "integer" }, { "name": "startMoney", - "humanName": { - "en": "Start Money", - "de": "Start Geld" - }, - "default": { - "en": 100 - }, - "description": { - "en": "The amount of money that is given to a new user", - "de": "Das Geld, welches einem neuen Benutzer gegeben wird" - }, + "humanName": "Start Money", + "default": 100, + "description": "The amount of money that is given to a new user", "type": "integer" }, { "name": "currencyName", - "humanName": { - "en": "currency name", - "de": "Währungsbezeichnung" - }, - "default": { - "en": "" - }, - "description": { - "en": "The name of the currency", - "de": "Der Name der Währung" - }, + "humanName": "currency name", + "default": "", + "description": "The name of the currency", "type": "string" }, { "name": "currencySymbol", - "humanName": { - "en": "Symbol of the currency", - "de": "Symbol der Währung" - }, - "default": { - "en": "💰" - }, - "description": { - "en": "The symbol of the currency", - "de": "Das Symbol der Währung" - }, + "humanName": "Symbol of the currency", + "default": "💰", + "description": "The symbol of the currency", "type": "string" }, { "name": "maxWorkMoney", - "humanName": { - "en": "max work money", - "de": "Maximaler Arbeits Lohn" - }, - "default": { - "en": 100 - }, - "description": { - "en": "The highest amount of money you can get for working", - "de": "Der höchte Betrag, den man fürs Arbeiten bekommen kann" - }, + "humanName": "max work money", + "default": 100, + "description": "The highest amount of money you can get for working", "type": "integer" }, { "name": "minWorkMoney", - "humanName": { - "en": "min work money", - "de": "Mainimaler Arbeits Lohn" - }, - "default": { - "en": 20 - }, - "description": { - "en": "The lowest amount of money you can get for working", - "de": "Der niedrigste Betrag, den man fürs Arbeiten bekommen kann" - }, + "humanName": "min work money", + "default": 20, + "description": "The lowest amount of money you can get for working", "type": "integer" }, { "name": "workCooldown", - "humanName": { - "en": "work cooldown", - "de": "Arbeits Cooldown" - }, - "default": { - "en": 20 - }, - "description": { - "en": "The amount of time a user needs to wait util they can use the work command again (in minutes)", - "de": "Die Dauer, die Benutzer*innen warten müssen, bevor der Arbeits-Command wieder ausgeführt werden kann (in Minuten)" - }, + "humanName": "work cooldown", + "default": 20, + "description": "The amount of time a user needs to wait util they can use the work command again (in minutes)", "type": "integer" }, { "name": "maxCrimeMoney", - "humanName": { - "en": "max crime money", - "de": "Maximales Verbrechens Geld" - }, - "default": { - "en": 1000 - }, - "description": { - "en": "The highest amount of money you can get for crime", - "de": "Das maximale Geld, was man dafür bekommen kann, ein Verbrechen zu begehen" - }, + "humanName": "max crime money", + "default": 1000, + "description": "The highest amount of money you can get for crime", "type": "integer" }, { "name": "minCrimeMoney", - "humanName": { - "en": "min crime money", - "de": "Minimales Verbrechens Geld" - }, - "default": { - "en": 100 - }, - "description": { - "en": "The lowest amount of money you can get for crime", - "de": "Das minimale Geld, was man dafür bekommen kann, ein Verbrechen zu begehen" - }, + "humanName": "min crime money", + "default": 100, + "description": "The lowest amount of money you can get for crime", "type": "integer" }, { "name": "crimeCooldown", - "humanName": { - "en": "crime cooldown", - "de": "Verbrechens Cooldown" - }, - "default": { - "en": 30 - }, - "description": { - "en": "The amount of time a user needs to wait util they can use the crime command again (in minutes)", - "de": "Die Dauer, die Benutzer*innen warten müssen, bevor der Verbrechens-Command wieder ausgeführt werden kann (in Minuten)" - }, + "humanName": "crime cooldown", + "default": 30, + "description": "The amount of time a user needs to wait util they can use the crime command again (in minutes)", "type": "integer" }, { "name": "maxRobAmount", - "humanName": { - "en": "max rob amount", - "de": "Maximale Raub Beute" - }, - "default": { - "en": 400 - }, - "description": { - "en": "The highest amount of money that a user can rob", - "de": "Das maximale Geld, was man durch Rauben bekommen kann" - }, + "humanName": "max rob amount", + "default": 400, + "description": "The highest amount of money that a user can rob", "type": "integer" }, { "name": "robPercent", - "humanName": { - "en": "rob percent", - "de": "Raub Prozent" - }, - "default": { - "en": 10 - }, - "description": { - "en": "The amount that can get robed in percent", - "de": "Das maximale Geld, was bei einem Raub erbeutet werden kann, in Prozent" - }, + "humanName": "rob percent", + "default": 10, + "description": "The amount that can get robed in percent", "type": "integer" }, { "name": "robCooldown", - "humanName": { - "en": "rob cooldown", - "de": "Raub Cooldown" - }, - "default": { - "en": 60 - }, - "description": { - "en": "The amount of time a user needs to wait util they can use the rob command again (in minutes)", - "de": "Die Zeit die Benutzer warten müssen, bis sie den Raub-Command nochmal ausführen können (in Minuten)" - }, + "humanName": "rob cooldown", + "default": 60, + "description": "The amount of time a user needs to wait util they can use the rob command again (in minutes)", "type": "integer" }, { "name": "leaderboardChannel", - "humanName": { - "en": "leaderboard-channel", - "de": "Leaderboard-Kanal" - }, - "default": { - "en": "" - }, + "humanName": "leaderboard-channel", + "default": "", "allowNull": true, - "description": { - "en": "The channel for the leaderboard. On this leaderboard everyone can see who has the most money.", - "de": "Der Kanals für das Leaderboard. Hier kann jeder sehen, wer das meiste Geld hat" - }, + "description": "The channel for the leaderboard. On this leaderboard everyone can see who has the most money.", "type": "channelID" }, { "name": "shopChannel", - "humanName": { - "en": "shop channel", - "de": "Shop Kanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "The id of the channel for the shop-Message. This message shows the items of the shop", - "de": "Die ID des Kanals für die Shop-Nachricht. Diese Nachricht zeigt alle Items des Shops" - }, + "humanName": "shop channel", + "default": "", + "description": "The id of the channel for the shop-Message. This message shows the items of the shop", "type": "channelID", "allowNull": true }, { "name": "msgDropsIgnoredChannels", - "humanName": { - "en": "message-drops ignored channels", - "de": "Ignorierte Message-Drop Kanäle" - }, - "default": { - "en": [] - }, - "description": { - "en": "List of Channels where Users can't get message-drops", - "de": "Liste an Kanälen, in denen Benutzer keine Message-Drops bekommen können" - }, + "humanName": "message-drops ignored channels", + "default": [], + "description": "List of Channels where Users can't get message-drops", "type": "array", "content": "string" }, { "name": "messageDrops", - "humanName": {}, - "default": { - "en": 25 - }, - "description": { - "en": "Chance to get money for a message (Chance: 1/ This value). Set to 0 to disable message drops" - }, + "humanName": "Message Drop Chance", + "default": 25, + "description": "Chance to get money for a message (Chance: 1/ This value). Set to 0 to disable message drops", "type": "integer" }, { "name": "messageDropsMax", - "humanName": {}, - "default": { - "en": 50 - }, - "description": { - "en": "The max amount of money in a message Drop" - }, + "humanName": "Max Message Drop Amount", + "default": 50, + "description": "The max amount of money in a message Drop", "type": "integer" }, { "name": "messageDropsMin", - "humanName": {}, - "default": { - "en": 5 - }, - "description": { - "en": "The min amount of money in a message Drop" - }, + "humanName": "Min Message Drop Amount", + "default": 5, + "description": "The min amount of money in a message Drop", "type": "integer" }, { "name": "dailyReward", - "humanName": {}, - "default": { - "en": 25 - }, - "description": { - "en": "The daily reward" - }, + "humanName": "Daily Reward Amount", + "default": 25, + "description": "The daily reward", "type": "integer" }, { "name": "weeklyReward", - "humanName": {}, - "default": { - "en": 100 - }, - "description": { - "en": "The weekly reward" - }, + "humanName": "Weekly Reward Amount", + "default": 100, + "description": "The weekly reward", "type": "integer" }, { "name": "publicCommandReplies", - "humanName": { - "en": "Public Command-Replies", - "de": "Öffentliche Command-Antworten" - }, - "default": { - "en": false - }, - "description": { - "en": "Should the Command-replies be displayed for everyone?", - "de": "Sollen die Command-Antworten für alle angezeigt werden?" - }, + "humanName": "Public Command-Replies", + "default": false, + "description": "Should the Command-replies be displayed for everyone?", "type": "boolean" } ] -} \ No newline at end of file +} diff --git a/modules/economy-system/configs/strings.json b/modules/economy-system/configs/strings.json index f4ff4528..4b3bae90 100644 --- a/modules/economy-system/configs/strings.json +++ b/modules/economy-system/configs/strings.json @@ -1,248 +1,155 @@ { - "description": { - "en": "Configure messages of this module here", - "de": "Passe hier die Nachrichten des Modules an" - }, - "humanName": { - "en": "Messages", - "de": "Nachrichten" - }, + "description": "Configure messages of this module here", + "humanName": "Messages", "filename": "strings.json", "content": [ { "name": "notFound", - "humanName": { - "en": "not found message", - "de": "Nicht gefunden Nachricht" - }, - "default": { - "en": "This item could not be found", - "de": "Dieses Item konnte nicht gefunden werden" - }, - "description": { - "en": "The message that is send if the item wasn't found", - "de": "Die Nachricht, die gesendet wird, wenn das Item nicht gefunden wird" - }, + "humanName": "not found message", + "default": "This item could not be found", + "description": "The message that is send if the item wasn't found", "type": "string", "allowEmbed": true }, { "name": "notEnoughMoney", - "humanName": { - "en": "not enough money", - "de": "Nicht genug Geld" - }, - "default": { - "en": "You haven't enough money to buy this Item", - "de": "Du hast nicht genug Geld, um dieses Item zu kaufen" - }, - "description": { - "en": "The message that is send if the user haven't enough money to buy an item", - "de": "Die Nachricht, die gesendet wird, wenn ein Benutzer nicht genug geld hat, um ein Item zu kaufen" - }, + "humanName": "not enough money", + "default": "You haven't enough money to buy this Item", + "description": "The message that is send if the user haven't enough money to buy an item", "type": "string", "allowEmbed": true }, { "name": "shopMsg", - "humanName": { - "en": "shop message", - "de": "Shop-Nachricht" - }, + "humanName": "shop message", "default": { - "en": { - "title": "Shop", - "description": "%shopItems%" - } - }, - "description": { - "en": "Message for the shop. The Items gets added at the end", - "de": "Die Nachricht, die den aktuellen Shop anzeigt" + "title": "Shop", + "description": "%shopItems%" }, + "description": "Message for the shop. The Items gets added at the end", "type": "string", "allowEmbed": true, "params": [ { "name": "shopItems", - "description": { - "en": "All items of the shop (format specified below)", - "de": "Alle Items des Shops (Format wird unten angegeben)" - } + "description": "All items of the shop (format specified below)" } ] }, { "name": "itemString", - "humanName": { - "en": "item string", - "de": "Item Text" - }, - "default": { - "en": "**%id%** %itemName%, **price**: %price%, **sellcount**: %sellcount%", - "de": "**%id%** %itemName%: **Preis**: %price%, **Verkäufe**: %sellcount%" - }, - "description": { - "en": "String for the items for the shop message", - "de": "Text für die Items für die Shop-Nachricht" - }, + "humanName": "item string", + "default": "**%id%** %itemName%, **price**: %price%, **sellcount**: %sellcount%", + "description": "String for the items for the shop message", "type": "string", "allowEmbed": false, "params": [ { "name": "id", - "description": { - "en": "Id of the item", - "de": "ID des Items" - } + "description": "Id of the item" }, { "name": "itemName", - "description": { - "en": "Name of the item", - "de": "Name des Items" - } + "description": "Name of the item" }, { "name": "price", - "description": { - "en": "Price of the item", - "de": "Preis des Items" - } + "description": "Price of the item" }, { "name": "sellcount", - "description": { - "en": "Count of the sales of the item", - "de": "Anzahl, wie häufig das Item verkauft wurde" - } + "description": "Count of the sales of the item" } ] }, { "name": "cooldown", - "humanName": { - "en": "cooldown", - "de": "Cooldown" - }, - "default": { - "en": "Please wait before using this command again" - }, - "description": { - "en": "This message gets send when a user is currently in cooldown" - }, + "humanName": "cooldown", + "default": "Please wait before using this command again", + "description": "This message gets send when a user is currently in cooldown", "type": "string", "allowEmbed": true }, { "name": "workSuccess", - "humanName": {}, - "default": { - "en": [ - "You worked and earned **%earned%**" - ] - }, - "description": { - "en": "Array of messages from which one random gets send when a user works successfully" - }, + "humanName": "Work Success Messages", + "default": [ + "You worked and earned **%earned%**" + ], + "description": "Array of messages from which one random gets send when a user works successfully", "type": "array", "content": "string", "allowEmbed": true, "params": [ { "name": "earned", - "description": { - "en": "Money that the user had earned" - } + "description": "Money that the user had earned" } ] }, { "name": "crimeSuccess", - "humanName": {}, - "default": { - "en": [ - "You stole a wallet and earned **%earned%**" - ] - }, - "description": { - "en": "Array of messages from which one random gets send when a user commits a crime successfully" - }, + "humanName": "Crime Success Messages", + "default": [ + "You stole a wallet and earned **%earned%**" + ], + "description": "Array of messages from which one random gets send when a user commits a crime successfully", "type": "array", "content": "string", "allowEmbed": true, "params": [ { "name": "earned", - "description": { - "en": "Money that the user had earned" - } + "description": "Money that the user had earned" } ] }, { "name": "crimeFail", - "humanName": {}, - "default": { - "en": [ - "You've stolen a wallet and get caught.You loose **%loose%**" - ] - }, - "description": { - "en": "Array of messages from which one random gets send when a user fails to do some crime" - }, + "humanName": "Crime Fail Messages", + "default": [ + "You've stolen a wallet and get caught.You loose **%loose%**" + ], + "description": "Array of messages from which one random gets send when a user fails to do some crime", "type": "array", "content": "string", "allowEmbed": true, "params": [ { "name": "loose", - "description": { - "en": "Money that the user looses" - } + "description": "Money that the user looses" } ] }, { "name": "robSuccess", - "humanName": {}, - "default": { - "en": "You robed %user% earned **%earned%**" - }, - "description": { - "en": "This message gets send when a user robs another user successfully" - }, + "humanName": "Rob Success Message", + "default": "You robed %user% earned **%earned%**", + "description": "This message gets send when a user robs another user successfully", "type": "string", "allowEmbed": true, "params": [ { "name": "earned", - "description": { - "en": "Money that the user had earned" - } + "description": "Money that the user had earned" }, { "name": "user", - "description": { - "en": "The user that gets robed by you" - } + "description": "The user that gets robed by you" } ] }, { "name": "leaderboardEmbed", - "humanName": {}, + "humanName": "Leaderboard Embed", "default": { - "en": { - "title": "Leaderboard", - "color": "GREEN", - "thumbnail": " ", - "image": " ", - "description": "Here you can see who has the most money" - } - }, - "description": { - "en": "Configure the leaderboard embed here" + "title": "Leaderboard", + "color": "GREEN", + "thumbnail": " ", + "image": " ", + "description": "Here you can see who has the most money" }, + "description": "Configure the leaderboard embed here", "type": "keyed", "content": { "key": "string", @@ -253,462 +160,298 @@ }, { "name": "dailyReward", - "humanName": {}, - "default": { - "en": "You earned **%earned%** by collecting your daily reward" - }, - "description": { - "en": "Message that gets send after the user has claimed the daily reward" - }, + "humanName": "Daily Reward Message", + "default": "You earned **%earned%** by collecting your daily reward", + "description": "Message that gets send after the user has claimed the daily reward", "type": "string", "allowEmbed": true, "params": [ { "name": "earned", - "description": { - "en": "Money that the user had earned" - } + "description": "Money that the user had earned" } ] }, { "name": "weeklyReward", - "humanName": {}, - "default": { - "en": "You earned **%earned%** by collecting your weekly reward" - }, - "description": { - "en": "Message that gets send after the user has claimed the weekly reward" - }, + "humanName": "Weekly Reward Message", + "default": "You earned **%earned%** by collecting your weekly reward", + "description": "Message that gets send after the user has claimed the weekly reward", "type": "string", "allowEmbed": true, "params": [ { "name": "earned", - "description": { - "en": "Money that the user had earned" - } + "description": "Money that the user had earned" } ] }, { "name": "balanceReply", - "humanName": {}, + "humanName": "Balance Reply", "default": { - "en": { - "title": "Balance of %user%", - "fields": [ - { - "name": "Balance:", - "value": "%balance%" - }, - { - "name": "Bank:", - "value": "%bank%" - }, - { - "name": "Total:", - "value": "%total%" - } - ] - } - }, - "description": { - "en": "Reply for the balance command" + "title": "Balance of %user%", + "fields": [ + { + "name": "Balance:", + "value": "%balance%" + }, + { + "name": "Bank:", + "value": "%bank%" + }, + { + "name": "Total:", + "value": "%total%" + } + ] }, + "description": "Reply for the balance command", "type": "string", "allowEmbed": true, "params": [ { "name": "balance", - "description": { - "en": "Current balance of the user" - } + "description": "Current balance of the user" }, { "name": "bank", - "description": { - "en": "Current value that the user has on the bank" - } + "description": "Current value that the user has on the bank" }, { "name": "total", - "description": { - "en": "Total balance of the user" - } + "description": "Total balance of the user" }, { "name": "user", - "description": { - "en": "Username and discriminator of the User" - } + "description": "Username and discriminator of the User" } ] }, { "name": "userNotFound", - "humanName": {}, - "default": { - "en": "I can't find the user **%user%**" - }, - "description": { - "en": "The message that gets sent when the bot can't find a user" - }, + "humanName": "User Not Found", + "default": "I can't find the user **%user%**", + "description": "The message that gets sent when the bot can't find a user", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "User that can't been found" - } + "description": "User that can't been found" } ] }, { "name": "buyMsg", - "humanName": {}, - "default": { - "en": "You got the item **%item%**" - }, - "description": { - "en": "Message that gets send when a user buys something in the shop" - }, + "humanName": "Purchase Message", + "default": "You got the item **%item%**", + "description": "Message that gets send when a user buys something in the shop", "type": "string", "allowEmbed": true, "params": [ { "name": "item", - "description": { - "en": "Name of the item" - } + "description": "Name of the item" } ] }, { "name": "itemCreate", - "humanName": {}, - "default": { - "en": "Successfully created the item %name% with the id %id%. It costs %price% and you get the role %role%" - }, - "description": { - "en": "Message that gets send when a new shop item gets created" - }, + "humanName": "Item Created Message", + "default": "Successfully created the item %name% with the id %id%. It costs %price% and you get the role %role%", + "description": "Message that gets send when a new shop item gets created", "type": "string", "allowEmbed": true, "params": [ { "name": "name", - "description": { - "en": "Name of the created item" - } + "description": "Name of the created item" }, { "name": "id", - "description": { - "en": "Id of the created item" - } + "description": "Id of the created item" }, { "name": "price", - "description": { - "en": "Price of the created item" - } + "description": "Price of the created item" }, { "name": "role", - "description": { - "en": "Role that everyone gets who buys the item" - } + "description": "Role that everyone gets who buys the item" } ] }, { "name": "itemDelete", - "humanName": {}, - "default": { - "en": "Successfully deleted the item %name%." - }, - "description": { - "en": "Message that gets send when a new shop item gets deleted" - }, + "humanName": "Item Deleted Message", + "default": "Successfully deleted the item %name%.", + "description": "Message that gets send when a new shop item gets deleted", "type": "string", "allowEmbed": true, "params": [ { "name": "name", - "description": { - "en": "Name of the deleted item", - "de": "Name des gelöschten Items" - } + "description": "Name of the deleted item" }, { "name": "id", - "description": { - "en": "Id of the deleted item", - "de": "ID des gelöschten Items" - } + "description": "Id of the deleted item" } ] }, { "name": "itemEdit", - "humanName": {}, - "default": { - "en": "Successfully edited the item %name%. Check it out using `/shop list`" - }, - "description": { - "en": "Message that gets sent when a shop item gets edited" - }, + "humanName": "Item Edited Message", + "default": "Successfully edited the item %name%. Check it out using `/shop list`", + "description": "Message that gets sent when a shop item gets edited", "type": "string", "allowEmbed": true, "params": [ { "name": "name", - "description": { - "en": "Name of the edited item", - "de": "Name des bearbeiteten Items" - } + "description": "Name of the edited item" }, { "name": "id", - "description": { - "en": "Id of the edited item", - "de": "ID des bearbeiteten Items" - } + "description": "Id of the edited item" } ] }, { "name": "depositMsg", - "humanName": { - "en": "deposit message" - }, - "default": { - "en": "Successfully deposited **%amount%** to your bank" - }, - "description": { - "en": "The reply when a user deposits money to the bank" - }, + "humanName": "deposit message", + "default": "Successfully deposited **%amount%** to your bank", + "description": "The reply when a user deposits money to the bank", "type": "string", "params": [ { "name": "amount", - "description": {} + "description": "Amount deposited" } ] }, { "name": "withdrawMsg", - "humanName": { - "en": "withdraw message" - }, - "default": { - "en": "Successfully withdrew **%amount%** from your bank" - }, - "description": { - "en": "The reply when a user withdraws money from the bank" - }, + "humanName": "withdraw message", + "default": "Successfully withdrew **%amount%** from your bank", + "description": "The reply when a user withdraws money from the bank", "type": "string", "params": [ { "name": "amount", - "description": {} + "description": "Amount withdrawn" } ] }, { "name": "msgDropMsg", - "humanName": { - "en": "message drop message" - }, - "default": { - "en": [ - "Message-Drop: You earned %earned% simply by chatting!" - ] - }, - "description": { - "en": "The message that gets sent on a message-drop" - }, + "humanName": "message drop message", + "default": [ + "Message-Drop: You earned %earned% simply by chatting!" + ], + "description": "The message that gets sent on a message-drop", "type": "array", "content": "string", "params": [ { "name": "earned", - "description": {} + "description": "Money earned from the drop" } ] }, { "name": "NaN", - "humanName": { - "en": "not a number" - }, - "default": { - "en": "**%input%** isn't a number" - }, - "description": { - "en": "Message that gets send if the bot needs a number but gets something different" - }, + "humanName": "not a number", + "default": "**%input%** isn't a number", + "description": "Message that gets send if the bot needs a number but gets something different", "type": "string", "params": [ { "name": "input", - "description": {} + "description": "The invalid input" } ] }, { "name": "msgDropAlreadyEnabled", - "humanName": { - "en": "message-drop already enabled" - }, - "default": { - "en": "The Mesage-Drop message is already enabled!" - }, - "description": { - "en": "Message that gets send if a User trys to enable the Message-Drop message, but it's already enabled" - }, + "humanName": "message-drop already enabled", + "default": "The Mesage-Drop message is already enabled!", + "description": "Message that gets send if a User trys to enable the Message-Drop message, but it's already enabled", "type": "string" }, { "name": "msgDropEnabled", - "humanName": { - "en": "message-drop enabled" - }, - "default": { - "en": "Successfully enabled the Message-Drop message" - }, - "description": { - "en": "Message that gets send when a User enables the Message-Drop message" - }, + "humanName": "message-drop enabled", + "default": "Successfully enabled the Message-Drop message", + "description": "Message that gets send when a User enables the Message-Drop message", "type": "string" }, { "name": "msgDropAlreadyDisabled", - "humanName": { - "en": "message-drop already disabled" - }, - "default": { - "en": "The Mesage-Drop message is already disabled!" - }, - "description": { - "en": "Message that gets send if a User trys to disable the Message-Drop message, but it's already disabled" - }, + "humanName": "message-drop already disabled", + "default": "The Mesage-Drop message is already disabled!", + "description": "Message that gets send if a User trys to disable the Message-Drop message, but it's already disabled", "type": "string" }, { "name": "msgDropDisabled", - "humanName": { - "en": "message-drop disabled" - }, - "default": { - "en": "Successfully disabled the Message-Drop message" - }, - "description": { - "en": "Message that gets send when a User disables the Message-Drop message" - }, + "humanName": "message-drop disabled", + "default": "Successfully disabled the Message-Drop message", + "description": "Message that gets send when a User disables the Message-Drop message", "type": "string" }, { "name": "rebuyItem", - "humanName": { - "en": "rebuy message", - "de": "Erneutkaufen Nachricht" - }, - "default": { - "en": "You already own this Item", - "de": "Du hast dieses Item bereits gekauft" - }, - "description": { - "en": "The message that is send when the user trys to buy an Item that he already own", - "de": "Die Nachricht, die gesendet wird, wenn der Nutzer das Item bereits besitzt" - }, + "humanName": "rebuy message", + "default": "You already own this Item", + "description": "The message that is send when the user trys to buy an Item that he already own", "type": "string", "allowEmbed": true }, { "name": "multipleMatches", - "humanName": { - "en": "multiple matches", - "de": "mehrere Treffer" - }, - "default": { - "en": "Multiple items match the query", - "de": "Mehrere Items entsprechen der Suche" - }, - "description": { - "en": "The message that gets send when multiple items match the query", - "de": "Die Nachricht, die gesendet wird, wenn mehrere Items der Suche entsprechen" - }, + "humanName": "multiple matches", + "default": "Multiple items match the query", + "description": "The message that gets send when multiple items match the query", "type": "string", "allowEmbed": true }, { "name": "noMatches", - "humanName": { - "en": "no matches", - "de": "keine Treffer" - }, - "default": { - "en": "The item with the id %id%/ the name %name% doesn't exists", - "de": "Das Item mit der ID %id%/ dem Namen %name% wurde nicht gefunden" - }, - "description": { - "en": "The message that gets send when the item can't be found", - "de": "Die Nachricht, die gesendet wird, wenn das Item nicht gefunden wird" - }, + "humanName": "no matches", + "default": "The item with the id %id%/ the name %name% doesn't exists", + "description": "The message that gets send when the item can't be found", "type": "string", "allowEmbed": true, "params": [ { "name": "id", - "description": { - "en": "The specified ID", - "de": "Die angegebene ID" - } + "description": "The specified ID" }, { "name": "name", - "description": { - "en": "The specified name", - "de": "Der angegebene Name" - } + "description": "The specified name" } ] }, { "name": "itemDuplicate", - "humanName": { - "en": "item duplicate", - "de": "Item Duplikat" - }, - "default": { - "en": "There's already an item with the id %id% or the name %name%", - "de": "Es gibt schon ein Item mit der ID %id% oder dem Namen %name%" - }, - "description": { - "en": "The message that gets send when an item with the specified id or name already exists", - "de": "Die Nachricht, die gesendet wird, wenn ein Item mit dem angegebenen Namen oder der angegebenen ID schon existiert" - }, + "humanName": "item duplicate", + "default": "There's already an item with the id %id% or the name %name%", + "description": "The message that gets send when an item with the specified id or name already exists", "type": "string", "allowEmbed": true, "params": [ { "name": "id", - "description": { - "en": "The specified ID", - "de": "Die angegebene ID" - } + "description": "The specified ID" }, { "name": "name", - "description": { - "en": "The specified name", - "de": "Der angegebene Name" - } + "description": "The specified name" } ] } ] -} \ No newline at end of file +} diff --git a/modules/economy-system/economy-system.js b/modules/economy-system/economy-system.js index 0167b685..cd86b4ee 100644 --- a/modules/economy-system/economy-system.js +++ b/modules/economy-system/economy-system.js @@ -188,7 +188,7 @@ async function createShopItem(interaction) { return resolve(localize('economy-system', 'role-to-high')); } - if(price<=0) { + if (price <= 0) { await interaction.editReply(localize('economy-system', 'price-less-than-zero')); return resolve(localize('economy-system', 'price-less-than-zero')); } @@ -392,10 +392,10 @@ async function deleteShopItem(interaction) { } /** -* Function to update a shop-item -* @param {*} interaction Interaction -* @returns {Promise} -*/ + * Function to update a shop-item + * @param {*} interaction Interaction + * @returns {Promise} + */ async function updateShopItem(interaction) { return new Promise(async (resolve) => { const id = interaction.options.get('item-id')['value']; @@ -427,7 +427,7 @@ async function updateShopItem(interaction) { return resolve(localize('economy-system', 'role-to-high')); } - if(newPrice !== null && newPrice<=0) { + if (newPrice !== null && newPrice <= 0) { await interaction.editReply(localize('economy-system', 'price-less-than-zero')); return resolve(localize('economy-system', 'price-less-than-zero')); } @@ -441,7 +441,7 @@ async function updateShopItem(interaction) { if (collidingItem && collidingItem['id'] !== id) { await interaction.editReply(embedType(interaction.client.configurations['economy-system']['strings']['itemDuplicate'], { '%id%': id, - '%name%': "-" + '%name%': '-' })); return resolve(localize('economy-system', 'item-duplicate')); } @@ -466,16 +466,16 @@ async function updateShopItem(interaction) { interaction.client.logger.info(`[economy-system] ` + localize('economy-system', 'edit-item', { u: interaction.user.tag, i: id, - n: newNameOption ? newNameOption['value'] : "-", - p: newPrice ? newPrice : "-", - r: newRole ? newRole['name'] : "-", + n: newNameOption ? newNameOption['value'] : '-', + p: newPrice ? newPrice : '-', + r: newRole ? newRole['name'] : '-' })); if (interaction.client.logChannel) await interaction.client.logChannel.send(`[economy-system] ` + localize('economy-system', 'edit-item', { u: interaction.user.tag, i: id, - n: newNameOption ? newNameOption['value'] : "-", - p: newPrice ? newPrice : "-", - r: newRole ? newRole['name'] : "-", + n: newNameOption ? newNameOption['value'] : '-', + p: newPrice ? newPrice : '-', + r: newRole ? newRole['name'] : '-' })); resolve(`Edited the item ${item.name} successfully`); }); @@ -589,10 +589,10 @@ async function leaderboard(client) { name: client.user.username, iconURL: client.user.avatarURL() }) - .setFooter({ + .setFooter(client.strings.footer ? { text: client.strings.footer, iconURL: client.strings.footerImgUrl - }); + } : null); if (model.length !== 0) embed.addFields({ name: 'Leaderboard:', diff --git a/modules/economy-system/events/interactionCreate.js b/modules/economy-system/events/interactionCreate.js index 7b40565b..127b1200 100644 --- a/modules/economy-system/events/interactionCreate.js +++ b/modules/economy-system/events/interactionCreate.js @@ -6,5 +6,6 @@ module.exports.run = async function (client, interaction) { if (!interaction.isSelectMenu()) return; if (interaction.customId !== 'economy-system_shop-select') return; await interaction.deferReply({ephemeral: true}); + console.log(interaction.values); buyShopItem(interaction, interaction.values[0], null); }; \ No newline at end of file diff --git a/modules/economy-system/module.json b/modules/economy-system/module.json index 9be79097..c0763718 100644 --- a/modules/economy-system/module.json +++ b/modules/economy-system/module.json @@ -15,14 +15,10 @@ "configs/config.json", "configs/strings.json" ], + "fa-icon": "fa-solid fa-bank", "tags": [ "community" ], - "humanReadableName": { - "en": "Economy" - }, - "description": { - "en": "A simple economy-system, containing a shop system, message-drops and commands to earn money", - "de": "Ein einfaches economy-system mit einem Shop, Nachrichten-Drops und Befehlen, um Geld zu verdienen" - } -} \ No newline at end of file + "humanReadableName": "Economy", + "description": "A simple economy-system, containing a shop system, message-drops and commands to earn money" +} diff --git a/modules/fun/config.json b/modules/fun/config.json index ef0f1bd7..dae88fa9 100644 --- a/modules/fun/config.json +++ b/modules/fun/config.json @@ -1,395 +1,219 @@ { - "description": {}, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Customize the messages and images for fun commands here", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "ikeaMessage", - "humanName": { - "de": "IKEA-Nachricht", - "en": "IKEA Message" - }, - "default": { - "en": "Here's a ikea-product-name: %name%", - "de": "Hier ist ein IKEA-Produkt-Name: %name%" - }, - "description": { - "en": "Message that gets send when someone uses /random ikea-name", - "de": "Nachricht welche gesendet wird, wenn jemand /random ikea-name benutzt" - }, + "humanName": "IKEA Message", + "default": "Here's a ikea-product-name: %name%", + "description": "Message that gets send when someone uses /random ikea-name", "type": "string", "allowEmbed": true, "params": [ { "name": "name", - "description": { - "en": "Randomly generated name of an ikea product (probably not real)", - "de": "Zufällig generierter Name eines IKEA-Produkts (wahrscheinlich nicht real)" - } + "description": "Randomly generated name of an ikea product (probably not real)" } ] }, { "name": "randomNumberMessage", - "humanName": { - "de": "Zufallszahl-Nachricht", - "en": "Random numer message" - }, - "default": { - "en": "Here your random number between %min% and %max%: %number%", - "de": "Hier ist deine Zufallszahl zwischen %min% und %max%: %number%" - }, - "description": { - "en": "Message that gets send when someone uses /random number", - "de": "Nachricht, welche gesendet wird, wenn jemand /random number benutzt" - }, + "humanName": "Random numer message", + "default": "Here your random number between %min% and %max%: %number%", + "description": "Message that gets send when someone uses /random number", "type": "string", "allowEmbed": true, "params": [ { "name": "min", - "description": { - "en": "Minimal value", - "de": "Niedrigster Wert" - } + "description": "Minimal value" }, { "name": "max", - "description": { - "en": "Maximal value", - "de": "Höchster Wert" - } + "description": "Maximal value" }, { "name": "number", - "description": { - "en": "Generated number", - "de": "Generierte Zahl" - } + "description": "Generated number" } ] }, { "name": "diceRollMessage", - "humanName": { - "de": "Würfel-Nachricht", - "en": "Dice Roll message" - }, - "default": { - "en": "🎲 %number%", - "de": "🎲 %number%" - }, - "description": { - "en": "Message that gets send when someone uses /random dice", - "de": "Nachricht, welche gesendet wird, wenn jemand /random dice benutzt" - }, + "humanName": "Dice Roll message", + "default": "🎲 %number%", + "description": "Message that gets send when someone uses /random dice", "type": "string", "allowEmbed": true, "params": [ { "name": "number", - "description": { - "en": "Generated number", - "de": "Generierte Zahl" - } + "description": "Generated number" } ] }, { "name": "coinFlipMessage", - "humanName": { - "de": "Münzwurf-Nachricht", - "en": "Coin toss message" - }, - "default": { - "en": "\uD83E\uDE99 %site%", - "de": "\uD83E\uDE99 %site%" - }, - "description": { - "en": "Message that gets send when someone uses /random coinfilp", - "de": "Nachricht, welche gesendet wird, wenn jemand /random coinfilp benutzt" - }, + "humanName": "Coin toss message", + "default": "🪙 %site%", + "description": "Message that gets send when someone uses /random coinfilp", "type": "string", "allowEmbed": true, "params": [ { "name": "site", - "description": { - "de": "Seite, auf den die Münze gefallen ist", - "en": "Site on which the coin landed" - } + "description": "Site on which the coin landed" } ] }, { "name": "hugMessage", - "humanName": { - "de": "Umarmungsnachricht", - "en": "Hug message" - }, - "default": { - "en": "<@%authorID%> hugs <@%userID%>", - "de": "<@%authorID%> umarmt <@%userID%>" - }, - "description": { - "de": "Nachricht, welche gesendet wird, wenn jemand /hug benutzt", - "en": "Message that gets send when someone uses /hug" - }, + "humanName": "Hug message", + "default": "<@%authorID%> hugs <@%userID%>", + "description": "Message that gets send when someone uses /hug", "type": "string", "allowEmbed": true, "params": [ { "name": "authorID", - "description": { - "en": "ID of the user who ran this command", - "de": "ID des Nutzers, welcher den Befehl aus" - } + "description": "ID of the user who ran this command" }, { "name": "userID", - "description": { - "en": "ID of the user that gets hugged", - "de": "ID des umarmten Nutzers" - } + "description": "ID of the user that gets hugged" } ] }, { "name": "hugImages", - "humanName": { - "de": "Umarmungsbilder", - "en": "Hug images" - }, - "default": { - "en": [ - "https://scnx-cdn.scootkit.net/1723477011519-tjCfeHPcYYzFe3jRnoUVI7dn.gif", - "https://scnx-cdn.scootkit.net/1723477171157-3wGistN45zd9kwrP67YKfRgU.gif", - "https://scnx-cdn.scootkit.net/1753891037940-pdaiqed4ffL4XHbLe2N0j6fbW6zRvPDzy0ZCwKIRwmOz85yX.gif" - ] - }, - "description": { - "de": "Bilder aus welchen, wenn jemand /hug ausführt, zufällig ausgewählt wird", - "en": "Images that one will be randomly selected from when someone uses /hug." - }, + "humanName": "Hug images", + "default": [ + "https://scnx-cdn.scootkit.net/1723477011519-tjCfeHPcYYzFe3jRnoUVI7dn.gif", + "https://scnx-cdn.scootkit.net/1723477171157-3wGistN45zd9kwrP67YKfRgU.gif", + "https://scnx-cdn.scootkit.net/1753891037940-pdaiqed4ffL4XHbLe2N0j6fbW6zRvPDzy0ZCwKIRwmOz85yX.gif" + ], + "description": "Images that one will be randomly selected from when someone uses /hug.", "type": "array", "content": "imgURL" }, { "name": "kissMessage", - "humanName": { - "en": "Kiss message", - "de": "Kuss-Nachrichten" - }, - "default": { - "en": "<@%authorID%> kissed <@%userID%>", - "de": "<@%authorID%> küsst <@%userID%>" - }, - "description": { - "en": "Message that gets send when someone uses /kiss", - "de": "Nachricht, welche gesendet wird, wenn jemand /kiss benutzt" - }, + "humanName": "Kiss message", + "default": "<@%authorID%> kissed <@%userID%>", + "description": "Message that gets send when someone uses /kiss", "type": "string", "allowEmbed": true, "params": [ { "name": "authorID", - "description": { - "en": "ID of the user who ran this command", - "de": "ID des Nutzers, welcher den Befehl aus" - } + "description": "ID of the user who ran this command" }, { "name": "userID", - "description": { - "en": "ID of the user that gets kissed", - "de": "ID des geküssten Nutzers" - } + "description": "ID of the user that gets kissed" } ] }, { "name": "kissImages", - "humanName": { - "de": "Kussbilder", - "en": "Kiss images" - }, - "default": { - "en": [ - "https://scnx-cdn.scootkit.net/1743549285215-t9x4Fm9ZqE0f4vxyKfrTNo7JlGLO2hFHae8R8arRQHjQeylk.gif", - "https://scnx-cdn.scootkit.net/1695864480892-EVwr6ighEdpxY22G8jUweAPt.gif", - "https://scnx-cdn.scootkit.net/1743549267626-cSru5Kn1Dg2zv5KAefHMtRL5XuWqCW84hegW40aty4b8iFH7.gif" - ] - }, - "description": { - "en": "Images that one will be randomly selected from when someone uses /kiss.", - "de": "Bilder aus welchen, wenn jemand /kiss ausführt, zufällig ausgewählt wird" - }, + "humanName": "Kiss images", + "default": [ + "https://scnx-cdn.scootkit.net/1743549285215-t9x4Fm9ZqE0f4vxyKfrTNo7JlGLO2hFHae8R8arRQHjQeylk.gif", + "https://scnx-cdn.scootkit.net/1695864480892-EVwr6ighEdpxY22G8jUweAPt.gif", + "https://scnx-cdn.scootkit.net/1743549267626-cSru5Kn1Dg2zv5KAefHMtRL5XuWqCW84hegW40aty4b8iFH7.gif" + ], + "description": "Images that one will be randomly selected from when someone uses /kiss.", "type": "array", "content": "imgURL" }, { "name": "slapMessage", - "humanName": { - "de": "Schlag-Nachricht", - "en": "Slap message" - }, - "default": { - "en": "<@%authorID%> slapped <@%userID%>", - "de": "<@%authorID%> schlägt <@%userID%>" - }, - "description": { - "de": "Nachricht, welche gesendet wird, wenn jemand /slap benutzt", - "en": "Message that gets send when someone uses /slap" - }, + "humanName": "Slap message", + "default": "<@%authorID%> slapped <@%userID%>", + "description": "Message that gets send when someone uses /slap", "type": "string", "allowEmbed": true, "params": [ { "name": "authorID", - "description": { - "en": "ID of the user who ran this command", - "de": "ID des Nutzers, welcher den Befehl aus" - } + "description": "ID of the user who ran this command" }, { "name": "userID", - "description": { - "en": "ID of the user that gets slapped", - "de": "ID des geschlagenen Nutzers" - } + "description": "ID of the user that gets slapped" } ] }, { "name": "slapImages", - "humanName": { - "de": "Schlag-Bilder", - "en": "Slap images" - }, - "default": { - "en": [ - "https://scnx-cdn.scootkit.net/1744620013783-xEkcviAsrCZulbhoVoPPWtTUWlJbQda6kk43eQb58CMLFvDU.gif", - "https://scnx-cdn.scootkit.net/1744620140479-qz6nc8xzCSW2TB6Yy40vj6WzCBi31ezRZVElFrKuKCIfc6vZ.gif", - "https://scnx-cdn.scootkit.net/1744620083811-RYado8KTb7E8AzCVfncyNgUxD2GyQFdhjH4YxzVc5aLkGvN4.gif", - "https://scnx-cdn.scootkit.net/1744620244031-0JO1dEMxvKBAz12dj08BIVw8njCxgj8CJ89SnUihMZxnzyDE.gif" - ] - }, - "description": { - "de": "Bilder aus welchen, wenn jemand /slap ausführt, zufällig ausgewählt wird", - "en": "Images that one will be randomly selected from when someone uses /slap." - }, + "humanName": "Slap images", + "default": [ + "https://scnx-cdn.scootkit.net/1744620013783-xEkcviAsrCZulbhoVoPPWtTUWlJbQda6kk43eQb58CMLFvDU.gif", + "https://scnx-cdn.scootkit.net/1744620140479-qz6nc8xzCSW2TB6Yy40vj6WzCBi31ezRZVElFrKuKCIfc6vZ.gif", + "https://scnx-cdn.scootkit.net/1744620083811-RYado8KTb7E8AzCVfncyNgUxD2GyQFdhjH4YxzVc5aLkGvN4.gif", + "https://scnx-cdn.scootkit.net/1744620244031-0JO1dEMxvKBAz12dj08BIVw8njCxgj8CJ89SnUihMZxnzyDE.gif" + ], + "description": "Images that one will be randomly selected from when someone uses /slap.", "type": "array", "content": "imgURL" }, { "name": "patMessage", - "humanName": { - "de": "Tätschel-Nachricht", - "en": "Pat message" - }, - "default": { - "en": "<@%authorID%> patted <@%userID%>", - "de": "<@%authorID%> tätschelt <@%userID%>" - }, - "description": { - "de": "Nachricht, welche gesendet wird, wenn jemand /pat benutzt", - "en": "Message that gets send when someone uses /pat" - }, + "humanName": "Pat message", + "default": "<@%authorID%> patted <@%userID%>", + "description": "Message that gets send when someone uses /pat", "type": "string", "allowEmbed": true, "params": [ { "name": "authorID", - "description": { - "en": "ID of the user who ran this command", - "de": "ID des Nutzers, welcher den Befehl aus" - } + "description": "ID of the user who ran this command" }, { "name": "userID", - "description": { - "en": "ID of the user that gets patted", - "de": "ID des getätschelten Nutzers" - } + "description": "ID of the user that gets patted" } ] }, { "name": "patImages", - "humanName": { - "de": "Tätschel-Bilder", - "en": "Pat images" - }, - "default": { - "en": [ - "https://scnx-cdn.scootkit.net/1744619869697-AYVUENwLWjusxCOKvJLOnpdSiiiQZJC2dmSwnHMSOLr7eLbH.gif", - "https://scnx-cdn.scootkit.net/1744619643063-Iw3QdOJ9LsQLKv3Moe3zvMfakKu0NVfqlrmmd2ssrBqLEJai.gif", - "https://scnx-cdn.scootkit.net/1671631825485-6eaH1p3ngebQigoVjBicgaRy.gif", - "https://scnx-cdn.scootkit.net/1744619413990-auYiCEqSxZnp2QldAOgav77oVb2EiXnPS83icTlX7AkV1JzV.gif" - ] - }, - "description": { - "de": "Bilder aus welchen, wenn jemand /pat ausführt, zufällig ausgewählt wird", - "en": "Images that one will be randomly selected from when someone uses /pat." - }, + "humanName": "Pat images", + "default": [ + "https://scnx-cdn.scootkit.net/1744619869697-AYVUENwLWjusxCOKvJLOnpdSiiiQZJC2dmSwnHMSOLr7eLbH.gif", + "https://scnx-cdn.scootkit.net/1744619643063-Iw3QdOJ9LsQLKv3Moe3zvMfakKu0NVfqlrmmd2ssrBqLEJai.gif", + "https://scnx-cdn.scootkit.net/1671631825485-6eaH1p3ngebQigoVjBicgaRy.gif", + "https://scnx-cdn.scootkit.net/1744619413990-auYiCEqSxZnp2QldAOgav77oVb2EiXnPS83icTlX7AkV1JzV.gif" + ], + "description": "Images that one will be randomly selected from when someone uses /pat.", "type": "array", "content": "imgURL" }, { "name": "8ballMessage", - "humanName": { - "de": "8ball-Nachricht", - "en": "8ball Message" - }, - "default": { - "en": "The oracle has spoken... %answer%", - "de": "Das Orakel hat gesprochen... %answer%" - }, - "description": { - "de": "Nachricht, welche gesendet wird, wenn jemand /random 8ball benutzt", - "en": "Message that gets send when someone uses /random 8ball" - }, + "humanName": "8ball Message", + "default": "The oracle has spoken... %answer%", + "description": "Message that gets send when someone uses /random 8ball", "type": "string", "allowEmbed": true, "params": [ { - "name": "%answer", - "description": { - "en": "Answer to the question", - "de": "Antwort auf die Frage" - } + "name": "answer", + "description": "Answer to the question" } ] }, { "name": "8BallMessages", - "humanName": { - "de": "8ball-Antworten", - "en": "8ball responses" - }, - "default": { - "en": [ - "", - "No", - "Maybe", - "Try again", - "42 is the answer" - ], - "de": [ - "Ja", - "Nein", - "Vielleicht", - "Bitte versuche es erneut", - "42 ist die Antwort" - ] - }, - "description": { - "de": "Mögliche Antworten für /random 8ball", - "en": "Possible answers for /random 8ball" - }, + "humanName": "8ball responses", + "default": [ + "", + "No", + "Maybe", + "Try again", + "42 is the answer" + ], + "description": "Possible answers for /random 8ball", "type": "array", "content": "string" } diff --git a/modules/fun/module.json b/modules/fun/module.json index 36fe7e39..683f1e6c 100644 --- a/modules/fun/module.json +++ b/modules/fun/module.json @@ -1,5 +1,6 @@ { "name": "fun", + "fa-icon": "fas fa-laugh-squint", "author": { "scnxOrgID": "1", "name": "SCDerox (SC Network Team)", @@ -13,12 +14,6 @@ "fun" ], "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/fun", - "humanReadableName": { - "en": "Fun-Commands", - "de": "Fun-Befehle" - }, - "description": { - "en": "Some random fun commands like /hug or /random", - "de": "Einige Spaß-Commands, wie /hug oder /random" - } -} \ No newline at end of file + "humanReadableName": "Fun-Commands", + "description": "Some random fun commands like /hug or /random" +} diff --git a/modules/guess-the-number/config.json b/modules/guess-the-number/config.json deleted file mode 100644 index 2522419b..00000000 --- a/modules/guess-the-number/config.json +++ /dev/null @@ -1,152 +0,0 @@ -{ - "description": {}, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, - "filename": "config.json", - "commandsWarnings": { - "special": [ - { - "name": "/guess-the-number", - "info": { - "en": "You need to first set the permissions in your server settings for this command and after that add them under \"adminRoles\" here.", - "de": "Du musst zuerst die Rechte in deinen Server-Einstellungen einstellen und danach diese unter \"AdminRollen\" hinzufügen." - } - } - ] - }, - "content": [ - { - "name": "adminRoles", - "humanName": { - "de": "Adminrollen", - "en": "Admin-Roles" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Every role that can manage game sessions", - "de": "Jede Rolle, welche Spielrunden verwalten kann" - }, - "type": "array", - "content": "roleID" - }, - { - "name": "startMessage", - "humanName": { - "de": "Startnachricht", - "en": "Start-Message" - }, - "default": { - "en": { - "title": "Guess the Number - Game started", - "description": "Guess a number between %min% and %max%. Good look!" - }, - "de": { - "title": "Errate die Zahl - Das Spiel beginnt", - "description": "Errate eine Zahl zwischen %min% und %max%. Viel Glück!" - } - }, - "description": { - "de": "Nachricht, die am Anfang einer Runde gesendet wird", - "en": "Message that gets send when a new round gets started" - }, - "type": "string", - "allowEmbed": true, - "params": [ - { - "name": "min", - "description": { - "en": "Minimal value to guess", - "de": "Niedrigester möglichster Wert" - } - }, - { - "name": "max", - "description": { - "en": "Maximal value to guess", - "de": "Höchster möglichster Wert" - } - } - ] - }, - { - "name": "endMessage", - "humanName": { - "de": "Endnachricht", - "en": "End-Message" - }, - "default": { - "en": { - "title": "Guess the Number - Game ended", - "description": "Good game everyone!\nThe winner is %winner%.\nThe number was **%number%**.\nThere were around **%guessCount% guesses** in total." - }, - "de": { - "title": "Errate die Zahl - Das Spiel ist beendet", - "description": "Gutes Spiel!\nDer Gewinner ist %winner%.\nDie Zahl war **%number%**.\nInsgesamt wurde **%guessCount% mal** geraten." - } - }, - "description": { - "de": "Nachricht, die am Ende einer Runde gesendet wird", - "en": "Message that gets send when a round ends" - }, - "type": "string", - "allowEmbed": true, - "params": [ - { - "name": "min", - "description": { - "en": "Minimal value to guess", - "de": "Niedrigester möglichster Wert" - } - }, - { - "name": "max", - "description": { - "en": "Maximal value to guess", - "de": "Höchster möglichster Wert" - } - }, - { - "name": "winner", - "description": { - "en": "@-mention of the winner", - "de": "@-Erwähnung des Gewinners" - } - }, - { - "name": "guessCount", - "description": { - "en": "Count of guesses in this game session", - "de": "Anzahl der Versuche in dieser Runde" - } - }, - { - "name": "number", - "description": { - "en": "Winning number", - "de": "Nummer, die gesucht wurde" - } - } - ] - }, - { - "name": "higherLowerReactions", - "type": "boolean", - "humanName": { - "de": "Reagiere mit Höher / Geringer Emojis", - "en": "React with Lower / Higher reactions" - }, - "default": { - "en": false - }, - "description": { - "de": "Wenn aktiviert, reagiert der Bot bei falschen Versuchen mit ⬇ (wenn die gesuchte Zahl unter der gesendeten Zahl ist) oder mit ⬆ (wenn die gesuchte Zahl größer als die gesendete Zahl ist). Falls deaktiviert, wird der Bot nur mit ❌ bei falschen Versuchen reagieren.", - "en": "If enabled, the bot will react with ⬇ (if the guess is higher than the correct number) or with ⬆ (if the guess is lower than the correct number) on wrong guesses. If disabled, the bot will just react with ❌ on wrong guesses." - } - } - ] -} \ No newline at end of file diff --git a/modules/guess-the-number/configs/channel.json b/modules/guess-the-number/configs/channel.json index 58ecea0f..a9065498 100644 --- a/modules/guess-the-number/configs/channel.json +++ b/modules/guess-the-number/configs/channel.json @@ -1,42 +1,20 @@ { - "description": { - "en": "Enable the Gamechannel mode to automatically re-start games", - "de": "Aktiviere den Spielkanalmodus, um das Spiel automatisch neuzustarten" - }, - "humanName": { - "en": "Gamechannel Mode", - "de": "Spielkanal-Modus" - }, + "description": "Enable the Gamechannel mode to automatically re-start games", + "humanName": "Gamechannel Mode", "filename": "channel.json", "content": [ { - "default": { - "en": false - }, + "default": false, "name": "enabled", - "description": { - "en": "If enabled, you can configure a game channel, in which a new guess the number game will be started if a number got guessed correctly. You still will be able to manually start games in other channels. Everyone, including admins, can guess in game channels.", - "de": "Wenn aktiviert, kannst du einen Spielkanal konfigurieren, in welchem neue Nummer-Erraten-Spiele gestartet werden, sobald eine Zahl korrekt erraten wurde. Du kannst auch weiterhin manuell Spiele in anderen Kanälen starten. In Spielkanälen kann jeder, also auch Admins, raten." - }, - "humanName": { - "en": "Enable Gamechannel mode?", - "de": "Spielkanalmodus aktivieren?" - }, + "description": "If enabled, you can configure a game channel, in which a new guess the number game will be started if a number got guessed correctly. You still will be able to manually start games in other channels. Everyone, including admins, can guess in game channels.", + "humanName": "Enable Gamechannel mode?", "type": "boolean" }, { - "default": { - "en": "" - }, + "default": "", "dependsOn": "enabled", - "description": { - "en": "In this channel, games will be automatically started if a game ends or no game is currently running", - "de": "In diesem Kanal werden Spiele automatisch gestartet, wenn ein Spiel endet oder gerade kein Spiel läuft." - }, - "humanName": { - "en": "Gamechannel", - "de": "Spielkanal" - }, + "description": "In this channel, games will be automatically started if a game ends or no game is currently running", + "humanName": "Gamechannel", "content": [ "GUILD_TEXT" ], @@ -46,34 +24,18 @@ { "type": "integer", "dependsOn": "enabled", - "default": { - "en": 1 - }, + "default": 1, "name": "minInt", - "humanName": { - "en": "Minimum number", - "de": "Kleinste Nummer" - }, - "description": { - "en": "A number between this and the highest number will be selected at random when a game starts.", - "de": "Eine Nummer zwischen dieser under der höchsten Nummer wird automatisch ausgewählt, wenn das Spiel startet." - } + "humanName": "Minimum number", + "description": "A number between this and the highest number will be selected at random when a game starts." }, { "type": "integer", "dependsOn": "enabled", - "default": { - "en": 1000 - }, + "default": 1000, "name": "maxInt", - "humanName": { - "en": "Highest number", - "de": "Höchste Nummer" - }, - "description": { - "en": "A number between this and the minimum number will be selected at random when a game starts.", - "de": "Eine Nummer zwischen dieser under der kleinsten Nummer wird automatisch ausgewählt, wenn das Spiel startet." - } + "humanName": "Highest number", + "description": "A number between this and the minimum number will be selected at random when a game starts." } ] } \ No newline at end of file diff --git a/modules/guess-the-number/configs/config.json b/modules/guess-the-number/configs/config.json index 3c583908..68a6ae7d 100644 --- a/modules/guess-the-number/configs/config.json +++ b/modules/guess-the-number/configs/config.json @@ -1,155 +1,91 @@ { - "description": { - "en": "Adjust messages and permissions here", - "de": "Passe Nachrichten und Rechte hier an" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Adjust messages and permissions here", + "humanName": "Configuration", "filename": "config.json", "commandsWarnings": { "special": [ { "name": "/guess-the-number", - "info": { - "en": "You need to first set the permissions in your server settings for this command and after that add them under \"adminRoles\" here.", - "de": "Du musst zuerst die Rechte in deinen Server-Einstellungen einstellen und danach diese unter \"AdminRollen\" hinzufügen." - } + "info": "You need to first set the permissions in your server settings for this command and after that add them under \"adminRoles\" here." } ] }, "content": [ { "name": "adminRoles", - "humanName": { - "de": "Adminrollen", - "en": "Admin-Roles" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Every role that can manage game sessions.", - "de": "Jede Rolle, welche Spielrunden verwalten kann" - }, + "humanName": "Admin-Roles", + "default": [], + "description": "Every role that can manage game sessions.", "type": "array", "content": "roleID" }, { "name": "startMessage", - "humanName": { - "de": "Startnachricht", - "en": "Start-Message" - }, + "humanName": "Start-Message", "default": { - "en": { - "title": "Guess the Number - Game started", - "description": "Guess a number between %min% and %max%. Good luck!" - }, - "de": { - "title": "Errate die Zahl - Das Spiel beginnt", - "description": "Errate eine Zahl zwischen %min% und %max%. Viel Glück!" - } - }, - "description": { - "de": "Nachricht, die am Anfang einer Runde gesendet wird", - "en": "Message that gets send when a new round gets started" + "title": "Guess the Number - Game started", + "description": "Guess a number between %min% and %max%. Good luck!" }, + "description": "Message that gets send when a new round gets started", "type": "string", "allowEmbed": true, "params": [ { "name": "min", - "description": { - "en": "Minimal value to guess", - "de": "Niedrigester möglichster Wert" - } + "description": "Minimal value to guess" }, { "name": "max", - "description": { - "en": "Maximal value to guess", - "de": "Höchster möglichster Wert" - } + "description": "Maximal value to guess" } ] }, { "name": "endMessage", - "humanName": { - "de": "Endnachricht", - "en": "End-Message" - }, + "humanName": "End-Message", "default": { - "en": { - "title": "Guess the Number - Game ended", - "description": "Good game everyone!\nThe winner is %winner%.\nThe number was **%number%**.\nThere were around **%guessCount% guesses** in total." - }, - "de": { - "title": "Errate die Zahl - Das Spiel ist beendet", - "description": "Gutes Spiel!\nDer Gewinner ist %winner%.\nDie Zahl war **%number%**.\nInsgesamt wurde **%guessCount% mal** geraten." - } - }, - "description": { - "de": "Nachricht, die am Ende einer Runde gesendet wird", - "en": "Message that gets send when a round ends" + "title": "Guess the Number - Game ended", + "description": "Good game everyone!\nThe winner is %winner%.\nThe number was **%number%**.\nThere were around **%guessCount% guesses** in total." }, + "description": "Message that gets send when a round ends", "type": "string", "allowEmbed": true, "params": [ { "name": "min", - "description": { - "en": "Minimal value to guess", - "de": "Niedrigester möglichster Wert" - } + "description": "Minimal value to guess" }, { "name": "max", - "description": { - "en": "Maximal value to guess", - "de": "Höchster möglichster Wert" - } + "description": "Maximal value to guess" }, { "name": "winner", - "description": { - "en": "@-mention of the winner", - "de": "@-Erwähnung des Gewinners" - } + "description": "@-mention of the winner" }, { "name": "guessCount", - "description": { - "en": "Count of guesses in this game session", - "de": "Anzahl der Versuche in dieser Runde" - } + "description": "Count of guesses in this game session" }, { "name": "number", - "description": { - "en": "Winning number", - "de": "Nummer, die gesucht wurde" - } + "description": "Winning number" } ] }, { "name": "higherLowerReactions", "type": "boolean", - "humanName": { - "de": "Reagiere mit Höher / Geringer Emojis", - "en": "React with Lower / Higher reactions" - }, - "default": { - "en": false - }, - "description": { - "de": "Wenn aktiviert, reagiert der Bot bei falschen Versuchen mit ⬇ (wenn die gesuchte Zahl unter der gesendeten Zahl ist) oder mit ⬆ (wenn die gesuchte Zahl größer als die gesendete Zahl ist). Falls deaktiviert, wird der Bot nur mit ❌ bei falschen Versuchen reagieren.", - "en": "If enabled, the bot will react with ⬇ (if the guess is higher than the correct number) or with ⬆ (if the guess is lower than the correct number) on wrong guesses. If disabled, the bot will just react with ❌ on wrong guesses." - } + "humanName": "React with Lower / Higher reactions", + "default": false, + "description": "If enabled, the bot will react with ⬇ (if the guess is higher than the correct number) or with ⬆ (if the guess is lower than the correct number) on wrong guesses. If disabled, the bot will just react with ❌ on wrong guesses." + }, + { + "name": "enableLeaderboard", + "type": "boolean", + "humanName": "Enable leaderboard?", + "default": false, + "description": "If enabled, a leaderboard button is shown on new game messages and user statistics (wins, guesses) are tracked." } ] } \ No newline at end of file diff --git a/modules/guess-the-number/events/interactionCreate.js b/modules/guess-the-number/events/interactionCreate.js index 14419e60..edbfaed1 100644 --- a/modules/guess-the-number/events/interactionCreate.js +++ b/modules/guess-the-number/events/interactionCreate.js @@ -1,5 +1,35 @@ const {localize} = require('../../../src/functions/localize'); module.exports.run = async function (client, interaction) { + if (interaction.customId === 'gtn-leaderboard') { + const users = await client.models['guess-the-number']['User'].findAll({ + order: [['wins', 'DESC'], ['totalGuesses', 'ASC']], + limit: 20 + }); + + if (users.length === 0) return interaction.reply({ + ephemeral: true, + content: '⚠️ ' + localize('guess-the-number', 'leaderboard-empty') + }); + + let description = ''; + for (let i = 0; i < users.length; i++) { + const u = users[i]; + const name = `<@${u.userID}>`; + description += `**${i + 1}.** ${name} — 🏆 ${u.wins} ${localize('guess-the-number', 'wins')} | ${u.totalGuesses} ${localize('guess-the-number', 'guesses')}\n`; + } + + const {MessageEmbed} = require('discord.js'); + const {parseEmbedColor} = require('../../../src/functions/helpers'); + const embed = new MessageEmbed() + .setTitle('🏆 ' + localize('guess-the-number', 'leaderboard-title')) + .setDescription(description) + .setColor(parseEmbedColor('GOLD')); + + return interaction.reply({ + ephemeral: true, + embeds: [embed] + }); + } if (interaction.customId === 'gtn-reaction-meaning') return interaction.reply({ ephemeral: true, content: `## ${localize('guess-the-number', 'emoji-guide-button')}\n* :x:: ${localize('guess-the-number', 'guide-wrong-guess')}\n* :white_check_mark:: ${localize('guess-the-number', 'guide-win')}\n* :no_entry_sign:: ${localize('guess-the-number', 'guide-invalid-guess')}\n* :no_entry:: ${localize('guess-the-number', 'guide-admin-guess')}` diff --git a/modules/guess-the-number/events/messageCreate.js b/modules/guess-the-number/events/messageCreate.js index a7b7f4f7..81f7ca5a 100644 --- a/modules/guess-the-number/events/messageCreate.js +++ b/modules/guess-the-number/events/messageCreate.js @@ -11,6 +11,7 @@ module.exports.run = async (client, msg) => { if (msg.author.bot) return; if (!msg.guild) return; if (msg.guild.id !== client.guildID) return; + if (!msg.member) return; const game = await client.models['guess-the-number']['Channel'].findOne({ where: { channelID: msg.channel.id, @@ -24,6 +25,18 @@ module.exports.run = async (client, msg) => { if (parsedInt < game.min || parsedInt > game.max) return msg.react('🚫'); game.guessCount++; await game.save(); + if (client.configurations['guess-the-number']['config'].enableLeaderboard) { + const [userStats] = await client.models['guess-the-number']['User'].findOrCreate({ + where: {userID: msg.author.id}, + defaults: { + userID: msg.author.id, + wins: 0, + totalGuesses: 0 + } + }); + userStats.totalGuesses++; + await userStats.save(); + } if (parsedInt !== game.number) { if (client.configurations['guess-the-number']['config']['higherLowerReactions']) { if (game.number < parsedInt) await msg.react('⬇'); else await msg.react('⬆'); @@ -33,7 +46,20 @@ module.exports.run = async (client, msg) => { } await msg.react('✅'); game.ended = true; + game.winnerID = msg.author.id; await game.save(); + if (client.configurations['guess-the-number']['config'].enableLeaderboard) { + const [userStats] = await client.models['guess-the-number']['User'].findOrCreate({ + where: {userID: msg.author.id}, + defaults: { + userID: msg.author.id, + wins: 0, + totalGuesses: 0 + } + }); + userStats.wins++; + await userStats.save(); + } const isGamechannel = client.configurations['guess-the-number']['channel'].enabled && client.configurations['guess-the-number']['channel'].channel === msg.channel.id; if (!isGamechannel) await lockChannel(msg.channel, client.configurations['guess-the-number']['config'].adminRoles, '[guess-the-number] ' + localize('guess-the-number', 'game-ended')); await msg.reply(embedType(client.configurations['guess-the-number']['config']['endMessage'], { diff --git a/modules/guess-the-number/guessTheNumber.js b/modules/guess-the-number/guessTheNumber.js index 748a2a53..de7db2ed 100644 --- a/modules/guess-the-number/guessTheNumber.js +++ b/modules/guess-the-number/guessTheNumber.js @@ -18,18 +18,30 @@ module.exports.startGame = async function (channel, number, min, max, ownerID = if (pin.author.id !== channel.client.user.id) continue; await pin.unpin(); } + const buttonComponents = [ + { + type: 'BUTTON', + label: localize('guess-the-number', 'emoji-guide-button'), + style: 'SECONDARY', + customId: 'gtn-reaction-meaning' + } + ]; + if (channel.client.configurations['guess-the-number']['config'].enableLeaderboard) { + buttonComponents.push({ + type: 'BUTTON', + label: localize('guess-the-number', 'leaderboard-button'), + style: 'PRIMARY', + customId: 'gtn-leaderboard', + emoji: '🏆' + }); + } const m = await channel.send(embedType(channel.client.configurations['guess-the-number']['config'].startMessage, { '%min%': min, '%max%': max }, { components: [{ type: 'ACTION_ROW', - components: [{ - type: 'BUTTON', - label: localize('guess-the-number', 'emoji-guide-button'), - style: 'SECONDARY', - customId: 'gtn-reaction-meaning' - }] + components: buttonComponents }] })); await m.pin(); diff --git a/modules/guess-the-number/models/User.js b/modules/guess-the-number/models/User.js new file mode 100644 index 00000000..8a88e30a --- /dev/null +++ b/modules/guess-the-number/models/User.js @@ -0,0 +1,32 @@ +const { + DataTypes, + Model +} = require('sequelize'); + +module.exports = class GuessTheNumberUser extends Model { + static init(sequelize) { + return super.init({ + userID: { + type: DataTypes.STRING, + primaryKey: true + }, + wins: { + type: DataTypes.INTEGER, + defaultValue: 0 + }, + totalGuesses: { + type: DataTypes.INTEGER, + defaultValue: 0 + } + }, { + tableName: 'guess_the_number_Users', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + 'name': 'User', + 'module': 'guess-the-number' +}; \ No newline at end of file diff --git a/modules/guess-the-number/module.json b/modules/guess-the-number/module.json index 0bc9ae44..67556dbf 100644 --- a/modules/guess-the-number/module.json +++ b/modules/guess-the-number/module.json @@ -17,12 +17,6 @@ "fun" ], "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/guess-the-number", - "humanReadableName": { - "en": "Guess the number", - "de": "Errate die Nummer" - }, - "description": { - "en": "Select a number and let your users guess", - "de": "Wähle eine Nummer und lass deine Mitglieder diese erraten" - } -} \ No newline at end of file + "humanReadableName": "Guess the number", + "description": "Select a number and let your users guess" +} diff --git a/modules/info-commands/commands/info.js b/modules/info-commands/commands/info.js index 1c063dd7..f5bbd7a2 100644 --- a/modules/info-commands/commands/info.js +++ b/modules/info-commands/commands/info.js @@ -5,10 +5,13 @@ const { dateToDiscordTimestamp, formatDiscordUserName, formatNumber, - parseEmbedColor + parseEmbedColor, + safeSetFooter, + moduleEnabled } = require('../../../src/functions/helpers'); const {ChannelType, MessageEmbed} = require('discord.js'); const {AgeFromDate} = require('age-calculator'); +const {stringNames} = require('../../invite-tracking/events/guildMemberJoin'); const {calculateLevelXP, isMaxLevel, displayLevel} = require('../../levels/events/messageCreate'); const legacyChannelType = (type) => { @@ -40,8 +43,8 @@ module.exports.subcommands = { .setTitle(localize('info-commands', 'information-about-server', {s: interaction.guild.name})) .setColor(parseEmbedColor('GOLD')) .setThumbnail(interaction.guild.iconURL()) - .setImage(interaction.guild.bannerURL()) - .setFooter({text: interaction.client.strings.footer, iconURL: interaction.client.strings.footerImgUrl}); + .setImage(interaction.guild.bannerURL()); + safeSetFooter(embed, interaction.client); if (!interaction.client.strings.disableFooterTimestamp) embed.setTimestamp(); if (interaction.guild.afkChannel) embed.addField(moduleStrings.serverinfo.afkChannel, `<#${interaction.guild.afkChannelID}> (${interaction.guild.afkTimeout}s)`, true); if (interaction.guild.description) embed.setDescription(interaction.guild.description); @@ -79,8 +82,8 @@ module.exports.subcommands = { .addField(moduleStrings.channelInfo.id, channel.id, true) .addField(moduleStrings.channelInfo.createdAt, ``, true) .addField(moduleStrings.channelInfo.name, channel.name, true) - .setFooter({text: interaction.client.strings.footer, iconURL: interaction.client.strings.footerImgUrl}) .setColor(parseEmbedColor('GREEN')); + safeSetFooter(embed, interaction.client); if (!interaction.client.strings.disableFooterTimestamp) embed.setTimestamp(); if (channel.parent) embed.addField(moduleStrings.channelInfo.parent, channel.parent.name, true); if (channel.position) embed.addField(moduleStrings.channelInfo.position, (channel.position + 1).toString(), true); @@ -111,12 +114,12 @@ module.exports.subcommands = { const role = interaction.options.getRole('role', true); const embed = new MessageEmbed() .setTitle(localize('info-commands', 'information-about-role', {r: role.name})) - .setFooter({text: interaction.client.strings.footer, iconURL: interaction.client.strings.footerImgUrl}) .addField(moduleStrings.roleInfo.createdAt, ``, true) .addField(moduleStrings.roleInfo.position, role.position.toString(), true) .addField(moduleStrings.roleInfo.id, role.id, true) .addField(moduleStrings.roleInfo.name, role.name, true) .setColor(role.color || parseEmbedColor('GREEN')); + safeSetFooter(embed, interaction.client); if (!interaction.client.strings.disableFooterTimestamp) embed.setTimestamp(); if (role.color) embed.addField(moduleStrings.roleInfo.color, role.hexColor, true); if (role.members) { @@ -152,14 +155,14 @@ module.exports.subcommands = { if (!member) return interaction.reply(embedType(moduleStrings['user_not_found'], {}, {ephemeral: true})); let birthday = null; let levelUserData = null; - if (interaction.client.models['birthday']) { + if (moduleEnabled(interaction.client, 'birthday')) { birthday = await interaction.client.models['birthday']['User'].findOne({ where: { id: member.user.id } }); } - if (interaction.client.models['levels']) { + if (moduleEnabled(interaction.client, 'levels')) { levelUserData = await interaction.client.models['levels']['User'].findOne({ where: { userID: member.user.id @@ -171,11 +174,11 @@ module.exports.subcommands = { .setTitle(localize('info-commands', 'information-about-user', {u: formatDiscordUserName(member.user)})) .setColor(member.displayColor || parseEmbedColor('GREEN')) .setThumbnail(member.user.avatarURL({forceStatic: false})) - .setFooter({text: interaction.client.strings.footer, iconURL: interaction.client.strings.footerImgUrl}) .addField(moduleStrings.userinfo.tag, formatDiscordUserName(member.user), true) .addField(moduleStrings.userinfo.id, member.user.id, true) .addField(moduleStrings.userinfo.createdAt, ` ()`, true) .addField(moduleStrings.userinfo.joinedAt, ` ()`, true); + safeSetFooter(embed, interaction.client); if (!interaction.client.strings.disableFooterTimestamp) embed.setTimestamp(); if (member.user.presence) embed.addField(moduleStrings.userinfo.currentStatus, member.user.presence.status, true); if (member.nickname) embed.addField(moduleStrings.userinfo.nickname, member.nickname, true); @@ -197,6 +200,22 @@ module.exports.subcommands = { embed.addField(moduleStrings.userinfo.level, displayLevel(levelUserData.level, interaction.client), true); embed.addField(moduleStrings.userinfo.messages, levelUserData.messages.toString(), true); } + if (moduleEnabled(interaction.client, 'invite-tracking')) { + const invitedUsers = await interaction.client.models['invite-tracking']['UserInvite'].findAll({ + where: { + inviter: member.user.id + } + }); + const userInvites = await interaction.client.models['invite-tracking']['UserInvite'].findAll({ + where: { + userID: member.user.id, + left: false + }, + order: [['createdAt', 'DESC']] + }); + if (userInvites[0]) embed.addField(moduleStrings.userinfo['invited-by'], `${localize('invite-tracking', stringNames[userInvites[0].inviteType])}${userInvites[0].inviter ? ` by <@${userInvites[0].inviter}>` : ''}`, true); + embed.addField(moduleStrings.userinfo.invites, `\`\`\`| ${localize('info-commands', 'total-invites')} | ${localize('info-commands', 'active-invites')} | ${localize('info-commands', 'left-invites')} |\n| ${pufferStringToSize(invitedUsers.length.toString(), localize('info-commands', 'total-invites').length)} | ${pufferStringToSize(invitedUsers.filter(i => !i.left).length.toString(), localize('info-commands', 'active-invites').length)} | ${pufferStringToSize(invitedUsers.filter(i => i.left).length.toString(), localize('info-commands', 'left-invites').length)} |\`\`\``); + } let permstring = ''; member.permissions.toArray().forEach(p => { if (!member.permissions.toArray().includes('ADMINISTRATOR')) permstring = permstring + `${p}, `; diff --git a/modules/info-commands/module.json b/modules/info-commands/module.json index 93d917f4..54c89806 100644 --- a/modules/info-commands/module.json +++ b/modules/info-commands/module.json @@ -14,12 +14,6 @@ "moderation" ], "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/info-commands", - "humanReadableName": { - "en": "Info-Commands", - "de": "Info-Befehle" - }, - "description": { - "en": "Adds info-commands with information about specific parts of your server", - "de": "Fügt viele Info-Commands zu deinen Server hinzu" - } + "humanReadableName": "Info-Commands", + "description": "Adds info-commands with information about specific parts of your server" } diff --git a/modules/info-commands/strings.json b/modules/info-commands/strings.json index 3065a8c4..b263b497 100644 --- a/modules/info-commands/strings.json +++ b/modules/info-commands/strings.json @@ -1,57 +1,31 @@ { - "description": {}, - "humanName": {}, + "description": "Edit the messages and strings of the module here", + "humanName": "Messages", "filename": "strings.json", "content": [ { "name": "serverinfo", - "humanName": { - "de": "Serverinfo" - }, + "humanName": "Server Info", "default": { - "en": { - "id": "ID", - "owner": "Owner", - "boosts": "Boosts", - "emojiCount": "Emoji-Count", - "region": "Region", - "roleCount": "Role-Count", - "rulesChannel": "Rules-Channel", - "dcSystemChannel": "Discord-System-Channel", - "verificationLevel": "Verification-Level", - "banCount": "Bans", - "createdAt": "Created at", - "members": "Members", - "channels": "Channels", - "features": "Features", - "noFeaturesEnabled": "No features enabled", - "afkChannel": "AFK-Channel", - "stickerCount": "Sticker-Count" - }, - "de": { - "id": "ID", - "owner": "Eigentümer", - "boosts": "Boosts", - "emojiCount": "Emoji-Anzahl", - "region": "Region", - "roleCount": "Rollen-Anzahl", - "rulesChannel": "Regelkanal", - "dcSystemChannel": "Discord-Systemkanal", - "verificationLevel": "Verifizierungsstufe", - "banCount": "Banns", - "createdAt": "Erstellt am", - "members": "Mitglieder", - "channels": "Kanäle", - "features": "Funktionen", - "noFeaturesEnabled": "Keine Funktionen aktiviert", - "afkChannel": "AFK-Kanal", - "stickerCount": "Sticker-Anzahl" - } - }, - "description": { - "en": "You can change the parts of the serverinfo-command here", - "de": "Hier kannst du die Teile des serverinfo-Befehls anpassen" - }, + "id": "ID", + "owner": "Owner", + "boosts": "Boosts", + "emojiCount": "Emoji-Count", + "region": "Region", + "roleCount": "Role-Count", + "rulesChannel": "Rules-Channel", + "dcSystemChannel": "Discord-System-Channel", + "verificationLevel": "Verification-Level", + "banCount": "Bans", + "createdAt": "Created at", + "members": "Members", + "channels": "Channels", + "features": "Features", + "noFeaturesEnabled": "No features enabled", + "afkChannel": "AFK-Channel", + "stickerCount": "Sticker-Count" + }, + "description": "You can change the parts of the serverinfo-command here", "type": "keyed", "content": { "key": "string", @@ -61,57 +35,29 @@ }, { "name": "userinfo", - "humanName": { - "de": "Userinfo" - }, + "humanName": "User Info", "default": { - "en": { - "id": "ID", - "tag": "Tag", - "currentStatus": "Current status", - "createdAt": "Account created at", - "joinedAt": "Joined Server at", - "nickname": "Nickname", - "boosterSince": "Server-Booster since", - "displayColor": "Display-Color", - "currentVoiceChannel": "Current Voice-Channel", - "highestRole": "Highest role", - "hoistRole": "Hoisted role", - "birthday": "Birthday", - "permissions": "Permissions", - "xp": "XP", - "invited-by": "Invited by", - "invites": "Invites", - "level": "Level", - "messages": "Messages", - "noPermissions": "This user does not have any permissions ):" - }, - "de": { - "id": "ID", - "tag": "Tag", - "currentStatus": "Aktueller Status", - "createdAt": "Account erstellt am", - "joinedAt": "Server beigetreten am", - "nickname": "Nickname", - "boosterSince": "Server-Booster seit", - "displayColor": "Anzeigefarbe", - "currentVoiceChannel": "Aktueller Sprachkanal", - "highestRole": "Höchste Rolle", - "hoistRole": "Gelistete Rolle", - "birthday": "Geburtstag", - "permissions": "Berechtigungen", - "xp": "XP", - "level": "Level", - "messages": "Nachrichten", - "noPermissions": "Dieser Nutzer hat keine Berechtigungen ):", - "invited-by": "Invited by", - "invites": "Invites" - } - }, - "description": { - "en": "You can change the parts of the userinfo-command here", - "de": "Hier kannst du die Teile des userinfo-Befehls anpassen" - }, + "id": "ID", + "tag": "Tag", + "currentStatus": "Current status", + "createdAt": "Account created at", + "joinedAt": "Joined Server at", + "nickname": "Nickname", + "boosterSince": "Server-Booster since", + "displayColor": "Display-Color", + "currentVoiceChannel": "Current Voice-Channel", + "highestRole": "Highest role", + "hoistRole": "Hoisted role", + "birthday": "Birthday", + "permissions": "Permissions", + "xp": "XP", + "invited-by": "Invited by", + "invites": "Invites", + "level": "Level", + "messages": "Messages", + "noPermissions": "This user does not have any permissions ):" + }, + "description": "You can change the parts of the userinfo-command here", "type": "keyed", "content": { "key": "string", @@ -121,49 +67,25 @@ }, { "name": "channelInfo", - "humanName": { - "de": "Channelinfo" - }, + "humanName": "Channel Info", "default": { - "en": { - "id": "ID", - "createdAt": "Created at", - "type": "Type", - "name": "Name", - "parent": "Category", - "topic": "Topic", - "position": "Current position in category", - "stageInstanceName": "Stage topic", - "stageInstancePrivacy": "Stage Privacy", - "threadArchivedAt": "Thread archived at", - "threadAutoArchiveDuration": "Thread auto Archive Duration", - "threadOwner": "Thread-Owner", - "threadMessages": "Messages in thread", - "threadMemberCount": "Members in this thread", - "membersInChannel": "Members currently in this channel" - }, - "de": { - "id": "ID", - "createdAt": "Erstellt am", - "type": "Typ", - "name": "Name", - "parent": "Kategorie", - "topic": "Kanalbeschreibung", - "position": "Aktuelle Position in der Kategorie", - "stageInstanceName": "Stage Thema", - "stageInstancePrivacy": "Stage Privacy", - "threadArchivedAt": "Thread archiviert am", - "threadAutoArchiveDuration": "Automatische Threadarchivierungsdauer", - "threadOwner": "Thread-Besitzer", - "threadMessages": "Nachrichten im Thread", - "threadMemberCount": "Mitglieder in diesem Thread", - "membersInChannel": "Mitglieder, die sich aktuell in diesem Kanal befinden" - } - }, - "description": { - "en": "You can change the parts of the channelinfo-command here", - "de": "Hier kannst du die Teile des channelinfo-Befehls anpassen" - }, + "id": "ID", + "createdAt": "Created at", + "type": "Type", + "name": "Name", + "parent": "Category", + "topic": "Topic", + "position": "Current position in category", + "stageInstanceName": "Stage topic", + "stageInstancePrivacy": "Stage Privacy", + "threadArchivedAt": "Thread archived at", + "threadAutoArchiveDuration": "Thread auto Archive Duration", + "threadOwner": "Thread-Owner", + "threadMessages": "Messages in thread", + "threadMemberCount": "Members in this thread", + "membersInChannel": "Members currently in this channel" + }, + "description": "You can change the parts of the channelinfo-command here", "type": "keyed", "content": { "key": "string", @@ -173,35 +95,18 @@ }, { "name": "roleInfo", - "humanName": { - "de": "Roleinfo" - }, + "humanName": "Role Info", "default": { - "en": { - "id": "ID", - "createdAt": "Created at", - "color": "Color", - "name": "Name", - "position": "Current position", - "memberWithThisRoleCount": "Count of members with this role", - "memberWithThisRole": "Members with this role", - "permissions": "Permissions" - }, - "de": { - "id": "ID", - "createdAt": "Erstellt am", - "color": "Farbe", - "name": "Name", - "position": "Aktuelle Position", - "memberWithThisRoleCount": "Anzahl der Mitglieder mit dieser Rolle", - "memberWithThisRole": "Mitglieder mit dieser Rolle", - "permissions": "Berechtigungen" - } - }, - "description": { - "en": "You can change the parts of the roleinfo-command here", - "de": "Hier kannst du die Teile des serverinfo-Befehls anpassen" - }, + "id": "ID", + "createdAt": "Created at", + "color": "Color", + "name": "Name", + "position": "Current position", + "memberWithThisRoleCount": "Count of members with this role", + "memberWithThisRole": "Members with this role", + "permissions": "Permissions" + }, + "description": "You can change the parts of the roleinfo-command here", "type": "keyed", "content": { "key": "string", @@ -211,83 +116,45 @@ }, { "name": "user_not_found", - "humanName": { - "de": "Nutzer nicht gefunden" - }, - "default": { - "en": "I could not find this user - try using an ID or a mention", - "de": "Dieser Nutzer konnte nicht gefunden werden - versuche eine ID oder eine Erwähnung zu verwenden" - }, - "description": { - "en": "Message that gets send if the user provided an invalid userid", - "de": "Nachricht, die gesendet wird, wenn der Nutzer eine Ungültige NutzerID angibt" - }, + "humanName": "User Not Found", + "default": "I could not find this user - try using an ID or a mention", + "description": "Message that gets send if the user provided an invalid userid", "type": "string", "allowEmbed": true }, { "name": "channel_not_found", - "humanName": { - "de": "Kanal nicht gefunden" - }, - "default": { - "en": "I could not find this channel - try using an ID or a mention", - "de": "Dieser Kanal konnte nicht gefunden werden - versuche eine ID oder eine Erwähnung zu verwenden" - }, - "description": { - "en": "Message that gets send if the user provided an invalid userid", - "de": "Nachricht, die gesendet wird, wenn der Nutzer eine Ungültige KanalID angibt" - }, + "humanName": "Channel Not Found", + "default": "I could not find this channel - try using an ID or a mention", + "description": "Message that gets send if the user provided an invalid userid", "type": "string", "allowEmbed": true }, { "name": "role_not_found", - "humanName": { - "de": "Rolle nicht gefunden" - }, - "default": { - "en": "I could not find this role - try using an ID or a mention", - "de": "Diese Rolle konnte nicht gefunden werden - versuche eine ID oder eine Erwähnung zu verwenden" - }, - "description": { - "en": "Message that gets send if the user provided an invalid roleid", - "de": "Nachricht, die gesendet wird, wenn der Nutzer eine Ungültige RollenID angibt" - }, + "humanName": "Role Not Found", + "default": "I could not find this role - try using an ID or a mention", + "description": "Message that gets send if the user provided an invalid roleid", "type": "string", "allowEmbed": true }, { "name": "avatarMsg", - "humanName": { - "de": "Avatar-Nachricht" - }, - "default": { - "en": "Here is the avatar: (Please reminder that the image may be protected under copyright-law)", - "de": "Hier ist der Avatar: (Bitte beachte, dass das Bild eventuell urheberrechtlich geschützt ist)" - }, - "description": { - "en": "Message that gets send if the user requested an avatar", - "de": "Nachricht, die gesendet wird, wenn ein Nutzer einen Avatar anfragt" - }, + "humanName": "Avatar Message", + "default": "Here is the avatar: (Please reminder that the image may be protected under copyright-law)", + "description": "Message that gets send if the user requested an avatar", "type": "string", "allowEmbed": true, "params": [ { "name": "avatarUrl", - "description": { - "en": "URL to the avatar", - "de": "URL zum Avatar" - } + "description": "URL to the avatar" }, { "name": "tag", - "description": { - "en": "Tag of the requested user", - "de": "Tag des gewünschten Nutzers" - } + "description": "Tag of the requested user" } ] } ] -} \ No newline at end of file +} diff --git a/modules/levels/commands/leaderboard.js b/modules/levels/commands/leaderboard.js index 71c7ba27..9aa104f7 100644 --- a/modules/levels/commands/leaderboard.js +++ b/modules/levels/commands/leaderboard.js @@ -3,7 +3,8 @@ const { truncate, formatNumber, formatDiscordUserName, - parseEmbedColor + parseEmbedColor, + safeSetFooter } = require('../../../src/functions/helpers'); const {MessageEmbed} = require('discord.js'); const {localize} = require('../../../src/functions/localize'); @@ -34,13 +35,13 @@ module.exports.run = async function (interaction) { */ function addSite(fields) { const embed = new MessageEmbed() - .setFooter({text: interaction.client.strings.footer, iconURL: interaction.client.strings.footerImgUrl}) .setColor(parseEmbedColor(moduleStrings.leaderboardEmbed.color || 'GREEN')) .setThumbnail(interaction.guild.iconURL()) .setTitle(moduleStrings.leaderboardEmbed.title) .setDescription(moduleStrings.leaderboardEmbed.description) .addField('\u200b', '\u200b') .addFields(fields); + safeSetFooter(embed, interaction.client); if (thisUser) embed.addField('\u200b', '\u200b').addField(moduleStrings.leaderboardEmbed.your_level, moduleStrings.leaderboardEmbed.you_are_level_x_with_x_xp.split('%level%').join(displayLevel(thisUser['level'], client)).split('%xp%').join(formatNumber(thisUser['xp']))); sites.push(embed); } diff --git a/modules/levels/commands/manage-levels.js b/modules/levels/commands/manage-levels.js index 7e2c703c..bfede050 100644 --- a/modules/levels/commands/manage-levels.js +++ b/modules/levels/commands/manage-levels.js @@ -25,18 +25,18 @@ async function runXPAction(interaction, newXP) { if (user.xp < 0) return interaction.editReply({ content: '⚠️ ' + localize('levels', 'negative-xp') }); + if (!Number.isFinite(user.xp) || user.xp > 1e12) return interaction.editReply({ + content: '⚠️ ' + localize('levels', 'xp-out-of-range') + }); - function runXPCheck() { + let guard = 0; + while (guard++ < 1_000_000) { const nextLevelXp = calculateLevelXP(interaction.client, user.level + 1); - if (nextLevelXp <= user.xp) { - user.level = user.level + 1; - fixLevelRoles(interaction, member, user.level); - runXPCheck(); - } + if (!Number.isFinite(nextLevelXp) || nextLevelXp > user.xp) break; + user.level = user.level + 1; + await fixLevelRoles(interaction, member, user.level); } - runXPCheck(); - await user.save(); interaction.client.logger.info(localize('levels', 'manipulated', { @@ -84,12 +84,19 @@ async function runLevelAction(interaction, newLevel) { if (!user) return interaction.editReply({ content: '⚠️ ' + localize('levels', 'cheat-no-profile') }); + const isZero = newLevel(user.level) === user.level; user.level = newLevel(user.level); - if (interaction.client.configurations['levels']['config'].startFromZero) user.level = user.level + 1; + if (interaction.client.configurations['levels']['config'].startFromZero && !isZero) user.level = user.level + 1; if (user.level < 1) return interaction.editReply({ content: '⚠️ ' + localize('levels', 'negative-level') }); + if (!Number.isFinite(user.level) || user.level > 1e6) return interaction.editReply({ + content: '⚠️ ' + localize('levels', 'level-out-of-range') + }); user.xp = calculateLevelXP(interaction.client, user.level); + if (!Number.isFinite(user.xp) || user.xp > 1e12) return interaction.editReply({ + content: '⚠️ ' + localize('levels', 'xp-out-of-range') + }); await fixLevelRoles(interaction, member, user.level); diff --git a/modules/levels/commands/profile.js b/modules/levels/commands/profile.js index ba6dc03f..576ff9fd 100644 --- a/modules/levels/commands/profile.js +++ b/modules/levels/commands/profile.js @@ -2,7 +2,8 @@ const { embedType, formatDate, formatNumber, - parseEmbedColor + parseEmbedColor, + safeSetFooter } = require('../../../src/functions/helpers'); const {MessageEmbed} = require('discord.js'); const {localize} = require('../../../src/functions/localize'); @@ -31,10 +32,6 @@ module.exports.run = async function (interaction) { const nextLevelXp = calculateLevelXP(interaction.client, user.level + 1); const embed = new MessageEmbed() - .setFooter({ - text: interaction.client.strings.footer, - iconURL: interaction.client.strings.footerImgUrl - }) .setColor(parseEmbedColor(moduleStrings.embed.color || 'GREEN')) .setThumbnail(member.user.avatarURL({forceStatic: false})) .setTitle(moduleStrings.embed.title.replaceAll('%username%', member.user.username)) @@ -43,13 +40,15 @@ module.exports.run = async function (interaction) { .addField(moduleStrings.embed.xp, `${formatNumber(isMaxLevel(user.level, interaction.client) ? calculateLevelXP(interaction.client, interaction.client.configurations['levels']['config'].maximumLevel) : user.xp)}/${isMaxLevel(user.level, interaction.client) ? '∞' : formatNumber(nextLevelXp)}`, true) .addField(moduleStrings.embed.level, displayLevel(user.level, interaction.client), true); + safeSetFooter(embed, interaction.client); + const roleFactor = getMemberRoleFactor(member); if (roleFactor !== 1) { let roleString = ''; for (const role of member.roles.cache.filter(f => moduleConfig['multiplication_roles'][f.id]).values()) { roleString = roleString + `\n* <@&${role.id}>: ${moduleConfig['multiplication_roles'][role.id]}x`; } - embed.addField(moduleStrings.embed.roleFactor, `${roleString}\n${localize('levels', 'role-factors-total', {f: roleFactor})}`, true); + embed.addField(moduleStrings.embed.roleFactor, `${roleString}\n${localize('levels', 'role-factors-total', {f: formatNumber(roleFactor, {maximumFractionDigits: 2})})}`, true); } embed.addField(moduleStrings.embed.joinedAt, formatDate(member.joinedAt), true); interaction.reply({ diff --git a/modules/levels/configs/config.json b/modules/levels/configs/config.json index ddd03df4..5369b6e2 100644 --- a/modules/levels/configs/config.json +++ b/modules/levels/configs/config.json @@ -1,12 +1,6 @@ { - "description": { - "en": "Configure the function of the module here", - "de": "Stelle hier die Funktionen des Modules ein" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the function of the module here", + "humanName": "Configuration", "filename": "config.json", "commandsWarnings": { "normal": [ @@ -16,144 +10,82 @@ "content": [ { "name": "min-xp", - "humanName": { - "en": "XP given at least for messages", - "de": "Für Nachrichten mindestens gegebenes XP" - }, - "default": { - "en": 25, - "de": 25 - }, - "description": { - "en": "How much XP the user gets at least for each message", - "de": "So viel XP bekommt ein Benutzer mindestens pro Nachricht" - }, - "type": "integer" + "humanName": "XP given at least for messages", + "default": 25, + "description": "How much XP the user gets at least for each message", + "type": "integer", + "category": "xp" }, { "name": "max-xp", - "humanName": { - "en": "XP given at most for messages", - "de": "Für Nachrichten maximal gegebenes XP" - }, - "default": { - "en": 65, - "de": 65 - }, - "description": { - "en": "How much XP the user gets at most for each messages", - "de": "So viel XP bekommt ein Benutzer maximal pro Nachricht" - }, - "type": "integer" + "humanName": "XP given at most for messages", + "default": 65, + "description": "How much XP the user gets at most for each messages", + "type": "integer", + "category": "xp" }, { "name": "voiceXPPerMinute", "type": "float", - "default": { - "en": 0.5 - }, - "humanName": { - "en": "XP given per Voice Minute", - "de": "Pro Sprachminute vergebenes XP" - }, - "description": { - "en": "How many XP will be given to users per minute when they are in a voice channel with other members. No XP will be given if they are alone in their channel or are muted or deafened. Numbers will be rounded and XP will be given every 15 minutes or when the user leaves the channel.", - "de": "Wie viel XP Nutzer pro Minute erhalten, wenn sie sich in einem Sprachkanal mit anderen Nutzern befinden. Es wird kein XP vergeben, wenn sie alleine in einem Kanal sind oder stummgeschaltet sind oder den Ton deaktiviert haben. Zahlen werden gerundet und XP wird alle 15 Minuten vergeben, oder wenn der Nutzer den Kanal verlässt." - } + "default": 0.5, + "humanName": "XP given per Voice Minute", + "description": "How many XP will be given to users per minute when they are in a voice channel with other members. No XP will be given if they are alone in their channel or are muted or deafened. Numbers will be rounded and XP will be given every 15 minutes or when the user leaves the channel.", + "category": "xp" }, { "name": "cooldown", - "humanName": { - "en": "Cooldown" - }, - "default": { - "en": 1500, - "de": 1500 - }, - "description": { - "en": "In ms. How much cooldown there is between each XP getting", - "de": "In Millisekunden! So viel Zeit muss ein Nutzer zwischen jeder Nachricht mit XP warten" - }, - "type": "integer" + "humanName": "Cooldown", + "default": 1500, + "description": "In ms. How much cooldown there is between each XP getting", + "type": "integer", + "category": "xp" }, { "name": "curveType", "type": "select", "content": [ { - "displayName": { - "en": "Easy Linear", - "de": "Einfacherer Linearfunktion" - }, + "displayName": "Easy Linear", "value": "EXPONENTIAL" }, { - "displayName": { - "en": "Default Linear", - "de": "Standardmässige Linearfunktion" - }, + "displayName": "Default Linear", "value": "LINEAR" }, { - "displayName": { - "en": "Exponentiation (softer start, harder leveling after level 14)", - "de": "Potenzfunktion (leichter start, ab Level 14 härter)" - }, + "displayName": "Exponentiation (softer start, harder leveling after level 14)", "value": "EXPONENTIATION" }, { "value": "CUSTOM", - "displayName": { - "en": "Custom formula (dangerous!)", - "de": "Eigene Formel (gefährlich!)" - } + "displayName": "Custom formula (dangerous!)" } ], - "humanName": { - "en": "Type of the leveling curve", - "de": "Art der Levelingkurve" - }, - "default": { - "en": "LINEAR" - }, - "description": { - "en": "Type of the leveling curve. The exponential curve is recommended, as archiving new levels gets harder the higher your level is. Leveling is always the same if you use the linear curve.", - "de": "Art der Levelingkurve. Die exponentielle Kurve wird empfohlen, da mit dieser das Aufsteigen von Leveln schwerer wird je höher das eigene Level ist. Mit der linearen Kurve ist das Aufsteigen zum nächsten Level für alle gleich schwer." - }, + "humanName": "Type of the leveling curve", + "default": "LINEAR", + "description": "Type of the leveling curve. The exponential curve is recommended, as archiving new levels gets harder the higher your level is. Leveling is always the same if you use the linear curve.", "links": [ { - "label": { - "en": "Calculate how much XP is needed to level up", - "de": "Berechne, wie viel XP zum Aufsteigen notwendig ist" - }, + "label": "Calculate how much XP is needed to level up", "url": "https://scootk.it/level-calculator" } - ] + ], + "category": "xp" }, { "name": "customLevelCurve", - "default": { - "en": "" - }, + "default": "", "allowNull": true, - "humanName": { - "en": "Custom Level Formula (if enabled)", - "de": "Eigene Levelformel (wenn aktiviert)" - }, + "humanName": "Custom Level Formula (if enabled)", "type": "string", "links": [ { - "label": { - "en": "Calculate how much XP is needed to level up", - "de": "Berechne, wie viel XP zum Aufsteigen notwendig ist" - }, + "label": "Calculate how much XP is needed to level up", "url": "https://scootk.it/level-calculator" } ], - "description": { - "en": "Your custom leveling formula. Use the x variable (and no other variables). The result of the formula should be the required XP to reach level x (your variable). Example: \"x*750+((x-1)*500)\" (our default level curve)", - "de": "Deine eigene Levelformel. Nutze nur die x Variabel (und keine andere Variablen). Das Ergebnis deiner Formel sollte die XP-Anzahl sein, die notwendig ist, um Level x zu erreichen (deine Variabel). Beispiel: \"x*750+((x-1)*500)\" (unsere Standartkurve)" - } + "description": "Your custom leveling formula. Use the x variable (and no other variables). The result of the formula should be the required XP to reach level x (your variable). Example: \"x*750+((x-1)*500)\" (our default level curve)", + "category": "xp" }, { "name": "levelUpMessagesConditions", @@ -163,306 +95,204 @@ "only-role-rewards", "none" ], - "humanName": { - "de": "Welche Level-Up-Nachrichten sollen gesendet werden?", - "en": "Which Level-Up-Messages should get sent?" - }, - "default": { - "en": "all" - }, - "description": { - "en": "This settings changes in which cases a level up message should be sent. With the setting \"all\", level up messages will be sent at every level up. With the setting \"only-role-rewards\", level up messages will only be sent if the new level has a role reward. With the \"none\" setting, no level up messages will be sent.", - "de": "Diese Einstellung verändert, welche Art von Level-Up-Nachrichten gesendet werden. Mit der Einstellung \"all\", werden Level-Up-Nachrichten bei jedem Level-Up versendet. Mit der Einstellung \"only-role-rewards\" werden Level-Up-Nachrichten nur gesandt, wenn das neue Level eine Rollenbelohnung hat. Wenn die Einstellung \"none\" gewählt ist, werden keine Level-Up-Nachrichten verschickt." - } + "humanName": "Which Level-Up-Messages should get sent?", + "default": "all", + "description": "This settings changes in which cases a level up message should be sent. With the setting \"all\", level up messages will be sent at every level up. With the setting \"only-role-rewards\", level up messages will only be sent if the new level has a role reward. With the \"none\" setting, no level up messages will be sent.", + "category": "messages" }, { "name": "level_up_channel_id", - "humanName": { - "en": "Level-Up-Channel" - }, - "default": { - "en": "" - }, - "description": { - "en": "Channel in which Level-Up-Messages should get send. (Leave empty to disable)", - "de": "Channel, in den die Level-Up-Nachrichten gesendet werden. (Leerlassen, um sie einfach in den aktuellen Channel zu schicken))" - }, + "humanName": "Level-Up-Channel", + "default": "", + "description": "Channel in which Level-Up-Messages should get send. (Leave empty to disable)", "type": "channelID", - "allowNull": true + "allowNull": true, + "category": "messages" }, { "name": "sortLeaderboardBy", - "humanName": { - "en": "Leaderboard-Sort-Category", - "de": "Ranglisten-Sortierung" - }, - "default": { - "en": "levels", - "de": "levels" - }, - "description": { - "en": "How the leaderboard should be sorted", - "de": "Wähle aus, wie der /leaderboard-command aussehen werden soll" - }, + "humanName": "Leaderboard-Sort-Category", + "default": "levels", + "description": "How the leaderboard should be sorted", "type": "select", "content": [ "levels", "xp" - ] + ], + "category": "leaderboard" }, { "name": "blacklisted_channels", - "humanName": { - "en": "Blacklisted Channels", - "de": "Channel ohne XP" - }, + "humanName": "Blacklisted Channels", "channelTypes": [ "GUILD_TEXT", "GUILD_NEWS", "GUILD_VOICE", "GUILD_FORUM" ], - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Blacklisted-Channels in which users can not earn XP", - "de": "Channel, in denen kein XP gesammelt werden kann" - }, + "default": [], + "description": "Blacklisted-Channels in which users can not earn XP", "type": "array", - "content": "channelID" + "content": "channelID", + "category": "xp" }, { "name": "blacklistedRoles", - "humanName": { - "en": "Blacklisted roles", - "de": "Rollen, die kein XP sammeln" - }, + "humanName": "Blacklisted roles", "type": "array", "content": "roleID", - "default": { - "en": [] - }, - "description": { - "de": "Diese Rollen werden kein XP für ihre Nachrichten erhalten", - "en": "These roles won't receive XP when writing messages" - } + "default": [], + "description": "These roles won't receive XP when writing messages", + "category": "xp" }, { "name": "reward_roles", - "humanName": { - "en": "Level Reward roles", - "de": "Level-Belohnung-Rollen" - }, - "default": { - "en": {}, - "de": {} - }, - "description": { - "de": "Level, bei denen der Nutzer eine Rolle bekommt. Parameter 1: Level, Parameter 2: Rollen-ID", - "en": "Level at which users should get roles. Parameter 1: Level, Parameter 2: Role-ID" - }, + "humanName": "Level Reward roles", + "default": {}, + "description": "Level at which users should get roles. Parameter 1: Level, Parameter 2: Role-ID", "type": "keyed", "content": { "key": "integer", "value": "roleID" - } + }, + "category": "roles" }, { "name": "multiplication_roles", - "humanName": { - "en": "XP Multiplication Roles", - "de": "XP-Multiplikator Rollen" - }, - "default": { - "en": {}, - "de": {} - }, - "description": { - "en": "Allows you to configure roles that have a higher multiplication factor than normal (default value is 1). If a user has more than one of the configured roles, the multiplication factors get multiplied together before multiplying the result with the amount of XP the user receives for their message.", - "de": "Erlaubt es dir, den Multiplikationsfaktor von bestimmten Rollen anzupassen. Standardmäßig haben Rollen einen Wert von 1. Bevor der XP Wert für eine Nachricht an den Nutzer gegeben wird, werden alle Faktoren von Rollen miteinander multipliziert und das Ergebnis dann mal den XP-Wert genommen." - }, + "humanName": "XP Multiplication Roles", + "default": {}, + "description": "Allows you to configure roles that have a higher multiplication factor than normal (default value is 1). If a user has more than one of the configured roles, the multiplication factors get multiplied together before multiplying the result with the amount of XP the user receives for their message.", "type": "keyed", "content": { "key": "roleID", "value": "float" - } + }, + "category": "xp" }, { "name": "multiplication_channels", - "humanName": { - "en": "XP Multiplication Channels", - "de": "XP-Multiplikator Kanäle" - }, - "default": { - "en": {}, - "de": {} - }, - "description": { - "en": "Allows you to configure channels that have a higher multiplication factor than normal (default value is 1). Messages sent in these channels will have their XP value multiplied by the multiplier configured here.", - "de": "Erlaubt es dir, den Multiplikationsfaktor von bestimmten Kanälen anzupassen. Standardmäßig haben Rollen einen Wert von 1. Die XP-Werte von Nachrichten, die in hier konfigurierten Kanälen gesendet werden, werden mit den hier eingestellten Multiplikator multipliziert." - }, + "humanName": "XP Multiplication Channels", + "default": {}, + "description": "Allows you to configure channels that have a higher multiplication factor than normal (default value is 1). Messages sent in these channels will have their XP value multiplied by the multiplier configured here.", "type": "keyed", "content": { "key": "channelID", "value": "float" - } + }, + "category": "xp" }, { "name": "onlyTopLevelRole", - "humanName": { - "en": "Only keep highest Level-Role", - "de": "Nur die höchste Level-Rolle behalten" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, all previous level roles a user had will get removed, when they advance to a new level.", - "de": "Wenn aktiviert, werden alle vorherigen Level-Rollen, die ein Nutzer hatte, entfernt, wenn dieser ein neues Level erreicht." - }, - "type": "boolean" + "humanName": "Only keep highest Level-Role", + "default": false, + "description": "If enabled, all previous level roles a user had will get removed, when they advance to a new level.", + "type": "boolean", + "category": "roles" }, { "name": "reset-on-leave", - "humanName": { - "en": "Rest Level on leave", - "de": "Level beim Verlassen zurücksetzen" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, all levels and the XP of a user will be deleted, when they leave your server.", - "de": "Wenn aktiviert, werden alle Level und das XP eines Nutzers gelöscht, wenn er den Server verlässt." - }, - "type": "boolean" + "humanName": "Rest Level on leave", + "default": false, + "description": "If enabled, all levels and the XP of a user will be deleted, when they leave your server.", + "type": "boolean", + "category": "general" }, { "name": "randomMessages", - "humanName": { - "en": "Random messages", - "de": "Zufällige Nachrichten" - }, - "default": { - "en": false - }, - "description": { - "de": "Wenn aktiviert wird das Modul die Level-Up-Nachricht zufällig auswählen und nicht die in Nachrichten angegebene verwenden", - "en": "If enabled the module will randomly select a messages from random-levelup-messages and ignore the one set in strings" - }, - "type": "boolean" + "humanName": "Random messages", + "default": false, + "description": "If enabled the module will randomly select a messages from random-levelup-messages and ignore the one set in strings", + "type": "boolean", + "category": "messages" }, { "name": "leaderboard-channel", - "humanName": { - "en": "Live Leaderboard-Channel", - "de": "Live Ranglisten-Channel" - }, - "default": { - "en": "" - }, - "description": { - "de": "Wenn gesetzt wird der Bot in diesen Channel eine Nachricht senden, welche die aktuellen Level der Nutzern enthält", - "en": "If set, the bot will send a messages in this channel with the current leaderboard and edit it every five minutes" - }, + "humanName": "Live Leaderboard-Channel", + "default": "", + "description": "If set, the bot will send a messages in this channel with the current leaderboard and edit it every five minutes", "type": "channelID", "content": [ "GUILD_TEXT" ], - "allowNull": true + "allowNull": true, + "category": "leaderboard" }, { "name": "leaderboard-channel-max-amount", - "humanName": { - "en": "Maximum amount of users displayed in live leaderboard Channel", - "de": "Maximale Anzahl von Nutzern im Live Ranglistenkanal" - }, - "default": { - "en": 15 - }, + "humanName": "Maximum amount of users displayed in live leaderboard Channel", + "default": 15, "maxValue": 25, - "description": { - "de": "Dies ist die Anzahl von Nutzern, die in der Live Rangliste angezeigt werden sollen. /leaderboard zeigt weiterhin die vollständige Rangliste.", - "en": "This is the maximum amount of users displayed in the Live Leaderboard channel. /leaderboard will still show the full leaderboard." - }, - "type": "integer" + "description": "This is the maximum amount of users displayed in the Live Leaderboard channel. /leaderboard will still show the full leaderboard.", + "type": "integer", + "category": "leaderboard" }, { "name": "maximumLevelEnabled", - "humanName": { - "en": "Enable maximum level?", - "de": "Maximales Level aktivieren?" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, users can only level until they reach the configured maximum level. After that, they can't level up and can't earn XP. Can be enabled retroactively.", - "de": "Wenn aktiviert können Nutzer nur ein bestimmtes Level erreichen. Sobald sie dieses Level erreicht haben, können sie nicht weiter aufsteigen oder weiter XP verdienen. Kann rückwirkend aktiviert werden." - }, - "type": "boolean" + "humanName": "Enable maximum level?", + "default": false, + "description": "If enabled, users can only level until they reach the configured maximum level. After that, they can't level up and can't earn XP. Can be enabled retroactively.", + "type": "boolean", + "category": "general" }, { "dependsOn": "maximumLevelEnabled", "name": "maximumLevel", - "humanName": { - "en": "Maximum level", - "de": "Maximales Level" - }, - "default": { - "en": 200 - }, - "description": { - "en": "Once a user reaches this level, they neither earn more XP nor level up anymore.", - "de": "Sobald ein Nutzer dieses Level erreicht hat, kann dieser weder mehr XP verdienen noch weiter Level aufsteigen." - }, - "type": "integer" + "humanName": "Maximum level", + "default": 200, + "description": "Once a user reaches this level, they neither earn more XP nor level up anymore.", + "type": "integer", + "category": "general" }, { "name": "startFromZero", - "humanName": { - "en": "Start with Level 0?", - "de": "Von Level 0 starten?" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, the initial level of users will be displayed as zero. This doesn't affect leveling, this is a cosmetic setting and can be applied retroactively.", - "de": "Wenn aktiviert werden die Anfangslevel von Nutzern als null angezeigt. Das hat keinen Einfluss auf das Leveling, das ist eine kosmetische Einstellung und kann rückwirkend angewandt werden." - }, - "type": "boolean" + "humanName": "Start with Level 0?", + "default": false, + "description": "If enabled, the initial level of users will be displayed as zero. This doesn't affect leveling, this is a cosmetic setting and can be applied retroactively.", + "type": "boolean", + "category": "general" }, { "name": "useTags", - "humanName": { - "en": "Use User's Tags instead of their Mention in the Leaderboard-Channel-Embed", - "de": "Nutze den Tag der Nutzer, anstatt eine Erwähnung im Ranglisten-Channel-Embed" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, the bot will use the tag of users in the Leaderboard-Channel-Embed instead of their mention.", - "de": "Wenn aktiviert, wird im Ranglisten-Channel-Embed der Tag des Nutzers angezeigt und nicht eine Erwähnung (bei großen Servern empfohlen)" - }, - "type": "boolean" + "humanName": "Use User's Tags instead of their Mention in the Leaderboard-Channel-Embed", + "default": false, + "description": "If enabled, the bot will use the tag of users in the Leaderboard-Channel-Embed instead of their mention.", + "type": "boolean", + "category": "general" }, { "name": "allowCheats", - "humanName": { - "en": "Cheats" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled admins can change the XP of other users (not recommended (please leave it off if you want to have a fair levelling system!!!))", - "de": "Wenn aktiviert können Administratoren die XP von anderen Nutzern editieren (nicht empfohlen, wenn du einen coolen, fairen Server haben willst (wirklich nicht!!!)))" - }, - "type": "boolean" + "humanName": "Cheats", + "default": false, + "description": "If enabled admins can change the XP of other users (not recommended (please leave it off if you want to have a fair levelling system!!!))", + "type": "boolean", + "category": "general" + } + ], + "categories": [ + { + "id": "general", + "icon": "fas fa-gears", + "displayName": "General Settings" + }, + { + "id": "xp", + "icon": "fas fa-arrow-up-1-9", + "displayName": "XP Settings" + }, + { + "id": "leaderboard", + "icon": "fas fa-ranking-stars", + "displayName": "Leaderboard" + }, + { + "id": "roles", + "icon": "fa-solid fa-users", + "displayName": "Level Roles" + }, + { + "id": "messages", + "icon": "fas fa-comment-dots", + "displayName": "Level-up Messages" } ] } \ No newline at end of file diff --git a/modules/levels/configs/random-levelup-messages.json b/modules/levels/configs/random-levelup-messages.json index 4f632475..04c02a6d 100644 --- a/modules/levels/configs/random-levelup-messages.json +++ b/modules/levels/configs/random-levelup-messages.json @@ -1,28 +1,14 @@ { - "description": { - "en": "If enabled, the bot will randomly select a message from here", - "de": "Wenn aktiviert, wird der Bot zufällige eine Nachricht von hier auswählen" - }, - "humanName": { - "en": "Random-Level-Up-Messages", - "de": "Zufällige Level-Up-Nachrichten" - }, + "description": "If enabled, the bot will randomly select a message from here", + "humanName": "Random-Level-Up-Messages", "filename": "random-levelup-messages.json", "configElements": true, "content": [ { "name": "type", - "humanName": { - "de": "Nachrichtentyp" - }, - "default": { - "en": "normal", - "de": "normal" - }, - "description": { - "en": "Type of this message", - "de": "Typ dieser Nachricht" - }, + "humanName": "Message Type", + "default": "normal", + "description": "Type of this message", "type": "select", "content": [ "normal", @@ -31,64 +17,39 @@ }, { "name": "message", - "humanName": { - "de": "Nachrichten" - }, + "humanName": "Messages", "allowGeneratedImage": true, - "default": { - "en": "" - }, - "description": { - "en": "Messages which should be send", - "de": "Nachrichten, die gesendet werden sollen" - }, + "default": "", + "description": "Messages which should be send", "type": "string", "allowEmbed": true, "params": [ { "name": "mention", - "description": { - "en": "Mention of the user", - "de": "Erwähnung des Nutzers" - } + "description": "Mention of the user" }, { "name": "avatarURL", "isImage": true, - "description": { - "en": "Avatar of the user", - "de": "Profilbild des Nutzers" - } + "description": "Avatar of the user" }, { "name": "username", - "description": { - "en": "Username of the user", - "de": "Nutzername des Nutzers" - } + "description": "Username of the user" }, { "name": "tag", - "description": { - "en": "Tag of the user", - "de": "Tag des Nutzers" - } + "description": "Tag of the user" }, { "name": "newLevel", - "description": { - "en": "New level of the user", - "de": "Neues Level des Nutzers" - } + "description": "New level of the user" }, { "name": "role", - "description": { - "en": "Mention of the role (No ping, only if type = with-reward)", - "de": "Erwähnung der Rolle (Kein \"Ping\", nur, wenn Nachrichtentyp = with-reward)" - } + "description": "Mention of the role (No ping, only if type = with-reward)" } ] } ] -} \ No newline at end of file +} diff --git a/modules/levels/configs/special-levelup-messages.json b/modules/levels/configs/special-levelup-messages.json index 0808788f..b6523439 100644 --- a/modules/levels/configs/special-levelup-messages.json +++ b/modules/levels/configs/special-levelup-messages.json @@ -1,89 +1,51 @@ { - "description": { - "en": "If enabled, the bot will randomly select a message from here", - "de": "Wenn aktiviert, wird der Bot zufällige eine Nachricht von hier auswählen" - }, - "humanName": { - "en": "Selected messages", - "de": "Bestimmte Nachrichten" - }, + "description": "If enabled, the bot will randomly select a message from here", + "humanName": "Selected messages", "filename": "special-levelup-messages.json", "configElements": true, "content": [ { "name": "level", - "humanName": { - "de": "Level" - }, - "default": { - "en": "" - }, - "description": { - "en": "Level at which this messages should get send", - "de": "Level, bei welchem diese Nachricht gesendet werden soll" - }, + "humanName": "Level", + "default": "", + "description": "Level at which this messages should get send", "type": "integer" }, { "name": "message", "allowGeneratedImage": true, - "humanName": { - "de": "Nachricht" - }, - "default": { - "en": "" - }, - "description": { - "en": "Messages which should be send", - "de": "Nachricht, die gesendet werden soll" - }, + "humanName": "Message", + "default": "", + "description": "Messages which should be send", "type": "string", "allowEmbed": true, "params": [ { "name": "mention", - "description": { - "en": "Mention of the user", - "de": "Erwähnung des Nutzers" - } + "description": "Mention of the user" }, { "name": "avatarURL", "isImage": true, - "description": { - "en": "Avatar of the user", - "de": "Profilbild des Nutzers" - } + "description": "Avatar of the user" }, { "name": "username", - "description": { - "en": "Username of the user", - "de": "Nutzername des Nutzers" - } + "description": "Username of the user" }, { "name": "tag", - "description": { - "en": "Tag of the user", - "de": "Tag des Nutzers" - } + "description": "Tag of the user" }, { "name": "newLevel", - "description": { - "en": "New level of the user", - "de": "Neues Level des Nutzers" - } + "description": "New level of the user" }, { "name": "role", - "description": { - "en": "Mention of the role (No ping, only if level has reward)", - "de": "Erwähnung der Rolle (Kein \"Ping\", nur, wenn das Level eine Belohnung hat)" - } + "description": "Mention of the role (No ping, only if level has reward)" } ] } ] -} \ No newline at end of file +} diff --git a/modules/levels/configs/strings.json b/modules/levels/configs/strings.json index 2cfcd899..e6456b64 100644 --- a/modules/levels/configs/strings.json +++ b/modules/levels/configs/strings.json @@ -1,301 +1,188 @@ { - "description": { - "en": "Edit the messages and strings of the module here", - "de": "Stelle hier die Nachrichten des Modules ein" - }, - "humanName": { - "en": "Messages", - "de": "Nachrichten" - }, + "description": "Edit the messages and strings of the module here", + "humanName": "Messages", "filename": "strings.json", "content": [ { "name": "user_not_found", - "humanName": { - "en": "User not found", - "de": "Nutzer nicht gefunden" - }, - "default": { - "en": "⚠️ We do not have any records of this user", - "de": "⚠️ Dieser Nutzer hat anscheinend noch keine Nachricht verschickt" - }, - "description": { - "en": "This messages gets send if someone checks a profile of a user when the user never send a message", - "de": "Diese Nachricht wird verschickt, wenn ein eine Person sein Profil sehen will, aber noch kein XP hat" - }, + "humanName": "User not found", + "default": "⚠️ We do not have any records of this user", + "description": "This messages gets send if someone checks a profile of a user when the user never send a message", "type": "string", - "allowEmbed": true + "allowEmbed": true, + "category": "general" }, { "name": "embed", - "humanName": { - "de": "Profilembed" - }, + "humanName": "Profile Embed", "default": { - "en": { - "title": "%username%'s Profile", - "description": "You can find %username%'s profile here.", - "messages": "Message-Count", - "xp": "XP", - "level": "Level", - "joinedAt": "Joined server", - "roleFactor": "Role Factor(s)", - "color": "GREEN" - }, - "de": { - "title": "%username%'s Profil", - "description": "Das Profil von %username% findest du hier.", - "messages": "Nachrichten-Anzahl", - "roleFactor": "Rollen-Faktor(en)", - "xp": "XP", - "level": "Level", - "joinedAt": "Server beigetreten", - "color": "GREEN" - } - }, - "description": { - "en": "Embed which gets send if !profile gets executed", - "de": "Embed das gesendet wird, wenn /profile ausgeführt wird" - }, + "title": "%username%'s Profile", + "description": "You can find %username%'s profile here.", + "messages": "Message-Count", + "xp": "XP", + "level": "Level", + "joinedAt": "Joined server", + "roleFactor": "Role Factor(s)", + "color": "GREEN" + }, + "description": "Embed which gets send if !profile gets executed", "type": "keyed", "content": { "key": "string", "value": "string" }, - "disableKeyEdits": true + "disableKeyEdits": true, + "category": "general" }, { "name": "leaderboardEmbed", - "humanName": { - "de": "Ranglisten-Embed" - }, + "humanName": "Leaderboard Embed", "default": { - "en": { - "title": "Leaderboard", - "description": "You can find the level of every user here", - "and_x_more_people": "And %count% other members", - "more_level": "More Levels", - "x_levels_are_not_shown": "And **%count% Level** are not being displayed", - "your_level": "Your Level", - "you_are_level_x_with_x_xp": "You are currently on **Level %level%** with **%xp% XP**. See more with `/profile`.", - "joinedAt": "Joined server", - "color": "GREEN" - }, - "de": { - "title": "Rangliste", - "description": "Hier findest du das Level von jedem Nutzer", - "and_x_more_people": "Und %count% andere Mitglieder", - "more_level": "Mehr Level", - "x_levels_are_not_shown": "Und **%count% Level** werden nicht angezeigt", - "your_level": "Dein Level", - "you_are_level_x_with_x_xp": "Du bist aktuell auf **Level %level%** mit **%xp% XP**. Siehe mehr mit `/profile`.", - "joinedAt": "Server beigetreten", - "color": "GREEN" - } - }, - "description": { - "en": "This embed gets send if !leaderboard (!lb) gets executed", - "de": "Dieses Embed wird gesendet, wenn /leaderboard (/lb) ausgeführt wird" - }, + "title": "Leaderboard", + "description": "You can find the level of every user here", + "and_x_more_people": "And %count% other members", + "more_level": "More Levels", + "x_levels_are_not_shown": "And **%count% Level** are not being displayed", + "your_level": "Your Level", + "you_are_level_x_with_x_xp": "You are currently on **Level %level%** with **%xp% XP**. See more with `/profile`.", + "joinedAt": "Joined server", + "color": "GREEN" + }, + "description": "This embed gets send if !leaderboard (!lb) gets executed", "type": "keyed", "content": { "key": "string", "value": "string" }, - "disableKeyEdits": true + "disableKeyEdits": true, + "category": "leaderboard" }, { "name": "level_up_message", "allowGeneratedImage": true, - "humanName": { - "de": "Level-Up-Nachricht" - }, - "default": { - "en": "Level Up! Your new level is **%newLevel%**!", - "de": "Level Up! Dein neues Level ist jetzt %newLevel%" - }, - "description": { - "en": "This messages gets send if a user levels up (gets overwritten if randomMessages is enabled)", - "de": "Diese Nachricht wird gesendet, wenn ein Nutzer ein Level aufsteigt (Wird überschrieben, wenn \"Zufällige Nachrichten\" aktiviert ist)" - }, + "humanName": "Level Up Message", + "default": "Level Up! Your new level is **%newLevel%**!", + "description": "This messages gets send if a user levels up (gets overwritten if randomMessages is enabled)", "type": "string", "allowEmbed": true, "params": [ { "name": "mention", - "description": { - "en": "Mention of the user", - "de": "Erwähnung des Nutzers" - } + "description": "Mention of the user" }, { "name": "avatarURL", "isImage": true, - "description": { - "en": "Avatar of the user", - "de": "Profilbild des Nutzers" - } + "description": "Avatar of the user" }, { "name": "username", - "description": { - "en": "Username of the user", - "de": "Nutzername des Nutzers" - } + "description": "Username of the user" }, { "name": "tag", - "description": { - "en": "Tag of the user", - "de": "Tag des Nutzers" - } + "description": "Tag of the user" }, { "name": "newLevel", - "description": { - "en": "New level of the user", - "de": "Neues Level des Nutzers" - } + "description": "New level of the user" } - ] + ], + "category": "general" }, { "name": "level_up_message_with_reward", "allowGeneratedImage": true, - "humanName": { - "de": "Level-Up-Nachricht mit Belohnung" - }, - "default": { - "en": "Level Up! Your new level is **%newLevel%**! You received %role%.", - "de": "Level Up! Dein neues Level ist **%newLevel%**! Du erhältst %role%." - }, - "description": { - "en": "This messages gets send if a user levels up and gets a role (gets overwritten if randomMessages is enabled)", - "de": "Diese Nachricht wird gesendet, wenn ein Nutzer ein Level aufsteigt und eine Rolle erhält (Wird überschrieben, wenn \"Zufällige Nachrichten\" aktiviert ist)" - }, + "humanName": "Level Up Message with Reward", + "default": "Level Up! Your new level is **%newLevel%**! You received %role%.", + "description": "This messages gets send if a user levels up and gets a role (gets overwritten if randomMessages is enabled)", "type": "string", "allowEmbed": true, "params": [ { "name": "mention", - "description": { - "en": "Mention of the user", - "de": "Erwähnung des Nutzers" - } + "description": "Mention of the user" }, { "name": "avatarURL", "isImage": true, - "description": { - "en": "Avatar of the user", - "de": "Profilbild des Nutzers" - } + "description": "Avatar of the user" }, { "name": "username", - "description": { - "en": "Username of the user", - "de": "Nutzername des Nutzers" - } + "description": "Username of the user" }, { "name": "tag", - "description": { - "en": "Tag of the user", - "de": "Tag des Nutzers" - } + "description": "Tag of the user" }, { "name": "newLevel", - "description": { - "en": "New level of the user", - "de": "Neues Level des Nutzers" - } + "description": "New level of the user" }, { "name": "role", - "description": { - "en": "Mention of the role (No ping)", - "de": "Erwähnung der Rolle (Ohne \"Ping\")" - } + "description": "Mention of the role (No ping)" } - ] + ], + "category": "general" }, { "name": "liveLeaderBoardEmbed", - "humanName": { - "de": "Echtzeit-Rangliste" - }, + "humanName": "Live Leaderboard", "default": { - "en": { - "title": "Live Leaderboard", - "description": "Find all the users levels here. Updated every five minutes.", - "color": "GREEN", - "button": "👤 Show my level" - }, - "de": { - "title": "Echtzeit-Rangliste", - "description": "Hier findest du das Level von jedem Nutzer. Diese Liste wird alle fünf Minuten aktualisiert.", - "color": "GREEN", - "button": "👤 Mein Level anzeigen" - } - }, - "description": { - "en": "Embed which gets send to the leaderboard-channel and gets updated", - "de": "Embed, welches in den Ranglisten-Kanal gesendet und danach geupdated wird" + "title": "Live Leaderboard", + "description": "Find all the users levels here. Updated every five minutes.", + "color": "GREEN", + "button": "👤 Show my level" }, + "description": "Embed which gets send to the leaderboard-channel and gets updated", "type": "keyed", "content": { "key": "string", "value": "string" }, - "disableKeyEdits": true + "disableKeyEdits": true, + "category": "leaderboard" }, { "name": "leaderboard-button-answer", - "humanName": { - "de": "Nachricht bei Klick auf den Knopf unter dem Live-Leaderboard" - }, - "default": { - "en": "Hi, %name%, you are currently on **level %level%** with **%userXP%**/%nextLevelXP% **XP**. Learn more with `/profile`.", - "de": "Hi, %name%, du bist aktuell auf **Level %level%** mit **%userXP%**/%nextLevelXP% **Erfahrungspunkten**. Erfahre mehr mit `/profile`." - }, - "description": { - "en": "This messages gets send if a user clicks on the button below the live-leaderboard", - "de": "Diese Nachricht wird gesendet, wenn ein Nutzer den Knopf unter dem Live-Embed drückt" - }, + "humanName": "Leaderboard Button Response", + "default": "Hi, %name%, you are currently on **level %level%** with **%userXP%**/%nextLevelXP% **XP**. Learn more with `/profile`.", + "description": "This messages gets send if a user clicks on the button below the live-leaderboard", "type": "string", "allowEmbed": true, "params": [ { "name": "name", - "description": { - "en": "Username of the user", - "de": "Username des Nutzers" - } + "description": "Username of the user" }, { "name": "level", - "description": { - "en": "Level of the user", - "de": "Level des Nutzers" - } + "description": "Level of the user" }, { "name": "userXP", - "description": { - "en": "XP of the user", - "de": "XP des Nutzers" - } + "description": "XP of the user" }, { "name": "nextLevelXP", - "description": { - "en": "XP of the next level", - "de": "Benötigtes XP zum nächsten Level" - } + "description": "XP of the next level" } - ] + ], + "category": "leaderboard" + } + ], + "categories": [ + { + "id": "leaderboard", + "icon": "fas fa-ranking-stars", + "displayName": "Leaderboard Messages" + }, + { + "id": "general", + "icon": "fas fa-comment-dots", + "displayName": "General Messages" } ] -} \ No newline at end of file +} diff --git a/modules/levels/events/messageCreate.js b/modules/levels/events/messageCreate.js index 8020fdc6..b721788d 100644 --- a/modules/levels/events/messageCreate.js +++ b/modules/levels/events/messageCreate.js @@ -61,6 +61,7 @@ module.exports.getMemberRoleFactor = getMemberRoleFactor; async function grantXPAndLevelUP(client, member, xp, xpType, channel, msg = null) { const moduleConfig = client.configurations['levels']['config']; + if (member.roles.cache.some(r => moduleConfig.blacklistedRoles.some(br => String(br) === r.id))) return; const moduleStrings = client.configurations['levels']['strings']; let user = await client.models['levels']['User'].findOne({ @@ -88,8 +89,35 @@ async function grantXPAndLevelUP(client, member, xp, xpType, channel, msg = null await user.save(); if (nextLevelXp <= user.xp && !currentlyLevelingUp.has(member.user.id)) { + const cachedXp = user.xp; + const cachedLevel = user.level; + // Sanity-check the stored values before entering the loop. Out-of-range values + // (NaN, Infinity, absurdly large XP, negative level) indicate a corrupted row + // and can make the level-up loop run effectively forever. + if ( + !Number.isFinite(cachedXp) || !Number.isFinite(cachedLevel) || + cachedXp < 0 || cachedLevel < 0 || + cachedXp > 1e12 || cachedLevel > 1e6 + ) { + client.logger.error(`[levels] skipping level-up for user ${member.user.id}: corrupted values (xp=${cachedXp}, level=${cachedLevel})`); + return; + } let i = 1; - while (user.xp >= calculateLevelXP(client, user.level + i)) i++; + let lastRequired = -Infinity; + while (i <= 1000) { + const required = calculateLevelXP(client, cachedLevel + i); + if (!Number.isFinite(required) || required <= lastRequired) { + client.logger.error(`[levels] level curve returned non-monotonic or non-finite value at level ${cachedLevel + i} (got ${required}); aborting level-up for user ${member.user.id}`); + return; + } + if (cachedXp < required) break; + lastRequired = required; + i++; + } + if (i > 1000) { + client.logger.error(`[levels] level-up loop exceeded 1000 iterations for user ${member.user.id} (xp=${cachedXp}, level=${cachedLevel}); skipping`); + return; + } currentlyLevelingUp.add(member.user.id); user.level = user.level + (i - 1); const levelUpChannel = client.channels.cache.find(c => c.id === moduleConfig.level_up_channel_id && c.type === ChannelType.GuildText); @@ -151,13 +179,14 @@ module.exports.run = async (client, msg) => { if (msg.author.bot || msg.system) return; if (!msg.guild) return; if (msg.guild.id !== client.guildID) return; + if (!msg.member) return; if (cooldown.has(msg.author.id)) return; const moduleConfig = client.configurations['levels']['config']; if (msg.content.includes(client.config.prefix)) return; if (moduleConfig.blacklisted_channels.includes(msg.channel.id) || moduleConfig.blacklisted_channels.includes(msg.channel.parentId) || moduleConfig.blacklisted_channels.includes(msg.channel.parent?.parentId)) return; - if (msg.member.roles.cache.filter(r => moduleConfig.blacklistedRoles.includes(r.id)).size !== 0) return; + if (msg.member.roles.cache.some(r => moduleConfig.blacklistedRoles.some(br => String(br) === r.id))) return; let xp = randomIntFromInterval(moduleConfig['min-xp'], moduleConfig['max-xp']); await grantXPAndLevelUP(client, msg.member, xp, 'message', msg.channel, msg); diff --git a/modules/levels/events/voiceStateUpdate.js b/modules/levels/events/voiceStateUpdate.js index 4d684812..7641a1f3 100644 --- a/modules/levels/events/voiceStateUpdate.js +++ b/modules/levels/events/voiceStateUpdate.js @@ -2,35 +2,67 @@ const {ChannelType} = require('discord.js'); const {grantXPAndLevelUP} = require('./messageCreate'); const states = new Map(); -async function startVoiceSession(client, currentState) { - const moduleConfig = client.configurations['levels']['config']; - if (moduleConfig.blacklisted_channels.includes(currentState.channel.id) || moduleConfig.blacklisted_channels.includes(currentState.channel.parentId)) return; +function isChannelBlacklisted(client, channel) { + if (!channel) return true; + const blacklist = client.configurations['levels']['config'].blacklisted_channels; + return blacklist.includes(channel.id) || blacklist.includes(channel.parentId) || blacklist.includes(channel.parent?.parentId); +} + +function isRoleBlacklisted(client, member) { + return member.roles.cache.some(r => client.configurations['levels']['config'].blacklistedRoles.some(br => String(br) === r.id)); +} + +function hasHumanCompany(channel) { + if (!channel) return false; + return channel.members.filter(m => !m.user.bot).size >= 2; +} + +function isEligible(client, voiceState) { + if (!voiceState || !voiceState.channel) return false; + if (!voiceState.member || voiceState.member.user.bot) return false; + if (voiceState.deaf || voiceState.mute) return false; + if (voiceState.channel.type === ChannelType.GuildStageVoice) return false; + if (isChannelBlacklisted(client, voiceState.channel)) return false; + if (isRoleBlacklisted(client, voiceState.member)) return false; + if (!hasHumanCompany(voiceState.channel)) return false; + return true; +} + +async function startVoiceSession(client, voiceState) { + if (states.has(voiceState.member.id)) return; const int = setInterval(() => { - grantXP(client, currentState?.member).then(() => { + grantXP(client, voiceState?.member).then(() => { }); }, 1000 * 60 * 15); - states.set(currentState.member.id, { + states.set(voiceState.member.id, { start: new Date(), - channel: currentState.channel, + channel: voiceState.channel, lastXPTime: new Date(), end: null, interval: int }); } -async function endVoiceSession(client, currentState) { - if (!states.has(currentState.member.id)) return; - const oldState = states.get(currentState.member.id); +async function endVoiceSession(client, member) { + if (!states.has(member.id)) return; + const oldState = states.get(member.id); clearInterval(oldState.interval); - states.delete(currentState.member.id); - await grantXP(client, currentState.member); + states.delete(member.id); + await grantXP(client, member, oldState); } -async function grantXP(client, member) { - const stateData = states.get(member?.id); +async function grantXP(client, member, overrideStateData) { + const stateData = overrideStateData || states.get(member?.id); if (!stateData) return; + if (isRoleBlacklisted(client, member)) { + if (states.has(member.id)) { + clearInterval(states.get(member.id).interval); + states.delete(member.id); + } + return; + } const diff = new Date().getTime() - stateData.lastXPTime.getTime(); stateData.lastXPTime = new Date(); const moduleConfig = client.configurations['levels']['config']; @@ -39,15 +71,30 @@ async function grantXP(client, member) { await grantXPAndLevelUP(client, member, xp, 'voice', stateData.channel); } +async function updateChannelSessions(client, channel) { + if (!channel) return; + for (const member of channel.members.values()) { + if (member.user.bot) continue; + const voiceState = member.voice; + if (isEligible(client, voiceState)) { + if (!states.has(member.id)) await startVoiceSession(client, voiceState); + } else if (states.has(member.id)) { + await endVoiceSession(client, member); + } + } +} + module.exports.run = async function (client, oldState, newState) { if (!client.botReadyAt) return; if (!newState.guild || newState.member.user.bot) return; if (newState.guild.id !== client.guildID || client.configurations['levels']['config']['voiceXPPerMinute'] === 0) return; - if (newState.channel && (client.configurations['levels']['config'].blacklisted_channels.includes(newState.channel.id) || client.configurations['levels']['config'].blacklisted_channels.includes(newState.channel.parentId) || client.configurations['levels']['config'].blacklisted_channels.includes(newState.channel.parent?.parentId))) return; - if (newState.member.roles.cache.filter(r => client.configurations['levels']['config'].blacklistedRoles.includes(r.id)).size !== 0) return; + const channelChanged = oldState.channel !== newState.channel; + const muteOrDeafChanged = oldState.deaf !== newState.deaf || oldState.mute !== newState.mute; + if (!channelChanged && !muteOrDeafChanged) return; - if (oldState.channel !== newState.channel || oldState.deaf !== newState.deaf || oldState.mute !== newState.mute) await endVoiceSession(client, newState); + if (states.has(newState.member.id)) await endVoiceSession(client, newState.member); - if (newState.channel && !newState.deaf && !newState.mute && newState.channel.type !== ChannelType.GuildStageVoice) await startVoiceSession(client, newState); -}; \ No newline at end of file + if (oldState.channel && oldState.channel !== newState.channel) await updateChannelSessions(client, oldState.channel); + if (newState.channel) await updateChannelSessions(client, newState.channel); +}; diff --git a/modules/levels/leaderboardChannel.js b/modules/levels/leaderboardChannel.js index 7f611806..bea25520 100644 --- a/modules/levels/leaderboardChannel.js +++ b/modules/levels/leaderboardChannel.js @@ -8,7 +8,8 @@ const {localize} = require('../../src/functions/localize'); const { formatDiscordUserName, formatNumber, - parseEmbedColor + parseEmbedColor, + safeSetFooter } = require('../../src/functions/helpers'); const {displayLevel, isMaxLevel, calculateLevelXP} = require('./events/messageCreate'); const {client} = require('../../main'); @@ -66,10 +67,11 @@ module.exports.updateLeaderBoard = async function (client, force = false) { .setTitle(moduleStrings.liveLeaderBoardEmbed.title) .setDescription(moduleStrings.liveLeaderBoardEmbed.description) .setColor(parseEmbedColor(moduleStrings.liveLeaderBoardEmbed.color)) - .setFooter({text: client.strings.footer, iconURL: client.strings.footerImgUrl}) .setThumbnail(channel.guild.iconURL()) .addField(localize('levels', 'leaderboard'), leaderboardString); + safeSetFooter(embed, client); + if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); const components = [{ diff --git a/modules/levels/module.json b/modules/levels/module.json index 4fbb00c6..fe94d622 100644 --- a/modules/levels/module.json +++ b/modules/levels/module.json @@ -1,8 +1,6 @@ { "name": "levels", - "humanReadableName": { - "en": "Level-System" - }, + "humanReadableName": "Level-System", "author": { "scnxOrgID": "1", "name": "SCDerox (SC Network Team)", @@ -18,11 +16,9 @@ "configs/random-levelup-messages.json", "configs/special-levelup-messages.json" ], + "fa-icon": "fas fa-comments", "tags": [ "community" ], - "description": { - "en": "Easy to use levelsystem with a lot of customization!", - "de": "Einfaches Level-System mit vielen Anpassungsmöglichkeiten!" - } -} \ No newline at end of file + "description": "Easy to use levelsystem with a lot of customization!" +} diff --git a/modules/massrole/configs/config.json b/modules/massrole/configs/config.json index 374c73cf..9147781d 100644 --- a/modules/massrole/configs/config.json +++ b/modules/massrole/configs/config.json @@ -1,40 +1,23 @@ { - "description": { - "en": "Configure the function of the module here", - "de": "Stelle hier die Funktionen des Modules ein" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the function of the module here", + "humanName": "Configuration", "filename": "config.json", "commandsWarnings": { "special": [ { "name": "/massrole", - "info": { - "en": "You need to first set the permissions in your server settings for this command and after that add them under \"adminRoles\" here.", - "de": "Du musst zuerst die Rechte in deinen Server-Einstellungen einstellen und danach diese unter \"AdminRollen\" hinzufügen." - } + "info": "You need to first set the permissions in your server settings for this command and after that add them under \"adminRoles\" here." } ] }, "content": [ { "name": "adminRoles", - "humanName": { - "de": "Adminrollen" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Every role that can use the massrole command", - "de": "Jede Rolle, welche den Massrole command verwenden kann" - }, + "humanName": "Admin Roles", + "default": [], + "description": "Every role that can use the massrole command", "type": "array", "content": "roleID" } ] -} \ No newline at end of file +} diff --git a/modules/massrole/configs/strings.json b/modules/massrole/configs/strings.json index 7cfd2da8..11e6b224 100644 --- a/modules/massrole/configs/strings.json +++ b/modules/massrole/configs/strings.json @@ -1,12 +1,6 @@ { - "description": { - "en": "Edit the messages and strings of the module here", - "de": "Stelle hier die Nachrichten des Modules ein" - }, - "humanName": { - "en": "Messages", - "de": "Nachrichten" - }, + "description": "Edit the messages and strings of the module here", + "humanName": "Messages", "commandsWarnings": { "normal": [ "/massrole" @@ -16,35 +10,17 @@ "content": [ { "name": "done", - "humanName": { - "en": "Action executed", - "de": "Aktion ausgeführt" - }, - "default": { - "en": "The action was executed successfully.", - "de": "Die Aktion wurde erfolgreich ausgeführt." - }, - "description": { - "en": "This messages gets send when a action was executed successfully", - "de": "Diese Nachricht wird verschickt, wenn eine Akton erfolgreich ausgeführt wurde" - }, + "humanName": "Action executed", + "default": "The action was executed successfully.", + "description": "This messages gets send when a action was executed successfully", "type": "string", "allowEmbed": true }, { "name": "notDone", - "humanName": { - "en": "Action not executed", - "de": "Aktion nicht ausgeführt" - }, - "default": { - "en": "The Action couldn't be executed because the bot has not enough permissions.", - "de": "Die Aktion konnte nicht vollständig ausgeführt werden, da der Bot nicht genug Rechte hat." - }, - "description": { - "en": "This messages gets send when a action was not executed successfully", - "de": "Diese Nachricht wird verschickt, wenn eine Aktion nicht erfolgreich ausgeführt wurde" - }, + "humanName": "Action not executed", + "default": "The Action couldn't be executed because the bot has not enough permissions.", + "description": "This messages gets send when a action was not executed successfully", "type": "string", "allowEmbed": true } diff --git a/modules/massrole/module.json b/modules/massrole/module.json index a9849d7b..0fc85335 100644 --- a/modules/massrole/module.json +++ b/modules/massrole/module.json @@ -1,8 +1,6 @@ { "name": "massrole", - "humanReadableName": { - "en": "Massrole" - }, + "humanReadableName": "Massrole", "author": { "name": "hfgd", "link": "https://github.com/hfgd123", @@ -10,6 +8,7 @@ }, "openSourceURL": "https://github.com/hfgd123/CustomDCBot/tree/main/modules/massrole", "commands-dir": "/commands", + "fa-icon": "fa-solid fa-users-viewfinder", "config-example-files": [ "configs/config.json", "configs/strings.json" @@ -17,8 +16,5 @@ "tags": [ "tools" ], - "description": { - "en": "Simple module to manage the roles of many members at once!", - "de": "Einfaches Modul, um die Rollen vieler Nutzer gleichzeitig zu verwalten!" - } -} \ No newline at end of file + "description": "Simple module to manage the roles of many members at once!" +} diff --git a/modules/moderation/commands/moderate.js b/modules/moderation/commands/moderate.js index c2ff96c5..2b884c8a 100644 --- a/modules/moderation/commands/moderate.js +++ b/modules/moderation/commands/moderate.js @@ -416,11 +416,11 @@ module.exports.subcommands = { fieldCount++; fieldCache.push({ name: `#${action.actionID}: ${action.type}`, - value: localize('moderation', 'action-description-format', { + value: truncate(localize('moderation', 'action-description-format', { reason: action.reason, u: action.memberID, t: dateToDiscordTimestamp(new Date(action.createdAt)) - }) + }), 1024) }); if (fieldCount % 3 === 0) { addSite(fieldCache); diff --git a/modules/moderation/configs/antiGrief.json b/modules/moderation/configs/antiGrief.json index ea9e16eb..dae4abf3 100644 --- a/modules/moderation/configs/antiGrief.json +++ b/modules/moderation/configs/antiGrief.json @@ -1,116 +1,56 @@ { - "description": { - "en": "This system can prevent moderation-tool-abuse by staff-members", - "de": "Dieses System kann Moderation-Tool-Missbrauch von Teammitgliedern verhindern" - }, - "humanName": { - "en": "Anti-Grief-Configuration", - "de": "Anti-Grief-Konfiguration" - }, - "informationBanner": { - "en": "This feature can automatically quarantine moderators that abuse their permissions (banning / warning / kicking more people than you set up). For this to work, place your bot above all other roles and make sure that the quarantine-role is right below it. This ensures that moderators / admins can not just give permissions to the quarantine-role or remove permissions from the bot.", - "de": "Diese Funktion kann automatisch Moderatoren in Quarantäne versetzen, wenn sie ihre Berechtigungen (wenn sie mehr Leute Bannen / Warnen / Kicken als du einstellst). Damit das fehlerfrei funktioniert, musst dein Bot über alle anderen Rollen platziert sein und direkt darunter muss die Quarantäne-Rolle sein. Das stellt sicher, dass Moderatoren / Administratoren nicht einfach der Quarantäne-Rolle Rechte geben können oder dem Bot Rechte entfernen können." - }, - "warningBanner": { - "en": "This feature is currently limited to actions run by the moderation-module. If you've given your moderators native discord-permissions, they can bypass this. We plan to support native actions (+ channel-deletes and other griefing actions) in future.", - "de": "Diese Funktion ist aktuell nur für Aktionen, die mit dem Moderations-Modul durchgeführt wurden, verfügbar. Wenn du deinen Moderatoren native Discord-Berechtigungen gegeben hast, können sie das ganz einfach umgehen. Wir planen, native Aktionen (und Channel-Löschungen oder andere Grief-Aktionen) in der Zukunft zu unterstützen." - }, + "description": "This system can prevent moderation-tool-abuse by staff-members", + "humanName": "Anti-Grief-Configuration", + "informationBanner": "This feature can automatically quarantine moderators that abuse their permissions (banning / warning / kicking more people than you set up). For this to work, place your bot above all other roles and make sure that the quarantine-role is right below it. This ensures that moderators / admins can not just give permissions to the quarantine-role or remove permissions from the bot.", + "warningBanner": "This feature is currently limited to actions run by the moderation-module. If you've given your moderators native discord-permissions, they can bypass this. We plan to support native actions (+ channel-deletes and other griefing actions) in future.", "filename": "antiGrief.json", "content": [ { "name": "enabled", - "humanName": { - "de": "Aktiviert", - "en": "Enabled?" - }, - "default": { - "en": false - }, - "description": { - "en": "Enables or disables the anti-join-grief-system", - "de": "Aktiviert oder deaktiviert das Anti-Join-Grief-System" - }, + "humanName": "Enabled?", + "default": false, + "description": "Enables or disables the anti-join-grief-system", "type": "boolean", "elementToggle": true, "category": "settings" }, { "name": "timeframe", - "humanName": { - "de": "Zeitfenster (in Stunden)", - "en": "Timeframe (in hours)" - }, - "default": { - "en": 3 - }, - "description": { - "en": "Timeframe in hours in which the limits can not be overstepped", - "de": "Zeitfenster in Stunden, in welchem die Limits nicht überschritten werden dürfen" - }, + "humanName": "Timeframe (in hours)", + "default": 3, + "description": "Timeframe in hours in which the limits can not be overstepped", "type": "integer", "category": "settings" }, { "name": "max_warn", - "humanName": { - "de": "Maximale Anzahl von Verwarnungen in dem Zeitfenster", - "en": "Maximal amount of warns in the timeframe" - }, - "default": { - "en": 15 - }, - "description": { - "en": "Maximal amount of warns a moderator can give in the timeframe until they get quarantined", - "de": "Maximale Anzahl von Verwarnungen, die ein Moderator in dem Zeitfenster vergeben kann, bis sie in Quarantäne gesteckt werden" - }, + "humanName": "Maximal amount of warns in the timeframe", + "default": 15, + "description": "Maximal amount of warns a moderator can give in the timeframe until they get quarantined", "type": "integer", "category": "actions" }, { "name": "max_mute", - "humanName": { - "de": "Maximale Anzahl von Mutes in dem Zeitfenster", - "en": "Maximal amount of mutes in the timeframe" - }, - "default": { - "en": 20 - }, - "description": { - "en": "Maximal amount of mutes a moderator can give in the timeframe until they get quarantined", - "de": "Maximale Anzahl von Mutes, die ein Moderator in dem Zeitfenster vergeben kann, bis sie in Quarantäne gesteckt werden" - }, + "humanName": "Maximal amount of mutes in the timeframe", + "default": 20, + "description": "Maximal amount of mutes a moderator can give in the timeframe until they get quarantined", "type": "integer", "category": "actions" }, { "name": "max_kick", - "humanName": { - "de": "Maximale Anzahl von Kicks in dem Zeitfenster", - "en": "Maximal amount of kicks in the timeframe" - }, - "default": { - "en": 10 - }, - "description": { - "en": "Maximal amount of kicks a moderator can give in the timeframe until they get quarantined", - "de": "Maximale Anzahl von Kicks, die ein Moderator in dem Zeitfenster vergeben kann, bis sie in Quarantäne gesteckt werden" - }, + "humanName": "Maximal amount of kicks in the timeframe", + "default": 10, + "description": "Maximal amount of kicks a moderator can give in the timeframe until they get quarantined", "type": "integer", "category": "actions" }, { "name": "max_ban", - "humanName": { - "de": "Maximale Anzahl von Bans in dem Zeitfenster", - "en": "Maximal amount of bans in the timeframe" - }, - "default": { - "en": 5 - }, - "description": { - "en": "Maximal amount of bans a moderator can give in the timeframe until they get quarantined", - "de": "Maximale Anzahl von Bans, die ein Moderator in dem Zeitfenster vergeben kann, bis sie in Quarantäne gesteckt werden" - }, + "humanName": "Maximal amount of bans in the timeframe", + "default": 5, + "description": "Maximal amount of bans a moderator can give in the timeframe until they get quarantined", "type": "integer", "category": "actions" } @@ -119,18 +59,12 @@ { "id": "settings", "icon": "fas fa-gears", - "displayName": { - "en": "Detection Settings", - "de": "Erkennungseinstellungen" - } + "displayName": "Detection Settings" }, { "id": "actions", "icon": "fas fa-hammer", - "displayName": { - "en": "Actions", - "de": "Aktionen" - } + "displayName": "Actions" } ] } \ No newline at end of file diff --git a/modules/moderation/configs/antiJoinRaid.json b/modules/moderation/configs/antiJoinRaid.json index aa7c6852..db2f11f4 100644 --- a/modules/moderation/configs/antiJoinRaid.json +++ b/modules/moderation/configs/antiJoinRaid.json @@ -1,80 +1,38 @@ { - "description": { - "en": "This system can prevent spammers from raiding your server", - "de": "Dieses System kann es Spammern verhindern, deinen Server zu raiden" - }, - "humanName": { - "en": "Anti-Join-Raid-Configuration", - "de": "Anti-Join-Raid-Konfiguration" - }, + "description": "This system can prevent spammers from raiding your server", + "humanName": "Anti-Join-Raid-Configuration", "filename": "antiJoinRaid.json", "content": [ { "name": "enabled", - "humanName": { - "de": "Aktiviert", - "en": "Enabled?" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "Enables or disables the anti-join-raid-system", - "de": "Aktiviert oder deaktiviert das Anti-Join-Raid-System" - }, + "humanName": "Enabled?", + "default": true, + "description": "Enables or disables the anti-join-raid-system", "type": "boolean", "elementToggle": true, "category": "settings" }, { "name": "timeframe", - "humanName": { - "de": "Zeitfenster (in Minuten)", - "en": "Timeframe (in minutes)" - }, - "default": { - "en": 5, - "de": 5 - }, - "description": { - "en": "Timeframe in which join actions should be recorded (in minutes)", - "de": "Zeitfenster, in welchem Serverbeitritte gezählt werden sollen (in Minuten)" - }, + "humanName": "Timeframe (in minutes)", + "default": 5, + "description": "Timeframe in which join actions should be recorded (in minutes)", "type": "integer", "category": "settings" }, { "name": "maxJoinsInTimeframe", - "humanName": { - "de": "Maximale Beitrittsanzahl", - "en": "Maximal count of new users" - }, - "default": { - "en": 3, - "de": 3 - }, - "description": { - "en": "Count of joins that are allowed to happen in the selected timeframe", - "de": "Anzahl an Serverbeitritten, die im ausgewählten Zeitfenster zugelassen werden" - }, + "humanName": "Maximal count of new users", + "default": 3, + "description": "Count of joins that are allowed to happen in the selected timeframe", "type": "integer", "category": "settings" }, { "name": "action", - "humanName": { - "de": "Aktion", - "en": "Action" - }, - "default": { - "en": "quarantine", - "de": "quarantine" - }, - "description": { - "en": "Select the action here that should get performed if the anti-join-system gets triggered", - "de": "Wähle hier die Aktion aus, die ausgeführt werden soll, wenn das Anti-Join-Raid-System ausgelöst wird" - }, + "humanName": "Action", + "default": "quarantine", + "description": "Select the action here that should get performed if the anti-join-system gets triggered", "type": "select", "content": [ "mute", @@ -87,34 +45,17 @@ }, { "name": "roleID", - "humanName": { - "de": "Rolle", - "en": "Role" - }, - "default": { - "en": "" - }, - "description": { - "en": "Only if action = give-role. Role that gets given to users who trigger the antiJoinRaid-System", - "de": "Nur verfügbar, wenn Aktion = give-role. Rolle, die Nutzern gegeben wird, die das Anti-Join-Raid-System auslösen" - }, + "humanName": "Role", + "default": "", + "description": "Only if action = give-role. Role that gets given to users who trigger the antiJoinRaid-System", "type": "roleID", "category": "actions" }, { "name": "removeOtherRoles", - "humanName": { - "de": "Andere Rollen entfernen", - "en": "Remove other roles" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "Only if action = give-role. If enabled other roles that have been give to the user get removed after a short interval (and the giving of the role from \"roleID\" will be delayed)", - "de": "Nur verfügbar, wenn Aktion = give-role. Wenn aktiviert, werden andere Rollen die der Nutzer hat nach einem kurzen Zeitraum entfernt (und das Vergeben der Rolle von \"Rolle\" wird verzögert)" - }, + "humanName": "Remove other roles", + "default": true, + "description": "Only if action = give-role. If enabled other roles that have been give to the user get removed after a short interval (and the giving of the role from \"roleID\" will be delayed)", "type": "boolean", "category": "actions" } @@ -123,18 +64,12 @@ { "id": "settings", "icon": "fas fa-gears", - "displayName": { - "en": "Detection Settings", - "de": "Erkennungseinstellungen" - } + "displayName": "Detection Settings" }, { "id": "actions", "icon": "fas fa-hammer", - "displayName": { - "en": "Actions", - "de": "Aktionen" - } + "displayName": "Actions" } ] } \ No newline at end of file diff --git a/modules/moderation/configs/antiSpam.json b/modules/moderation/configs/antiSpam.json index 3542a71e..637a97b9 100644 --- a/modules/moderation/configs/antiSpam.json +++ b/modules/moderation/configs/antiSpam.json @@ -1,131 +1,62 @@ { - "description": { - "en": "You can configure here, how your bot should react to spam", - "de": "Du kannst hier einstellen, wie dein Bot auf Spam reagieren soll" - }, - "humanName": { - "en": "Anti-Spam-Configuration", - "de": "Anti-Spam-Konfiguration" - }, + "description": "You can configure here, how your bot should react to spam", + "humanName": "Anti-Spam-Configuration", "filename": "antiSpam.json", "content": [ { "name": "enabled", - "humanName": { - "de": "Aktiviert", - "en": "Enabled?" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "Enable or disable the anti spam system", - "de": "Aktiviert oder deaktiviert das Anti-Spam-System" - }, + "humanName": "Enabled?", + "default": true, + "description": "Enable or disable the anti spam system", "type": "boolean", "elementToggle": true, "category": "settings" }, { "name": "timeframe", - "humanName": { - "de": "Zeitfenster (in Sekunden)", - "en": "Timeframe (in seconds)" - }, - "default": { - "en": 5, - "de": 5 - }, - "description": { - "en": "Timeframe in seconds after which message objects get deleted (and can not longer be used to detect spam)", - "de": "Zeitfenster in Sekunden, in dem Nachrichten gelöscht werden (und nicht länger zur Erkennung von Spam verwendet werden können)" - }, + "humanName": "Timeframe (in seconds)", + "default": 5, + "description": "Timeframe in seconds after which message objects get deleted (and can not longer be used to detect spam)", "type": "integer", "category": "settings" }, { "name": "maxMessagesInTimeframe", - "humanName": { - "de": "Maximale Nachrichten im Zeitfenster", - "en": "Maximal count of messages in timeframe" - }, - "default": { - "en": 10, - "de": 10 - }, - "description": { - "en": "Count of messages that are allowed to be sent in the selected timeframe", - "de": "Anzahl an Nachrichten, die im ausgewählten Zeitfenster erlaubt sind" - }, + "humanName": "Maximal count of messages in timeframe", + "default": 10, + "description": "Count of messages that are allowed to be sent in the selected timeframe", "type": "integer", "category": "settings" }, { "name": "maxDuplicatedMessagesInTimeframe", - "humanName": { - "de": "Maximale gleiche Nachrichten im Zeitfenster", - "en": "Maximal count of duplicated messages in timeframe" - }, - "default": { - "en": 5, - "de": 5 - }, - "description": { - "en": "Count of identical messages that are allowed to be sent in the selected timeframe", - "de": "Anzahl an gleichen Nachrichten, die im ausgewählten Zeitfenster erlaubt sind" - }, + "humanName": "Maximal count of duplicated messages in timeframe", + "default": 5, + "description": "Count of identical messages that are allowed to be sent in the selected timeframe", "type": "integer", "category": "settings" }, { "name": "maxPingsInTimeframe", - "humanName": { - "de": "Maximale Pings im Zeitfenster", - "en": "Maximal count of pings in timeframe" - }, - "default": { - "en": 4, - "de": 4 - }, - "description": { - "en": "Count of pings (also counts replies) that are allowed to be sent in the selected timeframe", - "de": "Anzahl an Erwähnungen (zählt auch Antworten), die im ausgewählten Zeitfenster erlaubt sind" - }, + "humanName": "Maximal count of pings in timeframe", + "default": 4, + "description": "Count of pings (also counts replies) that are allowed to be sent in the selected timeframe", "type": "integer", "category": "settings" }, { "name": "maxMassPings", - "humanName": { - "de": "Maximale Massenpings im Zeitfenster", - "en": "Maximal count of mass-pings in timeframe" - }, - "default": { - "en": 3, - "de": 3 - }, - "description": { - "en": "Count of mass pings (= @everyone, @here and roles) that are allowed to be sent in the selected timeframe", - "de": "Anzahl an Massenerwähnungen (= @everyone, @here und Rollen), die im ausgewählten Zeitfenster erlaubt sind" - }, + "humanName": "Maximal count of mass-pings in timeframe", + "default": 3, + "description": "Count of mass pings (= @everyone, @here and roles) that are allowed to be sent in the selected timeframe", "type": "integer", "category": "settings" }, { "name": "action", - "humanName": { - "de": "Aktion", - "en": "Action" - }, - "default": { - "en": "mute", - "de": "mute" - }, - "description": { - "en": "Select what should happen if someone spams", - "de": "Wähle hier die Aktion aus, die ausgeführt werden soll, wenn jemand spammt" - }, + "humanName": "Action", + "default": "mute", + "description": "Select what should happen if someone spams", "type": "select", "content": [ "mute", @@ -138,88 +69,46 @@ }, { "name": "sendChatMessage", - "humanName": { - "de": "Chatnachricht senden", - "en": "Send Chat-Message" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "If enabled the bot will send a chat message if it has to take action agains a bot", - "de": "Wenn aktiviert, wird der Bot eine Nachricht in den Chat senden, wenn er eine Aktion gegen einen Bot ausführen musste" - }, + "humanName": "Send Chat-Message", + "default": true, + "description": "If enabled the bot will send a chat message if it has to take action agains a bot", "type": "boolean", "category": "actions" }, { "name": "message", "dependsOn": "sendChatMessage", - "humanName": { - "de": "Nachricht", - "en": "Message" - }, - "default": { - "en": "Anti-Spam: I took action against <@%userid%> because of **%reason%**", - "de": "Anti-Spam: Ich habe wegen **%reason%** eine Aktion gegen <@%userid%> ausgeführt" - }, - "description": { - "en": "This will get send in the channel the spam is occurring in when anti-spam gets triggered", - "de": "Das wird in den Kanal gesendet, wenn das Anti-Spam-System ausgelöst wird" - }, + "humanName": "Message", + "default": "Anti-Spam: I took action against <@%userid%> because of **%reason%**", + "description": "This will get send in the channel the spam is occurring in when anti-spam gets triggered", "type": "string", "allowEmbed": true, "params": [ { "name": "userid", - "description": { - "en": "ID of the user", - "de": "ID des Nutzers" - } + "description": "ID of the user" }, { "name": "reason", - "description": { - "en": "Reason of the action", - "de": "Grund der Aktion" - } + "description": "Reason of the action" } ], "category": "actions" }, { "name": "ignoredChannels", - "humanName": { - "de": "Ignorierte Kanäle", - "en": "Whitelisted Channels" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "You can set channels that get ignored here", - "de": "Du kannst hier Kanäle einstellen, die ignoriert werden sollen" - }, + "humanName": "Whitelisted Channels", + "default": [], + "description": "You can set channels that get ignored here", "type": "array", "content": "channelID", "category": "exemptions" }, { "name": "ignoredRoles", - "humanName": { - "de": "Ignorierte Rollen", - "en": "Whitelisted roles" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "You can set roles that get ignored here", - "de": "Du kannst hier Rollen einstellen, die ignoriert werden sollen" - }, + "humanName": "Whitelisted roles", + "default": [], + "description": "You can set roles that get ignored here", "type": "array", "content": "roleID", "category": "exemptions" @@ -229,26 +118,17 @@ { "id": "settings", "icon": "fas fa-gears", - "displayName": { - "en": "Detection Settings", - "de": "Erkennungseinstellungen" - } + "displayName": "Detection Settings" }, { "id": "actions", "icon": "fas fa-hammer", - "displayName": { - "en": "Actions", - "de": "Aktionen" - } + "displayName": "Actions" }, { "id": "exemptions", "icon": "fa-solid fa-shield", - "displayName": { - "en": "Exemptions", - "de": "Ausnahmen" - } + "displayName": "Exemptions" } ] } \ No newline at end of file diff --git a/modules/moderation/configs/config.json b/modules/moderation/configs/config.json index ef76531f..4ab12fd3 100644 --- a/modules/moderation/configs/config.json +++ b/modules/moderation/configs/config.json @@ -1,226 +1,116 @@ { - "description": { - "en": "You can set up permissions and features of this module here", - "de": "Du kannst hier die Rechte dieses Modules einstellen" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "You can set up permissions and features of this module here", + "humanName": "Configuration", "filename": "config.json", "commandsWarnings": { "special": [ { "name": "/moderate", - "info": { - "en": "Each moderator needs to be able to execute the /moderate command, so set your permissions in your server-settings accordingly. Additionally, moderator need to be entered into their level below.", - "de": "Jeder Modator muss den /moderate Befehl ausführen können, bitte konfiguriere das in deinen Server-Einstellungen. Zusätzlich muss jede Moderator-Rolle zu ihrem Level unten manuell eingetragen werden." - } + "info": "Each moderator needs to be able to execute the /moderate command, so set your permissions in your server-settings accordingly. Additionally, moderator need to be entered into their level below." } ] }, "content": [ { "name": "logchannel-id", - "humanName": { - "de": "Log-Kanal", - "en": "Log-Channel" - }, - "default": { - "en": "" - }, - "description": { - "en": "Moderative actions will get logged in this channel", - "de": "Moderative Aktionen werden in diesem Kanal geloggt" - }, + "humanName": "Log-Channel", + "default": "", + "description": "Moderative actions will get logged in this channel", "type": "channelID", "category": "general" }, { "name": "quarantine-role-id", - "humanName": { - "de": "Quarantäne-Rolle", - "en": "Quarantine-Role" - }, - "default": { - "en": "" - }, - "description": { - "en": "When a user gets quarantined, all of their roles will get removed and this quarantine-role wil get assigned", - "de": "Wenn ein Nutzer in Quarantäne gesteckt wird, werden alle Rollen von diesem entfernt und nur diese hinzugefügt" - }, + "humanName": "Quarantine-Role", + "default": "", + "description": "When a user gets quarantined, all of their roles will get removed and this quarantine-role wil get assigned", "type": "roleID", "category": "roles" }, { "name": "report-channel-id", - "default": { - "en": "" - }, - "humanName": { - "en": "Report-Channel", - "de": "Report-Kanal" - }, - "description": { - "en": "Channel in which user-reports should get send. (optional, default: Log-Channel)", - "de": "Kanal, in welchem Nutzer-Reports should get send. (optional, default: Log-Kanal)" - }, + "default": "", + "humanName": "Report-Channel", + "description": "Channel in which user-reports should get send. (optional, default: Log-Channel)", "type": "channelID", "allowNull": true, "category": "reports" }, { "name": "remove-all-roles-on-quarantine", - "humanName": { - "de": "Bei Quarantäne alle Rollen entfernen", - "en": "Remove all roles on quarantine" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "If enabled all roles from a user get removed if they get quarantined (they get saved an can be restored with /unquarantine)", - "de": "Wenn diese Option aktiviert ist, werden alle Rollen eines Nutzers entfernt, wenn er in Quarantäne gesetzt wird (sie werden gespeichert und mit /unquarantine wiederhergestellt)" - }, + "humanName": "Remove all roles on quarantine", + "default": true, + "description": "If enabled all roles from a user get removed if they get quarantined (they get saved an can be restored with /unquarantine)", "type": "boolean", "category": "roles" }, { "name": "moderator-roles_level1", - "humanName": { - "en": "Moderator-Level 1" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Moderator roles that can perform the following actions: Warn", - "de": "Rollen, die folgende Aktionen ausführen können: Warn" - }, + "humanName": "Moderator-Level 1", + "default": [], + "description": "Moderator roles that can perform the following actions: Warn", "type": "array", "content": "roleID", "category": "roles" }, { "name": "moderator-roles_level2", - "humanName": { - "en": "Moderator-Level 2" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Moderator roles that can perform the following actions: Warn, Mute, Unmute, Lock, Unlock, Channelmute, Remove-Channel-Mute", - "de": "Rollen, die folgende Aktionen ausführen können: Warn, Mute, Unmute, Channelmute, Channel-Mute entfernen" - }, + "humanName": "Moderator-Level 2", + "default": [], + "description": "Moderator roles that can perform the following actions: Warn, Mute, Unmute, Lock, Unlock, Channelmute, Remove-Channel-Mute", "type": "array", "content": "roleID", "category": "roles" }, { "name": "moderator-roles_level3", - "humanName": { - "en": "Moderator-Level 3" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Moderator roles that can perform the following actions: Warn, Mute, Unmute, Kick, Clear", - "de": "Rollen, die folgende Aktionen ausführen können: Warn, Mute, Unmute, Kick, Clear" - }, + "humanName": "Moderator-Level 3", + "default": [], + "description": "Moderator roles that can perform the following actions: Warn, Mute, Unmute, Kick, Clear", "type": "array", "content": "roleID", "category": "roles" }, { "name": "moderator-roles_level4", - "humanName": { - "en": "Moderator-Level 4" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Moderator roles that can perform the following actions: Warn, Mute, Unmute, Kick, Clear, Ban, Unban", - "de": "Rollen, die folgende Aktionen ausführen können: Warn, Mute, Unmute, Kick, Clear, Ban, Unban" - }, + "humanName": "Moderator-Level 4", + "default": [], + "description": "Moderator roles that can perform the following actions: Warn, Mute, Unmute, Kick, Clear, Ban, Unban", "type": "array", "content": "roleID", "category": "roles" }, { "name": "roles-to-ping-on-report", - "humanName": { - "de": "Rollenpings bei Report", - "en": "Roles to ping on reports" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Roles that should get pinged in the log-channel when a user reports someone", - "de": "Rollen, die im log-Kanal gepingt werden sollen, wenn ein Nutzer jemanden Reportet" - }, + "humanName": "Roles to ping on reports", + "default": [], + "description": "Roles that should get pinged in the log-channel when a user reports someone", "type": "array", "content": "roleID", "category": "reports" }, { "name": "require_reason", - "humanName": { - "de": "Begründung erzwingen", - "en": "Force moderators to set a reason" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "Should moderators be required to set a reason?", - "de": "Sollen Moderatoren verpflichtet werden, eine Begründung anzugeben?" - }, + "humanName": "Force moderators to set a reason", + "default": true, + "description": "Should moderators be required to set a reason?", "type": "boolean", "category": "reports" }, { "name": "require_proof", - "humanName": { - "de": "Beweis-Bild erzwingen", - "en": "Force moderators to upload proof" - }, + "humanName": "Force moderators to upload proof", "dependsOn": "require_reason", - "default": { - "en": false, - "de": false - }, - "description": { - "en": "Should moderators be required to upload proof for their actions?", - "de": "Sollen Moderatoren verpflichtet werden, einen Beweis hochzuladen?" - }, + "default": false, + "description": "Should moderators be required to upload proof for their actions?", "type": "boolean", "category": "reports" }, { "name": "action_on_invite", - "humanName": { - "de": "Aktion bei Invite", - "en": "Action on invite" - }, - "default": { - "en": "mute", - "de": "mute" - }, - "description": { - "en": "What should the bot do if someone posts an invite link?", - "de": "Was soll der Bot tun, wenn jemand einen Invite sendet?" - }, + "humanName": "Action on invite", + "default": "mute", + "description": "What should the bot do if someone posts an invite link?", "type": "select", "content": [ "none", @@ -232,20 +122,21 @@ ], "category": "automod" }, + { + "name": "allowed_invite_guild_ids", + "humanName": "Allowed invite guild IDs", + "default": [], + "description": "Guild IDs whose invites should be allowed (in addition to this server's invites which are always allowed).", + "type": "array", + "content": "string", + "dependsOn": "action_on_invite", + "category": "automod" + }, { "name": "action_on_scam_link", - "humanName": { - "de": "Aktion bei Scam-Link", - "en": "Action on Scam-Link" - }, - "default": { - "en": "none", - "de": "mute" - }, - "description": { - "en": "What should the bot do if someone posts an suspicious or confirmed scam link?", - "de": "Was soll der Bot tun, wenn jemand einen Link zu einer verdächtigen oder bestätigten Scam-Seite sendet?" - }, + "humanName": "Action on Scam-Link", + "default": "none", + "description": "What should the bot do if someone posts an suspicious or confirmed scam link?", "type": "select", "content": [ "none", @@ -259,18 +150,9 @@ }, { "name": "scam_link_level", - "humanName": { - "de": "Level der Scam-Link-Erkennung", - "en": "Level of Scam-Link-Detection" - }, - "default": { - "en": "confirmed", - "de": "confirmed" - }, - "description": { - "en": "Select the Level of Scam-Link-Filter. \"confirmed\" only contains verified Scam-Domains, while \"suspicious\" may contain not-harmful domains.", - "de": "\"confirmed\" enthält nur Scam-Domains, die wirklich als solche verifiziert wurden, während \"suspicious\" auch nicht-gefährdende Domains beinhalten kann" - }, + "humanName": "Level of Scam-Link-Detection", + "default": "confirmed", + "description": "Select the Level of Scam-Link-Filter. \"confirmed\" only contains verified Scam-Domains, while \"suspicious\" may contain not-harmful domains.", "type": "select", "content": [ "confirmed", @@ -280,72 +162,36 @@ }, { "name": "whitelisted_channels_for_invite_blocking", - "humanName": { - "de": "Erlaubte Kanäle für Invitesperre", - "en": "Whitelisted channels for invite-ban" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Channels or categories where invite blocking is disabled", - "de": "Kanäle oder Kategorien, in welchen die Invitesperre deaktiviert ist" - }, + "humanName": "Whitelisted channels for invite-ban", + "default": [], + "description": "Channels or categories where invite blocking is disabled", "type": "array", "content": "channelID", "category": "automod" }, { "name": "whitelisted_roles_for_invite_blocking", - "humanName": { - "de": "Erlaubte Rollen für Invitesperre", - "en": "Whitelisted roles for invite-ban" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "ID of Roles which are allowed to bypass invite blocking", - "de": "Rollen, welche die Invitesperre umgehen dürfen" - }, + "humanName": "Whitelisted roles for invite-ban", + "default": [], + "description": "ID of Roles which are allowed to bypass invite blocking", "type": "array", "content": "roleID", "category": "automod" }, { "name": "blacklisted_words", - "humanName": { - "de": "Gesperrte Wörter", - "en": "Blacklisted words" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Words that are blacklisted", - "de": "Wörter, die blockiert sind" - }, + "humanName": "Blacklisted words", + "default": [], + "description": "Words that are blacklisted", "type": "array", "content": "string", "category": "automod" }, { "name": "action_on_posting_blacklisted_word", - "humanName": { - "de": "Aktion bei gesperrtem Wort", - "en": "Action on blacklisted Word" - }, - "default": { - "en": "mute", - "de": "mute" - }, - "description": { - "en": "What should the bot do if someone posts a blacklisted word?", - "de": "Was soll der Bot tun, wenn jemand ein gesperrtes Wort sagt?" - }, + "humanName": "Action on blacklisted Word", + "default": "mute", + "description": "What should the bot do if someone posts a blacklisted word?", "type": "select", "content": [ "none", @@ -359,102 +205,55 @@ }, { "name": "defaultMuteDuration", - "humanName": { - "de": "Standardmäßige Mute-Länge", - "en": "Default Mute-Duration" - }, + "humanName": "Default Mute-Duration", "type": "string", - "default": { - "en": "14d" - }, - "description": { - "en": "Default mute duration when none was configured. Will also be used for automod features (e.g. when someone posts a blacklisted word). Maximum value of 28 days.", - "de": "Standardmäßige Mute-Länge, wenn keine eingestellt wurde. Wird auch für Automod-Funktionen verwendet (also wenn z.B. jemand ein gesperrtes Wort postet). Höchstlänge von 28 Tagen." - }, + "default": "14d", + "description": "Default mute duration when none was configured. Will also be used for automod features (e.g. when someone posts a blacklisted word). Maximum value of 28 days.", "category": "actions" }, { "name": "changeNicknames", - "humanName": { - "de": "Nicknamen bei Mute- / Quarantäne ändern", - "en": "Change nicknames on Mute- / Quarantine" - }, - "default": { - "en": false, - "de": false - }, - "description": { - "en": "If enabled, the user will get renamed when they get muted or quarantined", - "de": "Wenn aktiviert, wird der Nutzer umbenannt, wenn er gemutet oder in Quarantäne gesteckt wird" - }, + "humanName": "Change nicknames on Mute- / Quarantine", + "default": false, + "description": "If enabled, the user will get renamed when they get muted or quarantined", "type": "boolean", "category": "nicknames" }, { "name": "changeNicknameOnMute", "dependsOn": "changeNicknames", - "humanName": { - "de": "Neuer Nickname bei Mute", - "en": "New nickname on mute" - }, - "default": { - "en": "%nickname%", - "de": "%nickname%" - }, - "description": { - "en": "The nickname in which the user should be renamed when they get muted", - "de": "Der Nickname, in welchen der Nutzer umbenannt werden soll, wenn er gemuted wird" - }, + "humanName": "New nickname on mute", + "default": "%nickname%", + "description": "The nickname in which the user should be renamed when they get muted", "type": "string", "params": [ { "name": "nickname", - "description": { - "en": "Original nickname of the user" - } + "description": "Original nickname of the user" } ], "category": "nicknames" }, { "name": "changeNicknameOnQuarantine", - "humanName": { - "de": "Nickname während der Quarantäne", - "en": "Nickname during quarantine" - }, + "humanName": "Nickname during quarantine", "dependsOn": "changeNicknames", - "default": { - "en": "%nickname%", - "de": "%nickname%" - }, - "description": { - "en": "The nickname in which the user should be renamed when they get quarantined" - }, + "default": "%nickname%", + "description": "The nickname in which the user should be renamed when they get quarantined", "type": "string", "params": [ { "name": "nickname", - "description": { - "en": "Original nickname of the user" - } + "description": "Original nickname of the user" } ], "category": "nicknames" }, { "name": "automod", - "humanName": { - "de": "Automod", - "en": "Automod" - }, - "default": { - "en": {}, - "de": {} - }, - "description": { - "en": "You can define here what should happen (options: mute, kick, ban, quarantine) when someone gets x warns. Specify duration by writing : after the action.", - "de": "Du kannst hier festlegen, was passieren soll (optionen: mute, kick, ban), wenn jemand x Verwarnungen bekommt. Länge festlegen, indem : hinter die Aktion geschrieben wird." - }, + "humanName": "Automod", + "default": {}, + "description": "You can define here what should happen (options: mute, kick, ban, quarantine) when someone gets x warns. Specify duration by writing : after the action.", "type": "keyed", "content": { "key": "integer", @@ -464,34 +263,18 @@ }, { "name": "warnsExpire", - "humanName": { - "de": "Sollen Warns automatisch gelöscht werden?", - "en": "Should warns be deleted automatically?" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, warns will be deleted automatically after a certain period of time. Warns expired this way will completely disappear and can not be viewed again after they expired.", - "de": "Wenn aktiviert, werden Warns automatisch nach einer bestimmten Zeitspanne gelöscht. Auf diese Weiße abgelaufene Warns werden komplett verschwinden und können nie erneut gesehen werden." - }, + "humanName": "Should warns be deleted automatically?", + "default": false, + "description": "If enabled, warns will be deleted automatically after a certain period of time. Warns expired this way will completely disappear and can not be viewed again after they expired.", "type": "boolean", "category": "actions" }, { "name": "warnExpiration", - "humanName": { - "de": "Zeit, nach der Warns automatisch ablaufen", - "en": "Time after which warns will be automatically removed" - }, - "default": { - "en": "3 months" - }, + "humanName": "Time after which warns will be automatically removed", + "default": "3 months", "dependsOn": "warnsExpire", - "description": { - "en": "Warns will be automatically deleted after this value after it's creation. Please note that this action will delete existing warns if they expired. Enter an english value, such as \"1y\" (= 1 year), \"3 Months\" (= 3 Months) oder \"2w\" (= 2 Weeks).", - "de": "Warnungen werden automatisch gelöscht, wenn sie diese Zeitspanne nach Erstellung erreicht haben. Trage einen englischen Wert, wie \"1y\" (= 1 Jahr), \"3 Months\" (= 3 Monate) oder \"2w\" (= 2 Woche) ein." - }, + "description": "Warns will be automatically deleted after this value after it's creation. Please note that this action will delete existing warns if they expired. Enter an english value, such as \"1y\" (= 1 year), \"3 Months\" (= 3 Months) oder \"2w\" (= 2 Weeks).", "type": "string", "category": "actions" } @@ -500,50 +283,32 @@ { "id": "general", "icon": "fas fa-gears", - "displayName": { - "en": "General Settings", - "de": "Allgemeine Einstellungen" - } + "displayName": "General Settings" }, { "id": "roles", "icon": "fa-solid fa-users", - "displayName": { - "en": "Roles & Permissions", - "de": "Rollen & Berechtigungen" - } + "displayName": "Roles & Permissions" }, { "id": "reports", "icon": "fa-solid fa-flag", - "displayName": { - "en": "Reports", - "de": "Meldungen" - } + "displayName": "Reports" }, { "id": "automod", "icon": "far fa-robot", - "displayName": { - "en": "Auto-Moderation", - "de": "Auto-Moderation" - } + "displayName": "Auto-Moderation" }, { "id": "actions", "icon": "fas fa-hammer", - "displayName": { - "en": "Actions & Punishments", - "de": "Aktionen & Bestrafungen" - } + "displayName": "Actions & Punishments" }, { "id": "nicknames", "icon": "fa-solid fa-user-pen", - "displayName": { - "en": "Nickname Management", - "de": "Nicknamen-Verwaltung" - } + "displayName": "Nickname Management" } ] } \ No newline at end of file diff --git a/modules/moderation/configs/joinGate.json b/modules/moderation/configs/joinGate.json index 95194423..e77776a5 100644 --- a/modules/moderation/configs/joinGate.json +++ b/modules/moderation/configs/joinGate.json @@ -1,62 +1,30 @@ { - "description": { - "en": "This system can prevent suspicious accounts from getting access to your server", - "de": "Dieses System kann verhindern, dass verdächtige Accounts Zugriff erhalten" - }, - "humanName": { - "de": "Join-Gate-Konfiguration", - "en": "Join-Gate-Configuration" - }, + "description": "This system can prevent suspicious accounts from getting access to your server", + "humanName": "Join-Gate-Configuration", "filename": "joinGate.json", "content": [ { "name": "enabled", - "humanName": { - "de": "Aktiviert?", - "en": "Enabled?" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "Enable or disable the join gate", - "de": "Aktiviere oder deaktiviere das Join-Gate" - }, + "humanName": "Enabled?", + "default": true, + "description": "Enable or disable the join gate", "type": "boolean", "elementToggle": true, "category": "general" }, { "name": "allUsers", - "humanName": { - "de": "Alle Nutzer filtern", - "en": "Filter all users" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled all users action against all new users will be taken", - "de": "Wenn aktiviert, werden Aktionen gegen alle neuen Nutzer ausgefüht" - }, + "humanName": "Filter all users", + "default": false, + "description": "If enabled all users action against all new users will be taken", "type": "boolean", "category": "general" }, { "name": "action", - "humanName": { - "de": "Aktion", - "en": "Action" - }, - "default": { - "en": "quarantine", - "de": "quarantine" - }, - "description": { - "en": "Select the action here that should get performed if the join gate gets triggered", - "de": "Wähle hier die Aktion, die ausgeführt werden soll, wenn das Join-Gate ausgelöst wird" - }, + "humanName": "Action", + "default": "quarantine", + "description": "Select the action here that should get performed if the join gate gets triggered", "type": "select", "content": [ "mute", @@ -69,85 +37,41 @@ }, { "name": "roleID", - "humanName": { - "de": "Rolle", - "en": "Role" - }, - "default": { - "en": "" - }, - "description": { - "en": "Only if action = give-role. Role that gets given to users who fail the join gate", - "de": "Nur verfügbar, wenn Aktion = give-role. Rolle, die Nutzern gegeben wird, die das Join-Gate nicht bestehen" - }, + "humanName": "Role", + "default": "", + "description": "Only if action = give-role. Role that gets given to users who fail the join gate", "type": "roleID", "category": "roles" }, { "name": "removeOtherRoles", - "humanName": { - "de": "Andere Rollen entfernen", - "en": "Remove other roles" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "Only if action = give-role. If enabled other roles that have been give to the user get removed after a short interval (and the giving of the role from \"roleID\" will be delayed)", - "de": "Nur verfügbar, wenn Aktion = give-role. Wenn aktiviert, werden andere Rollen die der Nutzer hat nach einem kurzen Zeitraum entfernt (und das Vergeben der Rolle von \"Rolle\" wird verzögert)" - }, + "humanName": "Remove other roles", + "default": true, + "description": "Only if action = give-role. If enabled other roles that have been give to the user get removed after a short interval (and the giving of the role from \"roleID\" will be delayed)", "type": "boolean", "category": "roles" }, { "name": "minAccountAge", - "humanName": { - "de": "Minimales Accountalter", - "en": "Minimum account age" - }, - "default": { - "en": "3", - "de": 3 - }, - "description": { - "en": "Age of the account of a new user that is required to be set to pass the join gate (in days)", - "de": "Alter des Accounts eines neuen Nutzers, der beitritt, welches benötigt wird um das Join-Gate zu bestehen (in Tagen)" - }, + "humanName": "Minimum account age", + "default": 3, + "description": "Age of the account of a new user that is required to be set to pass the join gate (in days)", "type": "integer", "category": "general" }, { "name": "requireProfilePicture", - "humanName": { - "de": "Benötige Profilbild", - "en": "Require profile picture" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "If enabled users are required to have a profile picture set to pass the join gate", - "de": "Wenn aktiviert, brauchen Nutzer ein Profilbild um das Join-Gate zu bestehen" - }, + "humanName": "Require profile picture", + "default": true, + "description": "If enabled users are required to have a profile picture set to pass the join gate", "type": "boolean", "category": "general" }, { "name": "ignoreBots", - "humanName": { - "de": "Ignoriere Bots", - "en": "Ignore bots" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "If enabled bots are allowed to pass the join gate without any restrictions", - "de": "Wenn aktiviert, bestehen Bots das Join-Gate ohne Beschränkungen" - }, + "humanName": "Ignore bots", + "default": true, + "description": "If enabled bots are allowed to pass the join gate without any restrictions", "type": "boolean", "category": "general" } @@ -156,18 +80,12 @@ { "id": "general", "icon": "fas fa-door-open", - "displayName": { - "en": "General Settings", - "de": "Allgemeine Einstellungen" - } + "displayName": "General Settings" }, { "id": "roles", "icon": "fa-solid fa-users", - "displayName": { - "en": "Roles", - "de": "Rollen" - } + "displayName": "Roles" } ] } \ No newline at end of file diff --git a/modules/moderation/configs/lockdown.json b/modules/moderation/configs/lockdown.json index 51b15db9..d0eded22 100644 --- a/modules/moderation/configs/lockdown.json +++ b/modules/moderation/configs/lockdown.json @@ -1,27 +1,13 @@ { - "description": { - "en": "Configure the server-wide lockdown system. This is separate from per-channel lock/unlock commands.", - "de": "Konfiguriere das serverweite Lockdown-System. Dies ist getrennt von den kanalweisen Sperr-/Entsperr-Befehlen." - }, - "humanName": { - "en": "Lockdown Configuration", - "de": "Lockdown-Konfiguration" - }, + "description": "Configure the server-wide lockdown system. This is separate from per-channel lock/unlock commands.", + "humanName": "Lockdown Configuration", "filename": "lockdown.json", "content": [ { "name": "enabled", - "humanName": { - "en": "Enable lockdown system?", - "de": "Lockdown-System aktivieren?" - }, - "default": { - "en": false - }, - "description": { - "en": "Enables the /moderate lockdown command and automatic lockdown triggers", - "de": "Aktiviert den /moderate lockdown Befehl und automatische Lockdown-Auslöser" - }, + "humanName": "Enable lockdown system?", + "default": false, + "description": "Enables the /moderate lockdown command and automatic lockdown triggers", "type": "boolean", "elementToggle": true, "category": "general" @@ -30,34 +16,28 @@ "name": "logChannel", "type": "channelID", "dependsOn": "enabled", - "humanName": { - "en": "Lockdown log channel", - "de": "Lockdown-Log-Kanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "Channel where detailed lockdown log entries are posted. Falls back to the moderation log channel if not set.", - "de": "Kanal, in dem detaillierte Lockdown-Logeinträge gepostet werden. Fällt auf den Moderations-Logkanal zurück, wenn nicht gesetzt." - }, + "humanName": "Lockdown log channel", + "default": "", + "description": "Channel where detailed lockdown log entries are posted. Falls back to the moderation log channel if not set.", "category": "general" }, { "name": "sendMessageInAffectedChannels", "type": "boolean", "dependsOn": "enabled", - "humanName": { - "en": "Send message in affected channels?", - "de": "Nachricht in betroffenen Kanälen senden?" - }, - "default": { - "en": true - }, - "description": { - "en": "If enabled, the lockdown/lift message will be sent in every affected channel", - "de": "Wenn aktiviert, wird die Lockdown-/Aufhebungsnachricht in jedem betroffenen Kanal gesendet" - }, + "humanName": "Send message in affected channels?", + "default": true, + "description": "If enabled, the lockdown/lift message will be sent in every affected channel", + "category": "messages" + }, + { + "name": "lockdownMessageChannels", + "type": "array", + "content": "channelID", + "dependsOn": "sendMessageInAffectedChannels", + "humanName": "Channels for lockdown messages", + "default": [], + "description": "If set, lockdown/lift messages will only be sent in these channels instead of all affected channels. Leave empty to send in all affected channels.", "category": "messages" }, { @@ -65,32 +45,17 @@ "type": "string", "allowEmbed": true, "dependsOn": "sendMessageInAffectedChannels", - "humanName": { - "en": "Lockdown activation message", - "de": "Lockdown-Aktivierungsnachricht" - }, - "description": { - "en": "Message sent in affected channels when lockdown is activated", - "de": "Nachricht, die in betroffenen Kanälen gesendet wird, wenn der Lockdown aktiviert wird" - }, - "default": { - "en": "🔒 **Server Lockdown** - This server is currently in lockdown mode. Reason: %reason%", - "de": "🔒 **Server-Lockdown** - Dieser Server befindet sich im Lockdown-Modus. Grund: %reason%" - }, + "humanName": "Lockdown activation message", + "description": "Message sent in affected channels when lockdown is activated", + "default": "🔒 **Server Lockdown** - This server is currently in lockdown mode. Reason: %reason%", "params": [ { "name": "reason", - "description": { - "en": "Reason for the lockdown", - "de": "Grund für den Lockdown" - } + "description": "Reason for the lockdown" }, { "name": "user", - "description": { - "en": "User who activated the lockdown (or 'System' for automatic)", - "de": "Nutzer, der den Lockdown aktiviert hat (oder 'System' bei automatisch)" - } + "description": "User who activated the lockdown (or 'System' for automatic)" } ], "category": "messages" @@ -100,25 +65,13 @@ "type": "string", "allowEmbed": true, "dependsOn": "sendMessageInAffectedChannels", - "humanName": { - "en": "Lockdown lifted message", - "de": "Lockdown-Aufhebungsnachricht" - }, - "description": { - "en": "Message sent in affected channels when lockdown is lifted", - "de": "Nachricht, die in betroffenen Kanälen gesendet wird, wenn der Lockdown aufgehoben wird" - }, - "default": { - "en": "🔓 **Lockdown Lifted** - The server lockdown has been lifted. You can chat again.", - "de": "🔓 **Lockdown aufgehoben** - Der Server-Lockdown wurde aufgehoben. Ihr könnt wieder schreiben." - }, + "humanName": "Lockdown lifted message", + "description": "Message sent in affected channels when lockdown is lifted", + "default": "🔓 **Lockdown Lifted** - The server lockdown has been lifted. You can chat again.", "params": [ { "name": "user", - "description": { - "en": "User who lifted the lockdown", - "de": "Nutzer, der den Lockdown aufgehoben hat" - } + "description": "User who lifted the lockdown" } ], "category": "messages" @@ -127,68 +80,36 @@ "name": "autoLiftAfter", "type": "integer", "dependsOn": "enabled", - "humanName": { - "en": "Auto-lift lockdown after (minutes, 0 = manual only)", - "de": "Lockdown automatisch aufheben nach (Minuten, 0 = nur manuell)" - }, - "default": { - "en": 0 - }, - "description": { - "en": "Automatically lift the lockdown after this many minutes. Set to 0 to require manual lifting.", - "de": "Den Lockdown nach dieser Anzahl Minuten automatisch aufheben. Auf 0 setzen für nur manuelle Aufhebung." - }, + "humanName": "Auto-lift lockdown after (minutes, 0 = manual only)", + "default": 0, + "description": "Automatically lift the lockdown after this many minutes. Set to 0 to require manual lifting.", "category": "automation" }, { "name": "autoTriggerOnJoinRaid", "type": "boolean", "dependsOn": "enabled", - "humanName": { - "en": "Auto-lockdown on join raid?", - "de": "Automatischer Lockdown bei Join-Raid?" - }, - "default": { - "en": false - }, - "description": { - "en": "Automatically activate lockdown when the anti-join-raid system is triggered", - "de": "Lockdown automatisch aktivieren, wenn das Anti-Join-Raid-System ausgelöst wird" - }, + "humanName": "Auto-lockdown on join raid?", + "default": false, + "description": "Automatically activate lockdown when the anti-join-raid system is triggered", "category": "automation" }, { "name": "autoTriggerOnJoinGate", "type": "boolean", "dependsOn": "enabled", - "humanName": { - "en": "Auto-lockdown on join-gate violations?", - "de": "Automatischer Lockdown bei Join-Gate-Verletzungen?" - }, - "default": { - "en": false - }, - "description": { - "en": "Automatically activate lockdown when the join-gate system is triggered. Thresholds are configured in the Join-Gate configuration.", - "de": "Lockdown automatisch aktivieren, wenn das Join-Gate-System ausgelöst wird. Schwellwerte werden in der Join-Gate-Konfiguration konfiguriert." - }, + "humanName": "Auto-lockdown on join-gate violations?", + "default": false, + "description": "Automatically activate lockdown when the join-gate system is triggered. Thresholds are configured in the Join-Gate configuration.", "category": "automation" }, { "name": "autoTriggerOnSpam", "type": "boolean", "dependsOn": "enabled", - "humanName": { - "en": "Auto-lockdown on spam detection?", - "de": "Automatischer Lockdown bei Spam-Erkennung?" - }, - "default": { - "en": false - }, - "description": { - "en": "Automatically activate lockdown when the anti-spam system is triggered. Thresholds are configured in the Anti-Spam configuration.", - "de": "Lockdown automatisch aktivieren, wenn das Anti-Spam-System ausgelöst wird. Schwellwerte werden in der Anti-Spam-Konfiguration konfiguriert." - }, + "humanName": "Auto-lockdown on spam detection?", + "default": false, + "description": "Automatically activate lockdown when the anti-spam system is triggered. Thresholds are configured in the Anti-Spam configuration.", "category": "automation" } ], @@ -196,26 +117,17 @@ { "id": "general", "icon": "fas fa-gears", - "displayName": { - "en": "General Settings", - "de": "Allgemeine Einstellungen" - } + "displayName": "General Settings" }, { "id": "messages", "icon": "fas fa-comment-dots", - "displayName": { - "en": "Messages", - "de": "Nachrichten" - } + "displayName": "Messages" }, { "id": "automation", "icon": "far fa-robot", - "displayName": { - "en": "Automation", - "de": "Automatisierung" - } + "displayName": "Automation" } ] } \ No newline at end of file diff --git a/modules/moderation/configs/strings.json b/modules/moderation/configs/strings.json index 392255a3..b5841570 100644 --- a/modules/moderation/configs/strings.json +++ b/modules/moderation/configs/strings.json @@ -1,516 +1,347 @@ { - "description": { - "en": "Set up which messages your bot should send", - "de": "Stelle hier ein, welche Nachrichten dein Bot schicken soll" - }, - "humanName": { - "en": "Messages", - "de": "Nachrichten" - }, + "description": "Set up which messages your bot should send", + "humanName": "Messages", "filename": "strings.json", "content": [ { "name": "no_permissions", - "humanName": {}, - "default": { - "en": "You can not do that. You need at least moderator level %required_level% to do this", - "de": "You can not do that. You need at least moderator level %required_level% to do this" - }, - "description": { - "en": "Message that gets send if the user doesn't has the required role and/or has not the required mod-level" - }, + "humanName": "No Permissions", + "default": "You can not do that. You need at least moderator level %required_level% to do this", + "description": "Message that gets send if the user doesn't has the required role and/or has not the required mod-level", "type": "string", "allowEmbed": true, "params": [ { "name": "required_level", - "description": { - "en": "Required mod-level to do this." - } + "description": "Required mod-level to do this." } ], "category": "actions" }, { "name": "user_not_found", - "humanName": {}, - "default": { - "en": "I could not find this user - try using an ID or a mention", - "de": "I could not find this user - try using an ID or a mention" - }, - "description": { - "en": "Message that gets send if the user provided an invalid userid" - }, + "humanName": "User Not Found", + "default": "I could not find this user - try using an ID or a mention", + "description": "Message that gets send if the user provided an invalid userid", "type": "string", "allowEmbed": true, "category": "actions" }, { "name": "missing_reason", - "humanName": {}, - "default": { - "en": "Please specify an reason", - "de": "Please specify an reason" - }, - "description": { - "en": "Message that gets send if the user does not provide a reason and 'require reason' is activated" - }, + "humanName": "Missing Reason", + "default": "Please specify an reason", + "description": "Message that gets send if the user does not provide a reason and 'require reason' is activated", "type": "string", "allowEmbed": true, "category": "errors" }, { "name": "this_is_a_mod", - "humanName": {}, - "default": { - "en": "You can not perform this action on your college.", - "de": "You can not perform this action on your college." - }, - "description": { - "en": "Message that gets send if the user tries to mute another moderator" - }, + "humanName": "Target Is a Moderator", + "default": "You can not perform this action on your college.", + "description": "Message that gets send if the user tries to mute another moderator", "type": "string", "allowEmbed": true, "category": "actions" }, { "name": "submitted-report-message", - "humanName": {}, - "default": { - "en": "Thanks for reporting %user%. I notified our server team and transmitted them an [encrypted snapshot](<%mURL%>) of the current messages in this channel, so they can see what really happened. Please make sure that our bots and staff can message you, so we can ask you follow-up-questions, if needed.", - "de": "Thanks for reporting %user%. I notified our server team and transmitted them an [encrypted snapshot](<%mURL%>) of the current messages in this channel, so they can see what really happened. Please make sure that our bots and staff can message you, so we can ask you follow-up-questions, if needed." - }, - "description": { - "en": "Message that gets send, if someone reports somebody." - }, + "humanName": "Report Submitted", + "default": "Thanks for reporting %user%. I notified our server team and transmitted them an [encrypted snapshot](<%mURL%>) of the current messages in this channel, so they can see what really happened. Please make sure that our bots and staff can message you, so we can ask you follow-up-questions, if needed.", + "description": "Message that gets send, if someone reports somebody.", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the user they reported" - } + "description": "Tag of the user they reported" }, { "name": "mURL", - "description": { - "en": "URL to the message log" - } + "description": "URL to the message log" } ], "category": "actions" }, { "name": "mute_message", - "humanName": {}, - "default": { - "en": "You got muted for **%reason%** by %user%!", - "de": "You got muted for **%reason%** by %user%!" - }, - "description": { - "en": "Message that gets send to a user when they got muted" - }, + "humanName": "Mute Message", + "default": "You got muted for **%reason%** by %user%!", + "description": "Message that gets send to a user when they got muted", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the mute" - } + "description": "Reason of the mute" } ], "category": "actions" }, { "name": "channel_mute", - "humanName": {}, - "default": { - "en": "You got channel-muted from %channel% for **%reason%** by %user%!" - }, - "description": { - "en": "Message that gets send to a user when they got muted" - }, + "humanName": "Channel Mute Message", + "default": "You got channel-muted from %channel% for **%reason%** by %user%!", + "description": "Message that gets send to a user when they got muted", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the mute" - } + "description": "Reason of the mute" }, { "name": "channel", - "description": { - "en": "Channel from which the user got muted" - } + "description": "Channel from which the user got muted" } ], "category": "actions" }, { "name": "remove-channel_mute", - "humanName": {}, - "default": { - "en": "Your channel-mute from %channel% got removed because of **%reason%** by %user%!" - }, - "description": { - "en": "Message that gets send to a user when they got muted" - }, + "humanName": "Channel Unmute Message", + "default": "Your channel-mute from %channel% got removed because of **%reason%** by %user%!", + "description": "Message that gets send to a user when they got muted", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the mute" - } + "description": "Reason of the mute" }, { "name": "channel", - "description": { - "en": "Channel from which the user got unmuted" - } + "description": "Channel from which the user got unmuted" } ], "category": "actions" }, { "name": "tmpmute_message", - "humanName": {}, - "default": { - "en": "You got temporarily muted for **%reason%** by %user%! This action is going to expire on %date%.", - "de": "You got temporarily muted for **%reason%** by %user%! This action is going to expire on %date%." - }, - "description": { - "en": "Message that gets send to a user when they got temporarily muted" - }, + "humanName": "Temporary Mute Message", + "default": "You got temporarily muted for **%reason%** by %user%! This action is going to expire on %date%.", + "description": "Message that gets send to a user when they got temporarily muted", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the mute" - } + "description": "Reason of the mute" }, { "name": "date", - "description": { - "en": "Timestamp when this action expires" - } + "description": "Timestamp when this action expires" } ], "category": "actions" }, { "name": "quarantine_message", - "humanName": {}, - "default": { - "en": "You got quarantined for **%reason%** by %user%!", - "de": "You got quarantined for **%reason%** by %user%!" - }, - "description": { - "en": "Message that gets send to a user when they get quarantined" - }, + "humanName": "Quarantine Message", + "default": "You got quarantined for **%reason%** by %user%!", + "description": "Message that gets send to a user when they get quarantined", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the mute" - } + "description": "Reason of the mute" } ], "category": "actions" }, { "name": "tmpquarantine_message", - "humanName": {}, - "default": { - "en": "You got quarantined temporarily for **%reason%** by %user%! This action is going to expire on %date%", - "de": "You got quarantined temporarily for **%reason%** by %user%! This action is going to expire on %date%" - }, - "description": { - "en": "Message that gets send to a user when they get quarantined" - }, + "humanName": "Temporary Quarantine Message", + "default": "You got quarantined temporarily for **%reason%** by %user%! This action is going to expire on %date%", + "description": "Message that gets send to a user when they get quarantined", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the mute" - } + "description": "Reason of the mute" }, { "name": "date", - "description": { - "en": "Date when the quarantine is going to be removed automatically" - } + "description": "Date when the quarantine is going to be removed automatically" } ], "category": "actions" }, { "name": "unquarantine_message", - "humanName": {}, - "default": { - "en": "You got unquarantined for **%reason%** by %user%!", - "de": "You got unquarantined for **%reason%** by %user%!" - }, - "description": { - "en": "Message that gets send to a user when they get unquarantined" - }, + "humanName": "Unquarantine Message", + "default": "You got unquarantined for **%reason%** by %user%!", + "description": "Message that gets send to a user when they get unquarantined", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the mute" - } + "description": "Reason of the mute" } ], "category": "actions" }, { "name": "unmute_message", - "humanName": {}, - "default": { - "en": "You got unmuted for **%reason%** by %user%!", - "de": "You got unmuted for **%reason%** by %user%!" - }, - "description": { - "en": "Message that gets send to a user when they got unmuted" - }, + "humanName": "Unmute Message", + "default": "You got unmuted for **%reason%** by %user%!", + "description": "Message that gets send to a user when they got unmuted", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the unmute" - } + "description": "Reason of the unmute" } ], "category": "actions" }, { "name": "kick_message", - "humanName": {}, - "default": { - "en": "You got kicked for **%reason%** by %user%!", - "de": "You got kicked for **%reason%** by %user%!" - }, - "description": { - "en": "Message that gets send to a user when they got kicked" - }, + "humanName": "Kick Message", + "default": "You got kicked for **%reason%** by %user%!", + "description": "Message that gets send to a user when they got kicked", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the kick" - } + "description": "Reason of the kick" } ], "category": "actions" }, { "name": "ban_message", - "humanName": {}, - "default": { - "en": "You got banned for **%reason%** by %user%!", - "de": "You got banned for **%reason%** by %user%!" - }, - "description": { - "en": "Message that gets send to a user when they got banned" - }, + "humanName": "Ban Message", + "default": "You got banned for **%reason%** by %user%!", + "description": "Message that gets send to a user when they got banned", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the ban" - } + "description": "Reason of the ban" } ], "category": "actions" }, { "name": "tmpban_message", - "humanName": {}, - "default": { - "en": "You got temporarily banned for **%reason%** by %user%! This action is going to expire on %date%", - "de": "You got temporarily banned for **%reason%** by %user%! This action is going to expire on %date%" - }, - "description": { - "en": "Message that gets send to a user when they got banned temporarily" - }, + "humanName": "Temporary Ban Message", + "default": "You got temporarily banned for **%reason%** by %user%! This action is going to expire on %date%", + "description": "Message that gets send to a user when they got banned temporarily", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the ban" - } + "description": "Reason of the ban" }, { "name": "date", - "description": { - "en": "Date on which the ban expires" - } + "description": "Date on which the ban expires" } ], "category": "actions" }, { "name": "warn_message", - "humanName": {}, - "default": { - "en": "You got warned for **%reason%** by %user%!", - "de": "You got warned for **%reason%** by %user%!" - }, - "description": { - "en": "Message that gets send to a user when they got warned" - }, + "humanName": "Warn Message", + "default": "You got warned for **%reason%** by %user%!", + "description": "Message that gets send to a user when they got warned", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the warn" - } + "description": "Reason of the warn" } ], "category": "actions" }, { "name": "lock_channel_message", - "humanName": {}, - "default": { - "en": "This channel got locked because %reason% by %user%", - "de": "This channel got locked because %reason% by %user%" - }, - "description": { - "en": "Message that gets send in a channel if it gets locked" - }, + "humanName": "Channel Lock Message", + "default": "This channel got locked because %reason% by %user%", + "description": "Message that gets send in a channel if it gets locked", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" }, { "name": "reason", - "description": { - "en": "Reason of the lock" - } + "description": "Reason of the lock" } ], "category": "actions" }, { "name": "unlock_channel_message", - "humanName": {}, - "default": { - "en": "This channel got unlocked by %user%", - "de": "This channel got unlocked by %user%" - }, - "description": { - "en": "Message that gets send in a channel if it gets unlocked" - }, + "humanName": "Channel Unlock Message", + "default": "This channel got unlocked by %user%", + "description": "Message that gets send in a channel if it gets unlocked", "type": "string", "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "Tag of the moderator" - } + "description": "Tag of the moderator" } ], "category": "actions" @@ -520,18 +351,12 @@ { "id": "actions", "icon": "fas fa-hammer", - "displayName": { - "en": "Action Messages", - "de": "Aktionsnachrichten" - } + "displayName": "Action Messages" }, { "id": "errors", "icon": "fa-duotone fa-regular fa-triangle-exclamation", - "displayName": { - "en": "Error Messages", - "de": "Fehlermeldungen" - } + "displayName": "Error Messages" } ] -} \ No newline at end of file +} diff --git a/modules/moderation/configs/verification.json b/modules/moderation/configs/verification.json index bd97f94f..f5a7652b 100644 --- a/modules/moderation/configs/verification.json +++ b/modules/moderation/configs/verification.json @@ -1,137 +1,105 @@ { - "description": { - "en": "Require accounts to verify that they are not a robot before accessing your server", - "de": "Zwinge neue Nutzer zu verifizieren, dass sie kein Roboter sind" - }, - "humanName": { - "de": "Verifikation-Konfiguration", - "en": "Verification-Configuration" - }, + "description": "Require accounts to verify that they are not a robot before accessing your server", + "humanName": "Verification-Configuration", "filename": "verification.json", "content": [ { "name": "enabled", - "humanName": { - "de": "Aktiviert?", - "en": "Enabled?" - }, - "default": { - "en": false - }, - "description": { - "en": "If checked, verification on your server will be enabled", - "de": "Wenn aktiviert, wird Verifikation auf deinem Server aktiviert" - }, + "humanName": "Enabled?", + "default": false, + "description": "If checked, verification on your server will be enabled", "type": "boolean", "elementToggle": true, "category": "general" }, { "name": "verification-needed-role", - "humanName": { - "de": "Rolle für Nutzer, die sich noch verifizieren müssen", - "en": "Role for users with pending verification" - }, - "default": { - "en": "" - }, - "description": { - "en": "Role, which members should be given before they verify themselves", - "de": "Rolle, die Nutzer erhalten, bevor sie sich verifiziert haben" - }, + "humanName": "Role for users with pending verification", + "default": "", + "description": "Role, which members should be given before they verify themselves", "type": "roleID", "allowNull": true, "category": "roles" }, { "name": "verification-passed-role", - "humanName": { - "de": "Rolle für Nutzer mit bestandener Verifikation", - "en": "Role for users that passed verification" - }, - "default": { - "en": "" - }, - "description": { - "en": "Role, which members should be given after they got verified successfully", - "de": "Rolle, die Nutzern gegeben werden soll, wenn sie sich erfolgreich verifiziert haben" - }, + "humanName": "Role for users that passed verification", + "default": "", + "description": "Role, which members should be given after they got verified successfully", "type": "roleID", "allowNull": true, "category": "roles" }, { "name": "verification-log", - "humanName": {}, - "default": { - "en": "Verification-Log", - "de": "Verifikation-Log" - }, - "description": { - "en": "Channel where all verification-actions should get logged", - "de": "Kanal, in welchem alle Verifikation-Aktionen dokumentiert werden sollen" - }, + "humanName": "Verification Log Channel", + "default": "Verification-Log", + "description": "Channel where all verification-actions should get logged", "type": "channelID", "allowNull": true, "category": "general" }, { "name": "type", - "humanName": { - "en": "Type of verification", - "de": "Art der Verifikation" - }, - "default": { - "en": "captcha", - "de": "captcha" - }, - "description": { - "en": "How should the verification process be performed on your server?", - "de": "Wie sollen sich Nutzer verifizieren müssen, wenn sie den Server beitreten?" - }, + "humanName": "Type of verification", + "default": "captcha", + "description": "How should new members verify themselves on your server?", "type": "select", "content": [ - "manual", - "captcha" + { + "displayName": "Image Captcha: distorted image, solved in-channel", + "value": "captcha" + }, + { + "displayName": "Image Captcha (DM): legacy, sent via direct message", + "value": "captcha-dm" + }, + { + "displayName": "Word challenge: retype a displayed word", + "value": "word" + }, + { + "displayName": "Math challenge: solve an arithmetic problem", + "value": "math" + }, + { + "displayName": "Manual: a moderator approves each new member", + "value": "manual" + }, + { + "displayName": "Button click: one click, no challenge", + "value": "button" + } ], "category": "general" }, { "name": "captchaLevel", - "humanName": { - "en": "Difficulty of captcha", - "de": "Schwäre des Captcha" - }, - "default": { - "en": "medium", - "de": "medium" - }, - "description": { - "en": "How difficult should the captcha sent to users be? (only if \"Type of verification\" = \"captcha\")", - "de": "Wie schwer soll das Captcha sein, dass an Nutzer gesendet wird? (Nur wenn \"Art der Verifikation\" = \"captcha\")" - }, + "humanName": "Challenge difficulty", + "default": "medium", + "description": "Difficulty of the verification challenge. Applies to Image Captcha, Image Captcha (DM), Word and Math. Not used for Manual or Button.", "type": "select", "content": [ - "easy", - "medium", - "hard" + { + "displayName": "Easy: short words / small numbers", + "value": "easy" + }, + { + "displayName": "Medium (default)", + "value": "medium" + }, + { + "displayName": "Hard: longer words / larger numbers & multiplication", + "value": "hard" + } ], - "category": "messages" + "category": "general" }, { "name": "actionOnFail", - "humanName": { - "de": "Aktion bei Fehlschlagen der Verifikation", - "en": "Action on failure of verification" - }, - "default": { - "en": "kick", - "de": "kick" - }, - "description": { - "en": "What should happen if someone fails the verification?", - "de": "Was soll passieren, wenn die Verifikation fehlschlägt?" - }, + "humanName": "Action on failure of verification", + "default": "kick", + "description": "What should happen if someone fails the verification?", "type": "select", "content": [ "kick", @@ -142,108 +110,94 @@ "category": "general" }, { - "name": "restart-verification-channel", - "humanName": { - "de": "Verifikation-Neustarten-Kanal", - "en": "Restart Verification-Channel" - }, - "default": { - "en": "" - }, - "description": { - "en": "(optional) Add support for a channel where users can easily restart their verification process (for example if they had DMs disabled when they tried) and get notified if we couldn't reach them", - "de": "(optional) Kanal in welchem Nutzer ganz einfach den Verifikationsprozess neustarten können (zum Beispiel, wenn der Nutzer PNs deaktiviert hat) und benachrichtigt werden, wenn wir sie nicht erreichen konnten" - }, + "name": "verification-channel", + "humanName": "Verification Channel", + "default": "", + "description": "Channel where users can verify themselves by clicking the Verify Me button. For the legacy DM type, this serves as a fallback channel for users with DMs disabled.", "type": "channelID", "allowNull": true, "category": "general" }, + { + "name": "maxRetries", + "humanName": "Maximum verification attempts", + "default": 3, + "description": "How many attempts a user gets before the failure action is applied. Applies to Image Captcha, Image Captcha (DM), Word and Math types.", + "type": "integer", + "category": "general" + }, + { + "name": "retryCooldown", + "humanName": "Cooldown between retries", + "default": "5m", + "description": "How long a user must wait between verification attempts (e.g. 5m, 10m, 1h).", + "type": "string", + "category": "general" + }, + { + "name": "actionOnFailDuration", + "humanName": "Punishment duration", + "default": "1h", + "description": "Duration for mute or quarantine punishment when a user exhausts all verification attempts (e.g. 1h, 1d). Only applies when action on fail is mute or quarantine.", + "type": "string", + "category": "general" + }, + { + "name": "cooldown-message", + "humanName": "Cooldown message", + "default": "⏳ Please wait %t% before trying again.", + "description": "Shown when a user needs to wait before verifying again.", + "type": "string", + "allowEmbed": true, + "category": "messages", + "params": [ + { + "name": "t", + "description": "Discord timestamp showing when the user can try again" + } + ] + }, { "name": "captcha-message", - "humanName": { - "de": "Captcha-Nachricht", - "en": "Captcha-Message" - }, - "default": { - "en": "Welcome! Please verify that you are a human. You have two minutes to complete this.", - "de": "Willkommen! Bitte verifiziere, dass du kein Bot bist. Du hast zwei Minuten, um dies zu tun." - }, - "description": { - "en": "This message gets sent to users who need to complete a captcha", - "de": "Diese Nachricht wird an den Nutzer gesendet, der ein Captcha durchführen muss" - }, + "humanName": "Captcha-Message", + "default": "Welcome! Please verify that you are a human. You have two minutes to complete this.", + "description": "This message gets sent to users who need to complete a captcha", "type": "string", "allowEmbed": true, "category": "messages" }, { "name": "manual-verification-message", - "humanName": { - "en": "Manual-Verification-Message", - "de": "Manuelle-Verifikation-Nachricht" - }, - "default": { - "en": "Welcome! A human will be verifying your account shortly. I will update you if I have any news.", - "de": "Willkommen! Ein Mensch wird deinen Account bald überprüfen und dir Zugriff auf den Server geben, bitte gedulde dich. Ich informiere dich bei Neuigkeiten." - }, - "description": { - "en": "This message gets sent to users who need to get verified manually.", - "de": "Diese Nachricht wird an Nutzer geschickt, die manuell verifiziert werden müssen" - }, + "humanName": "Manual-Verification-Message", + "default": "Welcome! A human will be verifying your account shortly. I will update you if I have any news.", + "description": "This message gets sent to users who need to get verified manually.", "type": "string", "allowEmbed": true, "category": "messages" }, { "name": "captcha-failed-message", - "humanName": { - "de": "Captcha fehlgeschlagen-Nachricht", - "en": "Captcha failed-Message" - }, - "default": { - "en": "It seems like you failed the verification. This is bad, I will have to take moderative actions against you - sorry fellow bot.", - "de": "Es scheint, als hättest du die Verifikation nicht bestanden. Schade, ich werde moderative Maßnahmen gegen dich ergreifen - entschuldige, Roboter." - }, - "description": { - "en": "This message gets sent when a user fails the verification", - "de": "Diese Nachricht wird an Nutzer gesendet, bei denen die Verifikation fehlgeschlagen ist" - }, + "humanName": "Captcha failed-Message", + "default": "It seems like you failed the verification. This is bad, I will have to take moderative actions against you - sorry fellow bot.", + "description": "This message gets sent when a user fails the verification", "type": "string", "allowEmbed": true, "category": "messages" }, { "name": "captcha-succeeded-message", - "humanName": { - "de": "Captcha abgeschlossen-Nachricht", - "en": "Captcha completed-Message" - }, - "default": { - "en": "Thanks! We have verified that you are indeed not a bot, so I granted you access to the whole server! Have fun <3", - "de": "Danke dir! Wir konnten verifizieren, dass du tatsächlich kein Bot bist, also haben wir dir auf den gesamten Server Zugriff gegeben! Viel Spaß <3" - }, - "description": { - "en": "This message gets sent to users when they complete the verification", - "de": "Diese Nachricht wird gesendet, wenn ein Nutzer die Verifikation erfolgreich abgeschlossen hat" - }, + "humanName": "Captcha completed-Message", + "default": "Thanks! We have verified that you are indeed not a bot, so I granted you access to the whole server! Have fun <3", + "description": "This message gets sent to users when they complete the verification", "type": "string", "allowEmbed": true, "category": "messages" }, { "name": "verify-channel-first-message", - "humanName": { - "de": "Verifkations-Kanal-Info-Nachricht", - "en": "Verification-Channel-Info-Message" - }, - "default": { - "en": "Welcome! I have send you a DM about your verification-process. Please read it carefully. If you have DMs disabled, please activate them and click the button below. This step is required to join this server.", - "de": "Willkommen! Ich habe dir eine PN über den Verifikationsprozess. Bitte lese sie dir genau durch. Wenn du PNs deaktiviert hast, aktiviere sie bitte und klicke den Knopf unten. Dieser Schritt ist notwendig, um dem Server beizutreten." - }, - "description": { - "en": "This message is the introduction message in the verify-channel.", - "de": "Das ist die Informations-Nachricht im Verfikationskanal." - }, + "humanName": "Verification-Channel-Info-Message", + "default": "Welcome! Please verify yourself by clicking the button below. This step is required to access this server.", + "description": "This message is the introduction message in the verify-channel.", "type": "string", "allowEmbed": true, "category": "messages" @@ -253,26 +207,17 @@ { "id": "general", "icon": "fa-solid fa-badge-check", - "displayName": { - "en": "General Settings", - "de": "Allgemeine Einstellungen" - } + "displayName": "General Settings" }, { "id": "messages", "icon": "fas fa-comment-dots", - "displayName": { - "en": "Messages", - "de": "Nachrichten" - } + "displayName": "Messages" }, { "id": "roles", "icon": "fa-solid fa-users", - "displayName": { - "en": "Roles", - "de": "Rollen" - } + "displayName": "Roles" } ] -} \ No newline at end of file +} diff --git a/modules/moderation/events/botReady.js b/modules/moderation/events/botReady.js index cff01d6c..3d6d1c78 100644 --- a/modules/moderation/events/botReady.js +++ b/modules/moderation/events/botReady.js @@ -38,8 +38,13 @@ exports.run = async (client) => { await restoreLockdownState(client); const verificationConfig = client.configurations['moderation']['verification']; - if (!verificationConfig.enabled || !verificationConfig['restart-verification-channel']) return; - const channel = await client.channels.fetch(verificationConfig['restart-verification-channel']).catch(() => { + if (!verificationConfig.enabled) return; + + // Support both new and legacy config field name + const channelId = verificationConfig['verification-channel'] || verificationConfig['restart-verification-channel']; + if (!channelId) return; + + const channel = await client.channels.fetch(channelId).catch(() => { }); if (!channel || (channel || {}).type !== ChannelType.GuildText) return client.logger.error('[moderation] ' + localize('moderation', 'verify-channel-set-but-not-found-or-wrong-type')); let message = (await channel.messages.fetch()).filter(msg => msg.author.id === client.user.id).last(); @@ -47,6 +52,8 @@ exports.run = async (client) => { message = await channel.send(localize('moderation', 'generating-message')); await message.pin(); } + + const isLegacyDM = verificationConfig.type === 'captcha-dm'; await message.edit(embedType(verificationConfig['verify-channel-first-message'], {}, { components: [ { @@ -54,8 +61,8 @@ exports.run = async (client) => { components: [ { type: 'BUTTON', - label: '📨 ' + localize('moderation', 'restart-verification-button'), - customId: `mod-rvp`, + label: isLegacyDM ? ('📨 ' + localize('moderation', 'restart-verification-button')) : ('✅ ' + localize('moderation', 'verify-me-button')), + customId: isLegacyDM ? 'mod-rvp' : 'mod-verify', style: 'PRIMARY' } ] diff --git a/modules/moderation/events/guildMemberAdd.js b/modules/moderation/events/guildMemberAdd.js index 44172322..cc16286f 100644 --- a/modules/moderation/events/guildMemberAdd.js +++ b/modules/moderation/events/guildMemberAdd.js @@ -3,10 +3,11 @@ const {moderationAction} = require('../moderationActions'); const {activateLockdown, isLockdownActive} = require('../lockdown'); const {localize} = require('../../../src/functions/localize'); const {embedType} = require('../../../src/functions/helpers'); -const {ChannelType, MessageAttachment} = require('discord.js'); +const {ChannelType, AttachmentBuilder} = require('discord.js'); const {client} = require('../../../main'); let joinCache = []; +let raidActionInProgress = false; module.exports.run = async (client, guildMember) => { if (guildMember.guild.id !== client.config.guildID) return; @@ -30,7 +31,7 @@ module.exports.run = async (client, guildMember) => { joinCache = joinCache.filter(e => e.id !== guildMember.user.id && e.timestamp !== timestamp); }, antiJoinRaidConfig.timeframe * 60000); - if (joinCache.length >= antiJoinRaidConfig.maxJoinsInTimeframe) await performJoinRaidAction(); + if (joinCache.length >= antiJoinRaidConfig.maxJoinsInTimeframe && !raidActionInProgress) await performJoinRaidAction(); /** * Performs anti-join-raid actions @@ -38,6 +39,7 @@ module.exports.run = async (client, guildMember) => { * @return {Promise} */ async function performJoinRaidAction() { + raidActionInProgress = true; for (const join of joinCache.filter(j => j.id !== guildMember.user.id)) { const member = await guildMember.guild.members.fetch(join.id).catch(() => { }); @@ -67,6 +69,10 @@ module.exports.run = async (client, guildMember) => { if (lockdownConfig && lockdownConfig.enabled && lockdownConfig.autoTriggerOnJoinRaid && !await isLockdownActive(client)) { await activateLockdown(client, localize('moderation', 'lockdown-joinraid-trigger'), localize('moderation', 'lockdown-system'), true); } + joinCache = []; + setTimeout(() => { + raidActionInProgress = false; + }, 30000); } } @@ -79,68 +85,38 @@ module.exports.run = async (client, guildMember) => { if (verificationConfig.enabled) { if (guildMember.user.bot) return; if (verificationConfig['verification-needed-role'].length !== 0) await guildMember.roles.add(verificationConfig['verification-needed-role'], '[moderation] ' + localize('moderation', 'verification-started')); - await sendDMPart(verificationConfig, guildMember).catch(() => dmFail()); - /** - * Sends a backup message for users who have their dms disabled - * @private - * @returns {Promise} - */ - async function dmFail() { - const channel = await client.channels.fetch(verificationConfig['restart-verification-channel'] || '').catch(() => { - }); - if (!channel || (channel || {}).type !== ChannelType.GuildText) return client.logger.error('[moderation] ' + localize('moderation', 'verify-channel-set-but-not-found-or-wrong-type')); - const m = await channel.send({ - content: localize('moderation', 'dms-not-enabled-ping', {p: guildMember.toString()}), + // Only send DMs for legacy captcha-dm type + if (verificationConfig.type === 'captcha-dm') { + await sendDMPart(verificationConfig, guildMember).catch(() => dmFail()); - components: [ - { - type: 'ACTION_ROW', - components: [ - { - type: 'BUTTON', - label: '📨 ' + localize('moderation', 'restart-verification-button'), - customId: `mod-rvp`, - style: 'PRIMARY' - } - ] - } - ] - } - ); - setTimeout(() => { - m.delete().then(() => { + async function dmFail() { + const channel = await client.channels.fetch(verificationConfig['verification-channel'] || verificationConfig['restart-verification-channel'] || '').catch(() => { }); - }, 300000); - } - - if (guildMember.guild.channels.cache.get(verificationConfig['verification-log']) && verificationConfig.type === 'manual') { - await guildMember.guild.channels.cache.get(verificationConfig['verification-log']).send({ - embeds: [{ - title: localize('moderation', 'verification'), - color: 'GREEN', - description: `${localize('moderation', 'user')}: ${guildMember.toString()} (\`${guildMember.user.id}\`)\n${localize('moderation', 'manual-verification-needed')}` - }], - components: [ - { - type: 'ACTION_ROW', + if (!channel || (channel || {}).type !== ChannelType.GuildText) return client.logger.error('[moderation] ' + localize('moderation', 'verify-channel-set-but-not-found-or-wrong-type')); + const m = await channel.send({ + content: localize('moderation', 'dms-not-enabled-ping', {p: guildMember.toString()}), components: [ { - type: 'BUTTON', - label: '❌ ' + localize('moderation', 'verification-deny'), - customId: `mod-ver-d-${guildMember.user.id}`, - style: 'DANGER' - }, - { - type: 'BUTTON', - label: '✅ ' + localize('moderation', 'verification-approve'), - customId: `mod-ver-p-${guildMember.user.id}`, - style: 'SUCCESS' + type: 'ACTION_ROW', + components: [ + { + type: 'BUTTON', + label: '📨 ' + localize('moderation', 'restart-verification-button'), + customId: `mod-rvp`, + style: 'PRIMARY' + } + ] } ] } - ] - }); + ); + setTimeout(() => { + m.delete().then(() => { + }); + }, 300000); + } + } } @@ -155,7 +131,7 @@ async function runJoinGate(guildMember) { const joinGateConfig = client.configurations['moderation']['joinGate']; if (guildMember.user.bot && joinGateConfig.ignoreBots) return; if (joinGateConfig.allUsers) return performJoinGateAction(localize('moderation', 'joingate-for-everyone')); - const daysSinceCreation = (new Date().getTime() / 86400000).toFixed(0) - (guildMember.user.createdTimestamp / 86400000).toFixed(0); + const daysSinceCreation = Math.floor((Date.now() - guildMember.user.createdTimestamp) / 86400000); if (daysSinceCreation <= joinGateConfig.minAccountAge) return performJoinGateAction(localize('moderation', 'account-age-to-low', { a: daysSinceCreation, c: joinGateConfig.minAccountAge @@ -200,66 +176,74 @@ module.exports.runJoinGate = runJoinGate; async function sendDMPart(verificationConfig, guildMember) { return new Promise(async (resolve, reject) => { try { - if (verificationConfig.type === 'manual') await guildMember.user.send(embedType(verificationConfig['manual-verification-message'], {})); - else { - if (!guildMember.client.scnxSetup) return guildMember.client.logger.error('[moderation] Captcha Generation is only available if your bot has an SCNX Integration set up.'); - const captcha = await require('../../../src/functions/scnx-integration').generateCaptcha(verificationConfig.captchaLevel); - await guildMember.user.send(embedType(verificationConfig['captcha-message'], {}, { - files: [new MessageAttachment(captcha.buffer, {name: 'you-call-it-captcha-we-call-it-ai-training.png'})] - })); - const c = await guildMember.user.createDM(); - const col = c.createMessageCollector({time: 120000}); - let p = false; - let d = null; - if (guildMember.guild.channels.cache.get(verificationConfig['verification-log'])) { - d = await guildMember.guild.channels.cache.get(verificationConfig['verification-log']).send({ - embeds: [{ - title: localize('moderation', 'verification'), - color: 'GREEN', - description: `${localize('moderation', 'user')}: ${guildMember.toString()} (\`${guildMember.user.id}\`)\n${localize('moderation', 'captcha-verification-pending')}` - }], - components: [ - { - type: 'ACTION_ROW', - components: [ - { - type: 'BUTTON', - label: '⏭️ ' + localize('moderation', 'verification-skip'), - customId: `mod-ver-p-${guildMember.user.id}`, - style: 'SECONDARY' - } - ] - } - ] - }); - const coli = d.createMessageComponentCollector({time: 120000}); - coli.on('collect', () => { - p = true; - }); - coli.on('end', () => { - d.delete(); - }); - } - col.on('collect', (m) => { - if (m.author.id === guildMember.user.id && !p) { - p = true; - if (m.content.toUpperCase() === captcha.solution.toUpperCase()) verificationPassed(guildMember); - else { - client.logger.log(`${guildMember.user.id} failed verification. Entered: "${m.content.toUpperCase()}", expected: "${captcha.solution.toUpperCase()}"`); - verificationFail(guildMember); + if (!guildMember.client.scnxSetup) return guildMember.client.logger.error('[moderation] Captcha Generation is only available if your bot has an SCNX Integration set up.'); + const captcha = await require('../../../src/functions/scnx-integration').generateCaptcha(verificationConfig.captchaLevel); + await guildMember.user.send(embedType(verificationConfig['captcha-message'], {}, { + files: [new AttachmentBuilder(captcha.buffer, {name: 'you-call-it-captcha-we-call-it-ai-training.png'})] + })); + const c = await guildMember.user.createDM(); + const col = c.createMessageCollector({time: 120000}); + let p = false; + let d = null; + let dDeleted = false; + if (guildMember.guild.channels.cache.get(verificationConfig['verification-log'])) { + d = await guildMember.guild.channels.cache.get(verificationConfig['verification-log']).send({ + embeds: [{ + title: localize('moderation', 'verification'), + color: 'GREEN', + description: `${localize('moderation', 'user')}: ${guildMember.toString()} (\`${guildMember.user.id}\`)\n${localize('moderation', 'captcha-verification-pending')}` + }], + components: [ + { + type: 'ACTION_ROW', + components: [ + { + type: 'BUTTON', + label: '⏭️ ' + localize('moderation', 'verification-skip'), + customId: `mod-ver-skip-${guildMember.user.id}`, + style: 'SECONDARY' + } + ] } - if (d && !d.deleted) d.delete().catch(() => { + ] + }); + const coli = d.createMessageComponentCollector({time: 120000}); + coli.on('collect', () => { + p = true; + }); + coli.on('end', () => { + if (!dDeleted) { + dDeleted = true; + d.delete().catch(() => { }); } }); - col.on('end', () => { - if (!p) { + } + col.on('collect', (m) => { + if (m.author.id === guildMember.user.id && !p) { + p = true; + if (m.content.toUpperCase() === captcha.solution.toUpperCase()) verificationPassed(guildMember); + else { + client.logger.log(`${guildMember.user.id} failed verification. Entered: "${m.content.toUpperCase()}", expected: "${captcha.solution.toUpperCase()}"`); verificationFail(guildMember); - if (d && !d.deleted) d.delete().catch(() => { + } + if (d && !dDeleted) { + dDeleted = true; + d.delete().catch(() => { }); } - }); - } + } + }); + col.on('end', () => { + if (!p) { + verificationFail(guildMember); + if (d && !dDeleted) { + dDeleted = true; + d.delete().catch(() => { + }); + } + } + }); resolve(); } catch (e) { reject(e); @@ -275,12 +259,20 @@ module.exports.sendDMPart = sendDMPart; * @param {GuildMember} guildMember Member who passed the verification * @returns {Promise} */ -async function verificationPassed(guildMember) { +async function verificationPassed(guildMember, interaction = null) { const verificationConfig = guildMember.client.configurations['moderation']['verification']; if (verificationConfig['verification-needed-role'].length !== 0) await guildMember.roles.remove(verificationConfig['verification-needed-role'], '[' + localize('moderation', 'verification') + '] ' + localize('moderation', 'verification-completed')); if (verificationConfig['verification-passed-role'].length !== 0) await guildMember.roles.add(verificationConfig['verification-passed-role'], '[' + localize('moderation', 'verification') + '] ' + localize('moderation', 'verification-completed')); - await guildMember.user.send(embedType(verificationConfig['captcha-succeeded-message'])).catch(() => { - }); + if (interaction) { + await interaction.followUp({ + ...embedType(verificationConfig['captcha-succeeded-message']), + ephemeral: true + }).catch(() => { + }); + } else { + await guildMember.user.send(embedType(verificationConfig['captcha-succeeded-message'])).catch(() => { + }); + } if (guildMember.guild.channels.cache.get(verificationConfig['verification-log'])) await guildMember.guild.channels.cache.get(verificationConfig['verification-log']).send({ embeds: [{ title: localize('moderation', 'verification'), @@ -298,17 +290,31 @@ module.exports.verificationPassed = verificationPassed; * @param {GuildMember} guildMember Member who failed verification * @returns {Promise} */ -async function verificationFail(guildMember) { +async function verificationFail(guildMember, interaction = null) { const verificationConfig = guildMember.client.configurations['moderation']['verification']; - await guildMember.user.send(embedType(verificationConfig['captcha-failed-message'])); - await moderationAction(guildMember.client, verificationConfig.actionOnFail, guildMember.guild.me, guildMember, '[' + localize('moderation', 'verification') + '] ' + localize('moderation', 'verification-failed')); + if (interaction) { + await interaction.followUp({ + ...embedType(verificationConfig['captcha-failed-message']), + ephemeral: true + }).catch(() => { + }); + } else { + await guildMember.user.send(embedType(verificationConfig['captcha-failed-message'])).catch(() => { + }); + } + const durationParser = require('parse-duration'); + let expiresAt = null; + if (['mute', 'quarantine'].includes(verificationConfig.actionOnFail) && verificationConfig.actionOnFailDuration) { + expiresAt = new Date(new Date().getTime() + durationParser(verificationConfig.actionOnFailDuration)); + } + await moderationAction(guildMember.client, verificationConfig.actionOnFail, guildMember.guild.members.me, guildMember, '[' + localize('moderation', 'verification') + '] ' + localize('moderation', 'verification-failed'), {}, expiresAt); if (guildMember.guild.channels.cache.get(verificationConfig['verification-log'])) await guildMember.guild.channels.cache.get(verificationConfig['verification-log']).send({ embeds: [{ title: localize('moderation', 'verification'), - color: 'GREEN', + color: 'RED', description: `${localize('moderation', 'user')}: ${guildMember.toString()} (\`${guildMember.user.id}\`)\n${localize('moderation', 'verification-failed')}` }] }); } -module.exports.verificationFail = verificationFail; +module.exports.verificationFail = verificationFail; \ No newline at end of file diff --git a/modules/moderation/events/guildMemberUpdate.js b/modules/moderation/events/guildMemberUpdate.js index 78aaa529..f2a30123 100644 --- a/modules/moderation/events/guildMemberUpdate.js +++ b/modules/moderation/events/guildMemberUpdate.js @@ -2,9 +2,8 @@ const {runJoinGate} = require('./guildMemberAdd'); module.exports.run = async function (client, oldGuildMember, newGuildMember) { if (!client.botReadyAt) return; const joinGateConfig = client.configurations['moderation']['joinGate']; - const verificationConfig = client.configurations['moderation']['verification']; if (oldGuildMember.pending && !newGuildMember.pending && joinGateConfig.enabled && !['kick', 'ban'].includes(joinGateConfig.action)) { await runJoinGate(newGuildMember); } -}; \ No newline at end of file +}; diff --git a/modules/moderation/events/interactionCreate.js b/modules/moderation/events/interactionCreate.js index 2604fc38..9a6cb4f4 100644 --- a/modules/moderation/events/interactionCreate.js +++ b/modules/moderation/events/interactionCreate.js @@ -1,10 +1,82 @@ const {verificationPassed, verificationFail, sendDMPart} = require('./guildMemberAdd'); const {localize} = require('../../../src/functions/localize'); +const {ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle, AttachmentBuilder} = require('discord.js'); +const {embedType} = require('../../../src/functions/helpers'); +const durationParser = require('parse-duration'); + +// In-memory captcha solutions: userId -> { solution, expiresAt } +const pendingCaptchas = new Map(); + +// Cooldown for captcha image generation: userId -> timestamp of last generation +const captchaGenerationCooldowns = new Map(); +const CAPTCHA_GENERATION_COOLDOWN_MS = 60000; // 1 minute + +// Clean up expired captchas and cooldowns every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [userId, data] of pendingCaptchas) { + if (now > data.expiresAt) pendingCaptchas.delete(userId); + } + for (const [userId, timestamp] of captchaGenerationCooldowns) { + if (now - timestamp > 600000) captchaGenerationCooldowns.delete(userId); // cleanup after 10 min max + } +}, 300000); + +const WORD_LIST_EASY = ['RAIN', 'MOON', 'STAR', 'WOLF', 'TREE', 'FIRE', 'GOLD', 'SNOW', 'LAKE', 'ROCK', + 'LEAF', 'BIRD', 'BOOK', 'DOOR', 'RING', 'BLUE', 'CAKE', 'CORN', 'DUST', 'WAVE']; + +const WORD_LIST_MEDIUM = ['BRIDGE', 'CASTLE', 'FLOWER', 'GUITAR', 'HARBOR', 'ISLAND', 'JUNGLE', 'KNIGHT', 'LEMON', 'MARBLE', + 'NEEDLE', 'ORANGE', 'PENCIL', 'QUARTZ', 'RABBIT', 'SILVER', 'TURTLE', 'VELVET', 'WALNUT', 'ZENITH', + 'ANCHOR', 'BREEZE', 'CANDLE', 'DESERT', 'EAGLE', 'FOREST', 'GLOBAL', 'HAMMER', 'IVORY', 'JACKET', + 'KITTEN', 'MIRROR', 'NECTAR', 'OYSTER', 'PLANET', 'RAVEN', 'SUNSET', 'THRONE', 'PEARL', 'COMET', + 'TIGER', 'CLOUD', 'PRISM', 'BLAZE', 'FROST', 'DELTA', 'OCEAN', 'STONE', 'VAPOR', 'CEDAR']; + +const WORD_LIST_HARD = ['THUNDER', 'HORIZON', 'MYSTERY', 'JOURNEY', 'PROPHET', 'VOYAGER', 'PYRAMID', 'ECLIPSE', + 'COMPASS', 'LAGOON', 'ARCHERY', 'TWILIGHT', 'PARADISE', 'MONARCHY', 'LABYRINTH', 'ALCHEMY', + 'CHEMISTRY', 'OCTOBER', 'CATHEDRAL', 'ORCHESTRA']; + +function generateSimpleChallenge(type, difficulty) { + const level = ['easy', 'medium', 'hard'].includes(difficulty) ? difficulty : 'medium'; + if (type === 'math') { + let a, b, op, answer; + if (level === 'easy') { + a = Math.floor(Math.random() * 10) + 1; + b = Math.floor(Math.random() * 10) + 1; + op = Math.random() < 0.5 ? '+' : '-'; + answer = op === '+' ? a + b : a - b; + } else if (level === 'hard') { + const ops = ['+', '-', '×']; + op = ops[Math.floor(Math.random() * ops.length)]; + if (op === '×') { + a = Math.floor(Math.random() * 12) + 1; + b = Math.floor(Math.random() * 12) + 1; + answer = a * b; + } else { + a = Math.floor(Math.random() * 100) + 1; + b = Math.floor(Math.random() * 100) + 1; + answer = op === '+' ? a + b : a - b; + } + } else { + // medium — current behaviour + a = Math.floor(Math.random() * 50) + 1; + b = Math.floor(Math.random() * 50) + 1; + op = Math.random() < 0.5 ? '+' : '-'; + answer = op === '+' ? a + b : a - b; + } + return {question: localize('moderation', 'simple-math-challenge', {a, op, b}), answer: String(answer)}; + } + // word + const list = level === 'easy' ? WORD_LIST_EASY : level === 'hard' ? WORD_LIST_HARD : WORD_LIST_MEDIUM; + const word = list[Math.floor(Math.random() * list.length)]; + return {question: localize('moderation', 'simple-word-challenge', {w: word}), answer: word}; +} module.exports.run = async (client, interaction) => { - if (!interaction.isMessageComponent()) return; + if (!interaction.isMessageComponent() && !interaction.isModalSubmit()) return; + const verificationConfig = client.configurations['moderation']['verification']; + + // === Legacy DM restart button (captcha-dm type) === if (interaction.customId === 'mod-rvp') { - const verificationConfig = client.configurations['moderation']['verification']; if (interaction.member.roles.cache.filter(r => verificationConfig['verification-passed-role'].includes(r.id)).size !== 0) return interaction.reply({ ephemeral: true, content: '⚠️ ' + localize('moderation', 'already-verified') @@ -20,18 +92,300 @@ module.exports.run = async (client, interaction) => { content: '⚠️ ' + localize('moderation', 'dms-still-disabled', {g: interaction.member.guild.name}) }); }); + return; + } + + // === New "Verify Me" button === + if (interaction.customId === 'mod-verify') { + // Already verified? + if (verificationConfig['verification-passed-role'] && interaction.member.roles.cache.filter(r => verificationConfig['verification-passed-role'].includes(r.id)).size !== 0) { + return interaction.reply({ephemeral: true, content: '⚠️ ' + localize('moderation', 'already-verified')}); + } + + const VerificationRequest = client.models['moderation']['VerificationRequest']; + let request = await VerificationRequest.findOne({ + where: {userID: interaction.user.id}, + order: [['createdAt', 'DESC']] + }); + + // Check cooldown and retries (for captcha / captcha-dm / word / math) + if (['captcha', 'captcha-dm', 'word', 'math'].includes(verificationConfig.type)) { + if (!request || request.status === 'approved') { + request = await VerificationRequest.create({ + userID: interaction.user.id, + type: verificationConfig.type + }); + } + + // Check max retries — re-execute punishment if somehow missed + const maxRetries = verificationConfig.maxRetries || 3; + if (request.attempts >= maxRetries) { + if (request.status !== 'denied') { + await request.update({status: 'denied'}); + await interaction.deferReply({ephemeral: true}); + await verificationFail(interaction.member, interaction); + return; + } + return interaction.reply({ + ephemeral: true, + content: '⚠️ ' + localize('moderation', 'retries-exhausted') + }); + } + + // Check cooldown + if (request.lastAttemptAt) { + const cooldown = durationParser(verificationConfig.retryCooldown || '5m'); + const lastAttemptTime = new Date(request.lastAttemptAt).getTime(); + const elapsed = Date.now() - lastAttemptTime; + if (elapsed < cooldown) { + const readyAt = Math.ceil((lastAttemptTime + cooldown) / 1000); + return interaction.reply(embedType(verificationConfig['cooldown-message'] || localize('moderation', 'cooldown-message'), {'%t%': ``}, {ephemeral: true})); + } + } + } + + // === Captcha type: send ephemeral with image === + if (verificationConfig.type === 'captcha') { + // Cooldown to prevent captcha image generation spam + const lastGeneration = captchaGenerationCooldowns.get(interaction.user.id); + if (lastGeneration) { + const elapsed = Date.now() - lastGeneration; + if (elapsed < CAPTCHA_GENERATION_COOLDOWN_MS) { + const readyAt = Math.ceil((lastGeneration + CAPTCHA_GENERATION_COOLDOWN_MS) / 1000); + return interaction.reply(embedType(verificationConfig['cooldown-message'] || localize('moderation', 'cooldown-message'), {'%t%': ``}, {ephemeral: true})); + } + } + + await interaction.deferReply({ephemeral: true}); + if (!client.scnxSetup) return interaction.editReply({content: '⚠️ Captcha generation is not available.'}); + const captcha = await require('../../../src/functions/scnx-integration').generateCaptcha(verificationConfig.captchaLevel); + captchaGenerationCooldowns.set(interaction.user.id, Date.now()); + + pendingCaptchas.set(interaction.user.id, { + solution: captcha.solution, + expiresAt: Date.now() + 300000 // 5 minutes + }); + + await interaction.editReply({ + ...embedType(verificationConfig['captcha-message'] || localize('moderation', 'captcha-verification-pending')), + files: [new AttachmentBuilder(captcha.buffer, {name: 'captcha.png'})], + components: [ + { + type: 1, // ACTION_ROW + components: [ + { + type: 2, // BUTTON + label: '🔑 ' + localize('moderation', 'enter-solution-button'), + customId: 'mod-captcha-solve', + style: 1 // PRIMARY + } + ] + } + ] + }); + return; + } + + // === Word / Math type: open modal directly === + if (verificationConfig.type === 'word' || verificationConfig.type === 'math') { + const challenge = generateSimpleChallenge(verificationConfig.type, verificationConfig.captchaLevel); + + pendingCaptchas.set(interaction.user.id, { + solution: challenge.answer, + expiresAt: Date.now() + 300000 + }); + + const modal = new ModalBuilder() + .setCustomId('mod-simple-modal') + .setTitle(localize('moderation', 'verification-modal-title')) + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('answer') + .setLabel(challenge.question) + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setPlaceholder(localize('moderation', 'simple-solution-label')) + ) + ); + await interaction.showModal(modal); + return; + } + + // === Manual type: submit for review === + if (verificationConfig.type === 'manual') { + if (request && request.type === 'manual' && request.status === 'pending') { + return interaction.reply({ + ephemeral: true, + content: '⏳ ' + localize('moderation', 'already-pending-review') + }); + } + + if (!request || request.status === 'denied') { + request = await VerificationRequest.create({userID: interaction.user.id, type: 'manual'}); + } + + await interaction.reply({ephemeral: true, content: localize('moderation', 'verification-submitted')}); + + // Post approve/deny in log channel + const logChannel = interaction.guild.channels.cache.get(verificationConfig['verification-log']); + if (logChannel) { + const logMsg = await logChannel.send({ + embeds: [{ + title: localize('moderation', 'verification'), + color: 0x57F287, // GREEN + description: `${localize('moderation', 'user')}: ${interaction.member.toString()} (\`${interaction.user.id}\`)\n${localize('moderation', 'manual-verification-needed')}` + }], + components: [ + { + type: 1, + components: [ + { + type: 2, + label: '❌ ' + localize('moderation', 'verification-deny'), + customId: `mod-ver-d-${interaction.user.id}`, + style: 4 // DANGER + }, + { + type: 2, + label: '✅ ' + localize('moderation', 'verification-approve'), + customId: `mod-ver-p-${interaction.user.id}`, + style: 3 // SUCCESS + } + ] + } + ] + }); + await request.update({logMessageID: logMsg.id}); + } + return; + } + + // === Button type: one click, no challenge === + if (verificationConfig.type === 'button') { + await verificationPassed(interaction.member, interaction); + return; + } + + return; } + + // === "Enter Solution" button for captcha type === + if (interaction.customId === 'mod-captcha-solve') { + const pending = pendingCaptchas.get(interaction.user.id); + if (!pending || Date.now() > pending.expiresAt) { + pendingCaptchas.delete(interaction.user.id); + return interaction.reply({ephemeral: true, content: '⚠️ ' + localize('moderation', 'captcha-expired')}); + } + + const modal = new ModalBuilder() + .setCustomId('mod-captcha-modal') + .setTitle(localize('moderation', 'verification-modal-title')) + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('answer') + .setLabel(localize('moderation', 'captcha-solution-label')) + .setStyle(TextInputStyle.Short) + .setRequired(true) + ) + ); + await interaction.showModal(modal); + return; + } + + // === Modal submit for captcha === + if (interaction.customId === 'mod-captcha-modal') { + await handleVerificationModalSubmit(client, interaction, verificationConfig); + return; + } + + // === Modal submit for simple === + if (interaction.customId === 'mod-simple-modal') { + await handleVerificationModalSubmit(client, interaction, verificationConfig); + return; + } + + // === Manual approve/deny buttons === if (!interaction.customId.startsWith('mod-ver-')) return; - interaction.customId = interaction.customId.replaceAll('mod-ver-', ''); - const a = interaction.customId.split('-')[0]; - const id = interaction.customId.split('-')[1]; - const member = await interaction.guild.members.fetch(id).catch(() => {}); + const parsedId = interaction.customId.replace('mod-ver-', ''); + const action = parsedId.split('-')[0]; + const userId = parsedId.split('-')[1]; + const member = await interaction.guild.members.fetch(userId).catch(() => { + }); if (!member) return interaction.reply({ ephemeral: true, content: '⚠️ ' + localize('moderation', 'member-not-found') }); - if (a === 'p') await verificationPassed(member); + + // Update VerificationRequest record + const VerificationRequest = client.models['moderation']['VerificationRequest']; + const request = await VerificationRequest.findOne({where: {userID: userId, status: 'pending'}}); + if (request) await request.update({status: action === 'p' ? 'approved' : 'denied'}); + + if (action === 'p') await verificationPassed(member); else await verificationFail(member); await interaction.message.edit({embeds: interaction.message.embeds, components: []}); - interaction.reply({ephemeral: true, content: localize('moderation', 'verification-update-proceeded')}); -}; \ No newline at end of file + await interaction.reply({ephemeral: true, content: localize('moderation', 'verification-update-proceeded')}); +}; + +async function handleVerificationModalSubmit(client, interaction, verificationConfig) { + const answer = interaction.fields.getTextInputValue('answer').trim(); + const pending = pendingCaptchas.get(interaction.user.id); + + if (!pending || Date.now() > pending.expiresAt) { + pendingCaptchas.delete(interaction.user.id); + return interaction.reply({ephemeral: true, content: '⚠️ ' + localize('moderation', 'captcha-expired')}); + } + + const VerificationRequest = client.models['moderation']['VerificationRequest']; + let request = await VerificationRequest.findOne({where: {userID: interaction.user.id, status: 'pending'}}); + if (!request) { + const denied = await VerificationRequest.findOne({ + where: {userID: interaction.user.id, status: 'denied'}, + order: [['createdAt', 'DESC']] + }); + if (denied) { + const maxRetries = verificationConfig.maxRetries || 3; + if (denied.attempts >= maxRetries) { + return interaction.reply({ + ephemeral: true, + content: '⚠️ ' + localize('moderation', 'retries-exhausted') + }); + } + request = denied; + await request.update({status: 'pending'}); + } else { + request = await VerificationRequest.create({userID: interaction.user.id, type: verificationConfig.type}); + } + } + + const isCorrect = answer.toUpperCase() === pending.solution.toUpperCase(); + pendingCaptchas.delete(interaction.user.id); + + if (isCorrect) { + await request.update({status: 'approved'}); + await interaction.deferReply({ephemeral: true}); + await verificationPassed(interaction.member, interaction); + return; + } + + // Wrong answer + const attempts = request.attempts + 1; + await request.update({attempts, lastAttemptAt: new Date()}); + + const maxRetries = verificationConfig.maxRetries || 3; + if (attempts >= maxRetries) { + await request.update({status: 'denied'}); + await interaction.deferReply({ephemeral: true}); + await verificationFail(interaction.member, interaction); + return; + } + + const cooldownMs = durationParser(verificationConfig.retryCooldown || '5m'); + const cooldownMinutes = Math.ceil(cooldownMs / 60000); + await interaction.reply({ + ephemeral: true, + content: '❌ ' + localize('moderation', 'retry-message', {t: cooldownMinutes + 'm', a: attempts, m: maxRetries}) + }); +} \ No newline at end of file diff --git a/modules/moderation/events/messageCreate.js b/modules/moderation/events/messageCreate.js index 1af6bd09..bc85ac70 100644 --- a/modules/moderation/events/messageCreate.js +++ b/modules/moderation/events/messageCreate.js @@ -4,7 +4,23 @@ const {embedType} = require('../../../src/functions/helpers'); const {localize} = require('../../../src/functions/localize'); const stopPhishing = require('stop-discord-phishing'); +// Cache resolved invite codes to guild IDs to avoid repeated API calls +const inviteGuildCache = new Map(); + +const INVITE_PATTERN = /(?:discord\.gg|discordapp\.com\/invite|discord\.com\/invite)\/([a-zA-Z0-9-]+)/g; + +function extractInviteCodes(content) { + const codes = []; + let match; + while ((match = INVITE_PATTERN.exec(content)) !== null) { + codes.push(match[1]); + } + INVITE_PATTERN.lastIndex = 0; + return codes; +} + const messageCache = {}; +const actionInProgress = new Set(); module.exports.run = async (client, msg) => { if (!client.botReadyAt) return; @@ -34,6 +50,7 @@ module.exports.run = async (client, msg) => { * @return {Promise} */ async function antiSpam() { + if (actionInProgress.has(msg.author.id)) return; if (!messageCache[msg.author.id]) messageCache[msg.author.id] = []; messageCache[msg.author.id].push({ id: msg.id, @@ -42,7 +59,9 @@ module.exports.run = async (client, msg) => { massMentions: msg.mentions.everyone || Array.from(msg.mentions.roles.keys()).length !== 0 }); setTimeout(() => { + if (!messageCache[msg.author.id]) return; messageCache[msg.author.id] = messageCache[msg.author.id].filter(m => m.id !== msg.id); + if (messageCache[msg.author.id].length === 0) delete messageCache[msg.author.id]; }, antiSpamConfig.timeframe * 1000); if (messageCache[msg.author.id].length >= antiSpamConfig.maxMessagesInTimeframe) return await performAntiSpamAction(localize('moderation', 'reached-messages-in-timeframe', { m: antiSpamConfig.maxMessagesInTimeframe, @@ -68,6 +87,8 @@ module.exports.run = async (client, msg) => { * @return {Promise} */ async function performAntiSpamAction(reason) { + actionInProgress.add(msg.author.id); + delete messageCache[msg.author.id]; await moderationAction(client, antiSpamConfig.action, {user: client.user}, msg.member, `[${localize('moderation', 'anti-spam')}]: ${reason}`, {roles: roles}); if (antiSpamConfig.sendChatMessage) await msg.channel.send(embedType(antiSpamConfig.message, { '%reason%': reason, @@ -77,6 +98,7 @@ module.exports.run = async (client, msg) => { if (lockdownConfig && lockdownConfig.enabled && lockdownConfig.autoTriggerOnSpam && !await isLockdownActive(client)) { await activateLockdown(client, localize('moderation', 'lockdown-spam-trigger'), localize('moderation', 'lockdown-system'), true); } + setTimeout(() => actionInProgress.delete(msg.author.id), 10000); } } @@ -113,9 +135,29 @@ async function performBadWordAndInviteProtection(msg) { if (moduleConfig['whitelisted_channels_for_invite_blocking'].includes(msg.channel.id) || moduleConfig['whitelisted_channels_for_invite_blocking'].includes(msg.channel.parentId)) return; if (msg.member.roles.cache.find(r => moduleConfig['whitelisted_roles_for_invite_blocking'].includes(r.id))) return; if (moduleConfig['action_on_invite'] !== 'none') { - if (msg.content.includes('discord.gg/') || msg.content.includes('discordapp.com/invite/')) { + const inviteCodes = extractInviteCodes(msg.content); + for (const code of inviteCodes) { + let guildId = inviteGuildCache.get(code); + if (!guildId) { + try { + const invite = await msg.client.fetchInvite(code); + guildId = invite.guild ? invite.guild.id : null; + if (guildId) { + if (inviteGuildCache.size > 500) { + const firstKey = inviteGuildCache.keys().next().value; + inviteGuildCache.delete(firstKey); + } + inviteGuildCache.set(code, guildId); + } + } catch (e) { + guildId = null; + } + } + if (guildId === msg.guild.id) continue; + if (guildId && (moduleConfig['allowed_invite_guild_ids'] || []).includes(guildId)) continue; await msg.delete(); await moderationAction(msg.client, moduleConfig['action_on_invite'], msg.client, msg.member, localize('moderation', 'invite-sent', {c: msg.channel.toString()}), {roles}); + return; } } } diff --git a/modules/moderation/lockdown.js b/modules/moderation/lockdown.js index 3f468172..c49cfb5a 100644 --- a/modules/moderation/lockdown.js +++ b/modules/moderation/lockdown.js @@ -128,10 +128,11 @@ async function activateLockdown(client, reason, triggeredBy, isAutomatic = false if (role.position >= botHighestRole.position) continue; if (moderatorRoles.has(role.id)) continue; + // Safety check before accessing cache if (!channel.permissionOverwrites || !channel.permissionOverwrites.cache) continue; const overwrite = channel.permissionOverwrites.cache.get(role.id); - if (overwrite && overwrite.allow.has(PermissionFlagsBits.SendMessages)) { + if (overwrite && !overwrite.deny.has(PermissionFlagsBits.SendMessages)) { await channel.permissionOverwrites.edit(role, { SendMessages: false, SendMessagesInThreads: false, @@ -171,10 +172,11 @@ async function activateLockdown(client, reason, triggeredBy, isAutomatic = false if (role.position >= botHighestRole.position) continue; if (moderatorRoles.has(role.id)) continue; + // Safety check before accessing cache if (!channel.permissionOverwrites || !channel.permissionOverwrites.cache) continue; const overwrite = channel.permissionOverwrites.cache.get(role.id); - if (overwrite && (overwrite.allow.has(PermissionFlagsBits.Connect) || overwrite.allow.has(PermissionFlagsBits.Speak) || overwrite.allow.has(PermissionFlagsBits.SendMessages))) { + if (overwrite && !(overwrite.deny.has(PermissionFlagsBits.Connect) && overwrite.deny.has(PermissionFlagsBits.Speak) && overwrite.deny.has(PermissionFlagsBits.SendMessages))) { await channel.permissionOverwrites.edit(role, { Connect: false, Speak: false, @@ -222,7 +224,7 @@ async function activateLockdown(client, reason, triggeredBy, isAutomatic = false if (!channel.permissionOverwrites || !channel.permissionOverwrites.cache) continue; const overwrite = channel.permissionOverwrites.cache.get(role.id); - if (overwrite && (overwrite.allow.has(PermissionFlagsBits.Connect) || overwrite.allow.has(PermissionFlagsBits.RequestToSpeak) || overwrite.allow.has(PermissionFlagsBits.SendMessages))) { + if (overwrite && !(overwrite.deny.has(PermissionFlagsBits.Connect) && overwrite.deny.has(PermissionFlagsBits.RequestToSpeak) && overwrite.deny.has(PermissionFlagsBits.SendMessages))) { await channel.permissionOverwrites.edit(role, { Connect: false, RequestToSpeak: false, @@ -249,21 +251,33 @@ async function activateLockdown(client, reason, triggeredBy, isAutomatic = false affectedChannels.push(channel.id); successfullyLockedCount++; - - if (lockdownConfig.sendMessageInAffectedChannels && typeof channel.send === 'function') { - const msgPayload = embedType(lockdownConfig.lockdownMessage, { - '%reason%': reason, - '%user%': triggeredBy - }); - await channel.send(msgPayload).catch(() => {}); - } } catch (error) { client.logger.error(`[moderation] [lockdown] Failed to lock channel ${channel.id}: ${error.message}`); + // Continue with next channel - backup is already saved } } client.logger.info(`[moderation] [lockdown] Successfully locked ${successfullyLockedCount}/${channelsToLockdown.length} channels`); + // PHASE 3b: Send notification messages + if (lockdownConfig.sendMessageInAffectedChannels) { + const msgPayload = embedType(lockdownConfig.lockdownMessage, { + '%reason%': reason, + '%user%': triggeredBy + }); + const targetChannels = (lockdownConfig.lockdownMessageChannels || []).length > 0 + ? lockdownConfig.lockdownMessageChannels + : affectedChannels; + for (const channelId of targetChannels) { + const ch = guild.channels.cache.get(channelId); + if (ch && typeof ch.send === 'function') { + await ch.send(msgPayload).catch(() => { + }); + } + } + } + + // PHASE 4: Kick non-moderator users from voice and stage channels let kickedUsersCount = 0; let totalVoiceUsers = 0; for (const [, channel] of guild.channels.cache) { @@ -272,9 +286,11 @@ async function activateLockdown(client, reason, triggeredBy, isAutomatic = false for (const [, member] of channel.members) { totalVoiceUsers++; + // Skip moderators const isModerator = member.roles.cache.some(role => moderatorRoles.has(role.id)); if (isModerator) continue; + // Kick non-moderator try { await member.voice.disconnect(`[moderation] [lockdown] ${reason}`); kickedUsersCount++; @@ -362,14 +378,27 @@ async function liftLockdown(client, reason, liftedBy) { deny: BigInt(o.deny) })), `[moderation] [lockdown-lift] ${reason}`); restoredCount++; + } catch (e) { + client.logger.warn(localize('moderation', 'lockdown-restore-failed', { + c: backup.channelID, + e: e.toString() + })); + } + } - if (lockdownConfig.sendMessageInAffectedChannels && typeof channel.send === 'function') { - await channel.send(embedType(lockdownConfig.liftMessage, { + // Send lift notification messages + if (lockdownConfig.sendMessageInAffectedChannels) { + const restoredChannelIds = (state.permissionBackup || []).map(b => b.channelID); + const targetChannels = (lockdownConfig.lockdownMessageChannels || []).length > 0 + ? lockdownConfig.lockdownMessageChannels + : restoredChannelIds; + for (const channelId of targetChannels) { + const ch = guild.channels.cache.get(channelId); + if (ch && typeof ch.send === 'function') { + await ch.send(embedType(lockdownConfig.liftMessage, { '%user%': liftedBy })).catch(() => {}); } - } catch (e) { - client.logger.warn(localize('moderation', 'lockdown-restore-failed', {c: backup.channelID, e: e.toString()})); } } diff --git a/modules/moderation/models/VerificationRequest.js b/modules/moderation/models/VerificationRequest.js new file mode 100644 index 00000000..356de851 --- /dev/null +++ b/modules/moderation/models/VerificationRequest.js @@ -0,0 +1,46 @@ +const {DataTypes, Model} = require('sequelize'); + +module.exports = class VerificationRequest extends Model { + static init(sequelize) { + return super.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userID: { + type: DataTypes.STRING, + allowNull: false + }, + type: { + type: DataTypes.STRING, + allowNull: false + }, + status: { + type: DataTypes.STRING, + defaultValue: 'pending' + }, + attempts: { + type: DataTypes.INTEGER, + defaultValue: 0 + }, + lastAttemptAt: { + type: DataTypes.DATE, + allowNull: true + }, + logMessageID: { + type: DataTypes.STRING, + allowNull: true + } + }, { + tableName: 'moderation_VerificationRequests', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + 'name': 'VerificationRequest', + 'module': 'moderation' +}; diff --git a/modules/moderation/moderationActions.js b/modules/moderation/moderationActions.js index eadc7154..7715ab65 100644 --- a/modules/moderation/moderationActions.js +++ b/modules/moderation/moderationActions.js @@ -1,5 +1,13 @@ const {scheduleJob} = require('node-schedule'); -const {embedType, formatDate, dateToDiscordTimestamp, formatDiscordUserName, safeSetFooter} = require('../../src/functions/helpers'); +const { + embedType, + formatDate, + dateToDiscordTimestamp, + formatDiscordUserName, + safeSetFooter, + truncate, + tryArchiveDiscordAttachment +} = require('../../src/functions/helpers'); const {MessageEmbed} = require('discord.js'); const {localize} = require('../../src/functions/localize'); const durationParser = require('parse-duration'); @@ -173,7 +181,7 @@ async function moderationAction(client, type, user, victim, reason, additionalDa })).catch(() => { }); if (victim.bannable) await victim.ban({ - days: additionalData.days || 0, + deleteMessageDays: additionalData.days || 0, reason: '[moderation] ' + localize('moderation', 'banned-audit-log-reason', { u: formatDiscordUserName(user.user), r: reason @@ -184,7 +192,7 @@ async function moderationAction(client, type, user, victim, reason, additionalDa victim.user.tag = victim.id; victim.user.id = victim.id; await guild.members.ban(victim.id, { - days: additionalData.days || 0, + deleteMessageDays: additionalData.days || 0, reason: '[moderation] ' + localize('moderation', 'banned-audit-log-reason', { u: formatDiscordUserName(user.user), r: reason @@ -279,6 +287,16 @@ async function moderationAction(client, type, user, victim, reason, additionalDa if (!channel) { client.error('[moderation] ' + localize('moderation', 'missing-logchannel')); } else { + let proofURL = null; + if (proof) { + const victimName = victim?.user ? formatDiscordUserName(victim.user) : 'unknown'; + const archived = await tryArchiveDiscordAttachment(client, proof.url, { + displayName: `Moderation case #${modAction.actionID} (${type}) — evidence against ${victimName}`.slice(0, 100), + tags: ['moderation', 'report-evidence', type], + uploaderDiscordID: user?.user?.id || user?.id + }); + proofURL = archived ? archived.url : (proof.proxyURL || proof.url); + } const fields = []; if (expiringAt) fields.push({ name: localize('moderation', 'expires-at'), @@ -287,7 +305,7 @@ async function moderationAction(client, type, user, victim, reason, additionalDa }); if (proof) fields.push({ name: localize('moderation', 'proof'), - value: `[${localize('moderation', 'file')}](${proof.proxyURL || proof.url})`, + value: `[${localize('moderation', 'file')}](${proofURL})`, inline: true }); if (additionalData.channel) fields.push({ @@ -298,7 +316,7 @@ async function moderationAction(client, type, user, victim, reason, additionalDa const modEmbed = new MessageEmbed() .setColor(expiringAt ? 0xf1c40f : (type.includes('un') ? 0x2ecc71 : 0xe74c3c)) .setTimestamp() - .setImage(proof ? (proof.proxyURL || proof.url) : null) + .setImage(proofURL) .setAuthor({ name: formatDiscordUserName(client.user), iconURL: client.user.avatarURL() @@ -309,7 +327,7 @@ async function moderationAction(client, type, user, victim, reason, additionalDa .addField('User', `${formatDiscordUserName(user.user)}\n\`${user.user.id}\``, true) .addField(localize('moderation', 'action'), expiringAt ? `tmp-${type}` : type, true) .addFields(fields) - .addField(localize('moderation', 'reason'), reason); + .addField(localize('moderation', 'reason'), truncate(reason, 1024)); safeSetFooter(modEmbed, client); await channel.send({ embeds: [modEmbed] diff --git a/modules/moderation/module.json b/modules/moderation/module.json index f656ec6c..51d795b9 100644 --- a/modules/moderation/module.json +++ b/modules/moderation/module.json @@ -15,18 +15,14 @@ "configs/antiSpam.json", "configs/antiGrief.json", "configs/antiJoinRaid.json", - "configs/verification.json" + "configs/verification.json", + "configs/lockdown.json" ], + "fa-icon": "fas fa-hammer", "tags": [ "moderation" ], "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/moderation", - "humanReadableName": { - "en": "Moderation & Security", - "de": "Moderation & Sicherheit" - }, - "description": { - "en": "Advanced security- and moderation-system with tons of features", - "de": "Fortgeschrittenes Moderation- und Sicherheit-System mit vielen Funktionen" - } -} \ No newline at end of file + "humanReadableName": "Moderation & Security", + "description": "Advanced security- and moderation-system with tons of features" +} diff --git a/modules/nicknames/configs/config.json b/modules/nicknames/configs/config.json index 000397da..a087f74b 100644 --- a/modules/nicknames/configs/config.json +++ b/modules/nicknames/configs/config.json @@ -1,27 +1,13 @@ { - "description": { - "en": "Configure the function of the module here", - "de": "Stelle hier die Funktionen des Modules ein" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the function of the module here", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "forceDisplayname", - "humanName": { - "en": "Force display name", - "de": "Anzeigenamen erzwingen" - }, - "default": { - "en": false - }, - "description": { - "en": "Use display names of users instead of custom nicknames.", - "de": "Anzeigenamen von Benutzern anstelle von benutzerdefinierten Nicknamen verwenden." - }, + "humanName": "Force display name", + "default": false, + "description": "Use display names of users instead of custom nicknames.", "type": "boolean" } ] diff --git a/modules/nicknames/configs/strings.json b/modules/nicknames/configs/strings.json index 343e8739..6b8ed954 100644 --- a/modules/nicknames/configs/strings.json +++ b/modules/nicknames/configs/strings.json @@ -1,58 +1,28 @@ { - "description": { - "en": "Set a prefixes and/or suffixes for roles.", - "de": "Setze Präfixe und/oder Suffixe für Rollen." - }, - "humanName": { - "en": "Roles", - "de": "Rollen" - }, + "description": "Set a prefixes and/or suffixes for roles.", + "humanName": "Roles", "filename": "strings.json", "configElements": true, "content": [ { "name": "roleID", - "humanName": { - "en": "Role", - "de": "Rolle" - }, - "default": { - "en": "" - }, - "description": { - "en": "The role you want to set a prefix/suffix for.", - "de": "Die Rolle, für die ein Präfix/Suffix vergeben werden soll." - }, + "humanName": "Role", + "default": "", + "description": "The role you want to set a prefix/suffix for.", "type": "roleID" }, { "name": "prefix", - "humanName": { - "en": "Prefix", - "de": "Präfix" - }, - "default": { - "en": "" - }, - "description": { - "en": "The Prefix to be set.", - "de": "Das Präfix." - }, + "humanName": "Prefix", + "default": "", + "description": "The Prefix to be set.", "type": "string" }, { "name": "suffix", - "humanName": { - "en": "Suffix", - "de": "Suffix" - }, - "default": { - "en": "" - }, - "description": { - "en": "The Suffix to be set.", - "de": "Das Suffix." - }, + "humanName": "Suffix", + "default": "", + "description": "The Suffix to be set.", "type": "string" } ] diff --git a/modules/nicknames/module.json b/modules/nicknames/module.json index 39c91b14..6390e005 100644 --- a/modules/nicknames/module.json +++ b/modules/nicknames/module.json @@ -1,15 +1,13 @@ { "name": "nicknames", - "humanReadableName": { - "en": "Role-Nicknames", - "de": "Rollen-Nicknamen" - }, + "humanReadableName": "Role-Nicknames", "author": { "name": "hfgd", "link": "https://github.com/hfgd123", "scnxOrgID": "2" }, "openSourceURL": "https://github.com/hfgd123/CustomDCBot/tree/main/modules/nicknames", + "fa-icon": "fa-solid fa-user-pen", "events-dir": "/events", "models-dir": "/models", "config-example-files": [ @@ -19,8 +17,5 @@ "tags": [ "community" ], - "description": { - "en": "Simple module to edit user nicknames based on roles!", - "de": "Einfaches Modul, um die Nicknames von Nutzern basierend auf ihren Rollen zu bearbeiten!" - } -} \ No newline at end of file + "description": "Simple module to edit user nicknames based on roles!" +} diff --git a/modules/ping-on-vc-join/actual-config.json b/modules/ping-on-vc-join/actual-config.json index 7865aead..c0f75c95 100644 --- a/modules/ping-on-vc-join/actual-config.json +++ b/modules/ping-on-vc-join/actual-config.json @@ -1,46 +1,32 @@ { - "description": { - "en": "Configure messages that should get send when a user joins a Voice-Channel", - "de": "Stelle hier Nachrichten ein, die versendet werden, wenn ein Nutzer einem Sprachkanal beitritt" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure messages that should get send when a user joins a Voice-Channel", + "humanName": "Configuration", "filename": "actual-config.json", "content": [ { "name": "assignRoleToUsersInVoiceChannels", - "humanName": { - "en": "Assign roles to members connected to voice channels?", - "de": "Nutzer, die mit Sprachkanälen verbunden sind, Rollen zuweisen?" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, users will receive a role when they join a voice channel. This role will be removed when they leave the voice channel (switching voice channels does not trigger a role removal).", - "de": "Wenn aktiviert, werden Nutzer beim Beitritt eines Sprachkanals eine Rolle erhalten. Diese Rolle wird entfernt, wenn sie den Sprachkanal verlassen (Sprachkanäle wechseln zählt nicht)." - }, - "type": "boolean" + "humanName": "Assign roles to members connected to voice channels?", + "default": false, + "description": "If enabled, users will receive a role when they join a voice channel. This role will be removed when they leave the voice channel (switching voice channels does not trigger a role removal).", + "type": "boolean", + "category": "roles" }, { "name": "voiceRoles", "dependsOn": "assignRoleToUsersInVoiceChannels", - "humanName": { - "en": "Roles for users that are connected to voice channels", - "de": "Nutzer, die mit Sprachkanälen verbunden sind, Rollen zuweisen?" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Users that are currently connected to a voice channel will be assigned these roles.", - "de": "Nutzer, die aktuell mit einem Sprachkanal verbunden sind, erhalten diese Rolen." - }, + "humanName": "Roles for users that are connected to voice channels", + "default": [], + "description": "Users that are currently connected to a voice channel will be assigned these roles.", "type": "array", - "content": "roleID" + "content": "roleID", + "category": "roles" + } + ], + "categories": [ + { + "id": "roles", + "icon": "fa-solid fa-users", + "displayName": "Voice Roles" } ] } \ No newline at end of file diff --git a/modules/ping-on-vc-join/config.json b/modules/ping-on-vc-join/config.json index cce3e041..c726f453 100644 --- a/modules/ping-on-vc-join/config.json +++ b/modules/ping-on-vc-join/config.json @@ -1,130 +1,109 @@ { - "description": { - "en": "Configure messages that should get send when a user joins a Voice-Channel", - "de": "Stelle hier Nachrichten ein, die versendet werden, wenn ein Nutzer einem Sprachkanal beitritt" - }, - "humanName": { - "en": "Message on Voice Join", - "de": "Nachricht beim Kanalbeitritt" - }, + "description": "Configure messages that should get send when a user joins a Voice-Channel", + "humanName": "Message on Voice Join", "filename": "config.json", "configElements": true, "content": [ { "name": "channels", - "humanName": { - "en": "Channels", - "de": "Auslöserkanäle" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Channel-ID in which this messages should get triggered", - "de": "Kanäle, bei denen der Bot reagieren soll, wenn ein Nutzer joint" - }, + "humanName": "Channels", + "default": [], + "description": "Channel-ID in which this messages should get triggered", "type": "array", - "content": "channelID" + "content": "channelID", + "category": "general" }, { "name": "message", - "humanName": { - "de": "Nachricht", - "en": "Message" - }, - "default": { - "en": "The user %tag% joined the voicechat %vc%", - "de": "Der Nutzer %tag% ist dem Voicechat %vc% beigetreten." - }, - "description": { - "en": "Here you can set the message that should be send if someone joins a selected voicechat", - "de": "Hier kannst du die Nachricht einstellen, die gesendet werden soll, wenn jemand dem Sprachkanal beitritt" - }, + "humanName": "Message", + "default": "The user %tag% joined the voicechat %vc%", + "description": "Here you can set the message that should be send if someone joins a selected voicechat", "type": "string", "allowEmbed": true, "params": [ { "name": "tag", - "description": { - "en": "Tag of the user", - "de": "Tag des Nutzers" - } + "description": "Tag of the user" }, { "name": "vc", - "description": { - "en": "Name of the voicechat", - "de": "Name des Sprackkanals" - } + "description": "Name of the voicechat" }, { "name": "mention", - "description": { - "en": "Mention of the user", - "de": "Erwähnung des Nutzers" - } + "description": "Mention of the user" } - ] + ], + "category": "messages" }, { "name": "notify_channel_id", - "humanName": { - "de": "Benachrichtigungskanal", - "en": "Notification-Channel" - }, - "default": { - "en": "" - }, + "humanName": "Notification-Channel", + "default": "", "content": [ "GUILD_TEXT" ], - "description": { - "en": "Channel where the message should be send", - "de": "Kanal, in welchen die Nachricht gesendet werden soll" - }, - "type": "channelID" + "description": "Channel where the message should be send", + "type": "channelID", + "category": "general" + }, + { + "name": "cooldownEnabled", + "humanName": "Enable Cooldown?", + "default": false, + "description": "When enabled, messages will only be sent once per channel within the cooldown period", + "type": "boolean", + "category": "cooldown" + }, + { + "name": "cooldownMinutes", + "humanName": "Cooldown Duration (Minutes)", + "default": 5, + "description": "Duration in minutes to wait before sending another message for the same channel", + "type": "integer", + "dependsOn": "cooldownEnabled", + "category": "cooldown" }, { "name": "send_pn_to_member", - "humanName": { - "en": "Join-DM", - "de": "Join-PN" - }, - "default": { - "en": false - }, - "description": { - "en": "Should the bot send a PN to the member?", - "de": "Soll der Bot eine PN an den Nutzer schicken?" - }, - "type": "boolean" + "humanName": "Join-DM", + "default": false, + "description": "Should the bot send a PN to the member?", + "type": "boolean", + "category": "messages" }, { "name": "pn_message", - "humanName": { - "en": "Join-DM-Message", - "de": "Join-PN-Nachricht" - }, - "default": { - "en": "Hi, I saw you joined the voice chat %vc%. Nice (;", - "de": "Hi, ich habe gesehen, dass du %vc% beigetreten bist. Nice (;" - }, - "description": { - "de": "Diese Nachricht wird an den Nutzer versandt, wenn er einem Voicechat beitritt (wenn \"Join-PN\" aktiviert ist)." - }, + "humanName": "Join-DM-Message", + "default": "Hi, I saw you joined the voice chat %vc%. Nice (;", + "description": "This message is sent to the user when they join a voice chat (if \"Join DM\" is enabled).", "type": "string", "dependsOn": "send_pn_to_member", "allowEmbed": true, "params": [ { "name": "vc", - "description": { - "en": "Name of the voicechat", - "de": "Name des Sprachkanals" - } + "description": "Name of the voicechat" } - ] + ], + "category": "messages" + } + ], + "categories": [ + { + "id": "general", + "icon": "fas fa-gears", + "displayName": "General Settings" + }, + { + "id": "cooldown", + "icon": "fa-regular fa-clock-rotate-left", + "displayName": "Cooldown" + }, + { + "id": "messages", + "icon": "fas fa-comment-dots", + "displayName": "Messages" } ] -} \ No newline at end of file +} diff --git a/modules/ping-on-vc-join/events/voiceStateUpdate.js b/modules/ping-on-vc-join/events/voiceStateUpdate.js index 4703c930..8eb529ce 100644 --- a/modules/ping-on-vc-join/events/voiceStateUpdate.js +++ b/modules/ping-on-vc-join/events/voiceStateUpdate.js @@ -1,15 +1,19 @@ const {embedType, disableModule, formatDiscordUserName} = require('../../../src/functions/helpers'); const {localize} = require('../../../src/functions/localize'); -const cooldown = new Set(); +const userCooldown = new Set(); // Per-user cooldown (legacy) +const channelCooldown = new Map(); // Per-channel cooldown: Map exports.run = async (client, oldState, newState) => { if (!client.botReadyAt) return; const roleConfig = client.configurations['ping-on-vc-join']['actual-config']; - if (roleConfig.assignRoleToUsersInVoiceChannels && roleConfig.voiceRoles.length !== 0) { + + // Ignore bots for role assignment + if (roleConfig.assignRoleToUsersInVoiceChannels && roleConfig.voiceRoles.length !== 0 && !newState.member.user.bot) { if (oldState.channel && !newState.channel) newState.member.roles.remove(roleConfig.voiceRoles); if (!oldState.channel && newState.channel) newState.member.roles.add(roleConfig.voiceRoles); } + if (!newState.channel || newState.channel.id === oldState?.channel?.id) return; const channel = await client.channels.fetch(newState.channelId); if (channel.guild.id !== client.guild.id) return; @@ -21,24 +25,60 @@ exports.run = async (client, oldState, newState) => { const member = await client.guild.members.fetch(newState.id); if (member.user.bot) return; - if (cooldown.has(member.user.id)) return; + // Check cooldown based on configuration + const cooldownEnabled = configElement['cooldownEnabled'] || false; + + if (cooldownEnabled) { + // Per-channel cooldown + const cooldownKey = `${channel.id}`; + const now = Date.now(); + const cooldownEnd = channelCooldown.get(cooldownKey); + + if (cooldownEnd && now < cooldownEnd) { + // Still in cooldown, don't send message + return; + } + } else { + // Legacy per-user cooldown + if (userCooldown.has(member.user.id)) return; + } const notifyChannel = newState.guild.channels.cache.get(configElement['notify_channel_id']); - if (!notifyChannel) return disableModule('partner-list', localize('ping-on-vc-join', 'channel-bot-found', {c: configElement['notify_channel_id']})); + if (!notifyChannel) return disableModule('ping-on-vc-join', localize('ping-on-vc-join', 'channel-not-found', {c: configElement['notify_channel_id']})); setTimeout(async () => { // Wait 3 seconds before pinging a role if (!member.voice) return; if (member.voice.channelId !== channel.id) return; + await notifyChannel.send(embedType(configElement['message'], { '%vc%': channel.name, '%tag%': formatDiscordUserName(member.user), '%mention%': `<@${member.user.id}>` })); - cooldown.add(member.user.id); - setTimeout(() => { - cooldown.delete(member.user.id); - }, 300000); // 5 min + // Set cooldown after sending message + if (cooldownEnabled) { + // Per-channel cooldown + const cooldownMinutes = configElement['cooldownMinutes'] || 5; + const cooldownMs = cooldownMinutes * 60 * 1000; + const cooldownKey = `${channel.id}`; + + channelCooldown.set(cooldownKey, Date.now() + cooldownMs); + + // Clean up expired cooldowns periodically + setTimeout(() => { + const now = Date.now(); + if (channelCooldown.get(cooldownKey) <= now) { + channelCooldown.delete(cooldownKey); + } + }, cooldownMs); + } else { + // Legacy per-user cooldown + userCooldown.add(member.user.id); + setTimeout(() => { + userCooldown.delete(member.user.id); + }, 300000); // 5 min + } if (configElement['send_pn_to_member']) { await member.send(embedType(configElement['pn_message'], { diff --git a/modules/ping-on-vc-join/module.json b/modules/ping-on-vc-join/module.json index 25206a7b..2d84496a 100644 --- a/modules/ping-on-vc-join/module.json +++ b/modules/ping-on-vc-join/module.json @@ -15,12 +15,6 @@ "tags": [ "support" ], - "humanReadableName": { - "en": "Voice-Channel Actions", - "de": "Sprachkanal-Aktionen" - }, - "description": { - "en": "Sends messages when someone joins a voicechat and assign roles to users in Voice-Channels", - "de": "Sende Nachrichten, wenn jemand einem Sprachkanal beitritt und vergebe Rollen an Nutzer in Sprachkanälen" - } -} \ No newline at end of file + "humanReadableName": "Voice-Channel Actions", + "description": "Sends messages when someone joins a voicechat and assign roles to users in Voice-Channels" +} diff --git a/modules/ping-protection/commands/ping-protection.js b/modules/ping-protection/commands/ping-protection.js index d8ac43c7..4d61d83d 100644 --- a/modules/ping-protection/commands/ping-protection.js +++ b/modules/ping-protection/commands/ping-protection.js @@ -1,180 +1,196 @@ -const { - fetchModHistory, - getPingCountInWindow, - generateHistoryResponse, - generateActionsResponse +const { + fetchModHistory, + getPingCountInWindow, + generateHistoryResponse, + generateActionsResponse } = require('../ping-protection'); -const { localize } = require('../../../src/functions/localize'); -const { truncate } = require('../../../src/functions/helpers'); -const { ActionRowBuilder, ButtonBuilder, EmbedBuilder, ButtonStyle, MessageFlags } = require('discord.js'); +const {localize} = require('../../../src/functions/localize'); +const {truncate, safeSetFooter} = require('../../../src/functions/helpers'); +const { + ActionRowBuilder, + ButtonBuilder, + EmbedBuilder, + ButtonStyle, + MessageFlags +} = require('discord.js'); module.exports.run = async function (interaction) { - const group = interaction.options.getSubcommandGroup(false); - const sub = interaction.options.getSubcommand(false); + const group = interaction.options.getSubcommandGroup(false); + const sub = interaction.options.getSubcommand(false); - if (group) { - return module.exports.subcommands[group][sub](interaction); - } - return module.exports.subcommands[sub](interaction); + if (group) { + return module.exports.subcommands[group][sub](interaction); + } + return module.exports.subcommands[sub](interaction); }; // Handles subcommands module.exports.subcommands = { - 'user': { - 'history': async function (interaction) { - const user = interaction.options.getUser('user'); - const payload = await generateHistoryResponse(interaction.client, user.id, 1); - await interaction.reply({ ...payload, flags: MessageFlags.Ephemeral }); - }, - 'actions-history': async function (interaction) { - const user = interaction.options.getUser('user'); - const payload = await generateActionsResponse(interaction.client, user.id, 1); - await interaction.reply({ ...payload, flags: MessageFlags.Ephemeral }); - }, - 'panel': async function (interaction) { - const user = interaction.options.getUser('user'); - const pingerId = user.id; - const storageConfig = interaction.client.configurations['ping-protection']['storage']; - const retentionWeeks = (storageConfig && storageConfig.pingHistoryRetention) - ? storageConfig.pingHistoryRetention - : 12; - const timeframeDays = retentionWeeks * 7; - - const pingCount = await getPingCountInWindow(interaction.client, pingerId, timeframeDays); - const modData = await fetchModHistory(interaction.client, pingerId, 1, 1000); - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`ping-protection_history_${user.id}`) - .setLabel(localize('ping-protection', 'btn-history')) - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(`ping-protection_actions_${user.id}`) - .setLabel(localize('ping-protection', 'btn-actions')) - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(`ping-protection_delete_${user.id}`) - .setLabel(localize('ping-protection', 'btn-delete')) - .setStyle(ButtonStyle.Danger) - ); - - const embed = new EmbedBuilder() - .setTitle(localize('ping-protection', 'panel-title', { u: user.tag })) - .setDescription(localize('ping-protection', 'panel-description', { u: user.toString(), i: user.id })) - .setColor('Blue') - .setThumbnail(user.displayAvatarURL({ dynamic: true })) - .addFields([{ - name: localize('ping-protection', 'field-quick-history', { w: retentionWeeks }), - value: localize('ping-protection', 'field-quick-desc', { p: pingCount, m: modData.total }), - inline: false - }]) - .setFooter({ - text: interaction.client.strings.footer, - iconURL: interaction.client.strings.footerImgUrl - }); - if (!interaction.client.strings.disableFooterTimestamp) embed.setTimestamp(); - - await interaction.reply({ - embeds: [embed.toJSON()], - components: [row.toJSON()], - flags: MessageFlags.Ephemeral - }); - } - }, - 'list': { - 'protected': async function (interaction) { - await listHandler(interaction, 'protected'); + 'user': { + 'history': async function (interaction) { + const user = interaction.options.getUser('user'); + const payload = await generateHistoryResponse(interaction.client, user.id, 1); + await interaction.reply({ + ...payload, + flags: MessageFlags.Ephemeral + }); + }, + 'actions-history': async function (interaction) { + const user = interaction.options.getUser('user'); + const payload = await generateActionsResponse(interaction.client, user.id, 1); + await interaction.reply({ + ...payload, + flags: MessageFlags.Ephemeral + }); + }, + 'panel': async function (interaction) { + const user = interaction.options.getUser('user'); + const pingerId = user.id; + const storageConfig = interaction.client.configurations['ping-protection']['storage']; + const retentionWeeks = (storageConfig && storageConfig.pingHistoryRetention) + ? storageConfig.pingHistoryRetention + : 12; + const timeframeDays = retentionWeeks * 7; + + const pingCount = await getPingCountInWindow(interaction.client, pingerId, timeframeDays); + const modData = await fetchModHistory(interaction.client, pingerId, 1, 1000); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`ping-protection_history_${user.id}`) + .setLabel(localize('ping-protection', 'btn-history')) + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(`ping-protection_actions_${user.id}`) + .setLabel(localize('ping-protection', 'btn-actions')) + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(`ping-protection_delete_${user.id}`) + .setLabel(localize('ping-protection', 'btn-delete')) + .setStyle(ButtonStyle.Danger) + ); + + const embed = new EmbedBuilder() + .setTitle(localize('ping-protection', 'panel-title', {u: user.tag})) + .setDescription(localize('ping-protection', 'panel-description', { + u: user.toString(), + i: user.id + })) + .setColor('Blue') + .setThumbnail(user.displayAvatarURL({dynamic: true})) + .addFields([{ + name: localize('ping-protection', 'field-quick-history', {w: retentionWeeks}), + value: localize('ping-protection', 'field-quick-desc', { + p: pingCount, + m: modData.total + }), + inline: false + }]); + + safeSetFooter(embed, interaction.client); + if (!interaction.client.strings.disableFooterTimestamp) embed.setTimestamp(); + + await interaction.reply({ + embeds: [embed.toJSON()], + components: [row.toJSON()], + flags: MessageFlags.Ephemeral + }); + } }, - 'whitelisted': async function (interaction) { - await listHandler(interaction, 'whitelisted'); + 'list': { + 'protected': async function (interaction) { + await listHandler(interaction, 'protected'); + }, + 'whitelisted': async function (interaction) { + await listHandler(interaction, 'whitelisted'); + } } - } }; // Handles list subcommands async function listHandler(interaction, type) { - const config = interaction.client.configurations['ping-protection']['configuration']; - const embed = new EmbedBuilder() - .setColor('Green') - .setFooter({ - text: interaction.client.strings.footer, - iconURL: interaction.client.strings.footerImgUrl - }); + const config = interaction.client.configurations['ping-protection']['configuration']; + const embed = new EmbedBuilder() + .setColor('Green'); + + safeSetFooter(embed, interaction.client); + + if (!interaction.client.strings.disableFooterTimestamp) embed.setTimestamp(); + + if (type === 'protected') { + embed.setTitle(localize('ping-protection', 'list-protected-title')); + embed.setDescription(localize('ping-protection', 'list-protected-desc')); + + const usersList = config.protectedUsers.length > 0 + ? config.protectedUsers.map(id => `<@${id}>`).join('\n') + : localize('ping-protection', 'list-none'); + + const rolesList = config.protectedRoles.length > 0 + ? config.protectedRoles.map(id => `<@&${id}>`).join('\n') + : localize('ping-protection', 'list-none'); + + embed.addFields([ + { + name: localize('ping-protection', 'field-protected-users'), + value: truncate(usersList, 1024), + inline: true + }, + { + name: localize('ping-protection', 'field-protected-roles'), + value: truncate(rolesList, 1024), + inline: true + } + ]); + + } else if (type === 'whitelisted') { + embed.setTitle(localize('ping-protection', 'list-whitelist-title')); + embed.setDescription(localize('ping-protection', 'list-whitelist-desc')); + + const rolesList = config.ignoredRoles.length > 0 + ? config.ignoredRoles.map(id => `<@&${id}>`).join('\n') + : localize('ping-protection', 'list-none'); + + const channelsList = config.ignoredChannels.length > 0 + ? config.ignoredChannels.map(id => `<#${id}>`).join('\n') + : localize('ping-protection', 'list-none'); + + const usersList = config.ignoredUsers.length > 0 + ? config.ignoredUsers.map(id => `<@${id}>`).join('\n') + : localize('ping-protection', 'list-none'); + + embed.addFields([ + { + name: localize('ping-protection', 'field-wl-roles'), + value: truncate(rolesList, 1024), + inline: true + }, + { + name: localize('ping-protection', 'field-wl-channels'), + value: truncate(channelsList, 1024), + inline: true + }, + { + name: localize('ping-protection', 'field-wl-users'), + value: truncate(usersList, 1024), + inline: true + } + ]); + } - if (!interaction.client.strings.disableFooterTimestamp) embed.setTimestamp(); - - if (type === 'protected') { - embed.setTitle(localize('ping-protection', 'list-protected-title')); - embed.setDescription(localize('ping-protection', 'list-protected-desc')); - - const usersList = config.protectedUsers.length > 0 - ? config.protectedUsers.map(id => `<@${id}>`).join('\n') - : localize('ping-protection', 'list-none'); - - const rolesList = config.protectedRoles.length > 0 - ? config.protectedRoles.map(id => `<@&${id}>`).join('\n') - : localize('ping-protection', 'list-none'); - - embed.addFields([ - { - name: localize('ping-protection', 'field-protected-users'), - value: truncate(usersList, 1024), - inline: true - }, - { - name: localize('ping-protection', 'field-protected-roles'), - value: truncate(rolesList, 1024), - inline: true - } - ]); - - } else if (type === 'whitelisted') { - embed.setTitle(localize('ping-protection', 'list-whitelist-title')); - embed.setDescription(localize('ping-protection', 'list-whitelist-desc')); - - const rolesList = config.ignoredRoles.length > 0 - ? config.ignoredRoles.map(id => `<@&${id}>`).join('\n') - : localize('ping-protection', 'list-none'); - - const channelsList = config.ignoredChannels.length > 0 - ? config.ignoredChannels.map(id => `<#${id}>`).join('\n') - : localize('ping-protection', 'list-none'); - - const usersList = config.ignoredUsers.length > 0 - ? config.ignoredUsers.map(id => `<@${id}>`).join('\n') - : localize('ping-protection', 'list-none'); - - embed.addFields([ - { - name: localize('ping-protection', 'field-wl-roles'), - value: truncate(rolesList, 1024), - inline: true }, - { - name: localize('ping-protection', 'field-wl-channels'), - value: truncate(channelsList, 1024), - inline: true }, - { - name: localize('ping-protection', 'field-wl-users'), - value: truncate(usersList, 1024), - inline: true - } - ]); - } - - await interaction.reply({ - embeds: [embed.toJSON()], - flags: MessageFlags.Ephemeral - }); + await interaction.reply({ + embeds: [embed.toJSON()], + flags: MessageFlags.Ephemeral + }); } module.exports.config = { - name: 'ping-protection', - description: localize('ping-protection', 'cmd-desc-module'), - usage: '/ping-protection', - type: 'slash', - defaultPermission: false, - options: [ - { + name: 'ping-protection', + description: localize('ping-protection', 'cmd-desc-module'), + usage: '/ping-protection', + type: 'slash', + defaultPermission: false, + options: [ + { type: 'SUB_COMMAND_GROUP', name: 'user', description: localize('ping-protection', 'cmd-desc-group-user'), diff --git a/modules/ping-protection/configs/configuration.json b/modules/ping-protection/configs/configuration.json index acd5b7d0..53a7ea0d 100644 --- a/modules/ping-protection/configs/configuration.json +++ b/modules/ping-protection/configs/configuration.json @@ -1,207 +1,132 @@ { "filename": "configuration.json", - "humanName": { - "en": "General Configuration" - }, + "humanName": "General Configuration", "commandsWarnings": { "normal": [ "/ping-protection user history", "/ping-protection user actions-history", - "/ping-protection list roles", - "/ping-protection list users", + "/ping-protection list protected", "/ping-protection list whitelisted" ] }, - "description": { - "en": "Configure protected users/roles, whitelisted roles/members, ignored channels and the notification message." - }, + "description": "Configure protected users/roles, whitelisted roles/members, ignored channels and the notification message.", "categories": [ { "id": "protection", "icon": "fa-solid fa-shield", - "displayName": { - "en": "Protected" - } + "displayName": "Protected" }, { "id": "whitelisted", "icon": "fa-solid fa-badge-check", - "displayName": { - "en": "Whitelists" - } + "displayName": "Whitelists" }, { "id": "rules", "icon": "fas fa-gears", - "displayName": { - "en": "Ping rules" - } + "displayName": "Ping rules" }, { "id": "automod", "icon": "far fa-robot", - "displayName": { - "en": "AutoMod settings" - } + "displayName": "AutoMod settings" }, { "id": "messages", "icon": "fa-duotone fa-regular fa-triangle-exclamation", - "displayName": { - "en": "Warning message" - } + "displayName": "Warning message" } ], "content": [ { "name": "protectedRoles", "category": "protection", - "humanName": { - "en": "Protected Roles" - }, - "description": { - "en": "Specific roles which are protected from pings." - }, + "humanName": "Protected Roles", + "description": "Specific roles which are protected from pings.", "type": "array", "content": "roleID", - "default": { - "en": [] - } + "default": [] }, { "name": "protectAllUsersWithProtectedRole", "category": "protection", - "humanName": { - "en": "Protect all users with a protected role" - }, - "description": { - "en": "if enabled, all users with at least one protected role will be protected from pings, even if they are not specifically listed as protected users." - }, + "humanName": "Protect all users with a protected role", + "description": "if enabled, all users with at least one protected role will be protected from pings, even if they are not specifically listed as protected users.", "type": "boolean", - "default": { - "en": true - } + "default": true }, { "name": "protectedUsers", "category": "protection", - "humanName": { - "en": "Protected Users" - }, - "description": { - "en": "Specific users who are protected from pings." - }, + "humanName": "Protected Users", + "description": "Specific users who are protected from pings.", "type": "array", "content": "userID", - "default": { - "en": [] - } + "default": [] }, { "name": "ignoredRoles", "category": "whitelisted", - "humanName": { - "en": "Whitelisted Roles" - }, - "description": { - "en": "Roles allowed to ping protected members or roles." - }, + "humanName": "Whitelisted Roles", + "description": "Roles allowed to ping protected members or roles.", "type": "array", "content": "roleID", - "default": { - "en": [] - } + "default": [] }, { "name": "ignoredChannels", "category": "whitelisted", - "humanName": { - "en": "Whitelisted Channels" - }, - "description": { - "en": "Pings in these channels are ignored." - }, + "humanName": "Whitelisted Channels", + "description": "Pings in these channels are ignored.", "type": "array", "content": "channelID", - "default": { - "en": [] - } + "default": [] }, { "name": "ignoredUsers", "category": "whitelisted", - "humanName": { - "en": "Whitelisted Users" - }, - "description": { - "en": "Pings from these users are ignored." - }, + "humanName": "Whitelisted Users", + "description": "Pings from these users are ignored.", "type": "array", "content": "userID", - "default": { - "en": [] - } + "default": [] }, { "name": "allowReplyPings", "category": "rules", - "humanName": { - "en": "Allow Reply Pings" - }, - "description": { - "en": "If enabled, replying to a protected user (with mention ON) is allowed." - }, + "humanName": "Allow Reply Pings", + "description": "If enabled, replying to a protected user (with mention ON) is allowed.", "type": "boolean", - "default": { - "en": false - } + "default": false }, { "name": "selfPingConfiguration", "category": "rules", - "humanName": { - "en": "Self-Ping configuration" - }, - "description": { - "en": "Configure what happens when a protected user pings themselves. Note: Automod overrides this setting meaning this setting will not apply if Automod is enabled." - }, + "humanName": "Self-Ping configuration", + "description": "Configure what happens when a protected user pings themselves. Note: Automod overrides this setting meaning this setting will not apply if Automod is enabled.", "type": "select", "content": [ "Get punished like normal members", "Ignored", "Get fun easter eggs when pinging themselves" ], - "default": { - "en": "Ignored" - } + "default": "Ignored" }, { "name": "enableAutomod", "category": "automod", - "humanName": { - "en": "Enable automod" - }, - "description": { - "en": "If enabled, the bot will utilise Discord's native AutoMod to block the message with a ping of a protected user/role." - }, + "humanName": "Enable automod", + "description": "If enabled, the bot will utilise Discord's native AutoMod to block the message with a ping of a protected user/role.", "type": "boolean", - "default": { - "en": true - } + "default": true }, { "name": "autoModLogChannel", "category": "automod", - "humanName": { - "en": "AutoMod Log Channel" - }, - "description": { - "en": "Channel where AutoMod alerts are sent." - }, + "humanName": "AutoMod Log Channel", + "description": "Channel where AutoMod alerts are sent.", "type": "channelID", - "default": { - "en": [] - }, + "default": "", "channelTypes": [ "GUILD_TEXT" ], @@ -210,63 +135,43 @@ { "name": "autoModBlockMessage", "category": "automod", - "humanName": { - "en": "AutoMod custom message for message block" - }, - "description": { - "en": "Custom text shown to the user when blocked (Max 150 characters)." - }, + "humanName": "AutoMod custom message for message block", + "description": "Custom text shown to the user when blocked (Max 150 characters).", "type": "string", "maxLength": 150, - "default": { - "en": "Your message was blocked because you are trying to ping a protected user/role. The message content might be logged depending on the configuration." - }, + "default": "Your message was blocked because you are trying to ping a protected user/role. The message content might be logged depending on the configuration.", "dependsOn": "enableAutomod" }, { "name": "pingWarningMessage", "category": "messages", - "humanName": { - "en": "Warning Message" - }, - "description": { - "en": "The message that gets sent to the user when they ping someone." - }, + "humanName": "Warning Message", + "description": "The message that gets sent to the user when they ping someone.", "type": "string", "allowEmbed": true, "params": [ { "name": "target-name", - "description": { - "en": "Name of the pinged user/role" - } + "description": "Name of the pinged user/role" }, { "name": "target-mention", - "description": { - "en": "Mention of the pinged user/role" - } + "description": "Mention of the pinged user/role" }, { "name": "target-id", - "description": { - "en": "ID of the pinged user/role" - } + "description": "ID of the pinged user/role" }, { "name": "pinger-id", - "description": { - "en": "ID of the user who pinged" - } + "description": "ID of the user who pinged" } ], "default": { - "en": { - "title": "You are not allowed to ping %target-name%!", - "description": "<@%pinger-id%>, You are not allowed to ping %target-mention% due to your role. You can view which roles/members you are not allowed to ping by using the `/ping-protection list protected` command.\n\nIf you were replying, make sure to turn off the mention in the reply.", - "image": "https://scnx-cdn.scootkit.net/1769198862209-rJfCVKzAuo6uQLhPUe9o2P6ArJkDBSVUCEyUQM6bqt5WFKWK.gif", - "color": "#ed4245" - } + "title": "You are not allowed to ping %target-name%!", + "description": "<@%pinger-id%>, You are not allowed to ping %target-mention% due to your role. You can view which roles/members you are not allowed to ping by using the `/ping-protection list protected` command.\n\nIf you were replying, make sure to turn off the mention in the reply.", + "image": "https://scnx-cdn.scootkit.net/1769198862209-rJfCVKzAuo6uQLhPUe9o2P6ArJkDBSVUCEyUQM6bqt5WFKWK.gif", + "color": "#ed4245" } } ] diff --git a/modules/ping-protection/configs/moderation.json b/modules/ping-protection/configs/moderation.json index 1c15ed63..9bf55ec2 100644 --- a/modules/ping-protection/configs/moderation.json +++ b/modules/ping-protection/configs/moderation.json @@ -1,156 +1,96 @@ { "filename": "moderation.json", - "humanName": { - "en": "Moderation Actions" - }, + "humanName": "Moderation Actions", "configElementName": { - "en": { - "one": "punishment", - "more": "punishment" - } - }, - "description": { - "en": "Define triggers for punishments." + "one": "punishment", + "more": "punishment" }, + "description": "Define triggers for punishments.", "configElements": true, "content": [ { "name": "pingsCount", - "humanName": { - "en": "Pings to trigger moderation" - }, - "description": { - "en": "The amount of pings required to trigger a moderation action." - }, + "humanName": "Pings to trigger moderation", + "description": "The amount of pings required to trigger a moderation action.", "type": "integer", - "default": { - "en": 10 - } + "default": 10 }, { "name": "useCustomTimeframe", - "humanName": { - "en": "Use a custom timeframe" - }, - "description": { - "en": "If enabled, you can choose your own custom timeframe of days in which the pings must occur to trigger the moderation action." - }, + "humanName": "Use a custom timeframe", + "description": "If enabled, you can choose your own custom timeframe of days in which the pings must occur to trigger the moderation action.", "type": "boolean", - "default": { - "en": false - } + "default": false }, { "name": "timeframeDays", - "humanName": { - "en": "Timeframe (Days)" - }, - "description": { - "en": "In how many days must these pings occur?" - }, + "humanName": "Timeframe (Days)", + "description": "In how many days must these pings occur?", "type": "integer", - "default": { - "en": 7 - }, + "default": 7, "dependsOn": "useCustomTimeframe" }, { "name": "actionType", - "humanName": { - "en": "Action" - }, - "description": { - "en": "What punishment should be applied?" - }, + "humanName": "Action", + "description": "What punishment should be applied?", "type": "select", "content": [ "MUTE", "KICK" ], - "default": { - "en": "MUTE" - } + "default": "MUTE" }, { "name": "muteDuration", - "humanName": { - "en": "Mute Duration (only if action type is MUTE)" - }, - "description": { - "en": "How long to mute the user? (in minutes)" - }, + "humanName": "Mute Duration (only if action type is MUTE)", + "description": "How long to mute the user? (in minutes)", "type": "integer", - "default": { - "en": 60 - } + "default": 60 }, { "name": "enableActionLogging", - "humanName": { - "en": "Enable action logging" - }, - "description": { - "en": "If enabled, moderation actions will be logged in the channel where a protected user/role got pinged." - }, + "humanName": "Enable action logging", + "description": "If enabled, moderation actions will be logged in the channel where a protected user/role got pinged.", "type": "boolean", - "default": { - "en": true - } + "default": true }, { "name": "actionLogMessage", - "humanName": { - "en": "Action log message" - }, - "description": { - "en": "The message that will be sent when a user is punished for pinging protected users/roles." - }, + "humanName": "Action log message", + "description": "The message that will be sent when a user is punished for pinging protected users/roles.", "type": "string", "allowEmbed": true, "params": [ { "name": "pinger-mention", - "description": { - "en": "Mention of the user who pinged" - } + "description": "Mention of the user who pinged" }, { "name": "pinger-name", - "description": { - "en": "Name of the user who pinged" - } + "description": "Name of the user who pinged" }, { "name": "action", - "description": { - "en": "The action that was taken (muted/kicked)" - } + "description": "The action that was taken (muted/kicked)" }, { "name": "pings", - "description": { - "en": "Number of pings that triggered the action" - } + "description": "Number of pings that triggered the action" }, { "name": "timeframe", - "description": { - "en": "The timeframe in days in which the pings occurred" - } + "description": "The timeframe in days in which the pings occurred" }, { "name": "duration", - "description": { - "en": "Duration of the mute in minutes (only for the mute action)" - } + "description": "Duration of the mute in minutes (only for the mute action)" } ], "default": { - "en": { - "title": "Moderation action taken against %pinger-name%", - "description": "I have taken action against %pinger-mention% for pinging protected users/roles %pings% times within %timeframe% days.\n **Action:** %action%\n**Duration:** %duration% minutes", - "color": "#ed4245" - } + "title": "Moderation action taken against %pinger-name%", + "description": "I have taken action against %pinger-mention% for pinging protected users/roles %pings% times within %timeframe% days.\n **Action:** %action%\n**Duration:** %duration% minutes", + "color": "#ed4245" } } ] diff --git a/modules/ping-protection/configs/storage.json b/modules/ping-protection/configs/storage.json index 995a1ca1..586ba025 100644 --- a/modules/ping-protection/configs/storage.json +++ b/modules/ping-protection/configs/storage.json @@ -1,62 +1,40 @@ { "filename": "storage.json", - "humanName": { - "en": "Data Storage" - }, - "description": { - "en": "Configure how long moderation logs and leaver data are kept." - }, + "humanName": "Data Storage", + "description": "Configure how long moderation logs and leaver data are kept.", "categories": [ { "id": "pings", "icon": "fa-regular fa-clock-rotate-left", - "displayName": { - "en": "Ping History" - } + "displayName": "Ping History" }, { "id": "moderation", "icon": "fas fa-hammer", - "displayName": { - "en": "Moderation Logs" - } + "displayName": "Moderation Logs" }, { "id": "leavers", "icon": "fas fa-right-from-bracket", - "displayName": { - "en": "Leaver Data" - } + "displayName": "Leaver Data" } ], "content": [ { "name": "enablePingHistory", "category": "pings", - "humanName": { - "en": "Enable Ping History" - }, - "description": { - "en": "If enabled, the bot will keep a history of pings to enforce moderation actions." - }, + "humanName": "Enable Ping History", + "description": "If enabled, the bot will keep a history of pings to enforce moderation actions.", "type": "boolean", - "default": { - "en": true - } + "default": true }, { "name": "pingHistoryRetention", "category": "pings", - "humanName": { - "en": "Ping History Retention" - }, - "description": { - "en": "Decides on how long to keep ping logs. Minimum is 4 weeks (1 month) with a maximum of 96 weeks (2 years). This is the length factor of the 'Basic' punishment timeframe." - }, + "humanName": "Ping History Retention", + "description": "Decides on how long to keep ping logs. Minimum is 4 weeks (1 month) with a maximum of 96 weeks (2 years). This is the length factor of the 'Basic' punishment timeframe.", "type": "integer", - "default": { - "en": 12 - }, + "default": 12, "minValue": "4", "maxValue": "96", "dependsOn": "enablePingHistory" @@ -64,60 +42,36 @@ { "name": "deleteAllPingHistoryAfterTimeframe", "category": "pings", - "humanName": { - "en": "Delete all the pings in history after the timeframe?" - }, - "description": { - "en": "If enabled, the bot will delete ALL the pings history of an user after the timeframe instead of only the ping(s) exceeding the timeframe in the history." - }, + "humanName": "Delete all the pings in history after the timeframe?", + "description": "If enabled, the bot will delete ALL the pings history of an user after the timeframe instead of only the ping(s) exceeding the timeframe in the history.", "type": "boolean", - "default": { - "en": false - } + "default": false }, { "name": "modLogRetention", "category": "moderation", - "humanName": { - "en": "Moderation Log Retention (Months)" - }, - "description": { - "en": "How long to keep records of punishments (1 - 24 Months). This is applied when moderation actions are enabled." - }, + "humanName": "Moderation Log Retention (Months)", + "description": "How long to keep records of punishments (1 - 24 Months). This is applied when moderation actions are enabled.", "type": "integer", - "default": { - "en": 12 - }, + "default": 12, "minValue": "1", "maxValue": "24" }, { "name": "enableLeaverDataRetention", "category": "leavers", - "humanName": { - "en": "Keep user logs after they leave" - }, - "description": { - "en": "If enabled, the bot will keep a history of the user after they leave." - }, + "humanName": "Keep user logs after they leave", + "description": "If enabled, the bot will keep a history of the user after they leave.", "type": "boolean", - "default": { - "en": true - } + "default": true }, { "name": "leaverRetention", "category": "leavers", - "humanName": { - "en": "Leaver Data Retention (Days)" - }, - "description": { - "en": "How long to keep data after a user leaves (1-7 Days)." - }, + "humanName": "Leaver Data Retention (Days)", + "description": "How long to keep data after a user leaves (1-7 Days).", "type": "integer", - "default": { - "en": 1 - }, + "default": 1, "minValue": "1", "maxValue": "7", "dependsOn": "enableLeaverDataRetention" diff --git a/modules/ping-protection/events/autoModerationActionExecution.js b/modules/ping-protection/events/autoModerationActionExecution.js index 22f80fae..76d03d21 100644 --- a/modules/ping-protection/events/autoModerationActionExecution.js +++ b/modules/ping-protection/events/autoModerationActionExecution.js @@ -1,15 +1,15 @@ -const { processPing } = require('../ping-protection'); +const {processPing} = require('../ping-protection'); // Handles auto mod actions module.exports.run = async function (client, execution) { - if (execution.ruleTriggerType !== 1) return; + if (execution.ruleTriggerType !== 1) return; const config = client.configurations['ping-protection']['configuration']; if (config.ignoredUsers.includes(execution.userId)) return; - const matchedKeyword = execution.matchedKeyword || ""; + const matchedKeyword = execution.matchedKeyword || ''; const rawId = matchedKeyword.replace(/[^0-9]/g, ''); - + let isProtected = config.protectedRoles.includes(rawId) || config.protectedUsers.includes(rawId); let originChannel = execution.channel; @@ -24,7 +24,8 @@ module.exports.run = async function (client, execution) { if (targetMember && targetMember.roles.cache.some(r => config.protectedRoles.includes(r.id))) { isProtected = true; } - } catch (e) {} + } catch (e) { + } } if (!isProtected) return; diff --git a/modules/ping-protection/events/botReady.js b/modules/ping-protection/events/botReady.js index 6e43412d..1599c573 100644 --- a/modules/ping-protection/events/botReady.js +++ b/modules/ping-protection/events/botReady.js @@ -1,4 +1,7 @@ -const { enforceRetention, syncNativeAutoMod } = require('../ping-protection'); +const { + enforceRetention, + syncNativeAutoMod +} = require('../ping-protection'); const schedule = require('node-schedule'); module.exports.run = async function (client) { diff --git a/modules/ping-protection/events/guildMemberAdd.js b/modules/ping-protection/events/guildMemberAdd.js index 8420f997..1cdb394f 100644 --- a/modules/ping-protection/events/guildMemberAdd.js +++ b/modules/ping-protection/events/guildMemberAdd.js @@ -2,7 +2,7 @@ * Checks when a member rejoins the server and updates their leaver status */ -const { markUserAsRejoined } = require('../ping-protection'); +const {markUserAsRejoined} = require('../ping-protection'); module.exports.run = async function (client, member) { if (!client.botReadyAt) return; diff --git a/modules/ping-protection/events/guildMemberRemove.js b/modules/ping-protection/events/guildMemberRemove.js index 58fa7704..e07fdb3a 100644 --- a/modules/ping-protection/events/guildMemberRemove.js +++ b/modules/ping-protection/events/guildMemberRemove.js @@ -2,7 +2,10 @@ * Checks when a member leaves the server and handles data retention and/or deletion */ -const { markUserAsLeft, deleteAllUserData } = require('../ping-protection'); +const { + markUserAsLeft, + deleteAllUserData +} = require('../ping-protection'); module.exports.run = async function (client, member) { if (!client.botReadyAt) return; diff --git a/modules/ping-protection/events/interactionCreate.js b/modules/ping-protection/events/interactionCreate.js index 042de12a..f6288ae5 100644 --- a/modules/ping-protection/events/interactionCreate.js +++ b/modules/ping-protection/events/interactionCreate.js @@ -1,13 +1,23 @@ -const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, MessageFlags } = require('discord.js'); -const { deleteAllUserData, generateHistoryResponse, generateActionsResponse } = require('../ping-protection'); -const { localize } = require('../../../src/functions/localize'); +const { + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder, + MessageFlags +} = require('discord.js'); +const { + deleteAllUserData, + generateHistoryResponse, + generateActionsResponse +} = require('../ping-protection'); +const {localize} = require('../../../src/functions/localize'); // Interaction handler module.exports.run = async function (client, interaction) { if (!client.botReadyAt) return; - + if (interaction.isButton() && interaction.customId.startsWith('ping-protection_')) { - + // Ping history pagination if (interaction.customId.startsWith('ping-protection_hist-page_')) { const parts = interaction.customId.split('_'); @@ -16,14 +26,14 @@ module.exports.run = async function (client, interaction) { const replyOptions = await generateHistoryResponse(client, userId, targetPage); await interaction.update(replyOptions); - return; + return; } if (interaction.customId.startsWith('ping-protection_mod-page_')) { const parts = interaction.customId.split('_'); const userId = parts[2]; const targetPage = parseInt(parts[3]); - + const replyOptions = await generateActionsResponse(client, userId, targetPage); await interaction.update(replyOptions); return; @@ -31,39 +41,37 @@ module.exports.run = async function (client, interaction) { // Panel buttons const [prefix, action, userId] = interaction.customId.split('_'); - - const isAdmin = interaction.member.permissions.has('Administrator') || - (client.config.admins || []).includes(interaction.user.id); + + const isAdmin = interaction.member.permissions.has('Administrator') || + (client.config.admins || []).includes(interaction.user.id); if (['history', 'actions', 'delete'].includes(action)) { - if (!isAdmin) return interaction.reply({ - content: localize('ping-protection', 'no-permission'), - flags: MessageFlags.Ephemeral }); + if (!isAdmin) return interaction.reply({ + content: localize('ping-protection', 'no-permission'), + flags: MessageFlags.Ephemeral + }); } if (action === 'history') { const replyOptions = await generateHistoryResponse(client, userId, 1); - await interaction.reply({ - ...replyOptions, - flags: MessageFlags.Ephemeral + await interaction.reply({ + ...replyOptions, + flags: MessageFlags.Ephemeral }); - } - - else if (action === 'actions') { + } else if (action === 'actions') { const replyOptions = await generateActionsResponse(client, userId, 1); - await interaction.reply({ - ...replyOptions, - flags: MessageFlags.Ephemeral + await interaction.reply({ + ...replyOptions, + flags: MessageFlags.Ephemeral }); - } - else if (action === 'delete') { + } else if (action === 'delete') { const modal = new ModalBuilder() .setCustomId(`ping-protection_confirm-delete_${userId}`) .setTitle(localize('ping-protection', 'modal-title')); const input = new TextInputBuilder() .setCustomId('confirmation_text') - .setLabel(localize('ping-protection', 'modal-label')) + .setLabel(localize('ping-protection', 'modal-label')) .setStyle(TextInputStyle.Paragraph) .setPlaceholder(localize('ping-protection', 'modal-phrase')) .setRequired(true); @@ -78,17 +86,19 @@ module.exports.run = async function (client, interaction) { if (interaction.isModalSubmit() && interaction.customId.startsWith('ping-protection_confirm-delete_')) { const userId = interaction.customId.split('_')[2]; const userInput = interaction.fields.getTextInputValue('confirmation_text'); - const requiredPhrase = localize('ping-protection', 'modal-phrase', { locale: interaction.locale }); + const requiredPhrase = localize('ping-protection', 'modal-phrase', {locale: interaction.locale}); if (userInput === requiredPhrase) { await deleteAllUserData(client, userId); - await interaction.reply({ - content: `✅ ${localize('ping-protection', 'modal-success-data-deletion', {u: userId})}`, - flags: MessageFlags.Ephemeral }); + await interaction.reply({ + content: `✅ ${localize('ping-protection', 'modal-success-data-deletion', {u: userId})}`, + flags: MessageFlags.Ephemeral + }); } else { - await interaction.reply({ - content: `❌ ${localize('ping-protection', 'modal-failed')}`, - flags: MessageFlags.Ephemeral }); + await interaction.reply({ + content: `❌ ${localize('ping-protection', 'modal-failed')}`, + flags: MessageFlags.Ephemeral + }); } } }; \ No newline at end of file diff --git a/modules/ping-protection/events/messageCreate.js b/modules/ping-protection/events/messageCreate.js index e551fb04..6de69cdc 100644 --- a/modules/ping-protection/events/messageCreate.js +++ b/modules/ping-protection/events/messageCreate.js @@ -1,9 +1,9 @@ -const { +const { processPing, sendPingWarning } = require('../ping-protection'); -const { localize } = require('../../../src/functions/localize'); -const { randomElementFromArray } = require('../../../src/functions/helpers'); +const {localize} = require('../../../src/functions/localize'); +const {randomElementFromArray} = require('../../../src/functions/helpers'); // Tracks the last meme for duplicates + counts for grind message const lastMemeMap = new Map(); @@ -32,8 +32,7 @@ module.exports.run = async function (client, message) { mentionedUsers.forEach(user => { if (config.protectedUsers.includes(user.id)) { protectedMentions.add(user.id); - } - else if (config.protectAllUsersWithProtectedRole) { + } else if (config.protectAllUsersWithProtectedRole) { const member = message.mentions.members.get(user.id); if (member && member.roles.cache.some(r => config.protectedRoles.includes(r.id))) { protectedMentions.add(user.id); @@ -45,7 +44,7 @@ module.exports.run = async function (client, message) { // Handles reply pings if (config.allowReplyPings && message.mentions.repliedUser) { const repliedId = message.mentions.repliedUser.id; - + if (protectedMentions.has(repliedId)) { const manualMentionRegex = new RegExp(`<@!?${repliedId}>`); const isManualPing = manualMentionRegex.test(message.content); @@ -60,7 +59,7 @@ module.exports.run = async function (client, message) { const pingedProtectedUser = protectedMentions.size > 0; if (!pingedProtectedRole && !pingedProtectedUser) return; - + let target = null; if (pingedProtectedUser) { const firstId = protectedMentions.values().next().value; @@ -69,48 +68,49 @@ module.exports.run = async function (client, message) { target = message.mentions.roles.find(r => config.protectedRoles.includes(r.id)); } - if (!target) return; + if (!target) return; // Funny easter egg when they ping themselves - if (target.id === message.author.id && config.selfPingConfiguration === "Ignored") return; - if (target.id === message.author.id && config.selfPingConfiguration === "Get fun easter eggs when pinging themselves") { - const secretChance = 0.01; // Secret for a reason.. (1% chance) - const standardMemes = [ - localize('ping-protection', 'meme-why'), - localize('ping-protection', 'meme-played'), - localize('ping-protection', 'meme-spider') - ]; - const secretMeme = localize('ping-protection', 'meme-rick'); - const currentCount = (selfPingCountMap.get(message.author.id) || 0) + 1; - selfPingCountMap.set(message.author.id, currentCount); - - setTimeout(() => { - selfPingCountMap.delete(message.author.id); - }, 300000); - - const roll = Math.random(); - let content = ''; - - if (roll < secretChance) { - content = secretMeme; - lastMemeMap.set(message.author.id, -1); - selfPingCountMap.delete(message.author.id); - } else if (currentCount === 5) { - content = localize('ping-protection', 'meme-grind'); - } else { - const lastIndex = lastMemeMap.get(message.author.id); - - let possibleMemes = standardMemes.map((_, index) => index); - if (lastIndex !== undefined && lastIndex !== -1 && standardMemes.length > 1) { - possibleMemes = possibleMemes.filter(i => i !== lastIndex); - } - - const randomIndex = randomElementFromArray(possibleMemes); - content = standardMemes[randomIndex]; - lastMemeMap.set(message.author.id, randomIndex); + if (target.id === message.author.id && config.selfPingConfiguration === 'Ignored') return; + if (target.id === message.author.id && config.selfPingConfiguration === 'Get fun easter eggs when pinging themselves') { + const secretChance = 0.01; // Secret for a reason.. (1% chance) + const standardMemes = [ + localize('ping-protection', 'meme-why'), + localize('ping-protection', 'meme-played'), + localize('ping-protection', 'meme-spider') + ]; + const secretMeme = localize('ping-protection', 'meme-rick'); + const currentCount = (selfPingCountMap.get(message.author.id) || 0) + 1; + selfPingCountMap.set(message.author.id, currentCount); + + setTimeout(() => { + selfPingCountMap.delete(message.author.id); + }, 300000); + + const roll = Math.random(); + let content = ''; + + if (roll < secretChance) { + content = secretMeme; + lastMemeMap.set(message.author.id, -1); + selfPingCountMap.delete(message.author.id); + } else if (currentCount === 5) { + content = localize('ping-protection', 'meme-grind'); + } else { + const lastIndex = lastMemeMap.get(message.author.id); + + let possibleMemes = standardMemes.map((_, index) => index); + if (lastIndex !== undefined && lastIndex !== -1 && standardMemes.length > 1) { + possibleMemes = possibleMemes.filter(i => i !== lastIndex); } - await message.reply({ content: content }).catch(() => {}); - return; + + const randomIndex = randomElementFromArray(possibleMemes); + content = standardMemes[randomIndex]; + lastMemeMap.set(message.author.id, randomIndex); + } + await message.reply({content: content}).catch(() => { + }); + return; } await sendPingWarning(client, message, target, config); @@ -118,18 +118,20 @@ module.exports.run = async function (client, message) { const isRole = !target.username; let memberToPunish = message.member; if (!memberToPunish) { - try { - memberToPunish = await message.guild.members.fetch(message.author.id); - } catch (e) {return;} + try { + memberToPunish = await message.guild.members.fetch(message.author.id); + } catch (e) { + return; + } } await processPing( - client, - message.author.id, - target.id, - isRole, + client, + message.author.id, + target.id, + isRole, message.url, - message.channel, + message.channel, memberToPunish ); }; \ No newline at end of file diff --git a/modules/ping-protection/models/LeaverData.js b/modules/ping-protection/models/LeaverData.js index 1727dcff..b25e009d 100644 --- a/modules/ping-protection/models/LeaverData.js +++ b/modules/ping-protection/models/LeaverData.js @@ -1,4 +1,7 @@ -const { DataTypes, Model } = require('sequelize'); +const { + DataTypes, + Model +} = require('sequelize'); module.exports = class PingProtectionLeaverData extends Model { static init(sequelize) { diff --git a/modules/ping-protection/models/ModerationLog.js b/modules/ping-protection/models/ModerationLog.js index c90099f8..28691b04 100644 --- a/modules/ping-protection/models/ModerationLog.js +++ b/modules/ping-protection/models/ModerationLog.js @@ -1,9 +1,12 @@ -const { DataTypes, Model } = require('sequelize'); +const { + DataTypes, + Model +} = require('sequelize'); module.exports = class PingProtectionModerationLog extends Model { static init(sequelize) { return super.init({ - id: { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true, @@ -17,7 +20,7 @@ module.exports = class PingProtectionModerationLog extends Model { type: DataTypes.STRING, allowNull: false }, - reason: { + reason: { type: DataTypes.STRING, allowNull: true }, diff --git a/modules/ping-protection/models/PingHistory.js b/modules/ping-protection/models/PingHistory.js index 268418a8..709e26e1 100644 --- a/modules/ping-protection/models/PingHistory.js +++ b/modules/ping-protection/models/PingHistory.js @@ -1,4 +1,7 @@ -const { DataTypes, Model } = require('sequelize'); +const { + DataTypes, + Model +} = require('sequelize'); module.exports = class PingProtectionPingHistory extends Model { static init(sequelize) { diff --git a/modules/ping-protection/module.json b/modules/ping-protection/module.json index b945a1c7..f813f948 100644 --- a/modules/ping-protection/module.json +++ b/modules/ping-protection/module.json @@ -17,12 +17,7 @@ "tags": [ "moderation" ], - "humanReadableName": { - "en": "Ping-Protection", - "de": "Ping-Schutz" - }, - "description": { - "en": "Powerful and highly customizable ping-protection module to protect members/roles from unwanted mentions with moderation capabilities.", - "de": "Leistungsstarkes und hochgradig anpassbares Ping-Schutz-Modul zum Schutz von Mitgliedern/Rollen vor unerwünschten Erwähnungen mit Moderationsfunktionen." - } -} \ No newline at end of file + "fa-icon": "fa-duotone fa-clock-alarm", + "humanReadableName": "Ping-Protection", + "description": "Powerful and highly customizable ping-protection module to protect members/roles from unwanted mentions with moderation capabilities." +} diff --git a/modules/ping-protection/ping-protection.js b/modules/ping-protection/ping-protection.js index 012143dd..b0adb378 100644 --- a/modules/ping-protection/ping-protection.js +++ b/modules/ping-protection/ping-protection.js @@ -3,10 +3,20 @@ * @module ping-protection * @author itskevinnn */ -const { Op } = require('sequelize'); -const { ActionRowBuilder, ButtonBuilder, EmbedBuilder, ButtonStyle } = require('discord.js'); -const { embedType, embedTypeV2, formatDate } = require('../../src/functions/helpers'); -const { localize } = require('../../src/functions/localize'); +const {Op} = require('sequelize'); +const { + ActionRowBuilder, + ButtonBuilder, + EmbedBuilder, + ButtonStyle +} = require('discord.js'); +const { + embedType, + embedTypeV2, + formatDate, + safeSetFooter +} = require('../../src/functions/helpers'); +const {localize} = require('../../src/functions/localize'); const recentPings = new Set(); @@ -26,7 +36,7 @@ async function addPing(client, userId, messageUrl, targetId, isRole) { where: { userId: userId, targetId: targetId, - createdAt: { [Op.gt]: new Date(Date.now() - duplicateWindow) } + createdAt: {[Op.gt]: new Date(Date.now() - duplicateWindow)} } }); @@ -46,35 +56,53 @@ async function getPingCountInWindow(client, userId, days) { return await client.models['ping-protection']['PingHistory'].count({ where: { userId: userId, - createdAt: { [Op.gt]: cutoffDate } + createdAt: {[Op.gt]: cutoffDate} } }); } // Fetches ping history -async function fetchPingHistory(client, userId, page = 1, limit = 8) { +async function fetchPingHistory(client, userId, page = 1, limit = 8) { const offset = (page - 1) * limit; - const { count, rows } = await client.models['ping-protection']['PingHistory'].findAndCountAll({ - where: { userId: userId }, - order: [['createdAt', 'DESC']], + const { + count, + rows + } = await client.models['ping-protection']['PingHistory'].findAndCountAll({ + where: {userId: userId}, + order: [['createdAt', 'DESC']], limit: limit, offset: offset }); - return { total: count, history: rows }; + return { + total: count, + history: rows + }; } // Fetches moderation history async function fetchModHistory(client, userId, page = 1, limit = 8) { - if (!client.models['ping-protection'] || !client.models['ping-protection']['ModerationLog']) return { total: 0, history: [] }; + if (!client.models['ping-protection'] || !client.models['ping-protection']['ModerationLog']) return { + total: 0, + history: [] + }; try { const offset = (page - 1) * limit; - const { count, rows } = await client.models['ping-protection']['ModerationLog'].findAndCountAll({ - where: { victimID: userId }, + const { + count, + rows + } = await client.models['ping-protection']['ModerationLog'].findAndCountAll({ + where: {victimID: userId}, order: [['createdAt', 'DESC']], limit: limit, offset: offset }); - return { total: count, history: rows }; + return { + total: count, + history: rows + }; } catch (e) { - return { total: 0, history: [] }; + return { + total: 0, + history: [] + }; } } // Gets leaver status @@ -100,7 +128,7 @@ async function sendPingWarning(client, message, target, moduleConfig) { const warningMsg = moduleConfig.pingWarningMessage; if (!warningMsg) return; - let warnMsg = { ...warningMsg }; + let warnMsg = {...warningMsg}; const placeholders = { '%target-name%': target.name || target.tag || target.username || 'Unknown', '%target-mention%': target.toString(), @@ -111,7 +139,8 @@ async function sendPingWarning(client, message, target, moduleConfig) { try { let messageOptions = await embedTypeV2(warnMsg, placeholders); return message.reply(messageOptions).catch(async () => { - return message.channel.send(messageOptions).catch(() => {}); + return message.channel.send(messageOptions).catch(() => { + }); }); } catch (error) { client.logger.warn(`[Ping Protection] ${error.message}`); @@ -121,7 +150,7 @@ async function sendPingWarning(client, message, target, moduleConfig) { // Syncs the native AutoMod rule based on configuration async function syncNativeAutoMod(client) { const config = client.configurations['ping-protection']['configuration']; - + try { const guild = await client.guilds.fetch(client.guildID); const rules = await guild.autoModerationRules.fetch(); @@ -130,7 +159,8 @@ async function syncNativeAutoMod(client) { // Logic to disable/delete the rule if (!config || !config.enableAutomod) { if (existingRule) { - await existingRule.delete().catch(() => {}); + await existingRule.delete().catch(() => { + }); } return; } @@ -144,13 +174,13 @@ async function syncNativeAutoMod(client) { const protectedIdsSet = new Set(config.protectedUsers || []); if (config.protectAllUsersWithProtectedRole && config.protectedRoles && config.protectedRoles.length > 0) { - guild.members.cache.forEach(member => { + guild.members.cache.forEach(member => { if (member.roles.cache.some(r => config.protectedRoles.includes(r.id))) { protectedIdsSet.add(member.id); } }); } - + protectedIdsSet.forEach(id => { keywords.push(`<@${id}>`); keywords.push(`<@!${id}>`); @@ -158,36 +188,40 @@ async function syncNativeAutoMod(client) { if (keywords.length === 0) { if (existingRule) { - await existingRule.delete().catch(() => {}); + await existingRule.delete().catch(() => { + }); } return; } if (keywords.length > 1000) { client.logger.warn(localize('ping-protection', 'log-automod-keyword-limit')); - keywords.splice(1000); + keywords.splice(1000); } - + // AutoMod rule data const actions = []; const blockMetadata = {}; if (config.autoModBlockMessage) { blockMetadata.customMessage = config.autoModBlockMessage; } - actions.push({ type: 1, metadata: blockMetadata }); + actions.push({ + type: 1, + metadata: blockMetadata + }); const alertChannelId = getSafeChannelId(config.autoModLogChannel); if (alertChannelId) { actions.push({ - type: 2, - metadata: { channel: alertChannelId } + type: 2, + metadata: {channel: alertChannelId} }); } const ruleData = { name: 'Ping Protection System', - eventType: 1, - triggerType: 1, + eventType: 1, + triggerType: 1, triggerMetadata: { keywordFilter: keywords }, @@ -222,20 +256,20 @@ async function generateHistoryResponse(client, userId, page = 1) { totalPages = Math.ceil(total / limit) || 1; } - const user = await client.users.fetch(userId).catch(() => ({ - username: 'Unknown User', - displayAvatarURL: () => null + const user = await client.users.fetch(userId).catch(() => ({ + username: 'Unknown User', + displayAvatarURL: () => null })); - + const leaverData = await getLeaverStatus(client, userId); - let description = ""; - + let description = ''; + if (leaverData) { const dateStr = formatDate(leaverData.leftAt); - const warningKey = history.length > 0 - ? 'leaver-warning-long' - : 'leaver-warning-short'; - description += `⚠️ ${localize('ping-protection', warningKey, { d: dateStr })}\n\n`; + const warningKey = history.length > 0 + ? 'leaver-warning-long' + : 'leaver-warning-short'; + description += `⚠️ ${localize('ping-protection', warningKey, {d: dateStr})}\n\n`; } if (!isEnabled) { @@ -245,15 +279,15 @@ async function generateHistoryResponse(client, userId, page = 1) { } else { const lines = history.map((entry, index) => { const timeString = formatDate(entry.createdAt); - - let targetString = "Detected"; + + let targetString = 'Detected'; if (entry.targetId) { targetString = entry.isRole ? `<@&${entry.targetId}>` : `<@${entry.targetId}>`; } const hasValidLink = entry.messageUrl && entry.messageUrl !== 'Blocked by AutoMod'; const linkText = hasValidLink - ? `[${localize('ping-protection', 'label-jump')}](${entry.messageUrl})` + ? `[${localize('ping-protection', 'label-jump')}](${entry.messageUrl})` : localize('ping-protection', 'no-message-link'); return localize('ping-protection', 'list-entry-text', { @@ -285,23 +319,21 @@ async function generateHistoryResponse(client, userId, page = 1) { ); const embed = new EmbedBuilder() - .setTitle(localize('ping-protection', 'embed-history-title', { - u: user.username + .setTitle(localize('ping-protection', 'embed-history-title', { + u: user.username })) - .setThumbnail(user.displayAvatarURL({ - dynamic: true + .setThumbnail(user.displayAvatarURL({ + dynamic: true })) .setDescription(description) - .setColor('Orange') - .setFooter({ - text: client.strings.footer, - iconURL: client.strings.footerImgUrl - }); + .setColor('Orange'); + + safeSetFooter(embed, client); if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); - return { - embeds: [embed.toJSON()], - components: [row.toJSON()] + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] }; } @@ -318,12 +350,12 @@ async function generateActionsResponse(client, userId, page = 1) { history = data.history; totalPages = Math.ceil(total / limit) || 1; - const user = await client.users.fetch(userId).catch(() => ({ - username: 'Unknown User', - displayAvatarURL: () => null + const user = await client.users.fetch(userId).catch(() => ({ + username: 'Unknown User', + displayAvatarURL: () => null })); - - let description = ""; + + let description = ''; if (history.length === 0) { description += localize('ping-protection', 'no-data-found'); @@ -355,55 +387,53 @@ async function generateActionsResponse(client, userId, page = 1) { ); const embed = new EmbedBuilder() - .setTitle(localize('ping-protection', 'embed-actions-title', { - u: user.username + .setTitle(localize('ping-protection', 'embed-actions-title', { + u: user.username })) - .setThumbnail(user.displayAvatarURL({ - dynamic: true + .setThumbnail(user.displayAvatarURL({ + dynamic: true })) .setDescription(description) - .setColor(isEnabled - ? 'Red' + .setColor(isEnabled + ? 'Red' : 'Grey' - ) - .setFooter({ - text: client.strings.footer, - iconURL: client.strings.footerImgUrl - }); + ); + + safeSetFooter(embed, client); if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); - return { - embeds: [embed.toJSON()], - components: [row.toJSON()] + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] }; } // Handles data deletion async function deleteAllUserData(client, userId) { - await client.models['ping-protection']['PingHistory'].destroy({ - where: { userId: userId } + await client.models['ping-protection']['PingHistory'].destroy({ + where: {userId: userId} }); - await client.models['ping-protection']['ModerationLog'].destroy({ - where: { victimID: userId } + await client.models['ping-protection']['ModerationLog'].destroy({ + where: {victimID: userId} }); - await client.models['ping-protection']['LeaverData'].destroy({ - where: { userId: userId } + await client.models['ping-protection']['LeaverData'].destroy({ + where: {userId: userId} }); - client.logger.info(localize('ping-protection', 'log-data-deletion', { - u: userId + client.logger.info(localize('ping-protection', 'log-data-deletion', { + u: userId })); } async function markUserAsLeft(client, userId) { - await client.models['ping-protection']['LeaverData'].upsert({ - userId: userId, - leftAt: new Date() + await client.models['ping-protection']['LeaverData'].upsert({ + userId: userId, + leftAt: new Date() }); } async function markUserAsRejoined(client, userId) { - await client.models['ping-protection']['LeaverData'].destroy({ - where: { userId: userId } + await client.models['ping-protection']['LeaverData'].destroy({ + where: {userId: userId} }); } @@ -419,8 +449,8 @@ async function enforceRetention(client) { if (storageConfig.DeleteAllPingHistoryAfterTimeframe) { const usersWithExpiredData = await client.models['ping-protection']['PingHistory'].findAll({ - where: { - createdAt: { [Op.lt]: historyCutoff } + where: { + createdAt: {[Op.lt]: historyCutoff} }, attributes: ['userId'], group: ['userId'] @@ -429,32 +459,31 @@ async function enforceRetention(client) { const userIdsToWipe = usersWithExpiredData.map(entry => entry.userId); if (userIdsToWipe.length > 0) { await client.models['ping-protection']['PingHistory'].destroy({ - where: { userId: userIdsToWipe } + where: {userId: userIdsToWipe} }); } - } - else { - await client.models['ping-protection']['PingHistory'].destroy({ - where: { createdAt: { [Op.lt]: historyCutoff } } + } else { + await client.models['ping-protection']['PingHistory'].destroy({ + where: {createdAt: {[Op.lt]: historyCutoff}} }); } } if (storageConfig.modLogRetention) { const modCutoff = new Date(); modCutoff.setMonth(modCutoff.getMonth() - (storageConfig.modLogRetention || 12)); - await client.models['ping-protection']['ModerationLog'].destroy({ - where: { - createdAt: { [Op.lt]: modCutoff } - } + await client.models['ping-protection']['ModerationLog'].destroy({ + where: { + createdAt: {[Op.lt]: modCutoff} + } }); } if (storageConfig.enableLeaverDataRetention) { const leaverCutoff = new Date(); leaverCutoff.setDate(leaverCutoff.getDate() - (storageConfig.leaverRetention || 1)); - const leaversToDelete = await client.models['ping-protection']['LeaverData'].findAll({ - where: { - leftAt: { [Op.lt]: leaverCutoff } - } + const leaversToDelete = await client.models['ping-protection']['LeaverData'].findAll({ + where: { + leftAt: {[Op.lt]: leaverCutoff} + } }); for (const leaver of leaversToDelete) { await deleteAllUserData(client, leaver.userId); @@ -465,15 +494,15 @@ async function enforceRetention(client) { // Executes moderation action async function executeAction(client, member, rule, reason, storageConfig, originChannel = null, stats = {}) { - const actionType = rule.actionType; - + const actionType = rule.actionType; + // Sends action log if enabled const sendActionLog = async () => { if (!rule.enableActionLogging || !originChannel) return; const logMsgConfig = rule.actionLogMessage; if (!logMsgConfig) return; - let safeMsg = { ...logMsgConfig }; + let safeMsg = {...logMsgConfig}; const placeholders = { '%pinger-mention%': member.toString(), @@ -486,10 +515,11 @@ async function executeAction(client, member, rule, reason, storageConfig, origin try { let messageOptions = await embedTypeV2(safeMsg, placeholders); - await originChannel.send(messageOptions).catch(() => {}); + await originChannel.send(messageOptions).catch(() => { + }); } catch (error) { - client.logger.warn(localize('ping-protection', 'log-action-log-failed', { - e: error.message + client.logger.warn(localize('ping-protection', 'log-action-log-failed', { + e: error.message })); } }; @@ -497,29 +527,28 @@ async function executeAction(client, member, rule, reason, storageConfig, origin // Sends error message if action fails const sendErrorLog = async (error) => { if (!originChannel) return; - - const errorEmbed = new EmbedBuilder() - .setTitle(localize('ping-protection', 'punish-log-failed-title', { - u: member.user.tag - })) - .setDescription( - localize('ping-protection', 'punish-log-failed-desc', { - m: member.toString() - }) + - `\n${localize('ping-protection', 'punish-log-error', { - e: error.message - })}` - ) - .setColor("#ed4245") - .setFooter({ - text: client.strings.footer, - iconURL: client.strings.footerImgUrl - }); - if (!client.strings.disableFooterTimestamp) errorEmbed.setTimestamp(); - await originChannel.send({ embeds: [errorEmbed.toJSON()] }).catch(() => {}); + const errorEmbed = new EmbedBuilder() + .setTitle(localize('ping-protection', 'punish-log-failed-title', { + u: member.user.tag + })) + .setDescription( + localize('ping-protection', 'punish-log-failed-desc', { + m: member.toString() + }) + + `\n${localize('ping-protection', 'punish-log-error', { + e: error.message + })}` + ) + .setColor('#ed4245'); + + safeSetFooter(errorEmbed, client); + if (!client.strings.disableFooterTimestamp) errorEmbed.setTimestamp(); + + await originChannel.send({embeds: [errorEmbed.toJSON()]}).catch(() => { + }); }; - + if (!member) { client.logger.debug(localize('ping-protection', 'log-not-a-member')); return false; @@ -527,10 +556,10 @@ async function executeAction(client, member, rule, reason, storageConfig, origin const botMember = await member.guild.members.fetch(client.user.id); if (botMember.roles.highest.position <= member.roles.highest.position) { - await sendErrorLog({ - message: localize('ping-protection', 'punish-role-error', { - tag: member.user.tag - }) + await sendErrorLog({ + message: localize('ping-protection', 'punish-role-error', { + tag: member.user.tag + }) }); client.logger.warn(localize('ping-protection', 'log-punish-role-error', { tag: member.user.tag @@ -543,39 +572,39 @@ async function executeAction(client, member, rule, reason, storageConfig, origin await client.models['ping-protection']['ModerationLog'].create({ victimID: member.id, type, actionDuration: duration, reason }); - } catch (dbError) {} + } catch (dbError) { + } }; if (actionType === 'MUTE') { const durationMs = rule.muteDuration * 60000; await logDb('MUTE', rule.muteDuration); - try { - await member.timeout(durationMs, reason); + try { + await member.timeout(durationMs, reason); await sendActionLog(); - return true; - } catch (error) { + return true; + } catch (error) { await sendErrorLog(error); client.logger.warn(localize('ping-protection', 'log-mute-error', { - tag: member.user.tag, + tag: member.user.tag, e: error.message })); - return false; + return false; } - } - else if (actionType === 'KICK') { + } else if (actionType === 'KICK') { await logDb('KICK'); - try { - await member.kick(reason); + try { + await member.kick(reason); await sendActionLog(); - return true; - } catch (error) { + return true; + } catch (error) { await sendErrorLog(error); client.logger.warn(localize('ping-protection', 'log-kick-error', { - tag: member.user.tag, + tag: member.user.tag, e: error.message })); - return false; + return false; } } return false; @@ -590,7 +619,8 @@ async function processPing(client, userId, targetId, isRole, messageUrl, originC if (storageConfig?.enablePingHistory) { try { await addPing(client, userId, messageUrl, targetId, isRole); - } catch (e) {} + } catch (e) { + } } if (!moderationRules || !Array.isArray(moderationRules) || moderationRules.length === 0) return; @@ -599,16 +629,16 @@ async function processPing(client, userId, targetId, isRole, messageUrl, originC const rule = moderationRules[i]; const retentionWeeks = storageConfig?.pingHistoryRetention || 12; - const timeframeDays = rule.useCustomTimeframe - ? (rule.timeframeDays || 7) - : (retentionWeeks * 7); + const timeframeDays = rule.useCustomTimeframe + ? (rule.timeframeDays || 7) + : (retentionWeeks * 7); const pingCount = await getPingCountInWindow(client, userId, timeframeDays); const requiredCount = rule.pingsCount ?? rule.pingsCountAdvanced ?? rule.pingsCountBasic; - + // Skip this rule if no valid threshold is configured if (typeof requiredCount !== 'number' || !Number.isFinite(requiredCount)) { continue; @@ -618,21 +648,24 @@ async function processPing(client, userId, targetId, isRole, messageUrl, originC const oneMinuteAgo = new Date(Date.now() - 60000); try { const recentLog = await client.models['ping-protection']['ModerationLog'].findOne({ - where: { - victimID: userId, - createdAt: { [Op.gt]: oneMinuteAgo } + where: { + victimID: userId, + createdAt: {[Op.gt]: oneMinuteAgo} } }); if (recentLog) break; - } catch (e) {} + } catch (e) { + } - const generatedReason = rule.useCustomTimeframe - ? localize('ping-protection', 'reason-advanced', { - c: pingCount, - d: timeframeDays }) - : localize('ping-protection', 'reason-basic', { - c: pingCount, - w: retentionWeeks }); + const generatedReason = rule.useCustomTimeframe + ? localize('ping-protection', 'reason-advanced', { + c: pingCount, + d: timeframeDays + }) + : localize('ping-protection', 'reason-basic', { + c: pingCount, + w: retentionWeeks + }); if (memberToPunish) { const success = await executeAction( @@ -642,9 +675,12 @@ async function processPing(client, userId, targetId, isRole, messageUrl, originC generatedReason, storageConfig, originChannel, - { pingCount, timeframeDays } + { + pingCount, + timeframeDays + } ); - + if (success) break; } } diff --git a/modules/polls/configs/config.json b/modules/polls/configs/config.json index 63b26001..b8113d80 100644 --- a/modules/polls/configs/config.json +++ b/modules/polls/configs/config.json @@ -1,12 +1,6 @@ { - "description": { - "en": "Configure the function of the module here", - "de": "Stelle hier die Funktionen des Modules ein" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the function of the module here", + "humanName": "Configuration", "filename": "config.json", "commandsWarnings": { "normal": [ @@ -16,38 +10,19 @@ "content": [ { "name": "reactions", - "humanName": { - "de": "Emojis", - "en": "Emojis" - }, + "humanName": "Emojis", "default": { - "en": { - "1": "1️⃣", - "2": "2️⃣", - "3": "3️⃣", - "4": "4️⃣", - "5": "5️⃣", - "6": "6️⃣", - "7": "7️⃣", - "8": "8️⃣", - "9": "9️⃣" - }, - "de": { - "1": "1️⃣", - "2": "2️⃣", - "3": "3️⃣", - "4": "4️⃣", - "5": "5️⃣", - "6": "6️⃣", - "7": "7️⃣", - "8": "8️⃣", - "9": "9️⃣" - } - }, - "description": { - "en": "You can set the different emojis to use", - "de": "Du kannst die verschiedenen Emojis, die benutzt werden sollen, einstellen" + "1": "1️⃣", + "2": "2️⃣", + "3": "3️⃣", + "4": "4️⃣", + "5": "5️⃣", + "6": "6️⃣", + "7": "7️⃣", + "8": "8️⃣", + "9": "9️⃣" }, + "description": "You can set the different emojis to use", "type": "keyed", "content": { "key": "string", diff --git a/modules/polls/configs/strings.json b/modules/polls/configs/strings.json index ad3d920e..37d73e69 100644 --- a/modules/polls/configs/strings.json +++ b/modules/polls/configs/strings.json @@ -1,47 +1,23 @@ { - "description": { - "en": "Edit the messages and strings of the module here", - "de": "Stelle hier die Nachrichten des Modules ein" - }, - "humanName": { - "en": "Messages", - "de": "Nachrichten" - }, + "description": "Edit the messages and strings of the module here", + "humanName": "Messages", "filename": "strings.json", "content": [ { "name": "embed", - "humanName": { - "de": "Embed" - }, + "humanName": "Embed", "default": { - "en": { - "title": "New Poll", - "color": "BLUE", - "options": "Today's options", - "liveView": "Live-Views of the results", - "expiresOn": "End of this poll", - "thisPollExpiresOn": "This poll expires on %date%.", - "endedPollTitle": "Poll ended", - "visibility": "Visibility of votes", - "endedPollColor": "RED" - }, - "de": { - "title": "Neue Umfrage", - "color": "BLUE", - "options": "Heutige Auswahlmöglichkeiten", - "liveView": "Live-Anzeige der Ergebnisse", - "expiresOn": "Ende dieser Umfrage", - "visibility": "Sichtbarkeit der Stimmen", - "thisPollExpiresOn": "Diese Umfrage endet am %date%.", - "endedPollTitle": "Umfrage beendet", - "endedPollColor": "RED" - } - }, - "description": { - "en": "You can edit the settings of your embed here", - "de": "Du kannst die Einstellungen des Embeds hier bearbeiten" + "title": "New Poll", + "color": "BLUE", + "options": "Today's options", + "liveView": "Live-Views of the results", + "expiresOn": "End of this poll", + "thisPollExpiresOn": "This poll expires on %date%.", + "endedPollTitle": "Poll ended", + "visibility": "Visibility of votes", + "endedPollColor": "RED" }, + "description": "You can edit the settings of your embed here", "type": "keyed", "content": { "key": "string", @@ -50,4 +26,4 @@ "disableKeyEdits": true } ] -} \ No newline at end of file +} diff --git a/modules/polls/module.json b/modules/polls/module.json index 9c55497d..40e924e6 100644 --- a/modules/polls/module.json +++ b/modules/polls/module.json @@ -5,13 +5,11 @@ "name": "SCDerox (SC Network Team)", "link": "https://github.com/SCDerox" }, - "description": { - "en": "Simple module to create fresh polls on your server! Supports anonymous polls and more.", - "de": "Einfaches Modul, um coole Umfragen auf deinem Server zu erstellen! Unterstützt anonyme Umfragen und mehr." - }, + "description": "Simple module to create fresh polls on your server! Supports anonymous polls and more.", "events-dir": "/events", "commands-dir": "/commands", "models-dir": "/models", + "fa-icon": "fas fa-poll", "config-example-files": [ "configs/config.json", "configs/strings.json" @@ -20,8 +18,5 @@ "community" ], "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/polls", - "humanReadableName": { - "en": "Polls", - "de": "Umfragen" - } -} \ No newline at end of file + "humanReadableName": "Polls" +} diff --git a/modules/quiz/commands/quiz.js b/modules/quiz/commands/quiz.js index bc3a32fd..54773b69 100644 --- a/modules/quiz/commands/quiz.js +++ b/modules/quiz/commands/quiz.js @@ -3,7 +3,8 @@ const durationParser = require('parse-duration'); const { formatDate, shuffleArray, - parseEmbedColor + parseEmbedColor, + safeSetFooter } = require('../../../src/functions/helpers'); const {localize} = require('../../../src/functions/localize'); const {createQuiz} = require('../quizUtil'); @@ -158,10 +159,11 @@ module.exports.subcommands = { const embed = new MessageEmbed() .setTitle(moduleStrings.embed.leaderboardTitle) .setColor(parseEmbedColor(moduleStrings.embed.leaderboardColor)) - .setFooter({text: interaction.client.strings.footer, iconURL: interaction.client.strings.footerImgUrl}) .setThumbnail(interaction.guild.iconURL()) .addField(moduleStrings.embed.leaderboardSubtitle, leaderboardString); + safeSetFooter(embed, interaction.client); + if (!interaction.client.strings.disableFooterTimestamp) embed.setTimestamp(); const components = [{ diff --git a/modules/quiz/configs/config.json b/modules/quiz/configs/config.json index 3821745c..df755612 100644 --- a/modules/quiz/configs/config.json +++ b/modules/quiz/configs/config.json @@ -1,12 +1,6 @@ { - "description": { - "en": "Configure the function of the module here", - "de": "Stelle hier die Funktionen des Modules ein" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the function of the module here", + "humanName": "Configuration", "filename": "config.json", "commandsWarnings": { "normal": [ @@ -16,41 +10,21 @@ "content": [ { "name": "emojis", - "humanName": { - "de": "Emojis" - }, + "humanName": "Emojis", "default": { - "en": { - "1": "1️⃣", - "2": "2️⃣", - "3": "3️⃣", - "4": "4️⃣", - "5": "5️⃣", - "6": "6️⃣", - "7": "7️⃣", - "8": "8️⃣", - "9": "9️⃣", - "true": "✅", - "false": "❌" - }, - "de": { - "1": "1️⃣", - "2": "2️⃣", - "3": "3️⃣", - "4": "4️⃣", - "5": "5️⃣", - "6": "6️⃣", - "7": "7️⃣", - "8": "8️⃣", - "9": "9️⃣", - "true": "✅", - "false": "❌" - } - }, - "description": { - "en": "You can set the emojis to use", - "de": "Du kannst die verschiedenen Emojis, die benutzt werden sollen, einstellen" - }, + "1": "1️⃣", + "2": "2️⃣", + "3": "3️⃣", + "4": "4️⃣", + "5": "5️⃣", + "6": "6️⃣", + "7": "7️⃣", + "8": "8️⃣", + "9": "9️⃣", + "true": "✅", + "false": "❌" + }, + "description": "You can set the emojis to use", "type": "keyed", "content": { "key": "string", @@ -60,32 +34,16 @@ }, { "name": "dailyQuizLimit", - "humanName": { - "en": "Daily quiz limit", - "de": "Tägliches Quizlimit" - }, - "default": { - "en": 5 - }, - "description": { - "en": "How many quizzes can be played per day using /quiz play", - "de": "Wie viele Quiz pro Tag mit /quiz play gespielt werden können" - }, + "humanName": "Daily quiz limit", + "default": 5, + "description": "How many quizzes can be played per day using /quiz play", "type": "integer" }, { "name": "leaderboardChannel", - "humanName": { - "en": "Quiz leaderboard channel", - "de": "Quiz-Leaderboard-Kanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "In which channel the quiz leaderboard is displayed", - "de": "In welchem Kanal das Quiz-Leaderboard angezeigt wird" - }, + "humanName": "Quiz leaderboard channel", + "default": "", + "description": "In which channel the quiz leaderboard is displayed", "type": "channelID", "content": [ "GUILD_TEXT", @@ -95,32 +53,16 @@ }, { "name": "createAllowedRole", - "humanName": { - "en": "Role needed to create quizzes", - "de": "Rolle zum Erstellen von Quiz" - }, - "default": { - "en": "" - }, - "description": { - "en": "Which role a user needs to have to be able to create quizzes with /quiz create/create-bool", - "de": "Welche Rolle ein Nutzer haben muss, um Quiz mit /quiz create/create-bool erstellen zu können" - }, + "humanName": "Role needed to create quizzes", + "default": "", + "description": "Which role a user needs to have to be able to create quizzes with /quiz create/create-bool", "type": "roleID" }, { "name": "mode", - "humanName": { - "en": "Mode for quiz selection", - "de": "Modus zur Quizauswahl" - }, - "default": { - "en": "Random" - }, - "description": { - "en": "How a /quiz play quiz is selected for users", - "de": "Wie ein /quiz-play-Quiz für Nutzer ausgewählt wird" - }, + "humanName": "Mode for quiz selection", + "default": "Random", + "description": "How a /quiz play quiz is selected for users", "type": "select", "content": [ "Random", @@ -129,17 +71,9 @@ }, { "name": "livePreview", - "humanName": { - "en": "Live preview of results", - "de": "Live-Vorschau der Ergebnisse" - }, - "default": { - "en": false - }, - "description": { - "en": "Whether the live preview of results is enabled", - "de": "Ob die Live-Vorschau der Ergebnisse aktiviert ist" - }, + "humanName": "Live preview of results", + "default": false, + "description": "Whether the live preview of results is enabled", "type": "boolean" } ] diff --git a/modules/quiz/configs/quizList.json b/modules/quiz/configs/quizList.json index 5b380bd1..f99d71a6 100644 --- a/modules/quiz/configs/quizList.json +++ b/modules/quiz/configs/quizList.json @@ -1,74 +1,36 @@ { - "description": { - "en": "Create and edit the quizzes of the server", - "de": "Erstelle und bearbeite hier die Quiz des Servers" - }, - "humanName": { - "en": "Edit quiz", - "de": "Quiz bearbeiten" - }, + "description": "Create and edit the quizzes of the server", + "humanName": "Edit quiz", "configElements": true, "filename": "quizList.json", "content": [ { "name": "description", - "humanName": { - "en": "Question or statement", - "de": "Frage oder Behauptung" - }, - "default": { - "en": "" - }, - "description": { - "en": "Title/Question of the quiz", - "de": "Titel/Frage des Quiz" - }, + "humanName": "Question or statement", + "default": "", + "description": "Title/Question of the quiz", "type": "string" }, { "name": "duration", - "humanName": { - "en": "Time limit", - "de": "Zeitlimit" - }, - "default": { - "en": "1m" - }, - "description": { - "en": "How much time the user has to answer", - "de": "Wie viel Zeit der Nutzer zum Beantworten hat" - }, + "humanName": "Time limit", + "default": "1m", + "description": "How much time the user has to answer", "type": "string" }, { "name": "correctOptions", - "humanName": { - "en": "Correct answers", - "de": "Richtige Antworten" - }, - "default": { - "en": [] - }, - "description": { - "en": "Correct answers", - "de": "Richtige Antworten" - }, + "humanName": "Correct answers", + "default": [], + "description": "Correct answers", "type": "array", "content": "string" }, { "name": "wrongOptions", - "humanName": { - "en": "Wrong answers", - "de": "Falsche Antworten" - }, - "default": { - "en": [] - }, - "description": { - "en": "Wrong answers", - "de": "Falsche Antworten" - }, + "humanName": "Wrong answers", + "default": [], + "description": "Wrong answers", "type": "array", "content": "string" } diff --git a/modules/quiz/configs/strings.json b/modules/quiz/configs/strings.json index 4d5cc913..1bdc523e 100644 --- a/modules/quiz/configs/strings.json +++ b/modules/quiz/configs/strings.json @@ -1,53 +1,26 @@ { - "description": { - "en": "Edit the messages and strings of the module here", - "de": "Stelle hier die Nachrichten des Modules ein" - }, - "humanName": { - "en": "Messages", - "de": "Nachrichten" - }, + "description": "Edit the messages and strings of the module here", + "humanName": "Messages", "filename": "strings.json", "content": [ { "name": "embed", - "humanName": { - "de": "Embed" - }, + "humanName": "Embed", "default": { - "en": { - "title": "New quiz - What's right?", - "color": "BLUE", - "options": "Today's options", - "liveView": "Live view of the results", - "expiresOn": "End of this quiz", - "thisQuizExpiresOn": "This quiz expires on %date%.", - "endedQuizTitle": "Quiz ended", - "endedQuizColor": "RED", - "leaderboardTitle": "The best quiz players", - "leaderboardSubtitle": "Quiz leaderboard", - "leaderboardColor": "GREEN", - "leaderboardButton": "View my ranking" - }, - "de": { - "title": "Neues Quiz - Was ist richtig?", - "color": "BLUE", - "options": "Mögliche antworten", - "liveView": "Live-Anzeige der Ergebnisse", - "expiresOn": "Ende dieses Quiz", - "thisQuizExpiresOn": "Dieses Quiz endet am %date%.", - "endedQuizTitle": "Quiz beendet", - "endedQuizColor": "RED", - "leaderboardTitle": "Die besten Quiz-Spieler", - "leaderboardSubtitle": "Quiz-Rangliste", - "leaderboardColor": "GREEN", - "leaderboardButton": "Meine Platzierung ansehen" - } - }, - "description": { - "en": "You can edit the settings of your embed here", - "de": "Du kannst die Einstellungen des Embeds hier bearbeiten" + "title": "New quiz - What's right?", + "color": "BLUE", + "options": "Today's options", + "liveView": "Live view of the results", + "expiresOn": "End of this quiz", + "thisQuizExpiresOn": "This quiz expires on %date%.", + "endedQuizTitle": "Quiz ended", + "endedQuizColor": "RED", + "leaderboardTitle": "The best quiz players", + "leaderboardSubtitle": "Quiz leaderboard", + "leaderboardColor": "GREEN", + "leaderboardButton": "View my ranking" }, + "description": "You can edit the settings of your embed here", "type": "keyed", "content": { "key": "string", @@ -56,4 +29,4 @@ "disableKeyEdits": true } ] -} \ No newline at end of file +} diff --git a/modules/quiz/module.json b/modules/quiz/module.json index 1951b9c9..2bf2a817 100644 --- a/modules/quiz/module.json +++ b/modules/quiz/module.json @@ -1,18 +1,13 @@ { "name": "quiz", - "humanReadableName": { - "en": "Quiz Module", - "de": "Quiz-Modul" - }, + "humanReadableName": "Quiz Module", "author": { "scnxOrgID": "60", "name": "TomatoCake", "link": "https://github.com/DEVTomatoCake" }, - "description": { - "en": "Create quiz for your users and let them compete against each other.", - "de": "Erstelle Quiz für deine Nutzer und lasse sie gegeneinander antreten." - }, + "description": "Create quiz for your users and let them compete against each other.", + "fa-icon": "fas fa-clipboard-question", "events-dir": "/events", "commands-dir": "/commands", "models-dir": "/models", @@ -25,4 +20,4 @@ "fun" ], "openSourceURL": "https://github.com/DEVTomatoCake/ScootKit-CustomBot/tree/main/modules/quiz" -} \ No newline at end of file +} diff --git a/modules/quiz/quizUtil.js b/modules/quiz/quizUtil.js index e85e7554..01644d30 100644 --- a/modules/quiz/quizUtil.js +++ b/modules/quiz/quizUtil.js @@ -7,7 +7,8 @@ const {ChannelType, MessageEmbed} = require('discord.js'); const { renderProgressbar, formatDate, - parseEmbedColor + parseEmbedColor, + safeSetFooter } = require('../../src/functions/helpers'); const {localize} = require('../../src/functions/localize'); @@ -226,10 +227,11 @@ async function updateLeaderboard(client, force = false) { const embed = new MessageEmbed() .setTitle(moduleStrings.embed.leaderboardTitle) .setColor(parseEmbedColor(moduleStrings.embed.leaderboardColor)) - .setFooter({text: client.strings.footer, iconURL: client.strings.footerImgUrl}) .setThumbnail(channel.guild.iconURL()) .addField(moduleStrings.embed.leaderboardSubtitle, leaderboardString); + safeSetFooter(embed, client); + if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); const components = [{ diff --git a/modules/reminders/config.json b/modules/reminders/config.json index 3eb33511..98aee3d8 100644 --- a/modules/reminders/config.json +++ b/modules/reminders/config.json @@ -1,69 +1,37 @@ { "filename": "config.json", - "description": { - "en": "Configure the behavior of this module here", - "de": "Passe hier die Funktionen des Modules hier an" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the behavior of this module here", + "humanName": "Configuration", "content": [ { "name": "notificationMessage", "type": "string", "allowEmbed": true, - "humanName": { - "de": "Erinnerung-Nachricht", - "en": "Reminder-Message" - }, - "description": { - "de": "Diese Nachricht wird gesendet, wenn jemand erinnert wird", - "en": "This message gets send when someone gets remaindered" - }, + "humanName": "Reminder-Message", + "description": "This message gets send when someone gets remaindered", "default": { - "en": { - "title": "\uD83D\uDD14 Reminder", - "color": "#F1C40F", - "description": "%message%", - "message": "%mention%" - }, - "de": { - "title": "\uD83D\uDD14 Erinnerung", - "color": "#F1C40F", - "description": "%message%", - "message": "%mention%" - } + "title": "🔔 Reminder", + "color": "#F1C40F", + "description": "%message%", + "message": "%mention%" }, "params": [ { "name": "mention", - "description": { - "en": "Mention of the user", - "de": "Erwähnung des Nutzers" - } + "description": "Mention of the user" }, { "name": "message", - "description": { - "en": "Reminder message set by the user", - "de": "Vom Nutzer gesetze Erwähnungsnachricht" - } + "description": "Reminder message set by the user" }, { "name": "userTag", - "description": { - "en": "Tag of the user", - "de": "Tag des Nutzers" - } + "description": "Tag of the user" }, { "name": "userAvatarURL", "isImage": true, - "description": { - "en": "Avatar-URL of the user", - "de": "Profilbild-URL des Nutzers" - } + "description": "Avatar-URL of the user" } ] } diff --git a/modules/reminders/events/interactionCreate.js b/modules/reminders/events/interactionCreate.js new file mode 100644 index 00000000..0ad59d94 --- /dev/null +++ b/modules/reminders/events/interactionCreate.js @@ -0,0 +1,46 @@ +const {localize} = require('../../../src/functions/localize'); +const {formatDate} = require('../../../src/functions/helpers'); +const {planReminder} = require('../reminders'); + +const snoozeDurations = { + '10m': 10 * 60 * 1000, + '30m': 30 * 60 * 1000, + '1h': 60 * 60 * 1000, + '1d': 24 * 60 * 60 * 1000 +}; + +/** + * Handle snooze button interactions for reminders + * @param {Client} client Discord client + * @param {Interaction} interaction Button interaction + */ +module.exports.run = async function (client, interaction) { + if (!interaction.isButton()) return; + if (!interaction.customId.startsWith('reminder-snooze-')) return; + + const parts = interaction.customId.split('-'); + const durationKey = parts[2]; + const reminderID = parts[3]; + const duration = snoozeDurations[durationKey]; + if (!duration) return; + + const originalReminder = await client.models['reminders']['Reminder'].findOne({where: {id: reminderID}}); + if (!originalReminder || originalReminder.userID !== interaction.user.id) { + return interaction.reply({ephemeral: true, content: '⚠️ ' + localize('reminders', 'snooze-not-allowed')}); + } + + const newDate = new Date(new Date().getTime() + duration); + const newReminder = await client.models['reminders']['Reminder'].create({ + userID: interaction.user.id, + reminderText: originalReminder.reminderText, + date: newDate, + channelID: originalReminder.channelID + }); + planReminder(client, newReminder); + + await interaction.update({components: []}); + await interaction.followUp({ + ephemeral: true, + content: '✅ ' + localize('reminders', 'snoozed', {d: formatDate(newDate)}) + }); +}; diff --git a/modules/reminders/module.json b/modules/reminders/module.json index d790c2af..38187286 100644 --- a/modules/reminders/module.json +++ b/modules/reminders/module.json @@ -1,18 +1,12 @@ { "name": "reminders", - "humanReadableName": { - "en": "Reminders", - "de": "Erinnerungen" - }, + "humanReadableName": "Reminders", "author": { "scnxOrgID": "1", "name": "SCDerox (SC Network Team)", "link": "https://github.com/SCDerox" }, - "description": { - "en": "Let users set reminders for themselves - either via DMs or Channels", - "de": "Erlaubt es deinen Nutzer Erinnerungen für sich selbst zu setzen - entweder per PNs oder direkt in den Kanal" - }, + "description": "Let users set reminders for themselves - either via DMs or Channels", "commands-dir": "/commands", "events-dir": "/events", "config-example-files": [ @@ -22,5 +16,6 @@ "community" ], "models-dir": "/models", + "fa-icon": "far fa-bell", "holidayGift": true -} \ No newline at end of file +} diff --git a/modules/reminders/reminders.js b/modules/reminders/reminders.js index 3ce62595..0ceffd7a 100644 --- a/modules/reminders/reminders.js +++ b/modules/reminders/reminders.js @@ -1,6 +1,12 @@ const {scheduleJob} = require('node-schedule'); const {embedType, formatDiscordUserName} = require('../../src/functions/helpers'); +const {localize} = require('../../src/functions/localize'); +/** + * Plan a reminder notification + * @param {Client} client Discord client + * @param {Object} notificationObject Reminder database object + */ function planReminder(client, notificationObject) { if (!notificationObject.date || isNaN(notificationObject.date) || notificationObject.date.getTime() <= new Date().getTime()) return; const bj = scheduleJob(notificationObject.date, async () => { @@ -14,6 +20,40 @@ function planReminder(client, notificationObject) { '%message%': notificationObject.reminderText, '%userTag%': formatDiscordUserName(member.user), '%userAvatarURL%': member.user.avatarURL() + }, { + components: [{ + type: 'ACTION_ROW', + components: [ + { + type: 'BUTTON', + style: 'SECONDARY', + customId: `reminder-snooze-10m-${notificationObject.id}`, + label: localize('reminders', 'snooze-10m'), + emoji: '🔔' + }, + { + type: 'BUTTON', + style: 'SECONDARY', + customId: `reminder-snooze-30m-${notificationObject.id}`, + label: localize('reminders', 'snooze-30m'), + emoji: '🔔' + }, + { + type: 'BUTTON', + style: 'SECONDARY', + customId: `reminder-snooze-1h-${notificationObject.id}`, + label: localize('reminders', 'snooze-1h'), + emoji: '🔔' + }, + { + type: 'BUTTON', + style: 'SECONDARY', + customId: `reminder-snooze-1d-${notificationObject.id}`, + label: localize('reminders', 'snooze-1d'), + emoji: '🔔' + } + ] + }] })); }); client.jobs.push(bj); diff --git a/modules/rock-paper-scissors/commands/rock-paper-scissors.js b/modules/rock-paper-scissors/commands/rock-paper-scissors.js index 38dbb36d..127738c1 100644 --- a/modules/rock-paper-scissors/commands/rock-paper-scissors.js +++ b/modules/rock-paper-scissors/commands/rock-paper-scissors.js @@ -238,7 +238,11 @@ module.exports.run = async function (interaction) { const collector = msg.createMessageComponentCollector({ componentType: ComponentType.Button, - filter: i => i.user.id === interaction.user.id || i.user.id === user2.id + filter: i => i.user.id === interaction.user.id || i.user.id === user2.id, + time: 300000 + }); + collector.on('end', () => { + delete rpsgames[msg.id]; }); collector.on('collect', i => { const game = rpsgames[i.message.id]; diff --git a/modules/rock-paper-scissors/module.json b/modules/rock-paper-scissors/module.json index 6289dabe..43be0845 100644 --- a/modules/rock-paper-scissors/module.json +++ b/modules/rock-paper-scissors/module.json @@ -1,18 +1,13 @@ { "name": "rock-paper-scissors", - "humanReadableName": { - "en": "Rock Paper Scissors", - "de": "Schere Stein Papier" - }, + "humanReadableName": "Rock Paper Scissors", + "fa-icon": "fa-solid fa-scissors", "author": { "scnxOrgID": "60", "name": "TomatoCake", "link": "https://github.com/DEVTomatoCake" }, - "description": { - "en": "Let your users play Rock Paper Scissors against the bot and each other!", - "de": "Lasse Nutzer auf deinem Server Schere Stein Papier gegen den Bot und gegeneinander spielen" - }, + "description": "Let your users play Rock Paper Scissors against the bot and each other!", "commands-dir": "/commands", "noConfig": true, "releaseDate": "0", @@ -20,4 +15,4 @@ "fun" ], "openSourceURL": "https://github.com/DEVTomatoCake/ScootKit-CustomBot/tree/main/modules/rock-paper-scissors" -} \ No newline at end of file +} diff --git a/modules/staff-management-system/commands/duty.js b/modules/staff-management-system/commands/duty.js new file mode 100644 index 00000000..df2c0506 --- /dev/null +++ b/modules/staff-management-system/commands/duty.js @@ -0,0 +1,1547 @@ +const { MessageFlags, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } = require('discord.js'); +const { Op, fn, col, literal } = require('sequelize'); +const { + getConfig, + applyFooter, + getSafeChannelId, + formatDuration, + buildPaginationRow, + checkStaffPermissions +} = require('../staff-management'); +const { localize } = require('../../../src/functions/localize'); + +function getLookbackDate(config) { + const lookback = config.leaderboardLookback || 'Weekly'; + if (lookback === 'All-time') return null; + const date = new Date(); + if (lookback === 'Weekly') date.setDate(date.getDate() - 7); + else if (lookback === 'Monthly') date.setMonth(date.getMonth() - 1); + return date; +} + +function canUseDutyAdmin(client, member) { + const generalConfig = getConfig(client, 'configuration'); + return checkStaffPermissions(member, generalConfig, 'supervisor'); +} + +function checkDutyAdminPermission(client, interaction) { + if (canUseDutyAdmin(client, interaction.member)) return null; + + const payload = { + content: localize('staff-management-system', 'err-no-perm'), + flags: MessageFlags.Ephemeral + }; + + if (interaction.deferred || interaction.replied) { + return interaction.followUp(payload); + } + return interaction.reply(payload); +} + +async function applyBreakElapsedToShift(activeShift, breakStartTime, now = new Date()) { + if (!activeShift || !breakStartTime) return; + + const breakStartedAt = new Date(breakStartTime); + if (Number.isNaN(breakStartedAt.getTime()) || breakStartedAt > now) return; + + const elapsedBreakMs = now.getTime() - breakStartedAt.getTime(); + if (elapsedBreakMs <= 0) return; + + await activeShift.update({ + startTime: new Date(new Date(activeShift.startTime).getTime() + elapsedBreakMs) + }); +} + +function getQuotaForMember(member, config) { + if (!config.enableQuotas || !config.quotas || Object.keys(config.quotas).length === 0) return null; + + let bestQuota = null; + let highestPosition = -1; + + for (const [roleId, hoursStr] of Object.entries(config.quotas)) { + const hours = parseFloat(hoursStr); + if (isNaN(hours)) continue; + + const role = member.guild.roles.cache.get(roleId); + if (role && member.roles.cache.has(roleId) && role.position > highestPosition) { + highestPosition = role.position; + bestQuota = { roleId, hours }; + } + } + + return bestQuota; +} + +async function sendShiftEndDm(client, member, shift) { + if (!member || !shift) return; + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'duty-shift-report-title')) + .setThumbnail(member.user.displayAvatarURL({dynamic: true})) + .addFields( + { + name: localize('staff-management-system', 'duty-shift-information'), + value: + `>>> **${localize('staff-management-system', 'label-shift-type')}:** ${shift.type || 'Staff'}\n` + + `**${localize('staff-management-system', 'general-start')}:** \n` + + `**${localize('staff-management-system', 'general-end')}:** \n` + + `**${localize('staff-management-system', 'label-breaks')}:** ${shift.breakCount || 0}` + }, + { + name: localize('staff-management-system', 'label-elapsed-time'), + value: `> ${formatDuration(parseInt(shift.duration) || 0)}` + } + ) + ); + + try { + await member.user.send({embeds: [embed.toJSON()]}); + } catch (e) { + client.logger.warn(localize('staff-management-system', 'log-duty-dm-fail', { + user: member.user.tag, + error: e.message + })); + } +} + +async function logShiftChange(client, action, data) { + const shiftsConfig = getConfig(client, 'shifts'); + if (!shiftsConfig?.logShiftChanges) return; + const channelId = + getSafeChannelId(shiftsConfig.logShiftChangesChannel) || + getSafeChannelId(getConfig(client, 'configuration')?.generalLogChannel); + if (!channelId) return; + + const guild = client.guilds.cache.get(client.guildID); + if (!guild) return; + const channel = await guild.channels.fetch(channelId).catch(() => null); + if (!channel) return; + + const targetUserObj = data.targetUser || await client.users.fetch(data.userId).catch(() => null); + const mention = targetUserObj ? targetUserObj.toString() : `<@${data.userId}>`; + const username = targetUserObj ? targetUserObj.username : data.userId; + + const embed = new EmbedBuilder() + .setThumbnail(targetUserObj?.displayAvatarURL({dynamic: true}) || null); + + if (action === 'start') { + embed + .setTitle(localize('staff-management-system', 'log-duty-start-title', {username})) + .setColor('Green') + .setDescription(localize('staff-management-system', 'log-duty-start-desc', {mention})) + .addFields({ + name: localize('staff-management-system', 'log-duty-info-hdr'), + value: + `**${localize('staff-management-system', 'general-start')}:** \n` + + `**${localize('staff-management-system', 'label-shift-type')}:** ${data.shiftType || 'Staff'}` + }); + } else if (action === 'break') { + embed + .setTitle(localize('staff-management-system', 'log-duty-break-title', {username})) + .setColor('Yellow') + .setDescription(localize('staff-management-system', 'log-duty-break-desc', {mention})) + .addFields({ + name: localize('staff-management-system', 'log-duty-info-hdr'), + value: + `**${localize('staff-management-system', 'general-start')}:** \n` + + `**${localize('staff-management-system', 'label-shift-type')}:** ${data.shiftType || 'Staff'}\n` + + `**${localize('staff-management-system', 'label-breaks')}:** ${data.breakCount || 0}\n` + + `**${localize('staff-management-system', 'label-elapsed-time')}:** ${formatDuration(data.elapsedSeconds || 0)}` + }); + } else if (action === 'resume') { + embed + .setTitle(localize('staff-management-system', 'log-duty-resume-title', {username})) + .setColor('Green') + .setDescription(localize('staff-management-system', 'log-duty-resume-desc', {mention})) + .addFields({ + name: localize('staff-management-system', 'log-duty-info-hdr'), + value: + `**${localize('staff-management-system', 'general-start')}:** \n` + + `**${localize('staff-management-system', 'label-shift-type')}:** ${data.shiftType || 'Staff'}\n` + + `**${localize('staff-management-system', 'label-breaks')}:** ${data.breakCount || 0}\n` + + `**${localize('staff-management-system', 'label-elapsed-time')}:** ${formatDuration(data.elapsedSeconds || 0)}` + }); + } else if (action === 'end') { + embed + .setTitle(localize('staff-management-system', 'log-duty-end-title', {username})) + .setColor('Red') + .setDescription(localize('staff-management-system', 'log-duty-end-desc', {mention})) + .addFields({ + name: localize('staff-management-system', 'log-duty-info-hdr'), + value: + `**${localize('staff-management-system', 'general-start')}:** \n` + + `**${localize('staff-management-system', 'general-end')}:** \n` + + `**${localize('staff-management-system', 'label-shift-type')}:** ${data.shiftType || 'Staff'}\n` + + `**${localize('staff-management-system', 'label-breaks')}:** ${data.breakCount || 0}\n` + + `**${localize('staff-management-system', 'label-elapsed-time')}:** ${formatDuration(data.durationSeconds || 0)}` + + (data.executorId + ? `\n**${localize('staff-management-system', 'label-ended-by')}:** <@${data.executorId}>` + : '') + }); + } else if (action === 'void') { + embed + .setTitle(localize('staff-management-system', 'log-duty-void-title', {username})) + .setColor('DarkRed') + .setDescription(localize('staff-management-system', 'log-duty-void-desc', { + mention, + executor: `<@${data.executorId}>` + })) + .addFields({ + name: localize('staff-management-system', 'log-duty-info-hdr'), + value: + `**${localize('staff-management-system', 'general-start')}:** \n` + + `**${localize('staff-management-system', 'label-shift-type')}:** ${data.shiftType || 'Staff'}\n` + + `**${localize('staff-management-system', 'label-breaks')}:** ${data.breakCount || 0}` + }); + } else { + return; + } + + applyFooter(client, embed); + + try { + await channel.send({embeds: [embed.toJSON()]}); + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-duty-log-fail', { + action, + error: e.message + })); + } +} + +async function buildDutyManagePayload(client, userId, shiftType, endedShift = null) { + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + const user = await client.users.fetch(userId).catch(() => null); + const profile = await Profile.findByPk(userId); + + const onDuty = profile?.onDuty || false; + const onBreak = profile?.onBreak || false; + + let statusColor; + if (onDuty && onBreak) { + statusColor = 'Yellow'; + } else if (onDuty) { + statusColor = 'Green'; + } else { + statusColor = 'Red'; + } + + const completedShifts = await Shift.findAll({ + where: { + userId, + type: shiftType, + endTime: {[Op.not]: null}, + duration: {[Op.not]: null} + } + }); + const totalShifts = completedShifts.length; + const totalSeconds = completedShifts.reduce((sum, s) => sum + (parseInt(s.duration) || 0), 0); + const avgSeconds = totalShifts > 0 + ? Math.floor(totalSeconds / totalShifts) + : 0; + + const activeShift = onDuty + ? await Shift.findOne({ + where: { + userId, + endTime: null + }, + order: [['startTime', 'DESC']] + }) + : null; + + let titleKey = 'duty-panel-title'; + if (onDuty && onBreak) titleKey = 'duty-break-title'; + else if (onDuty) titleKey = 'duty-started-title'; + else if (endedShift) titleKey = 'duty-ended-title'; + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', titleKey, {type: shiftType})) + .setColor(statusColor) + .setThumbnail(user?.displayAvatarURL({ dynamic: true }) || null) + ); + + if (onDuty && activeShift) { + let elapsedSeconds; + if (onBreak && profile?.breakStartTime) { + elapsedSeconds = Math.max( + 0, + Math.floor( + (new Date(profile.breakStartTime).getTime() - new Date(activeShift.startTime).getTime()) / 1000 + ) + ); + } else { + elapsedSeconds = Math.max( + 0, + Math.floor((Date.now() - new Date(activeShift.startTime).getTime()) / 1000) + ); + } + + embed.addFields({ + name: localize('staff-management-system', 'duty-shift-overview'), + value: + `>>> **${localize('staff-management-system', 'label-started')}:** \n` + + `**${localize('staff-management-system', 'label-breaks')}:** ${activeShift.breakCount || 0}\n` + + `**${localize('staff-management-system', 'label-elapsed-time')}:** ${formatDuration(elapsedSeconds)}` + }); + } else if (endedShift) { + embed.addFields({ + name: localize('staff-management-system', 'duty-shift-overview'), + value: + `>>> **${localize('staff-management-system', 'label-started')}:** \n` + + `**${localize('staff-management-system', 'label-breaks')}:** ${endedShift.breakCount || 0}\n` + + `**${localize('staff-management-system', 'label-ended')}:** ` + }); + } else { + embed.addFields({ + name: localize('staff-management-system', 'duty-stats'), + value: localize('staff-management-system', 'duty-stat-desc', { + duration: formatDuration(totalSeconds), + count: totalShifts, + average: formatDuration(avgSeconds) + }) + }); + } + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`duty-mgmt_start_${userId}_${shiftType}`) + .setLabel(localize('staff-management-system', 'btn-duty-on')) + .setStyle(ButtonStyle.Success) + .setDisabled(onDuty), + new ButtonBuilder() + .setCustomId(`duty-mgmt_break_${userId}`) + .setLabel(onBreak + ? localize('staff-management-system', 'btn-duty-res') + : localize('staff-management-system', 'btn-duty-brk') + ) + .setStyle(ButtonStyle.Secondary) + .setDisabled(!onDuty), + new ButtonBuilder() + .setCustomId(`duty-mgmt_end_${userId}`) + .setLabel(localize('staff-management-system', 'btn-duty-off')) + .setStyle(ButtonStyle.Danger) + .setDisabled(!onDuty) + ); + + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function buildDutyTimePayload(client, interaction, shiftType) { + const config = getConfig(client, 'shifts'); + const Shift = client.models['staff-management-system']['StaffShift']; + const user = interaction.user; + + const whereClause = { + userId: user.id, + endTime: {[Op.not]: null}, + duration: {[Op.not]: null} + }; + if (shiftType !== 'All') whereClause.type = shiftType; + + const shifts = await Shift.findAll({ where: whereClause }); + + const totalSeconds = shifts.reduce((sum, s) => sum + (parseInt(s.duration) || 0), 0); + const shiftCount = shifts.length; + + let breakdownText = ''; + if (shiftType === 'All' && shiftCount > 0) { + const grouped = {}; + for (const s of shifts) { + const t = s.type || 'Staff'; + grouped[t] = (grouped[t] || 0) + (parseInt(s.duration) || 0); + } + breakdownText = `\n\n**${localize('staff-management-system', 'duty-breakdown')}:**\n` + Object.entries(grouped) + .sort((a, b) => b[1] - a[1]) + .map(([t, sec]) => `• ${t}: ${formatDuration(sec)}`) + .join('\n'); + } + + let quotaText = ''; + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + if (member) { + const quota = getQuotaForMember(member, config); + if (quota) { + const timeframe = config.quotaTimeframe || 'Weekly'; + const cutoff = new Date(); + if (timeframe === 'Weekly') cutoff.setDate(cutoff.getDate() - 7); + else cutoff.setMonth(cutoff.getMonth() - 1); + + const recentWhere = { + userId: user.id, + startTime: {[Op.gt]: cutoff}, + endTime: {[Op.not]: null}, + duration: {[Op.not]: null} + }; + if (shiftType !== 'All') recentWhere.type = shiftType; + + const recentShifts = await Shift.findAll({ where: recentWhere }); + const recentSeconds = recentShifts.reduce((sum, s) => sum + (parseInt(s.duration) || 0), 0); + const requiredSeconds = quota.hours * 3600; + const metQuota = recentSeconds >= requiredSeconds; + quotaText = localize('staff-management-system', 'duty-quota-str', { + timeframe, + duration: formatDuration(recentSeconds), + hours: quota.hours, + result: metQuota + ? localize('staff-management-system', 'quota-met') + : localize('staff-management-system', 'quota-fail') + }); + } + } + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'duty-time-title', { type: shiftType })) + .setColor('Blue') + .setThumbnail(user.displayAvatarURL({ dynamic: true })) + .setDescription(localize('staff-management-system', 'duty-time-desc', { + count: shiftCount, + duration: formatDuration(totalSeconds) + }) + breakdownText + quotaText) + ); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`duty-mgmt_hist_${user.id}_1_${shiftType}`) + .setLabel(localize('staff-management-system', 'btn-hist')) + .setStyle(ButtonStyle.Secondary) + .setDisabled(shiftCount === 0) + ); + + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function buildLeaderboardPayload(client, page = 1, shiftType) { + const config = getConfig(client, 'shifts'); + const Shift = client.models['staff-management-system']['StaffShift']; + const limit = 15; + const offset = (page - 1) * limit; + + const whereClause = { + endTime: {[Op.not]: null}, + duration: {[Op.not]: null} + }; + if (shiftType !== 'All') whereClause.type = shiftType; + + const lookbackDate = getLookbackDate(config); + if (lookbackDate) whereClause.startTime = { [Op.gt]: lookbackDate }; + + const allResults = await Shift.findAll({ + attributes: [ + 'userId', + [fn('SUM', col('duration')), 'totalDuration'], + [fn('COUNT', col('id')), 'shiftCount'] + ], + where: whereClause, + group: ['userId'], + order: [[literal('totalDuration'), 'DESC']] + }); + + const total = allResults.length; + if (total === 0) return { + content: localize('staff-management-system', 'err-no-lb', { + type: shiftType + }) + }; + + const totalPages = Math.ceil(total / limit) || 1; + const paginated = allResults.slice(offset, offset + limit); + + const lines = []; + for (let i = 0; i < paginated.length; i++) { + const entry = paginated[i]; + const dur = formatDuration(parseInt(entry.dataValues.totalDuration)); + lines.push(`${offset + i + 1}. **<@${entry.userId}>** • ${dur}`); + } + + const lookbackLabel = config.leaderboardLookback || 'Weekly'; + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'duty-lb-title', { + type: shiftType + })) + .setColor('Gold') + .setDescription(localize('staff-management-system', 'duty-lb-desc', { + lookback: lookbackLabel, + lines: lines.join('\n') + })) + ); + + embed.addFields({ + name: '\u200b', + value: localize('staff-management-system', 'page-count', { + page, + total: totalPages + }) + }); + + const row = buildPaginationRow( + `duty-mgmt_lb_${page - 1}_${shiftType}`, + 'duty_lb_count', + `duty-mgmt_lb_${page + 1}_${shiftType}`, + page, totalPages, 'back', 'next' + ); + + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function buildShiftHistoryPayload(client, userId, page = 1, shiftType) { + const Shift = client.models['staff-management-system']['StaffShift']; + const limit = 10; + const offset = (page - 1) * limit; + + const whereClause = { + userId, + endTime: {[Op.not]: null}, + duration: {[Op.not]: null} + }; + if (shiftType !== 'All') whereClause.type = shiftType; + + const { count, rows } = await Shift.findAndCountAll({ + where: whereClause, + order: [['startTime', 'DESC']], + limit, + offset + }); + + if (count === 0) return { content: localize('staff-management-system', 'info-no-sh-hi') }; + const totalPages = Math.ceil(count / limit) || 1; + + const lines = rows.map((shift, i) => { + const dur = formatDuration(shift.duration); + const startTs = Math.floor(new Date(shift.startTime).getTime() / 1000); + const endTs = Math.floor(new Date(shift.endTime).getTime() / 1000); + const typeBadge = shiftType === 'All' ? ` \`[${shift.type || 'Staff'}]\`` : ''; + + return `**${offset + i + 1}. ${dur}${typeBadge}:**\nStart: | End: `; + }); + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'duty-hi-title', { + type: shiftType + })) + .setColor('Blue') + .setDescription(lines.join('\n\n')) + ); + + embed.addFields({ + name: '\u200b', + value: localize('staff-management-system', 'page-count', { + page, + total: totalPages + }) + }); + + const row = buildPaginationRow( + `duty-mgmt_hist_${userId}_${page - 1}_${shiftType}`, + 'duty_hist_count', + `duty-mgmt_hist_${userId}_${page + 1}_${shiftType}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function buildDutyAdminPayload(client, targetMember, requestingMember) { + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + const targetUser = targetMember.user; + const profile = await Profile.findByPk(targetUser.id); + + const onDuty = profile?.onDuty || false; + const onBreak = profile?.onBreak || false; + + let statusText, statusColor; + if (onDuty && onBreak) { + statusText = localize('staff-management-system', 'stat-brk'); + statusColor = 'Yellow'; + } else if (onDuty) { + statusText = localize('staff-management-system', 'stat-on'); + statusColor = 'Green'; + } else { + statusText = localize('staff-management-system', 'stat-off'); + statusColor = 'Red'; + } + + const completedShifts = await Shift.findAll({ + where: { + userId: targetUser.id, + endTime: {[Op.not]: null}, + duration: {[Op.not]: null} + } + }); + const totalShifts = completedShifts.length; + const totalSeconds = completedShifts.reduce((sum, s) => sum + (parseInt(s.duration) || 0), 0); + const avgSeconds = totalShifts > 0 + ? Math.floor(totalSeconds / totalShifts) + : 0; + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'duty-adm-title', { + user: targetUser.username + })) + .setColor(statusColor) + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setDescription(`**${statusText}**`) + .addFields( + { + name: localize('staff-management-system', 'duty-stats'), + value: localize('staff-management-system', 'duty-stat-desc', { + duration: formatDuration(totalSeconds), + count: totalShifts, + average: formatDuration(avgSeconds) + }) + } + ) + ); + + const generalConfig = client.configurations['staff-management-system']['configuration']; + const isManagement = requestingMember.roles.cache.some(r => (generalConfig.managementRoles || []).includes(r.id)) || requestingMember.permissions.has('Administrator'); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`duty-mgmt_admin-forceend_${targetUser.id}`) + .setLabel(localize('staff-management-system', 'btn-f-off')) + .setEmoji('🔴') + .setStyle(ButtonStyle.Danger) + .setDisabled(!onDuty), + new ButtonBuilder() + .setCustomId(`duty-mgmt_admin-voidactive_${targetUser.id}`) + .setLabel(localize('staff-management-system', 'btn-v-act')) + .setEmoji('🗑️') + .setStyle(ButtonStyle.Danger) + .setDisabled(!onDuty), + new ButtonBuilder() + .setCustomId(`duty-mgmt_admin-addtime_${targetUser.id}`) + .setLabel(localize('staff-management-system', 'btn-add-t')) + .setEmoji('⏱️') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`duty-mgmt_admin-voidall_${targetUser.id}`) + .setLabel(localize('staff-management-system', 'btn-v-all')) + .setEmoji('⚠️') + .setStyle(ButtonStyle.Danger) + .setDisabled(!isManagement) + ); + + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +// ----- Button handlers ----- +async function handleDutyStartButton(client, interaction) { + const parts = interaction.customId.split('_'); + const userId = parts[2]; + const shiftType = parts[3] || 'Staff'; + + if (interaction.user.id !== userId) return interaction.editReply({ + content: localize('staff-management-system', 'err-not-yours'), + flags: MessageFlags.Ephemeral + }); + + const config = getConfig(client, 'shifts'); + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + const profile = await Profile.findByPk(userId); + if (profile?.onDuty) return interaction.followUp({ + content: localize('staff-management-system', 'err-alr-on'), + flags: MessageFlags.Ephemeral + }); + + const startTime = new Date(); + await Shift.create({ + userId, + startTime, + type: shiftType + }); + await Profile.upsert({ + userId, + onDuty: true, + onBreak: false, + lastClockIn: startTime + }); + + if (config.onDutyRole) { + const member = await interaction.guild.members.fetch(userId).catch(() => null); + if (member) await member.roles.add(config.onDutyRole).catch(() => {}); + } + + await logShiftChange(client, 'start', { + userId, + targetUser: interaction.user, + shiftType, + startTime + }); + + const payload = await buildDutyManagePayload(client, userId, shiftType); + return interaction.editReply(payload); +} + +async function handleDutyBreakButton(client, interaction) { + const userId = interaction.customId.split('_')[2]; + if (interaction.user.id !== userId) return interaction.editReply({ + content: localize('staff-management-system', 'err-not-yours'), + flags: MessageFlags.Ephemeral + }); + + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + const profile = await Profile.findByPk(userId); + + if (!profile?.onDuty) return interaction.followUp({ + content: localize('staff-management-system', 'err-not-on'), + flags: MessageFlags.Ephemeral + }); + + const activeShift = await Shift.findOne({ + where: {userId, endTime: null} + }); + const shiftType = activeShift?.type || 'Staff'; + + const nowOnBreak = !profile.onBreak; + let breakCount = activeShift?.breakCount || 0; + if (nowOnBreak && activeShift) { + breakCount += 1; + await activeShift.update({ + breakCount + }); + } + if (!nowOnBreak && profile.breakStartTime && activeShift) { + await applyBreakElapsedToShift(activeShift, profile.breakStartTime); + } + + const elapsedSeconds = activeShift + ? Math.max( + 0, + Math.floor( + ((nowOnBreak ? new Date() : new Date(profile.breakStartTime || Date.now())).getTime() - + new Date(activeShift.startTime).getTime()) / 1000 + ) + ) + : 0; + + const breakStartTime = nowOnBreak ? new Date() : null; + await Profile.update({ + onBreak: nowOnBreak, + breakStartTime + }, { + where: {userId} + }); + + if (activeShift) { + if (nowOnBreak) { + await logShiftChange(client, 'break', { + userId, + targetUser: interaction.user, + shiftType, + startTime: activeShift.startTime, + breakCount: activeShift.breakCount || 0, + elapsedSeconds + }); + } else { + await logShiftChange(client, 'resume', { + userId, + targetUser: interaction.user, + shiftType, + startTime: activeShift.startTime, + breakCount: activeShift.breakCount || 0, + elapsedSeconds + }); + } + } + + const payload = await buildDutyManagePayload(client, userId, shiftType); + return interaction.editReply(payload); +} + +async function handleDutyEndButton(client, interaction) { + const userId = interaction.customId.split('_')[2]; + if (interaction.user.id !== userId) return interaction.editReply({ + content: localize('staff-management-system', 'err-not-yours'), + flags: MessageFlags.Ephemeral + }); + + const config = getConfig(client, 'shifts'); + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + const profile = await Profile.findByPk(userId); + if (!profile?.onDuty) return interaction.followUp({ + content: localize('staff-management-system', 'err-not-on'), + flags: MessageFlags.Ephemeral + }); + + const activeShifts = await Shift.findAll({ where: { userId, endTime: null } }); + const shiftType = activeShifts.length > 0 ? activeShifts[0].type : 'Staff'; + let discardedForMinimum = false; + let endedShiftForDisplay = null; + + for (const activeShift of activeShifts) { + if (profile.onBreak && profile.breakStartTime) { + await applyBreakElapsedToShift(activeShift, profile.breakStartTime); + } + + const endTime = new Date(); + const durationSeconds = Math.floor( + (endTime.getTime() - new Date(activeShift.startTime).getTime()) / 1000 + ); + + if (config.minShiftDuration && (durationSeconds / 60) < config.minShiftDuration) { + await activeShift.destroy(); + discardedForMinimum = true; + } else { + await activeShift.update({ + endTime, + duration: durationSeconds + }); + endedShiftForDisplay = activeShift; + } + } + + await Profile.update({ + onDuty: false, + onBreak: false, + breakStartTime: null + }, { + where: {userId} + }); + + const member = await interaction.guild.members.fetch(userId).catch(() => null); + if (config.onDutyRole && member) { + await member.roles.remove(config.onDutyRole).catch(() => { + }); + } + if (member && endedShiftForDisplay) { + await sendShiftEndDm(client, member, endedShiftForDisplay); + } + + if (endedShiftForDisplay) { + await logShiftChange(client, 'end', { + userId, + targetUser: interaction.user, + shiftType: endedShiftForDisplay.type || shiftType, + startTime: endedShiftForDisplay.startTime, + endTime: endedShiftForDisplay.endTime, + breakCount: endedShiftForDisplay.breakCount || 0, + durationSeconds: parseInt(endedShiftForDisplay.duration) || 0 + }); + } + + const payload = await buildDutyManagePayload(client, userId, shiftType, endedShiftForDisplay); + await interaction.editReply(payload); + + if (discardedForMinimum) { + await interaction.followUp({ + content: localize('staff-management-system', 'err-shift-too-short', { + min: config.minShiftDuration + }), + flags: MessageFlags.Ephemeral + }); + } + return; +} + +async function handleDutyHistPageButton(client, interaction) { + const parts = interaction.customId.split('_'); + const userId = parts[2]; + const page = parseInt(parts[3]); + const shiftType = parts[4] || 'Staff'; + + if (interaction.user.id !== userId) return interaction.followUp({ + content: localize('staff-management-system', 'err-hist-oth'), + flags: MessageFlags.Ephemeral + }); + + const payload = await buildShiftHistoryPayload(client, userId, page, shiftType); + if (payload.content) return interaction.followUp({ + ...payload, + flags: MessageFlags.Ephemeral + }); + + const isOnHistEmbed = interaction.message?.embeds?.[0]?.title?.startsWith(localize('staff-management-system', 'duty-hi-title', { type: '' }).replace(' - ', '')); + if (isOnHistEmbed) { + return interaction.editReply(payload); + } else { + return interaction.followUp({ + ...payload, + flags: MessageFlags.Ephemeral + }); + } +} + +async function handleDutyLbPageButton(client, interaction) { + const parts = interaction.customId.split('_'); + const page = parseInt(parts[2]); + const shiftType = parts[3] || 'Staff'; + + const payload = await buildLeaderboardPayload(client, page, shiftType); + if (payload.content) return interaction.editReply({ ...payload, flags: MessageFlags.Ephemeral }); + return interaction.editReply(payload); +} + +// ----- Admin handler ----- +async function handleDutyAdminForceEnd(client, interaction) { + const permCheck = checkDutyAdminPermission(client, interaction); + if (permCheck) return permCheck; + + const targetUserId = interaction.customId.split('_')[2]; + const config = getConfig(client, 'shifts'); + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + const profile = await Profile.findByPk(targetUserId); + let endedShiftForDisplay = null; + + const activeShifts = await Shift.findAll({ + where: {userId: targetUserId, endTime: null} + }); + for (const activeShift of activeShifts) { + if (profile?.onBreak && profile.breakStartTime) { + await applyBreakElapsedToShift(activeShift, profile.breakStartTime); + } + + const endTime = new Date(); + const durationSeconds = Math.floor( + (endTime.getTime() - new Date(activeShift.startTime).getTime()) / 1000 + ); + + await activeShift.update({ + endTime, + duration: durationSeconds + }); + endedShiftForDisplay = activeShift; + } + + await Profile.update({ + onDuty: false, + onBreak: false, + breakStartTime: null + }, { + where: {userId: targetUserId} + }); + if (config.onDutyRole) { + const member = await interaction.guild.members.fetch(targetUserId).catch(() => null); + if (member) await member.roles.remove(config.onDutyRole).catch(() => {}); + } + + if (endedShiftForDisplay) { + await logShiftChange(client, 'end', { + userId: targetUserId, + shiftType: endedShiftForDisplay.type || 'Staff', + startTime: endedShiftForDisplay.startTime, + endTime: endedShiftForDisplay.endTime, + breakCount: endedShiftForDisplay.breakCount || 0, + durationSeconds: parseInt(endedShiftForDisplay.duration) || 0, + executorId: interaction.user.id + }); + } + + const targetMember = await interaction.guild.members.fetch(targetUserId).catch(() => null); + if (!targetMember) { + return interaction.editReply({ + content: localize('staff-management-system', 'duty-admin-target-left'), + embeds: [], + components: [] + }); + } + + const payload = await buildDutyAdminPayload(client, targetMember, interaction.member); + return interaction.editReply(payload); +} + +async function handleDutyAdminVoidActive(client, interaction) { + const permCheck = checkDutyAdminPermission(client, interaction); + if (permCheck) return permCheck; + + const targetUserId = interaction.customId.split('_')[2]; + const config = getConfig(client, 'shifts'); + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + const activeShifts = await Shift.findAll({ + where: { + userId: targetUserId, + endTime: null + }, + order: [['startTime', 'DESC']] + }); + const shiftForLog = activeShifts.length > 0 + ? activeShifts[0] + : null; + for (const activeShift of activeShifts) await activeShift.destroy(); + + await Profile.update({ + onDuty: false, + onBreak: false, + breakStartTime: null + }, { + where: {userId: targetUserId} + }); + if (config.onDutyRole) { + const member = await interaction.guild.members.fetch(targetUserId).catch(() => null); + if (member) await member.roles.remove(config.onDutyRole).catch(() => {}); + } + + if (shiftForLog) { + await logShiftChange(client, 'void', { + userId: targetUserId, + shiftType: shiftForLog.type || 'Staff', + startTime: shiftForLog.startTime, + breakCount: shiftForLog.breakCount || 0, + executorId: interaction.user.id + }); + } + + const targetMember = await interaction.guild.members.fetch(targetUserId).catch(() => null); + if (!targetMember) { + return interaction.editReply({ + content: localize('staff-management-system', 'duty-admin-target-left'), + embeds: [], + components: [] + }); + } + + const payload = await buildDutyAdminPayload(client, targetMember, interaction.member); + return interaction.editReply(payload); +} + +async function handleDutyAdminVoidAll(client, interaction) { + const permCheck = checkDutyAdminPermission(client, interaction); + if (permCheck) return permCheck; + + const targetUserId = interaction.customId.split('_')[2]; + const confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + const modal = new ModalBuilder() + .setCustomId(`duty-mgmt_admin-voidall-submit_${targetUserId}`) + .setTitle(localize('staff-management-system', 'mod-v-all-title')); + + modal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('confirm') + .setLabel(localize('staff-management-system', 'mod-v-all-lbl')) + .setStyle(TextInputStyle.Short) + .setPlaceholder(confirmPhrase) + .setRequired(true) + ) + ); + return interaction.showModal(modal); +} + +async function handleDutyAdminVoidAllSubmit(client, interaction) { + const permCheck = checkDutyAdminPermission(client, interaction); + if (permCheck) return permCheck; + + const targetUserId = interaction.customId.split('_')[2]; + const confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + + if (interaction.fields.getTextInputValue('confirm').trim() !== confirmPhrase) { + return interaction.reply({ + content: localize('staff-management-system', 'err-conf-fail'), + flags: MessageFlags.Ephemeral + }); + } + + const config = getConfig(client, 'shifts'); + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + await Shift.destroy({ + where: {userId: targetUserId} + }); + await Profile.update({ + onDuty: false, + onBreak: false, + breakStartTime: null + }, { + where: {userId: targetUserId} + }); + + if (config.onDutyRole) { + const member = await interaction.guild.members.fetch(targetUserId).catch(() => null); + if (member) await member.roles.remove(config.onDutyRole).catch(() => {}); + } + + client.logger.info(localize('staff-management-system', 'log-void-all', { + target: targetUserId, + admin: interaction.user.id + })); + + return interaction.reply({ + content: localize('staff-management-system', 'succ-v-all', {user: targetUserId}), + flags: MessageFlags.Ephemeral + }); +} + +async function handleDutyAdminAddTimeButton(client, interaction) { + const permCheck = checkDutyAdminPermission(client, interaction); + if (permCheck) return permCheck; + + const targetUserId = interaction.customId.split('_')[2]; + const config = getConfig(client, 'shifts'); + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 + ? config.dutyTypes + : ['Staff']; + + const modal = new ModalBuilder() + .setCustomId(`duty-mgmt_admin-addtime-submit_${targetUserId}`) + .setTitle(localize('staff-management-system', 'mod-add-t')); + modal.addComponents( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('minutes') + .setLabel(localize('staff-management-system', 'mod-add-min')) + .setStyle(TextInputStyle.Short) + .setPlaceholder('e.g. 60') + .setRequired(true) + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('type') + .setLabel(localize('staff-management-system', 'mod-add-type')) + .setStyle(TextInputStyle.Short) + .setPlaceholder(dutyTypes.join(', ')) + .setValue(dutyTypes[0]) + .setRequired(true) + ) + ); + return interaction.showModal(modal); +} + +async function handleDutyAdminAddTimeSubmit(client, interaction) { + const permCheck = checkDutyAdminPermission(client, interaction); + if (permCheck) return permCheck; + + const targetUserId = interaction.customId.split('_')[2]; + const minutesRaw = interaction.fields.getTextInputValue('minutes'); + const shiftType = interaction.fields.getTextInputValue('type'); + + const maxMinutes = 10080; + const minutes = parseInt(minutesRaw, 10); + + if (isNaN(minutes) || minutes <= 0 || minutes > maxMinutes) { + return interaction.reply({ + content: localize('staff-management-system', 'err-inv-min'), + flags: MessageFlags.Ephemeral + }); + } + + const config = getConfig(client, 'shifts'); + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 + ? config.dutyTypes + : ['Staff']; + + if (!dutyTypes.includes(shiftType)) { + return interaction.reply({ + content: localize('staff-management-system', 'err-inv-type', { + types: dutyTypes.join(', ') + }), + flags: MessageFlags.Ephemeral + }); + } + + const Shift = client.models['staff-management-system']['StaffShift']; + + const durationSeconds = minutes * 60; + const endTime = new Date(); + const startTime = new Date(endTime.getTime() - (durationSeconds * 1000)); + + await Shift.create({ + userId: targetUserId, + startTime: startTime, + endTime: endTime, + duration: durationSeconds, + type: shiftType + }); + + client.logger.info(localize('staff-management-system', 'log-add-time', { + admin: interaction.user.tag, + min: minutes, + type: shiftType, + target: targetUserId + })); + + const targetMember = await interaction.guild.members.fetch(targetUserId).catch(() => null); + if (!targetMember) { + return interaction.reply({ + content: localize('staff-management-system', 'duty-admin-target-left'), + flags: MessageFlags.Ephemeral + }); + } + + const payload = await buildDutyAdminPayload(client, targetMember, interaction.member); + return interaction.reply({ + ...payload, + flags: MessageFlags.Ephemeral + }); +} + +// ----- Dropdown handler ----- +async function handleDutyDropdown(client, interaction, action, selectedType) { + if (action === 'manage') { + const payload = await buildDutyManagePayload(client, interaction.user.id, selectedType); + return interaction.editReply({ content: '', ...payload }); + } + if (action === 'leaderboard') { + const payload = await buildLeaderboardPayload(client, 1, selectedType); + return interaction.editReply({ content: '', ...payload }); + } + if (action === 'time') { + const payload = await buildDutyTimePayload(client, interaction, selectedType); + return interaction.editReply({ content: '', ...payload }); + } +} + +async function handleCommonDutyCommand(i, action) { + const config = getConfig(i.client, 'shifts'); + if (!config || !config.enableShifts) return i.editReply({ content: localize('staff-management-system', 'err-sh-dis') }); + + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 ? config.dutyTypes : ['Staff']; + let shiftType = i.options.getString('type'); + + const allowedTypes = (action === 'leaderboard' || action === 'time') ? ['All', ...dutyTypes] : dutyTypes; + + if (action === 'manage') { + const Profile = i.client.models['staff-management-system']['StaffProfile']; + const Shift = i.client.models['staff-management-system']['StaffShift']; + const profile = await Profile.findByPk(i.user.id); + if (profile?.onDuty) { + const activeShift = await Shift.findOne({ where: { userId: i.user.id, endTime: null } }); + shiftType = activeShift?.type || dutyTypes[0]; + } + } + + if (!shiftType) { + if (dutyTypes.length === 1 && action === 'manage') { + shiftType = dutyTypes[0]; + } else if (dutyTypes.length === 1 && (action === 'leaderboard' || action === 'time')) { + shiftType = 'All'; + } else { + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(`duty-mgmt_dropdown_${action}`) + .setPlaceholder(localize('staff-management-system', 'ph-sel-type')); + + allowedTypes.forEach(t => selectMenu.addOptions({ label: t, value: t })); + const row = new ActionRowBuilder().addComponents(selectMenu); + return i.editReply({ content: localize('staff-management-system', 'msg-sel-type'), components: [row.toJSON()] }); + } + } else if (!allowedTypes.includes(shiftType)) { + return i.editReply({ content: localize('staff-management-system', 'err-inv-type', { types: allowedTypes.join(', ') }) }); + } + + if (action === 'manage') { + const payload = await buildDutyManagePayload(i.client, i.user.id, shiftType); + await i.editReply(payload); + } else if (action === 'leaderboard') { + const payload = await buildLeaderboardPayload(i.client, 1, shiftType); + await i.editReply(payload); + } else if (action === 'time') { + const payload = await buildDutyTimePayload(i.client, i, shiftType); + await i.editReply(payload); + } +} + +module.exports.autoComplete = { + 'manage': { + 'type': async function (interaction) { + const config = getConfig(interaction.client, 'shifts'); + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 + ? config.dutyTypes + : ['Staff']; + const focusedValue = interaction.value || ''; + + const filtered = dutyTypes.filter(choice => choice.toLowerCase().startsWith(focusedValue.toLowerCase())); + await interaction.respond(filtered.slice(0, 25).map(choice => ({ + name: choice, + value: choice + }))); + } + }, + 'leaderboard': { + 'type': async function (interaction) { + const config = getConfig(interaction.client, 'shifts'); + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 + ? config.dutyTypes + : ['Staff']; + const options = ['All', ...dutyTypes]; + const focusedValue = interaction.value || ''; + + const filtered = options.filter(choice => choice.toLowerCase().startsWith(focusedValue.toLowerCase())); + await interaction.respond(filtered.slice(0, 25).map(choice => ({ + name: choice, + value: choice + }))); + } + }, + 'time': { + 'type': async function (interaction) { + const config = getConfig(interaction.client, 'shifts'); + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 + ? config.dutyTypes + : ['Staff']; + const options = ['All', ...dutyTypes]; + const focusedValue = interaction.value || ''; + + const filtered = options.filter(choice => choice.toLowerCase().startsWith(focusedValue.toLowerCase())); + await interaction.respond(filtered.slice(0, 25).map(choice => ({ + name: choice, + value: choice + }))); + } + } +}; + +module.exports.beforeSubcommand = async function (interaction) { + await interaction.deferReply({ + flags: MessageFlags.Ephemeral + }); +}; + +module.exports.subcommands = { + 'manage': async function (i) { + await handleCommonDutyCommand(i, 'manage'); + }, + 'active': async function (i) { + const config = getConfig(i.client, 'shifts'); + if (!config || !config.enableShifts) return i.editReply({ + content: localize('staff-management-system', 'err-sh-dis') + }); + + const Shift = i.client.models['staff-management-system']['StaffShift']; + const Profile = i.client.models['staff-management-system']['StaffProfile']; + const activeShifts = await Shift.findAll({ + where: {endTime: null}, + order: [['startTime', 'ASC']] + }); + + if (activeShifts.length === 0) return i.editReply({ + content: localize('staff-management-system', 'info-no-act-sh') + }); + + const profiles = await Profile.findAll({ + where: { + userId: activeShifts.map(shift => shift.userId) + } + }); + const profileMap = new Map(profiles.map(profile => [profile.userId, profile])); + + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 + ? config.dutyTypes + : ['Staff']; + + const grouped = {}; + for (const shift of activeShifts) { + const type = shift.type || dutyTypes[0]; + if (!grouped[type]) grouped[type] = []; + grouped[type].push(shift); + } + + const embed = applyFooter(i.client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'duty-act-title')) + .setColor('Green') + .setDescription(localize('staff-management-system', 'duty-act-desc', { + count: activeShifts.length + })) + ); + + let index = 1; + for (const type of dutyTypes) { + if (grouped[type]) { + const lines = []; + for (const shift of grouped[type]) { + const profile = profileMap.get(shift.userId); + const isOnBreak = profile?.onBreak && profile?.breakStartTime; + + let elapsed; + if (isOnBreak) { + elapsed = Math.floor( + (new Date(profile.breakStartTime).getTime() - new Date(shift.startTime).getTime()) / 1000 + ); + } else { + elapsed = Math.floor( + (Date.now() - new Date(shift.startTime).getTime()) / 1000 + ); + } + + const breakSuffix = isOnBreak + ? ` (${localize('staff-management-system', 'stat-brk')})` + : ''; + + lines.push(`${index}. **<@${shift.userId}>** • ${formatDuration(elapsed)}${breakSuffix}`); + index++; + } + embed.addFields({ + name: `${type} (${grouped[type].length})`, + value: lines.join('\n') + }); + delete grouped[type]; + } + } + for (const [type, shifts] of Object.entries(grouped)) { + const lines = []; + for (const shift of shifts) { + const profile = profileMap.get(shift.userId); + const isOnBreak = profile?.onBreak && profile?.breakStartTime; + + let elapsed; + if (isOnBreak) { + elapsed = Math.floor( + (new Date(profile.breakStartTime).getTime() - new Date(shift.startTime).getTime()) / 1000 + ); + } else { + elapsed = Math.floor( + (Date.now() - new Date(shift.startTime).getTime()) / 1000 + ); + } + + const breakSuffix = isOnBreak + ? ` (${localize('staff-management-system', 'stat-brk')})` + : ''; + + lines.push(`${index}. **<@${shift.userId}>** • ${formatDuration(elapsed)}${breakSuffix}`); + index++; + } + + embed.addFields({ + name: `${type} (${shifts.length}) [Legacy]`, + value: lines.join('\n') + }); + } + await i.editReply({ + embeds: [embed.toJSON()] + }); + }, + 'leaderboard': async function (i) { + await handleCommonDutyCommand(i, 'leaderboard'); + }, + 'time': async function (i) { + await handleCommonDutyCommand(i, 'time'); + }, + 'admin': async function (i) { + const config = getConfig(i.client, 'shifts'); + if (!config || !config.enableShifts) return i.editReply({ + content: localize('staff-management-system', 'err-sh-dis') + }); + + const generalConfig = getConfig(i.client, 'configuration'); + const canManage = i.member.roles.cache.some(r => [...(generalConfig.supervisorRoles || []), ...(generalConfig.managementRoles || [])].includes(r.id)) || i.member.permissions.has('Administrator'); + if (!canManage) return i.editReply({ + content: localize('staff-management-system', 'err-no-perm') + }); + + const target = i.options.getMember('user'); + if (!target) return i.editReply({ + content: localize('staff-management-system', 'err-no-mem') + }); + + const payload = await buildDutyAdminPayload(i.client, target, i.member); + await i.editReply(payload); + } +}; + +module.exports.config = { + name: 'duty', + description: localize('staff-management-system', 'cmd-desc-duty'), + usage: '/duty', + type: 'slash', + defaultPermission: false, + disabled: function (client) { + return !client.configurations['staff-management-system']['shifts']?.enableShifts; + }, + options: [ + { + type: 'SUB_COMMAND', + name: 'manage', + description: localize('staff-management-system', 'cmd-desc-duty-manage'), + options: [ + { + type: 'STRING', + name: 'type', + description: localize('staff-management-system', 'cmd-desc-duty-manage-type'), + required: false, + autocomplete: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'active', + description: localize('staff-management-system', 'cmd-desc-duty-active') + }, + { + type: 'SUB_COMMAND', + name: 'leaderboard', + description: localize('staff-management-system', 'cmd-desc-duty-lb'), + options: [ + { + type: 'STRING', + name: 'type', + description: localize('staff-management-system', 'cmd-desc-duty-lb-type'), + required: false, + autocomplete: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'time', + description: localize('staff-management-system', 'cmd-desc-duty-time'), + options: [ + { + type: 'STRING', + name: 'type', + description: localize('staff-management-system', 'cmd-desc-duty-time-type'), + required: false, + autocomplete: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'admin', + description: localize('staff-management-system', 'cmd-desc-duty-admin'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-duty-admin-user'), + required: true + } + ] + } + ] +}; + +// Export handlers +module.exports.buttonHandlers = { + handleDutyStartButton, + handleDutyAdminAddTimeButton, + handleDutyBreakButton, + handleDutyEndButton, + handleDutyDropdown, + handleDutyHistPageButton, + handleDutyLbPageButton, + handleDutyAdminForceEnd, + handleDutyAdminVoidActive, + handleDutyAdminVoidAll, + handleDutyAdminVoidAllSubmit, + handleDutyAdminAddTimeSubmit +}; \ No newline at end of file diff --git a/modules/staff-management-system/commands/staff-management.js b/modules/staff-management-system/commands/staff-management.js new file mode 100644 index 00000000..e667ee19 --- /dev/null +++ b/modules/staff-management-system/commands/staff-management.js @@ -0,0 +1,773 @@ +const { MessageFlags, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } = require('discord.js'); +const { embedTypeV2 } = require('../../../src/functions/helpers'); +const { localize } = require('../../../src/functions/localize'); +const { + applyFooter, + issueInfraction, + getInfractionHistory, + issueSuspension, + voidInfraction, + promoteUser, + getPromotionHistory, + submitReview, + getReviewHistory, + startActivityCheck, + endActivityCheckProcess, + generateUserPanel +} = require('../staff-management'); + +function canManageChecks(client, member) { + if (member.permissions.has('Administrator')) return true; + const config = client.configurations['staff-management-system']['configuration'] || {}; + const supRoles = config.supervisorRoles || []; + const mgmtRoles = config.managementRoles || []; + return member.roles.cache.some(r => supRoles.includes(r.id) || mgmtRoles.includes(r.id)); +} + +async function handleProfileView(client, interaction, targetUser) { + const config = client.configurations['staff-management-system']['profiles']; + if (!config || !config.enableProfiles) return interaction.editReply({ + content: localize('staff-management-system', 'err-prof-dis') + }); + + if (!config.profileEmbedMessage) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-prof-cfg') + }); + } + + const user = targetUser || interaction.user; + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + if (!member) return interaction.editReply({ + content: localize('staff-management-system', 'err-no-mem') + }); + + const restrictToStaff = config.onlyAllowStaffProfile !== false; + if (restrictToStaff) { + const generalConfig = client.configurations['staff-management-system']['configuration'] || {}; + + const staffRoles = Array.isArray(generalConfig.staffRoles) + ? generalConfig.staffRoles + : (generalConfig.staffRoles + ? [generalConfig.staffRoles] + : [] + ); + const supRoles = Array.isArray(generalConfig.supervisorRoles) + ? generalConfig.supervisorRoles + : (generalConfig.supervisorRoles + ? [generalConfig.supervisorRoles] + : [] + ); + const mgmtRoles = Array.isArray(generalConfig.managementRoles) + ? generalConfig.managementRoles + : (generalConfig.managementRoles + ? [generalConfig.managementRoles] + : [] + ); + + const allStaffRoles = [...staffRoles, ...supRoles, ...mgmtRoles]; + const isAdmin = member.permissions.has('Administrator'); + const isStaff = allStaffRoles.length > 0 && member.roles.cache.some(r => allStaffRoles.includes(r.id)); + + if (!isAdmin && !isStaff) { + if (user.id === interaction.user.id) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-prof-no-own') + }); + } else { + return interaction.editReply({ + content: localize('staff-management-system', 'err-prof-no-tgt') + }); + } + } + } + + const Profile = client.models['staff-management-system']['StaffProfile']; + const Review = client.models['staff-management-system']['StaffReview']; + + const [profile] = await Profile.findOrCreate({ + where: {userId: user.id} + }); + + const reviewsConfig = client.configurations['staff-management-system']['reviews']; + const reviewsEnabled = reviewsConfig && reviewsConfig.enableReviews; + + let ratingDisplay = localize('staff-management-system', 'rev-dis-text'); + if (reviewsEnabled) { + let avgRatingText = localize('staff-management-system', 'rev-no-rate'); + const allReviews = await Review.findAll({ + where: {targetId: user.id}, + attributes: ['stars'] + }); + if (allReviews.length > 0) { + avgRatingText = (allReviews.reduce((a, b) => a + b.stars, 0) / allReviews.length).toFixed(1); + } + ratingDisplay = `⭐ ${avgRatingText}`; + } + + let discordStatus = localize('staff-management-system', 'stat-offl'); + if (member.presence) { + switch (member.presence.status) { + case 'online': discordStatus = localize('staff-management-system', 'stat-onl'); break; + case 'idle': discordStatus = localize('staff-management-system', 'stat-idl'); break; + case 'dnd': discordStatus = localize('staff-management-system', 'stat-dnd'); break; + case 'offline': discordStatus = localize('staff-management-system', 'stat-offl'); break; + } + } + + const statusLines = [discordStatus]; + if (profile.onDuty) statusLines.push(localize('staff-management-system', 'stat-prof-ond')); + if (profile.activityStatus === 'LOA') statusLines.push(localize('staff-management-system', 'stat-prof-loa')); + if (profile.activityStatus === 'RA') statusLines.push(localize('staff-management-system', 'stat-prof-ra')); + + const introText = profile.customIntro || localize('staff-management-system', 'prof-no-intro'); + const nicknameText = profile.customNickname || user.username; + + const placeholders = { + '%user-mention%': user.toString(), + '%username%': user.username, + '%nickname%': nicknameText, + '%intro%': introText, + '%status%': statusLines.join('\n'), + '%rating%': ratingDisplay, + '%avatar%': user.displayAvatarURL({ + dynamic: true, + format: 'png', + size: 1024 + }) || '' + }; + + let embedTemplate = config.profileEmbedMessage; + if (typeof embedTemplate === 'string') { + try { embedTemplate = JSON.parse(embedTemplate); } catch (e) {} + } + + let msgOpts = await embedTypeV2(embedTemplate, placeholders); + + if (!msgOpts) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-prof-empty') + }); + } + + await interaction.editReply(msgOpts); +} + +async function handleProfileEdit(client, interaction) { + const config = client.configurations['staff-management-system']['profiles']; + if (!config || !config.enableProfiles) return interaction.reply({ + content: localize('staff-management-system', 'err-prof-dis'), + flags: MessageFlags.Ephemeral + }); + + const restrictToStaff = config.onlyAllowStaffProfile !== false; + if (restrictToStaff) { + const generalConfig = client.configurations['staff-management-system']['configuration'] || {}; + + const staffRoles = Array.isArray(generalConfig.staffRoles) + ? generalConfig.staffRoles + : (generalConfig.staffRoles + ? [generalConfig.staffRoles] + : [] + ); + const supRoles = Array.isArray(generalConfig.supervisorRoles) + ? generalConfig.supervisorRoles + : (generalConfig.supervisorRoles + ? [generalConfig.supervisorRoles] + : [] + ); + const mgmtRoles = Array.isArray(generalConfig.managementRoles) + ? generalConfig.managementRoles + : (generalConfig.managementRoles + ? [generalConfig.managementRoles] + : [] + ); + + const allStaffRoles = [ + ...staffRoles, + ...supRoles, + ...mgmtRoles + ]; + + const isAdmin = interaction.member.permissions.has('Administrator'); + const hasStaffRole = allStaffRoles.length > 0 && interaction.member.roles.cache.some(r => allStaffRoles.includes(r.id)); + + if (!isAdmin && !hasStaffRole) { + return interaction.reply({ + content: localize('staff-management-system', 'err-prof-perm'), + flags: MessageFlags.Ephemeral + }); + } + } + + const Profile = client.models['staff-management-system']['StaffProfile']; + const profile = await Profile.findByPk(interaction.user.id); + + const modal = new ModalBuilder() + .setCustomId(`staff-mgmt_profile-edit`) + .setTitle(localize('staff-management-system', 'prof-edit-title')); + + modal.addComponents( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('nickname') + .setLabel(localize('staff-management-system', 'prof-edit-nick')) + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setValue(profile?.customNickname || '') + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('intro') + .setLabel(localize('staff-management-system', 'prof-edit-intro')) + .setStyle(TextInputStyle.Paragraph) + .setRequired(false) + .setValue(profile?.customIntro || '') + ) + ); + + return interaction.showModal(modal); +} + +async function handleProfileAdminWipe(client, interaction, targetUser) { + const profilesConfig = client.configurations['staff-management-system']['profiles']; + const generalConfig = client.configurations['staff-management-system']['configuration'] || {}; + + if (!profilesConfig || !profilesConfig.enableProfiles) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-prof-dis') + }); + } + + const mRoles = Array.isArray(generalConfig.managementRoles) + ? generalConfig.managementRoles + : (generalConfig.managementRoles + ? [generalConfig.managementRoles] + : [] + ); + const sRoles = Array.isArray(generalConfig.supervisorRoles) + ? generalConfig.supervisorRoles + : (generalConfig.supervisorRoles + ? [generalConfig.supervisorRoles] + : [] + ); + + const requiredRoles = profilesConfig.managePermission === 'Management' + ? mRoles + : [...sRoles, ...mRoles]; + + const isAdmin = interaction.member.permissions.has('Administrator'); + const hasRequiredRole = requiredRoles.length > 0 && interaction.member.roles.cache.some(r => requiredRoles.includes(r.id)); + + if (!isAdmin && !hasRequiredRole) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-no-perm') + }); + } + + const Profile = client.models['staff-management-system']['StaffProfile']; + await Profile.update({ + customNickname: null, + customIntro: null + }, + { + where: {userId: targetUser.id} + }); + + await interaction.editReply({ + content: localize('staff-management-system', 'succ-prof-wipe', {u: targetUser.username}) + }); +} + +module.exports.autoComplete = { + 'infraction': { + 'issue': { + 'type': async function (interaction) { + const config = interaction.client.configurations['staff-management-system']['infractions'] || {}; + const types = config.infractionTypes && config.infractionTypes.length > 0 + ? config.infractionTypes + : ['Warning', 'Strike']; + + const focusedValue = interaction.options.getFocused() || ''; + const filtered = types.filter(choice => choice.toLowerCase().startsWith(focusedValue.toLowerCase())); + await interaction.respond(filtered.slice(0, 25).map(choice => ({ name: choice, value: choice }))); + } + } + } +}; + +module.exports.subcommands = { + 'panel': async (i) => { + const user = i.options.getUser('user'); + const payload = await generateUserPanel(i.client, user); + await i.reply({ + ...payload, + flags: MessageFlags.Ephemeral + }); + }, + 'infraction': { + 'issue': async (i) => { + const user = i.options.getMember('user'); + const type = i.options.getString('type'); + const reason = i.options.getString('reason'); + const expiry = i.options.getString('expiry'); + await issueInfraction(i.client, i, user, type, reason, expiry); + }, + 'suspend': async (i) => { + const user = i.options.getMember('user'); + const duration = i.options.getString('duration'); + const reason = i.options.getString('reason'); + await issueSuspension(i.client, i, user, duration, reason); + }, + 'history': async (i) => { + const user = i.options.getUser('user'); + await getInfractionHistory(i.client, i, user); + }, + 'void': async (i) => { + const caseId = i.options.getString('reference'); + await voidInfraction(i.client, i, caseId); + } + }, + 'promotion': { + 'promote': async (i) => { + const user = i.options.getMember('user'); + const role = i.options.getRole('rank'); + const reason = i.options.getString('reason'); + await promoteUser(i.client, i, user, role, reason); + }, + 'history': async (i) => { + const user = i.options.getUser('user'); + await getPromotionHistory(i.client, i, user); + } + }, + 'activity-check': { + 'start': async (i) => { + await i.deferReply({ flags: MessageFlags.Ephemeral }); + if (!canManageChecks(i.client, i.member)) return i.editReply({ + content: localize('staff-management-system', 'err-no-perm') + }); + await startActivityCheck(i.client, i, false); + }, + 'view': async (i) => { + await i.deferReply({ flags: MessageFlags.Ephemeral }); + if (!canManageChecks(i.client, i.member)) return i.editReply({ + content: localize('staff-management-system', 'err-no-perm') + }); + + const ActivityCheck = i.client.models['staff-management-system']['ActivityCheck']; + const ActivityCheckResponse = i.client.models['staff-management-system']['ActivityCheckResponse']; + const activeCheck = await ActivityCheck.findOne({ + where: {status: 'ACTIVE'} + }); + + if (!activeCheck) { + const config = i.client.configurations['staff-management-system']['activity-checks'] || {}; + const generalConfig = i.client.configurations['staff-management-system']['configuration'] || {}; + let logChannelId = config.logChannel; + if (!logChannelId || (Array.isArray(logChannelId) && logChannelId.length === 0)) logChannelId = generalConfig.generalLogChannel; + if (Array.isArray(logChannelId)) logChannelId = logChannelId[0]; + + const channelPing = logChannelId + ? `<#${logChannelId}>` + : localize('staff-management-system', 'lbl-log-chan'); + + return i.editReply({ + content: localize('staff-management-system', 'info-ac-none', {c: channelPing}) + }); + } + + const responseCount = await ActivityCheckResponse.count({ + where: { activityCheckId: activeCheck.id } + }); + + const embed = applyFooter(i.client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'ac-live-title')) + .setColor('Blue') + .setDescription( + `**${localize('staff-management-system', 'general-ends')}:** \n` + + `**${localize('staff-management-system', 'general-chan')}:** <#${activeCheck.channelId}>\n` + + `**${localize('staff-management-system', 'ac-tot-res')}:** ${responseCount}` + ) + ); + await i.editReply({ + embeds: [embed] + }); + }, + 'end': async (i) => { + await i.deferReply({ flags: MessageFlags.Ephemeral }); + if (!canManageChecks(i.client, i.member)) return i.editReply({ + content: localize('staff-management-system', 'err-no-perm') + }); + + const ActivityCheck = i.client.models['staff-management-system']['ActivityCheck']; + const activeCheck = await ActivityCheck.findOne({ where: { status: 'ACTIVE' } }); + + if (!activeCheck) return i.editReply({ + content: localize('staff-management-system', 'err-ac-noact') + }); + + await endActivityCheckProcess(i.client, activeCheck); + await i.editReply({ + content: localize('staff-management-system', 'succ-ac-end') + }); + } + }, + 'profile': { + 'view': async (i) => { + await i.deferReply({ + flags: MessageFlags.Ephemeral + }); + const user = i.options.getUser('user') || i.user; + await handleProfileView(i.client, i, user); + }, + 'edit': async (i) => { + await handleProfileEdit(i.client, i); + }, + 'wipe': async (i) => { + await i.deferReply({ + flags: MessageFlags.Ephemeral + }); + const user = i.options.getUser('user'); + await handleProfileAdminWipe(i.client, i, user); + } + }, + 'review': { + 'submit': async (i) => { + const user = i.options.getUser('user'); + const stars = i.options.getInteger('stars'); + const comment = i.options.getString('comment'); + await submitReview(i.client, i, user, stars, comment); + }, + 'history': async (i) => { + const user = i.options.getUser('user') || i.user; + await getReviewHistory(i.client, i, user); + } + } +}; + +module.exports.config = { + name: 'staff-management', + description: localize('staff-management-system', 'cmd-desc-smg'), + usage: '/staff-management', + type: 'slash', + defaultPermission: false, + options: function (client) { + const array = []; + + const infractionsConfig = client.configurations['staff-management-system']['infractions'] || {}; + const promotionsConfig = client.configurations['staff-management-system']['promotions'] || {}; + const activityChecksConfig = client.configurations['staff-management-system']['activity-checks'] || {}; + const profilesConfig = client.configurations['staff-management-system']['profiles'] || {}; + const reviewsConfig = client.configurations['staff-management-system']['reviews'] || {}; + + array.push({ + type: 'SUB_COMMAND', + name: 'panel', + description: localize('staff-management-system', 'cmd-desc-panel'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-panel-user'), + required: true + } + ] + }); + + if (infractionsConfig.enableInfractions) { + array.push({ + type: 'SUB_COMMAND_GROUP', + name: 'infraction', + description: localize('staff-management-system', 'cmd-desc-infractions'), + options: [ + { + type: 'SUB_COMMAND', + name: 'issue', + description: localize('staff-management-system', 'cmd-desc-issue'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-issue-user'), + required: true + }, + { + type: 'STRING', + name: 'type', + description: localize('staff-management-system', 'cmd-desc-issue-type'), + required: true, + autocomplete: true + }, + { + type: 'STRING', + name: 'reason', + description: localize('staff-management-system', 'cmd-desc-issue-reason'), + required: true + }, + { + type: 'STRING', + name: 'expiry', + description: localize('staff-management-system', 'cmd-desc-issue-expiry'), + required: false + } + ] + }, + ...(infractionsConfig.enableSuspensions ? [{ + type: 'SUB_COMMAND', + name: 'suspend', + description: localize('staff-management-system', 'cmd-desc-suspend'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-suspend-user'), + required: true + }, + { + type: 'STRING', + name: 'duration', + description: localize('staff-management-system', 'cmd-desc-suspend-duration'), + required: true + }, + { + type: 'STRING', + name: 'reason', + description: localize('staff-management-system', 'cmd-desc-suspend-reason'), + required: true + } + ] + }] : []), + { + type: 'SUB_COMMAND', + name: 'history', + description: localize('staff-management-system', 'cmd-desc-history'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-history-user'), + required: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'void', + description: localize('staff-management-system', 'cmd-desc-void'), + options: [ + { + type: 'STRING', + name: 'reference', + description: localize('staff-management-system', 'cmd-desc-void-case-ref'), + required: true + } + ] + } + ] + }); + } + + if (promotionsConfig.enablePromotions) { + array.push({ + type: 'SUB_COMMAND_GROUP', + name: 'promotion', + description: localize('staff-management-system', 'cmd-desc-promotion'), + options: [ + { + type: 'SUB_COMMAND', + name: 'promote', + description: localize('staff-management-system', 'cmd-desc-promote'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-promote-user'), + required: true + }, + { + type: 'ROLE', + name: 'rank', + description: localize('staff-management-system', 'cmd-desc-promote-rank'), + required: true + }, + { + type: 'STRING', + name: 'reason', + description: localize('staff-management-system', 'cmd-desc-promote-reason'), + required: true + }, + { + type: 'CHANNEL', + name: 'channel', + description: localize('staff-management-system', 'cmd-desc-promote-channel'), + required: false, + channelTypes: [0, 5] + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'history', + description: localize('staff-management-system', 'cmd-desc-prom-history'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-prom-history-user'), + required: true + } + ] + } + ] + }); + } + + if (activityChecksConfig.enableActivityChecks) { + array.push({ + type: 'SUB_COMMAND_GROUP', + name: 'activity-check', + description: localize('staff-management-system', 'cmd-desc-ac'), + options: [ + { + type: 'SUB_COMMAND', + name: 'start', + description: localize('staff-management-system', 'cmd-desc-ac-start'), + options: [ + { + type: 'CHANNEL', + name: 'channel', + description: localize('staff-management-system', 'cmd-desc-ac-start-channel'), + required: false, + channelTypes: [0] + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'view', + description: localize('staff-management-system', 'cmd-desc-ac-view') + }, + { + type: 'SUB_COMMAND', + name: 'end', + description: localize('staff-management-system', 'cmd-desc-ac-end') + } + ] + }); + } + + if (profilesConfig.enableProfiles) { + array.push({ + type: 'SUB_COMMAND_GROUP', + name: 'profile', + description: localize('staff-management-system', 'cmd-desc-profile'), + options: [ + { + type: 'SUB_COMMAND', + name: 'view', + description: localize('staff-management-system', 'cmd-desc-profile-view'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-profile-view-user'), + required: false + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'edit', + description: localize('staff-management-system', 'cmd-desc-profile-edit') + }, + { + type: 'SUB_COMMAND', + name: 'wipe', + description: localize('staff-management-system', 'cmd-desc-profile-wipe'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-profile-wipe-user'), + required: true + } + ] + } + ] + }); + } + + if (reviewsConfig.enableReviews) { + array.push({ + type: 'SUB_COMMAND_GROUP', + name: 'review', + description: localize('staff-management-system', 'cmd-desc-review'), + options: [ + { + type: 'SUB_COMMAND', + name: 'submit', + description: localize('staff-management-system', 'cmd-desc-review-submit'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-review-submit-user'), + required: true + }, + { + type: 'INTEGER', + name: 'stars', + description: localize('staff-management-system', 'cmd-desc-review-submit-stars'), + required: true, + choices: [ + { + name: '1 ⭐', + value: 1 + }, + { + name: '2 ⭐⭐', + value: 2 + }, + { + name: '3 ⭐⭐⭐', + value: 3 + }, + { + name: '4 ⭐⭐⭐⭐', + value: 4 + }, + { + name: '5 ⭐⭐⭐⭐⭐', + value: 5 + } + ] + }, + { + type: 'STRING', + name: 'comment', + description: localize('staff-management-system', 'cmd-desc-review-submit-comment'), + required: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'history', + description: localize('staff-management-system', 'cmd-desc-review-history'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-review-history-user'), + required: false + } + ] + } + ] + }); + } + + return array; + } +}; \ No newline at end of file diff --git a/modules/staff-management-system/commands/staff-status.js b/modules/staff-management-system/commands/staff-status.js new file mode 100644 index 00000000..e7e7be70 --- /dev/null +++ b/modules/staff-management-system/commands/staff-status.js @@ -0,0 +1,1048 @@ +const { + MessageFlags, + EmbedBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} = require('discord.js'); +const { Op } = require('sequelize'); +const schedule = require('node-schedule'); +const { formatDate } = require('../../../src/functions/helpers'); +const { localize } = require('../../../src/functions/localize'); +const { + getConfig, + getSafeChannelId, + parseDurationToDays, + buildPaginationRow, + applyFooter, + checkStaffPermissions +} = require('../staff-management'); + +// ---------- Status DM's and logging ---------- +async function sendStatusDm(user, type, dmType, data = {}) { + const label = type === 'LOA' + ? 'LoA' + : 'RA'; + const viewCmd = type === 'LOA' + ? '`/staff-status loa view`' + : '`/staff-status ra view`'; + const endFmt = data.endDate + ? `` + : ''; + + // These messages use the locales key to be easily used later + const messages = { + approved: { + title: 'dm-appr-title', + color: 'Green', + desc: 'dm-appr-desc', + params: {label, approver: data.approver, endFmt, viewCmd} + }, + denied: { + title: 'dm-deny-title', + color: 'Red', + desc: 'dm-deny-desc', + params: {label, denier: data.denier, reason: data.reason} + }, + extended: { + title: 'dm-ext-title', + color: 'Yellow', + desc: 'dm-ext-desc', + params: {label, extender: data.extender, days: data.days, endFmt, reason: data.reason, viewCmd} + }, + ended_early: { + title: 'dm-early-title', + color: 'Red', + desc: 'dm-early-desc', + params: {label, ender: data.ender, reason: data.reason} + }, + ended: { + title: 'dm-end-title', + color: 'Black', + desc: 'dm-end-desc', + params: {label} + } + }; + + const msg = messages[dmType]; + if (!msg) return; + + const embed = new EmbedBuilder() + .setTitle(localize('staff-management-system', msg.title, msg.params)) + .setDescription(localize('staff-management-system', msg.desc, msg.params)) + .setColor(msg.color); + applyFooter(user.client, embed); + + try { + await user.send({ + embeds: [embed.toJSON()] + }); + } catch (e) { + user.client.logger.error( + localize('staff-management-system', 'log-stat-dm-error', { + e: e.message, + u: user.tag + }) + ); + } +} + +function isStatusTypeEnabled(config, type) { + if (!config?.enableStatusSystem) return false; + return type === 'LOA' + ? !!config.enableLoa + : !!config.enableRa; +} + +async function logStatusChange(client, type, action, data) { + const statusConfig = getConfig(client, 'status'); + if (!statusConfig?.logStatusChanges) return; + + const channelId = getSafeChannelId(statusConfig.statusChangeLogChannel) || getSafeChannelId(getConfig(client, 'configuration')?.generalLogChannel); + if (!channelId) return; + + const guild = client.guilds.cache.get(client.guildID); + if (!guild) return; + const channel = await guild.channels.fetch(channelId).catch(() => null); + if (!channel) return; + + const label = type === 'LOA' + ? 'LoA' + : 'RA'; + const targetUserObj = data.targetUser || await client.users.fetch(data.userId).catch(() => null); + const mention = targetUserObj + ? targetUserObj.toString() + : `<@${data.userId}>`; + const username = targetUserObj + ? targetUserObj.username + : data.userId; + + const embed = new EmbedBuilder() + .setThumbnail(targetUserObj + ?.displayAvatarURL({ dynamic: true }) || null); + + if (action === 'start') { + embed.setTitle(localize('staff-management-system', 'log-start-title', { label, username })) + .setColor('Green') + .setDescription(localize('staff-management-system', 'log-start-desc', + { + label, mention, apprText: data.approverId + ? ` ${localize('staff-management-system', 'label-appr-by')}: <@${data.approverId}>.` + : '' + })) + .addFields({ + name: localize('staff-management-system', 'log-info-hdr', {label}), + value: `**${localize('staff-management-system', 'general-start')}:** \n**${localize('staff-management-system', 'general-end')}:** \n**${localize('staff-management-system', 'general-rsn')}:** ${data.reason || localize('staff-management-system', 'none-provided')}` + }); + + } else if (action === 'end') { + embed.setTitle(localize('staff-management-system', 'log-end-title', { label, username })) + .setColor('Red') + .setDescription(localize('staff-management-system', 'log-end-desc', { label, mention })) + .addFields({ + name: localize('staff-management-system', 'log-info-hdr', {label}), + value: `**${localize('staff-management-system', 'general-started')}:** \n**${localize('staff-management-system', 'general-ended')}:** \n**${localize('staff-management-system', 'general-req-reason')}:** ${data.reqReason}\n**${localize('staff-management-system', 'general-rsn')}:** ${data.reason || localize('staff-management-system', 'none-provided')}` + }); + + } else if (action === 'adjusted') { + embed.setTitle(localize('staff-management-system', 'log-adj-title', { label, username })) + .setColor('Yellow') + .setDescription(localize('staff-management-system', 'log-adj-desc', { label, mention, executor: data.executorId })) + .addFields({ + name: localize('staff-management-system', 'log-changes'), + value: data.changesText + }); + } + + applyFooter(client, embed); + try { + await channel.send({ + embeds: [embed.toJSON()] + }); + } catch (e) { + client.logger.error( + localize('staff-management-system', 'log-status-adj-error', { + e: e.message + }) + ); + } +} + +// ----- Status ----- +const getStatusMeta = (type) => ({ + isLoa: type === 'LOA', + label: type === 'LOA' + ? 'LoA' + : 'RA', + enableKey: type === 'LOA' + ? 'enableLoa' + : 'enableRa', + roleKey: type === 'LOA' + ? 'loaRole' + : 'raRole', + maxDaysKey: type === 'LOA' + ? 'loaMaxDays' + : 'raMaxDays', + color: type === 'LOA' + ? 'Green' + : 'Orange', + activeText: localize('staff-management-system', type === 'LOA' + ? 'status-active-loa' + : 'status-active-ra' + ), + histTitle: localize('staff-management-system', type === 'LOA' + ? 'status-hist-loa' + : 'status-hist-ra' + ), + actionPrefix: type === 'LOA' + ? 'loa' + : 'ra' +}); + +async function handleStatusRequest(client, interaction, type, durationInput, reason) { + const config = getConfig(client, 'status'); + const isLoa = type === 'LOA'; + if (!isStatusTypeEnabled(config, type)) + return interaction.editReply({ + content: localize('staff-management-system', 'err-status-disabled', {type}) + } + ); + + const days = parseDurationToDays(durationInput?.trim()); + if (!days || isNaN(days) || days <= 0) return interaction.editReply({ + content: localize('staff-management-system', 'err-invalid-duration') + }); + + const maxDays = (isLoa ? config.loaMaxDays : config.raMaxDays) || (isLoa ? 60 : 30); + if (days > maxDays) return interaction.editReply({ + content: localize('staff-management-system', 'err-duration-max', {max: maxDays}) + }); + + const LoaRequest = client.models['staff-management-system']['LoaRequest']; + if (await LoaRequest.findOne({ + where: { + userId: interaction.user.id, type, status: {[Op.in]: ['PENDING', 'APPROVED']}, + endDate: {[Op.gt]: new Date()} + } + })) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-status-exists', {type}) + }); + } + + const startDate = new Date(); + const endDate = new Date(startDate.getTime() + days * 24 * 60 * 60 * 1000); + const needsApproval = isLoa + ? config.requireLoaApproval !== false + : config.requireRaApproval !== false; + + const req = await LoaRequest.create({ + userId: interaction.user.id, + type, + reason, + startDate, + endDate, + status: needsApproval + ? 'PENDING' + : 'APPROVED' + }); + + const logChannelId = getSafeChannelId(config.statusLogChannel); + if (logChannelId && needsApproval) { + const channel = await interaction.guild.channels.fetch(logChannelId).catch(() => null); + if (channel) { + const embed = new EmbedBuilder() + .setTitle(localize('staff-management-system', 'status-request-title', { type })) + .setColor('Blue') + .setAuthor({ name: `Request ID: ${req.id}`}) + .addFields( + { + name: localize('staff-management-system', 'status-req-user'), + value: interaction.user.toString(), + inline: true + }, + { + name: localize('staff-management-system', 'status-req-duration'), + value: `${days} ${localize('staff-management-system', 'label-days')}`, + inline: true + }, + { + name: localize('staff-management-system', 'general-rsn'), + value: reason + } + ); + + applyFooter(client, embed); + const row = new ActionRowBuilder() + .addComponents(new ButtonBuilder() + .setCustomId(`staff-mgmt_approve_${req.id}`) + .setLabel(localize('staff-management-system', 'btn-approve')) + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`staff-mgmt_deny_${req.id}`) + .setLabel(localize('staff-management-system', 'btn-deny')) + .setStyle(ButtonStyle.Danger)); + channel.send({ embeds: [embed.toJSON()], components: [row.toJSON()] }).catch(()=>{}); + } + } + + if (!needsApproval) { + const roleId = config[isLoa ? 'loaRole' : 'raRole']; + if (roleId) interaction.member.roles.add(roleId).catch(()=>{}); + await logStatusChange(client, type, 'start', { + targetUser: interaction.user, + startDate, + endDate, + reason, + approverId: null + }); + } + + await interaction.editReply({ + content: localize('staff-management-system', 'success-status-request', { + type, state: needsApproval + ? localize('staff-management-system', 'state-pending') + : localize('staff-management-system', 'state-auto') + }) + }); +} + +async function handleStatusView(client, interaction, type, targetUser) { + const user = targetUser || interaction.user; + const request = await client.models['staff-management-system']['LoaRequest'].findOne({ + where: { + userId: user.id, type, status: {[Op.in]: ['APPROVED', 'PENDING']}, + endDate: {[Op.gt]: new Date()} + }, + order: [['createdAt', 'DESC']] + }); + + if (!request) return interaction.editReply({ + content: localize('staff-management-system', 'no-active-status', { + user: user.username, + type + }) + }); + + const embed = new EmbedBuilder() + .setTitle(`${type} Status: ${user.username}`) + .setColor(request.status === 'APPROVED' + ? 'Green' + : 'Yellow' + ) + .addFields( + { + name: localize('staff-management-system', 'label-stat'), + value: request.status, + inline: true + }, + { + name: localize('staff-management-system', 'label-end'), + value: formatDate(request.endDate), + inline: true + }, + { + name: localize('staff-management-system', 'general-rsn'), + value: request.reason || localize('staff-management-system', 'info-none') + }) + .setThumbnail(user.displayAvatarURL({ dynamic: true })); + applyFooter(client, embed); + await interaction.editReply({ embeds: [embed.toJSON()] }); +} + +async function handleStatusList(client, interaction, type, filter) { + const LoaRequest = client.models['staff-management-system']['LoaRequest']; + const now = new Date(); + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 60); + + let whereClause = { type }; + let title = `${type} List`; + + if (filter === 'active') { + whereClause.status = 'APPROVED'; + whereClause.endDate = {[Op.gt]: now}; + title += localize('staff-management-system', 'filter-active'); + } else if (filter === 'expired') { + whereClause.status = {[Op.in]: ['APPROVED', 'ENDED']}; + whereClause.endDate = {[Op.between]: [cutoff, now]}; + title += localize('staff-management-system', 'filter-expired'); + } else { + whereClause.status = {[Op.in]: ['APPROVED', 'ENDED']}; + whereClause.endDate = {[Op.between]: [cutoff, now]}; + title += localize('staff-management-system', 'filter-history'); + } + + const rows = await LoaRequest.findAll({ + where: whereClause, + order: [['endDate', 'DESC']], + limit: 25 + }); + if (rows.length === 0) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-no-recs') + }); + } + + const embed = new EmbedBuilder() + .setTitle(title) + .setColor('Blue') + .setDescription( + rows.map(r => + `**<@${r.userId}>** ${r.status === 'APPROVED' ? '✅' : '⏹️'}\n` + + `${localize('staff-management-system', 'label-end')}: ${formatDate(r.endDate)}\n` + + `${localize('staff-management-system', 'general-rsn')}: ${r.reason || localize('staff-management-system', 'info-none')}` + ).join('\n\n') + ); + + applyFooter(client, embed); + await interaction.editReply({ embeds: [embed.toJSON()] }); +} + +async function handleStatusManage(client, interaction, targetMember, type) { + const config = getConfig(client, 'status'); + const meta = getStatusMeta(type); + if (!isStatusTypeEnabled(config, type)) + return interaction.editReply({ + content: localize('staff-management-system', 'err-status-disabled', {type}) + }); + + const generalConfig = getConfig(client, 'configuration'); + if (!checkStaffPermissions(interaction.member, generalConfig, 'supervisor')) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-gen-no-perm') + })}; + + const LoaRequest = client.models['staff-management-system']['LoaRequest']; + const activeRequest = await LoaRequest.findOne({ + where: { + userId: targetMember.user.id, + type, + status: {[Op.in]: ['APPROVED', 'PENDING']}, + endDate: {[Op.gt]: new Date()} + }, + order: [['createdAt', 'DESC']] + } + ); + const totalCount = await LoaRequest.count({ + where: {userId: targetMember.user.id, type} + }); + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'manage-status-title', { + label: meta.label, + username: targetMember.user.username + })) + .setThumbnail(targetMember.user.displayAvatarURL({ dynamic: true })) + .setColor(activeRequest + ? meta.color + : 'Grey' + ) + .setDescription(localize('staff-management-system', 'manage-stat-desc', { + status: activeRequest + ? meta.activeText + : localize('staff-management-system', 'no-act-stat', { + label: meta.label + }), + label: meta.label, + count: Math.max(0, totalCount - (activeRequest ? 1 : 0)) + })) + ); + + embed.addFields({ + name: localize('staff-management-system', 'manage-active-details', {label: meta.label}), + value: activeRequest ? `**${localize('staff-management-system', 'general-start')}:** ${formatDate(activeRequest.startDate)}\n**${localize('staff-management-system', 'general-end')}:** ${formatDate(activeRequest.endDate)}\n**${localize('staff-management-system', 'label-stat')}:** ${activeRequest.status}\n**${localize('staff-management-system', 'label-appr-by')}:** ${activeRequest.approverId ? `<@${activeRequest.approverId}>` : localize('staff-management-system', 'label-auto')}\n**${localize('staff-management-system', 'general-rsn')}:** ${activeRequest.reason || localize('staff-management-system', 'info-none')}` : localize('staff-management-system', 'manage-no-active-user', {label: meta.label}) + }); + + const p = meta.actionPrefix; + const rid = activeRequest?.id ?? 'none'; + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`staff-mgmt_${p}-end_${rid}`) + .setLabel(localize('staff-management-system', 'btn-end-early', { label: meta.label })) + .setEmoji('🚫').setStyle(ButtonStyle.Danger) + .setDisabled(!activeRequest), + new ButtonBuilder() + .setCustomId(`staff-mgmt_${p}-extend_${rid}`) + .setLabel(localize('staff-management-system', 'btn-extend', { label: meta.label })) + .setEmoji('⏳') + .setStyle(ButtonStyle.Primary) + .setDisabled(!activeRequest), + new ButtonBuilder() + .setCustomId(`staff-mgmt_${p}-hist_${targetMember.user.id}_1`) + .setLabel(localize('staff-management-system', 'btn-view-history')) + .setEmoji('📜') + .setStyle(ButtonStyle.Secondary) + .setDisabled(totalCount === 0) + ); + await interaction.editReply({ + embeds: [embed.toJSON()], + components: [row.toJSON()] + }); +} + +async function handleStatusEnd(interaction, type) { + const meta = getStatusMeta(type); + const requestId = interaction.customId.split('_')[2]; + if (requestId === 'none') return interaction.reply({ + content: localize('staff-management-system', 'err-no-active-end', {label: meta.label}), + flags: MessageFlags.Ephemeral + }); + + const modal = new ModalBuilder() + .setCustomId(`staff-mgmt_${meta.actionPrefix}-end-submit_${requestId}`) + .setTitle(localize('staff-management-system', 'modal-end-early-title', { label: meta.label })); + modal.addComponents(new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('end_reason') + .setLabel(localize('staff-management-system', 'modal-end-early-reason')) + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + )); + return interaction.showModal(modal); +} + +async function handleStatusEndSubmit(client, interaction, type) { + const generalConfig = getConfig(client, 'configuration'); + if (!checkStaffPermissions(interaction.member, generalConfig, 'supervisor')) { + return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-perm'), + flags: MessageFlags.Ephemeral + }); + } + + const meta = getStatusMeta(type); + const request = await client.models['staff-management-system']['LoaRequest'].findByPk(interaction.customId.split('_')[2]); + if (!request || request.status === 'ENDED' || request.status === 'DENIED') return interaction.reply({ + content: localize('staff-management-system', 'err-stat-inact', {label: meta.label}), + flags: MessageFlags.Ephemeral + }); + + const reason = interaction.fields.getTextInputValue('end_reason'); + const member = await interaction.guild.members.fetch(request.userId).catch(() => null); + + if (member && getConfig(client, 'status')[meta.roleKey]) await member.roles.remove(getConfig(client, 'status')[meta.roleKey]).catch(() => {}); + + await request.update({ status: 'ENDED', endDate: new Date() }); + await client.models['staff-management-system']['StaffProfile'].update({activityStatus: 'ACTIVE'}, { + where: {userId: request.userId} + }); + + if (member) await sendStatusDm(member.user, type, 'ended_early', { + ender: interaction.user.tag, + reason + }); + await logStatusChange(client, type, 'end', { + userId: request.userId, + startDate: request.startDate, + reason: reason, + reqReason: request.reason + }); + + const updatedEmbed = EmbedBuilder.from(interaction.message.embeds[0]) + .setColor('Grey') + .setDescription(localize('staff-management-system', 'status-ended-embed-desc', { + label: meta.label, user: interaction.user.tag, reason + })) + .spliceFields(0, 1, { + name: localize('staff-management-system', 'manage-active-details', {label: meta.label}), + value: localize('staff-management-system', 'manage-no-active-user', {label: meta.label}) + }); + + const p = meta.actionPrefix; + const disabledRow = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`${p}-end-done`) + .setLabel(localize('staff-management-system', 'btn-end-early', { label: meta.label })) + .setEmoji('🚫') + .setStyle(ButtonStyle.Danger) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`${p}-extend-done`) + .setLabel(localize('staff-management-system', 'btn-extend', { label: meta.label })) + .setEmoji('⏳') + .setStyle(ButtonStyle.Primary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`staff-mgmt_${p}-hist_${request.userId}_1`) + .setLabel(localize('staff-management-system', 'btn-view-history')) + .setEmoji('📜') + .setStyle(ButtonStyle.Secondary) + ); + return interaction.reply({ + embeds: [updatedEmbed.toJSON()], + components: [disabledRow.toJSON()], + flags: MessageFlags.Ephemeral + }); +} + +async function handleStatusExtend(interaction, type) { + const meta = getStatusMeta(type); + const requestId = interaction.customId.split('_')[2]; + if (requestId === 'none') return interaction.reply({ + content: localize('staff-management-system', 'err-no-active-extend', {label: meta.label}), + flags: MessageFlags.Ephemeral + }); + + const modal = new ModalBuilder() + .setCustomId(`staff-mgmt_${meta.actionPrefix}-extend-submit_${requestId}`) + .setTitle(localize('staff-management-system', 'modal-extend-title', { + label: meta.label + })); + modal.addComponents( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('extend_days') + .setLabel(localize('staff-management-system', 'modal-extend-days')) + .setStyle(TextInputStyle.Short) + .setPlaceholder("7") + .setRequired(true) + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('extend_reason') + .setLabel(localize('staff-management-system', 'modal-extend-reason')) + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + ) + ); + return interaction.showModal(modal); +} + +function scheduleStatusExpiry(client, request) { + const jobName = `staff-mgmt-status-expiry-${request.id}`; + const existingJob = schedule.scheduledJobs[jobName]; + if (existingJob) existingJob.cancel(); + + schedule.scheduleJob(jobName, new Date(request.endDate), async () => { + try { + const req = await client.models['staff-management-system']['LoaRequest'].findByPk(request.id); + if (!req || req.status !== 'APPROVED' || new Date(req.endDate) > new Date()) return; + + await req.update({ status: 'ENDED' }); + await client.models['staff-management-system']['StaffProfile'].update( + { activityStatus: 'ACTIVE' }, + { where: { userId: req.userId } } + ); + + const member = await client.guilds.cache.get(client.guildID)?.members.fetch(req.userId).catch(() => null); + if (member) { + const roleKey = req.type === 'LOA' ? 'loaRole' : 'raRole'; + const roleId = getConfig(client, 'status')[roleKey]; + if (roleId) await member.roles.remove(roleId).catch(() => {}); + await sendStatusDm(member.user, req.type, 'ended'); + } + + await logStatusChange(client, req.type, 'end', { + userId: req.userId, + startDate: req.startDate, + reason: localize('staff-management-system', 'status-expired-auto'), + reqReason: req.reason + }); + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-status-expiry-fail', { + error: e.message + })); + } + }); +} + +async function handleStatusExtendSubmit(client, interaction, type) { + const generalConfig = getConfig(client, 'configuration'); + if (!checkStaffPermissions(interaction.member, generalConfig, 'supervisor')) { + return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-perm'), + flags: MessageFlags.Ephemeral + }); + } + + const meta = getStatusMeta(type); + const request = await client.models['staff-management-system']['LoaRequest'].findByPk(interaction.customId.split('_')[2]); + if (!request || request.status === 'ENDED' || request.status === 'DENIED') { + return interaction.reply({ + content: localize('staff-management-system', 'err-stat-inact', { + label: meta.label + }), + flags: MessageFlags.Ephemeral + }); + } + + const days = parseInt(interaction.fields.getTextInputValue('extend_days'), 10); + const reason = interaction.fields.getTextInputValue('extend_reason'); + if (isNaN(days) || days <= 0 || days > 180) return interaction.reply({ + content: localize('staff-management-system', 'err-inv-dur'), + flags: MessageFlags.Ephemeral + }); + + const oldEndDate = new Date(request.endDate); + const newEndDate = new Date(oldEndDate.getTime() + days * 24 * 60 * 60 * 1000); + await request.update({ endDate: newEndDate }); + request.endDate = newEndDate; + scheduleStatusExpiry(client, request); + + const member = await interaction.guild.members.fetch(request.userId).catch(() => null); + if (member) await sendStatusDm(member.user, type, 'extended', { + extender: interaction.user.tag, + days, + endDate: newEndDate, + reason + }); + await logStatusChange(client, type, 'adjusted', { + userId: request.userId, + executorId: interaction.user.id, + changesText: localize('staff-management-system', 'status-adjusted-log', { + label: meta.label, + newEnd: ``, + oldEnd: ``, + reason + }) + }); + + const updatedEmbed = EmbedBuilder.from(interaction.message.embeds[0]) + .spliceFields(0, 1, { + name: localize('staff-management-system', 'manage-active-details', {label: meta.label}), + value: localize('staff-management-system', 'mod-stat-ext', { + s: formatDate(request.startDate), + e: formatDate(newEndDate), + d: days, + t: request.status, + a: request.approverId + ? `<@${request.approverId}>` + : localize('staff-management-system', 'label-auto'), + r: request.reason || localize('staff-management-system', 'info-none') + }) + }); + return interaction.reply({ + embeds: [updatedEmbed.toJSON()], + components: interaction.message.components.map(c => c.toJSON()), + flags: MessageFlags.Ephemeral + }); +} + +async function generateStatusHistoryResponse(client, targetUser, page = 1, type) { + const meta = getStatusMeta(type); + const limit = 5; + const offset = (page - 1) * limit; + + const {count, rows} = await client.models['staff-management-system']['LoaRequest'].findAndCountAll({ + where: {userId: targetUser.id, type}, + order: [['createdAt', 'DESC']], + limit, + offset + }); + if (count === 0) return { + content: localize('staff-management-system', 'info-no-status-history', {label: meta.label}), + flags: MessageFlags.Ephemeral + }; + + const totalPages = Math.ceil(count / limit) || 1; + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(`${meta.histTitle} - ${targetUser.username}`) + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setColor(meta.color) + .setDescription(localize('staff-management-system', 'status-history-desc', { + count: rows.length, + total: count, + label: meta.label + } + )) + ); + + const statusIcons = { + APPROVED: '✅', + DENIED: '❌', + ENDED: '⏹️', + PENDING: '🕐' + }; + rows.forEach((req, index) => embed.addFields({ + name: `${statusIcons[req.status] ?? '❓'} ${meta.label} #${offset + index + 1} - ${req.status}`, + value: `**${localize('staff-management-system', 'general-start')}:** ${formatDate(req.startDate)}\n**${localize('staff-management-system', 'general-end')}:** ${formatDate(req.endDate)}\n**${localize('staff-management-system', 'label-appr-by')}:** ${req.approverId ? `<@${req.approverId}>` : localize('staff-management-system', 'label-auto')}\n**${localize('staff-management-system', 'general-rsn')}:** ${req.reason || localize('staff-management-system', 'info-none')}` })); + embed.addFields({ + name: '\u200b', + value: localize('staff-management-system', 'page-count', {page, total: totalPages}) + }); + + const row = buildPaginationRow( + `staff-mgmt_${meta.actionPrefix}-hist_${targetUser.id}_${page - 1}`, + `${meta.actionPrefix}_hist_page_count`, + `staff-mgmt_${meta.actionPrefix}-hist_${targetUser.id}_${page + 1}`, + page, + totalPages + ); + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function handleStatusHistPage(client, interaction, type) { + const parts = interaction.customId.split('_'); + const targetUser = await client.users.fetch(parts[2]).catch(() => null); + if (!targetUser) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const payload = await generateStatusHistoryResponse(client, targetUser, parseInt(parts[3], 10), type); + if (payload.content) return interaction.reply({ + ...payload, + flags: MessageFlags.Ephemeral + }); + return interaction.message?.embeds?.[0]?.title?.startsWith(getStatusMeta(type).histTitle) + ? interaction.update(payload) + : interaction.reply({ ...payload, flags: MessageFlags.Ephemeral }); +} + +module.exports.beforeSubcommand = async function (interaction) { + if (!interaction.replied && !interaction.deferred) { + await interaction.deferReply({ + flags: MessageFlags.Ephemeral + }); + } +}; + +module.exports.subcommands = { + 'loa': { + 'request': async function (interaction) { + const duration = interaction.options.getString('duration'); + const reason = interaction.options.getString('reason'); + await handleStatusRequest(interaction.client, interaction, 'LOA', duration, reason); + }, + 'view': async function (interaction) { + const user = interaction.options.getUser('user') || interaction.user; + await handleStatusView(interaction.client, interaction, 'LOA', user); + }, + 'list': async function (interaction) { + const filter = interaction.options.getString('filter'); + await handleStatusList(interaction.client, interaction, 'LOA', filter); + }, + 'admin': async function (interaction) { + const user = interaction.options.getMember('user'); + if (!user) return interaction.editReply({ + content: localize('staff-management-system', 'err-no-mem') + }); + await handleStatusManage(interaction.client, interaction, user, 'LOA'); + } + }, + 'ra': { + 'request': async function (interaction) { + const duration = interaction.options.getString('duration'); + const reason = interaction.options.getString('reason'); + await handleStatusRequest(interaction.client, interaction, 'RA', duration, reason); + }, + 'view': async function (interaction) { + const user = interaction.options.getUser('user') || interaction.user; + await handleStatusView(interaction.client, interaction, 'RA', user); + }, + 'list': async function (interaction) { + const filter = interaction.options.getString('filter'); + await handleStatusList(interaction.client, interaction, 'RA', filter); + }, + 'admin': async function (interaction) { + const user = interaction.options.getMember('user'); + if (!user) return interaction.editReply({ + content: localize('staff-management-system', 'err-no-mem') + }); + await handleStatusManage(interaction.client, interaction, user, 'RA'); + } + } +}; + +module.exports.config = { + name: 'staff-status', + description: localize('staff-management-system', 'cmd-desc-status'), + usage: '/staff-status', + type: 'slash', + defaultPermission: false, + disabled: function (client) { + return !client.configurations['staff-management-system']['status']?.enableStatusSystem; + }, + + options: function (client) { + const config = getConfig(client, 'status'); + const array = []; + + if (!config?.enableStatusSystem) return array; + + if (config.enableLoa) { + array.push({ + type: 'SUB_COMMAND_GROUP', + name: 'loa', + description: localize('staff-management-system', 'cmd-desc-loa'), + options: [ + { + type: 'SUB_COMMAND', + name: 'request', + description: localize('staff-management-system', 'cmd-desc-loa-request'), + options: [ + { + type: 'STRING', + name: 'duration', + description: localize('staff-management-system', 'cmd-desc-loar-duration'), + required: true + }, + { + type: 'STRING', + name: 'reason', + description: localize('staff-management-system', 'cmd-desc-loar-reason'), + required: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'view', + description: localize('staff-management-system', 'cmd-desc-loa-view'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-loav-user'), + required: false + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'list', + description: localize('staff-management-system', 'cmd-desc-loa-list'), + options: [{ + type: 'STRING', + name: 'filter', + description: localize('staff-management-system', 'cmd-desc-loal-filter'), + required: true, + choices: [ + { + name: 'Active', + value: 'active' + }, + { + name: 'Expired', + value: 'expired' + }, + { + name: 'All', + value: 'all' + }] + }] + }, + { + type: 'SUB_COMMAND', + name: 'admin', + description: localize('staff-management-system', 'cmd-desc-loa-admin'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-loaa-user'), + required: true + } + ] + } + ] + }); + } + + if (config.enableRa) { + array.push({ + type: 'SUB_COMMAND_GROUP', + name: 'ra', + description: localize('staff-management-system', 'cmd-desc-ra'), + options: [ + { + type: 'SUB_COMMAND', + name: 'request', + description: localize('staff-management-system', 'cmd-desc-ra-request'), + options: [ + { + type: 'STRING', + name: 'duration', + description: localize('staff-management-system', 'cmd-desc-rar-duration'), + required: true + }, + { + type: 'STRING', + name: 'reason', + description: localize('staff-management-system', 'cmd-desc-rar-reason'), + required: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'view', + description: localize('staff-management-system', 'cmd-desc-ra-view'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-rav-user'), + required: false + }] + }, + { + type: 'SUB_COMMAND', + name: 'list', + description: localize('staff-management-system', 'cmd-desc-ra-list'), + options: [ + { + type: 'STRING', + name: 'filter', + description: localize('staff-management-system', 'cmd-desc-ral-filter'), + required: true, + choices: [ + { + name: 'Active', + value: 'active' + }, + { + name: 'Expired', + value: 'expired' + }, + { + name: 'All', + value: 'all' + } + ] + }] + }, + { + type: 'SUB_COMMAND', + name: 'admin', + description: localize('staff-management-system', 'cmd-desc-ra-admin'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-raa-user'), + required: true + } + ] + } + ] + }); + } + + return array; + } +}; + +module.exports.sendStatusDm = sendStatusDm; +module.exports.logStatusChange = logStatusChange; +module.exports.handleStatusRequest = handleStatusRequest; +module.exports.handleStatusView = handleStatusView; +module.exports.handleStatusList = handleStatusList; +module.exports.handleStatusManage = handleStatusManage; +module.exports.handleStatusEnd = handleStatusEnd; +module.exports.handleStatusEndSubmit = handleStatusEndSubmit; +module.exports.handleStatusExtend = handleStatusExtend; +module.exports.handleStatusExtendSubmit = handleStatusExtendSubmit; +module.exports.handleStatusHistPage = handleStatusHistPage; +module.exports.scheduleStatusExpiry = scheduleStatusExpiry; \ No newline at end of file diff --git a/modules/staff-management-system/configs/activity-checks.json b/modules/staff-management-system/configs/activity-checks.json new file mode 100644 index 00000000..634c0162 --- /dev/null +++ b/modules/staff-management-system/configs/activity-checks.json @@ -0,0 +1,212 @@ +{ + "filename": "activity-checks.json", + "humanName": "Activity Checks", + "description": "Configure automated staff activity checks and response logging.", + "categories": [ + { + "id": "general", + "icon": "fas fa-clipboard-user", + "displayName": "General Settings" + }, + { + "id": "exceptions", + "icon": "fa-solid fa-badge-check", + "displayName": "Exceptions" + }, + { + "id": "automation", + "icon": "far fa-robot", + "displayName": "Automation" + }, + { + "id": "results", + "icon": "fa-solid fa-check-to-slot", + "displayName": "Results & Logging" + } + ], + "content": [ + { + "name": "enableActivityChecks", + "category": "general", + "humanName": "Enable Activity Checks", + "description": "Allows admins to start an activity check to see who is active.", + "type": "boolean", + "default": true, + "elementToggle": true + }, + { + "name": "targetRoles", + "category": "general", + "humanName": "Roles to Check", + "description": "The roles required to respond to the activity check. Anyone with these roles will be expected to click the button. Leave empty to default to the General Staff Roles.", + "type": "array", + "content": "roleID", + "default": [], + "allowNull": true + }, + { + "name": "timeframe", + "category": "general", + "humanName": "Check Duration (Hours)", + "description": "How long staff have to respond to the activity check (Max 168 hours / 1 week).", + "type": "integer", + "minValue": 1, + "maxValue": 168, + "default": 24 + }, + { + "name": "checkMessage", + "category": "general", + "humanName": "Activity Check Embed", + "description": "The message sent when an activity check starts.", + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "end-time", + "description": "The Discord timestamp when the check ends." + }, + { + "name": "duration", + "description": "The configured duration in hours." + } + ], + "default": { + "title": "📋 Staff Activity Check", + "description": "Please click the button below to confirm your activity before %endtime%.", + "color": "#3498db" + } + }, + { + "name": "sendingChannel", + "category": "general", + "humanName": "Default Sending Channel", + "description": "The default channel where the activity check message will be posted. This can manually be overridden with the command.", + "type": "channelID", + "channelTypes": [ + "GUILD_TEXT", + "GUILD_NEWS" + ], + "default": "", + "allowNull": true + }, + { + "name": "exceptionsType", + "category": "exceptions", + "humanName": "Exceptions Rule", + "description": "Who are excused from the activity checks?", + "type": "select", + "content": [ + "No exceptions", + "Only LoA", + "Only RA", + "LoA and RA", + "Custom role(s)" + ], + "default": "LoA and RA" + }, + { + "name": "customExceptionRoles", + "category": "exceptions", + "humanName": "Custom Exception Roles", + "description": "Only applies if 'Custom role(s)' is selected above.", + "type": "array", + "content": "roleID", + "default": [], + "allowNull": true + }, + { + "name": "automatedChecks", + "category": "automation", + "humanName": "Automated Checks", + "description": "If enabled, the bot will automatically start activity checks at configured intervals.", + "type": "boolean", + "default": false + }, + { + "name": "automatedCheckInterval", + "category": "automation", + "humanName": "Automated Check Interval", + "description": "On which interval to start automatic checks. Choose cronjob for full customzation.", + "type": "select", + "content": [ + "Weekly", + "Biweekly", + "Monthly", + "Cronjob" + ], + "default": "Biweekly", + "dependsOn": "automatedChecks" + }, + { + "name": "automatedCheckCronjob", + "category": "automation", + "humanName": "Automated Check Cronjob", + "description": "The cronjob schedule for automatic checks. Only applies if 'Cronjob' is selected above.", + "type": "string", + "default": "", + "dependsOn": "automatedChecks", + "allowNull": true + }, + { + "name": "automatedCheckWeekDay", + "category": "automation", + "humanName": "Automated Check Week Day", + "description": "The week day to start automatic checks.", + "type": "select", + "content": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ], + "default": "Monday", + "dependsOn": "automatedChecks" + }, + { + "name": "automatedCheckMonthWeek", + "category": "automation", + "humanName": "Automated Check Month Week", + "description": "The week of the month to start automatic checks. Only applies if 'Monthly' is selected above.", + "type": "integer", + "minValue": 1, + "maxValue": 4, + "default": 1, + "dependsOn": "automatedChecks" + }, + { + "name": "logChannel", + "category": "results", + "humanName": "Results Channel", + "description": "Where the final results are posted. Leave empty if you want to use the general log channel.", + "type": "channelID", + "default": "", + "channelTypes": [ + "GUILD_TEXT", + "GUILD_NEWS" + ], + "allowNull": true + }, + { + "name": "pingResults", + "category": "results", + "humanName": "Ping on Results", + "description": "Ping specific roles when the results are posted.", + "type": "boolean", + "default": false + }, + { + "name": "pingRoles", + "category": "results", + "humanName": "Roles to Ping", + "description": "The roles to ping with the results message.", + "type": "array", + "content": "roleID", + "default": [], + "dependsOn": "pingResults" + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/configuration.json b/modules/staff-management-system/configs/configuration.json new file mode 100644 index 00000000..9b978d2c --- /dev/null +++ b/modules/staff-management-system/configs/configuration.json @@ -0,0 +1,58 @@ +{ + "filename": "configuration.json", + "humanName": "General Configuration", + "description": "Configure the main staff roles and the default log channel.", + "categories": [ + { + "id": "roles", + "icon": "fas fa-clipboard-user", + "displayName": "Staff Roles" + }, + { + "id": "logging", + "icon": "fa-solid fa-clipboard-list", + "displayName": "Logging" + } + ], + "content": [ + { + "name": "staffRoles", + "category": "roles", + "humanName": "Staff Roles", + "description": "Roles that can use basic staff commands (Shifts, LoA Request and RA Request, reviews etc.).", + "type": "array", + "content": "roleID", + "default": [] + }, + { + "name": "supervisorRoles", + "category": "roles", + "humanName": "Supervisor Roles", + "description": "Roles that can manage other staff members (Approve/Deny/Manage LoA's and RA's, Manage Shifts, promote and infract users).", + "type": "array", + "content": "roleID", + "default": [] + }, + { + "name": "managementRoles", + "category": "roles", + "humanName": "Management Roles", + "description": "Roles with full access, including data deletion abilities.", + "type": "array", + "content": "roleID", + "default": [] + }, + { + "name": "generalLogChannel", + "category": "logging", + "humanName": "General Log Channel", + "description": "The default channel where logs happen such as status changes/request and more. This can be overridden in some features.", + "type": "channelID", + "channelTypes": [ + "GUILD_TEXT", + "GUILD_NEWS" + ], + "default": "" + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/infractions.json b/modules/staff-management-system/configs/infractions.json new file mode 100644 index 00000000..89a6bc18 --- /dev/null +++ b/modules/staff-management-system/configs/infractions.json @@ -0,0 +1,325 @@ +{ + "filename": "infractions.json", + "humanName": "Infractions & Suspensions", + "description": "Configure how staff infractions, strikes, and suspensions are handled.", + "categories": [ + { + "id": "logic", + "icon": "fas fa-hammer", + "displayName": "General Logic" + }, + { + "id": "suspensions", + "icon": "fa fa-bell-exclamation", + "displayName": "Suspensions Logic" + }, + { + "id": "messages", + "icon": "fa fa-messages", + "displayName": "Messages & Embeds" + } + ], + "content": [ + { + "name": "enableInfractions", + "category": "logic", + "humanName": "Enable Infractions System", + "description": "Enabling this will unlock features such as issuing infractions to staff members, suspensions and more.", + "type": "boolean", + "elementToggle": true, + "default": true + }, + { + "name": "infractionTypes", + "category": "logic", + "humanName": "Infraction Types", + "description": "These are the types of infractions that can be issued to staff members. You can customize these to fit your infractions system.", + "type": "array", + "content": "string", + "default": [ + "Warning", + "Strike", + "Demotion", + "Termination", + "Under Investigation" + ] + }, + { + "name": "enableSuspensions", + "category": "suspensions", + "humanName": "Enable Suspensions System", + "description": "Suspensions temporarily strip a staff member of their roles.", + "type": "boolean", + "default": true + }, + { + "name": "suspensionHierarchyRole", + "category": "suspensions", + "humanName": "Hierarchy Base Role", + "description": "When suspending, the bot will remove all roles above and including this one. This would usually be your lowest 'Staff' role.", + "type": "roleID", + "allowNull": true, + "dependsOn": "enableSuspensions", + "default": "" + }, + { + "name": "suspensionRole", + "category": "suspensions", + "humanName": "Suspended Role (Optional)", + "description": "A role to assign the user while they are suspended (e.g., 'Suspended Staff').", + "type": "roleID", + "allowNull": true, + "dependsOn": "enableSuspensions", + "default": "" + }, + { + "name": "suspensionMessage", + "category": "suspensions", + "humanName": "Suspension Announcement Message", + "description": "The message sent to the log channel when a staff member is suspended.", + "type": "string", + "allowEmbed": true, + "dependsOn": "enableSuspensions", + "params": [ + { + "name": "user", + "description": "Mention of the staff member" + }, + { + "name": "user-avatar", + "description": "Avatar of the staff member", + "isImage": true + }, + { + "name": "issuer-mention", + "description": "Mention of the manager issuing it" + }, + { + "name": "issuer-name", + "description": "Name of the issuer" + }, + { + "name": "issuer-avatar", + "description": "Avatar of the issuer", + "isImage": true + }, + { + "name": "duration", + "description": "Duration of the suspension" + }, + { + "name": "end-date", + "description": "Timestamp of when the suspension ends" + }, + { + "name": "reason", + "description": "Reason provided" + }, + { + "name": "case-id", + "description": "Database Case ID" + } + ], + "default": { + "_schema": "v3", + "content": "%user%", + "embeds": [ + { + "author": { + "name": "Signed, %issuer-name% • Case #%case-id%", + "iconURL": "%issuer-avatar%" + }, + "title": "⛔ Staff Suspension", + "description": "**Staff Member:** %user%\n**Duration:** %duration%\n**Ends:** %end-date%\n**Reason:** %reason%", + "color": "#ed4245", + "thumbnailURL": "%user-avatar%" + } + ] + } + }, + { + "name": "infractionLogChannel", + "category": "messages", + "humanName": "Infraction Log Channel", + "description": "Where should infractions and suspensions be announced?", + "type": "channelID", + "channelTypes": [ + "GUILD_TEXT", + "GUILD_NEWS" + ], + "default": "" + }, + { + "name": "infractionMessage", + "category": "messages", + "humanName": "Infraction Announcement Message", + "description": "The message sent to the log channel for regular infractions.", + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "user", + "description": "Mention of the staff member" + }, + { + "name": "user-avatar", + "description": "Avatar of the staff member", + "isImage": true + }, + { + "name": "issuer-mention", + "description": "Mention of the manager issuing it" + }, + { + "name": "issuer-name", + "description": "Name of the issuer" + }, + { + "name": "issuer-avatar", + "description": "Avatar of the issuer", + "isImage": true + }, + { + "name": "type", + "description": "Type of infraction (e.g., Warning, Strike)" + }, + { + "name": "end-date", + "description": "Timestamp of when this infraction expires" + }, + { + "name": "reason", + "description": "Reason provided" + }, + { + "name": "case-id", + "description": "Database Case ID" + } + ], + "default": { + "_schema": "v3", + "content": "%user%", + "embeds": [ + { + "author": { + "name": "Signed, %issuer-name% • Case #%case-id%", + "iconURL": "%issuer-avatar%" + }, + "title": "⚠️ New infraction", + "description": "**Staff Member:** %user%\n**Action Taken:** %type%\n**Expires:** %end-date%\n**Reason:** %reason%", + "color": "#e67e22", + "thumbnailURL": "%user-avatar%" + } + ] + } + }, + { + "name": "dmInfractedUser", + "category": "messages", + "humanName": "DM User on infraction?", + "description": "If enabled, the bot will DM the staff member when they receive an infraction or suspension.", + "type": "boolean", + "default": true + }, + { + "name": "infractionDmMessage", + "category": "messages", + "humanName": "Infraction DM Message", + "description": "The message sent directly to the staff member.", + "type": "string", + "allowEmbed": true, + "dependsOn": "dmInfractedUser", + "params": [ + { + "name": "user", + "description": "Mention of the staff member" + }, + { + "name": "issuer-name", + "description": "Name of the issuer" + }, + { + "name": "type", + "description": "Type of infraction (e.g., Warning, Strike)" + }, + { + "name": "end-date", + "description": "Timestamp of when this infraction expires" + }, + { + "name": "reason", + "description": "Reason provided" + }, + { + "name": "case-id", + "description": "Database Case ID" + } + ], + "default": { + "_schema": "v3", + "embeds": [ + { + "author": { + "name": "Signed, %issuer-name% • Case #%case-id%" + }, + "title": "⚠️ You have been infracted", + "description": "**Type:** %type%\n**Reason:** %reason%\n**Expires:** %end-date%", + "color": "#e67e22" + } + ] + } + }, + { + "name": "suspensionDmMessage", + "category": "messages", + "humanName": "Suspension DM Message1", + "description": "The message sent directly to the staff member when suspended.", + "type": "string", + "allowEmbed": true, + "dependsOn": "dmInfractedUser", + "params": [ + { + "name": "user", + "description": "Mention of the staff member" + }, + { + "name": "issuer-name", + "description": "Name of the issuer" + }, + { + "name": "type", + "description": "Type of infraction (e.g., Warning, Strike)" + }, + { + "name": "duration", + "description": "Duration of the suspension" + }, + { + "name": "end-date", + "description": "Timestamp of when this infraction expires" + }, + { + "name": "reason", + "description": "Reason provided" + }, + { + "name": "case-id", + "description": "Database Case ID" + } + ], + "default": { + "_schema": "v3", + "embeds": [ + { + "author": { + "name": "Signed, %issuer-name% • Case #%case-id%" + }, + "title": "⛔ Staff Suspension", + "description": "You have been temporarily suspended.\n\n**Duration:** %duration%\n**Returns:** %end-date%\n**Reason:** %reason%", + "color": "#ed4245" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/profiles.json b/modules/staff-management-system/configs/profiles.json new file mode 100644 index 00000000..90737ac9 --- /dev/null +++ b/modules/staff-management-system/configs/profiles.json @@ -0,0 +1,105 @@ +{ + "filename": "profiles.json", + "humanName": "Staff Profiles", + "description": "Configure the staff profile system (Intros, custom nicknames, and stats).", + "categories": [ + { + "id": "settings", + "icon": "fa-user-tie", + "displayName": "Profile Settings" + } + ], + "content": [ + { + "name": "enableProfiles", + "category": "settings", + "humanName": "Enable Staff Profiles", + "description": "Allows staff to have a profile tracking their shifts, reviews, and a custom introduction.", + "type": "boolean", + "default": true, + "elementToggle": true + }, + { + "name": "onlyAllowStaffProfile", + "category": "settings", + "humanName": "Only allow staff and higher to have their own customizable profile", + "description": "If enabled, only staff members and higher will be able to set a custom profile nickname and introduction. If disabled, all members will be able to set a custom profile nickname and introduction.", + "type": "boolean", + "default": true + }, + { + "name": "managePermission", + "category": "settings", + "humanName": "Profile Moderation Permission", + "description": "Which group is allowed to forcibly wipe another staff member's profile?", + "type": "select", + "content": [ + "Supervisor", + "Management" + ], + "default": "Supervisor" + }, + { + "name": "profileEmbedMessage", + "category": "settings", + "humanName": "Profile Embed", + "description": "Customize the embed shown when viewing a staff profile.", + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "user-mention", + "description": "The user's mention." + }, + { + "name": "username", + "description": "The user's standard Discord username." + }, + { + "name": "nickname", + "description": "The user's custom profile nickname (uses default username if not set)." + }, + { + "name": "intro", + "description": "The user's custom introduction." + }, + { + "name": "status", + "description": "The user's current status (LoA, RA, etc.)." + }, + { + "name": "rating", + "description": "The user's average review rating." + }, + { + "name": "avatar", + "description": "The user's avatar URL.", + "isImage": true + } + ], + "default": { + "_schema": "v3", + "embeds": [ + { + "title": "Staff Profile: %nickname%", + "description": "%intro%", + "color": "#2b2d31", + "thumbnailURL": "%avatar%", + "fields": [ + { + "name": "Status", + "value": "%status%", + "inline": true + }, + { + "name": "Average Rating", + "value": "%rating%", + "inline": true + } + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/promotions.json b/modules/staff-management-system/configs/promotions.json new file mode 100644 index 00000000..9bb70557 --- /dev/null +++ b/modules/staff-management-system/configs/promotions.json @@ -0,0 +1,177 @@ +{ + "filename": "promotions.json", + "humanName": "Promotions", + "description": "Configure how staff promotions are handled and announced.", + "categories": [ + { + "id": "logic", + "icon": "fas fa-gears", + "displayName": "General logic" + }, + { + "id": "messages", + "icon": "fas fa-comment-dots", + "displayName": "Announcements" + } + ], + "content": [ + { + "name": "enablePromotions", + "category": "logic", + "humanName": "Enable Promotions System", + "description": "If disabled, the /staff-management promote command will not work.", + "type": "boolean", + "default": true, + "elementToggle": true + }, + { + "name": "autoAddRole", + "category": "logic", + "humanName": "Auto-Add New Role?", + "description": "If enabled, the bot will automatically give the user the new rank role. Note: This makes your server prone to raids by promoting someone to a role with more dangerous permissions which can be used to do malicious actions. It is recommended to keep this setting disabled.", + "type": "boolean", + "default": true + }, + { + "name": "promotionsChannel", + "category": "messages", + "humanName": "Promotions Channel", + "description": "The channel where promotion announcements will be sent.", + "type": "channelID", + "channelTypes": [ + "GUILD_TEXT", + "GUILD_NEWS" + ], + "default": "" + }, + { + "name": "promotionMessage", + "category": "messages", + "humanName": "Promotion Announcement Embed", + "description": "This will be the message sent when someone is promoted.", + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "user-mention", + "description": "Pings the promoted user." + }, + { + "name": "new-role-name", + "description": "The plain text name of the new role." + }, + { + "name": "new-role-mention", + "description": "The pingable mention of the new role." + }, + { + "name": "promoter-mention", + "description": "Pings the staff member who issued the promotion." + }, + { + "name": "promoter-name", + "description": "The username of the staff member who issued the promotion." + }, + { + "name": "reason", + "description": "The reason for the promotion." + }, + { + "name": "user-avatar", + "description": "The avatar URL of the promoted user.", + "isImage": true + }, + { + "name": "promoter-avatar", + "description": "The avatar URL of the promoter.", + "isImage": true + } + ], + "default": { + "_schema": "v3", + "content": "%user-mention%", + "embeds": [ + { + "author": { + "name": "Signed, %promoter-name%", + "imageURL": "%promoter-avatar%" + }, + "title": "🎉 New promotion!", + "description": "Congratulations, you have been promoted to **%new-role-name%**!\n\n**Promoted to:** %new-role-mention%\n**On behalf of:** %promoter-mention%\n**Reason:** %reason%", + "color": "#f1c40f", + "thumbnailURL": "%user-avatar%" + } + ] + } + }, + { + "name": "dmPromotedUser", + "category": "messages", + "humanName": "DM Promoted User?", + "description": "If enabled, the user will receive a direct message when promoted.", + "type": "boolean", + "default": false + }, + { + "name": "promotionDmMessage", + "category": "messages", + "humanName": "Promotion DM Embed", + "description": "The message sent directly to the user.", + "type": "string", + "allowEmbed": true, + "dependsOn": "dmPromotedUser", + "params": [ + { + "name": "user-mention", + "description": "Pings the promoted user." + }, + { + "name": "new-role-name", + "description": "The plain text name of the new role." + }, + { + "name": "new-role-mention", + "description": "The pingable mention of the new role." + }, + { + "name": "promoter-mention", + "description": "Pings the staff member who issued the promotion." + }, + { + "name": "promoter-name", + "description": "The username of the staff member who issued the promotion." + }, + { + "name": "reason", + "description": "The reason for the promotion." + }, + { + "name": "user-avatar", + "description": "The avatar URL of the promoted user.", + "isImage": true + }, + { + "name": "promoter-avatar", + "description": "The avatar URL of the promoter.", + "isImage": true + } + ], + "default": { + "_schema": "v3", + "content": "%user-mention%", + "embeds": [ + { + "author": { + "name": "Signed, %promoter-name%", + "imageURL": "%promoter-avatar%" + }, + "title": "🎉 New promotion!", + "description": "Congratulations, you have been promoted to **%new-role-name%**!\n\n**Promoted to:** %new-role-mention%\n**On behalf of:** %promoter-mention%\n**Reason:** %reason%", + "color": "#f1c40f", + "thumbnailURL": "%user-avatar%" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/reviews.json b/modules/staff-management-system/configs/reviews.json new file mode 100644 index 00000000..b23dd317 --- /dev/null +++ b/modules/staff-management-system/configs/reviews.json @@ -0,0 +1,107 @@ +{ + "filename": "reviews.json", + "humanName": "Staff Reviews", + "description": "Configure the staff rating system and feedback channels.", + "categories": [ + { + "id": "settings", + "icon": "fas fa-gears", + "displayName": "Settings" + }, + { + "id": "messages", + "icon": "fa fa-messages", + "displayName": "Notifications" + } + ], + "content": [ + { + "name": "enableReviews", + "category": "settings", + "humanName": "Enable Reviews System", + "description": "Enabling this unlocks the staff review system, allowing users to submit ratings and feedback for staff members.", + "type": "boolean", + "default": true + }, + { + "name": "reviewLogChannel", + "category": "settings", + "humanName": "Reviews Log Channel", + "description": "Channel where new reviews are posted.", + "type": "channelID", + "channelTypes": [ + "GUILD_TEXT", + "GUILD_NEWS" + ], + "default": "" + }, + { + "name": "allowSelfRating", + "category": "settings", + "humanName": "Allow Self-Rating?", + "description": "If enabled, staff can review themselves. This is not recommended to keep a fair ratings system.", + "type": "boolean", + "default": false + }, + { + "name": "onlyAllowStaffReview", + "category": "settings", + "humanName": "Only let users review staff", + "description": "If enabled, only staff members can review other staff members.", + "type": "boolean", + "default": true + }, + { + "name": "ratingMessage", + "category": "messages", + "humanName": "Review Message", + "description": "The message sent when a review is submitted.", + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "staff-mention", + "description": "Mention of the staff member" + }, + { + "name": "reviewer-mention", + "description": "Mention of the reviewer" + }, + { + "name": "stars", + "description": "Amount of stars rated in emoji's (⭐⭐⭐⭐⭐)" + }, + { + "name": "rating", + "description": "Amount of stars rated in text (1-5)" + }, + { + "name": "comment", + "description": "The review's text" + }, + { + "name": "staff-avatar", + "description": "The staff member's profile picture (URL)", + "isImage": true + }, + { + "name": "reviewer-avatar", + "description": "The reviewer's profile picture (URL)", + "isImage": true + } + ], + "default": { + "_schema": "v3", + "content": "%staff%", + "embeds": [ + { + "title": "🌟 New Staff Rating", + "description": "**Staff:** %staff-mention%\n**Rated by:** %reviewer-mention%\n\n**Rating:** %stars% (%rating%/5)\n**Comment:**\n%comment%", + "color": "#f1c40f", + "thumbnailURL": "%staff-avatar%" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/shifts.json b/modules/staff-management-system/configs/shifts.json new file mode 100644 index 00000000..728afd4a --- /dev/null +++ b/modules/staff-management-system/configs/shifts.json @@ -0,0 +1,145 @@ +{ + "filename": "shifts.json", + "humanName": "Shift Management", + "description": "Configure shift requirements, duty roles, leaderboards, and quotas.", + "categories": [ + { + "id": "settings", + "icon": "fas fa-gears", + "displayName": "Shift Settings" + }, + { + "id": "leaderboard", + "icon": "fas fa-ranking-stars", + "displayName": "Leaderboard" + }, + { + "id": "quotas", + "icon": "fa-solid fa-check-to-slot", + "displayName": "Quotas" + }, + { + "id": "logging", + "icon": "fas fa-message-lines", + "displayName": "Logging" + } + ], + "content": [ + { + "name": "enableShifts", + "category": "settings", + "humanName": "Enable Shifts", + "description": "This unlocks the ability for staff to use a shifts system, where they can get on-duty, off-duty, take a break and see their total duty time.", + "type": "boolean", + "default": true, + "elementToggle": true + }, + { + "name": "onDutyRole", + "category": "settings", + "humanName": "On-Duty Role", + "description": "Role given to users when they are on-duty. This is optional, but recommended to easily identify who is on-duty.", + "type": "roleID", + "allowNull": true, + "default": "" + }, + { + "name": "dutyTypes", + "category": "settings", + "humanName": "Duty Types", + "description": "The types of duty a staff member can select when going on-duty.", + "type": "array", + "content": "string", + "default": [ + "Staff" + ] + }, + { + "name": "minShiftDuration", + "category": "settings", + "humanName": "Minimum Shift Duration (minutes)", + "description": "A minimum shift duration for a shift to count towards their duty time. Default is 0, which means all shift time counts.", + "type": "integer", + "default": 0, + "minValue": 0 + }, + { + "name": "enableLeaderboard", + "category": "leaderboard", + "humanName": "Enable duty leaderboard", + "description": "If enabled, staff can see a leaderboard of who has the most duty time in the configured timeframe.", + "type": "boolean", + "default": true + }, + { + "name": "leaderboardLookback", + "category": "leaderboard", + "humanName": "Leaderboard Timeframe", + "description": "The timeframe of the duty time shown on the leaderboard.", + "type": "select", + "content": [ + "Weekly", + "Monthly", + "All-time" + ], + "default": "Weekly", + "dependsOn": "enableLeaderboard" + }, + { + "name": "enableQuotas", + "category": "quotas", + "humanName": "Enable Quota System", + "description": "If enabled, you can set a custom quota of hours for staff to meet in the configured timeframe.", + "type": "boolean", + "default": false + }, + { + "name": "quotaTimeframe", + "category": "quotas", + "humanName": "Quota Timeframe", + "description": "The timeframe in which the quota must be met.", + "type": "select", + "content": [ + "Weekly", + "Monthly" + ], + "default": "Weekly", + "dependsOn": "enableQuotas" + }, + { + "name": "quotas", + "category": "quotas", + "humanName": "Role Quotas", + "description": "Set required hours per role - the left side will be the role, and the right side is a number which is the hours for the quota. The user's highest role counts as their quota.", + "type": "keyed", + "content": { + "key": "roleID", + "value": "integer" + }, + "default": {}, + "dependsOn": "enableQuotas" + }, + { + "name": "logShiftChanges", + "category": "logging", + "humanName": "Log Shift Changes", + "description": "When enabled, shift changes (such as going on-duty, on break, or off-duty) will be logged in a custom channel.", + "type": "boolean", + "default": true + }, + { + "name": "logShiftChangesChannel", + "category": "logging", + "humanName": "Channel for shift change logs", + "description": "The channel where shift changes will be logged. You can set this empty to use the general log channel.", + "type": "channelID", + "channelTypes": [ + "GUILD_TEXT", + "GUILD_NEWS" + ], + "default": "", + "allowNull": true, + "dependsOn": "logShiftChanges" + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/status.json b/modules/staff-management-system/configs/status.json new file mode 100644 index 00000000..ae37834e --- /dev/null +++ b/modules/staff-management-system/configs/status.json @@ -0,0 +1,147 @@ +{ + "filename": "status.json", + "humanName": "LoA & RA Status", + "description": "Configure Leave of Absence (LoA) and Reduced Activity (RA) settings.", + "categories": [ + { + "id": "base", + "icon": "fas fa-gears", + "displayName": "Base Settings" + }, + { + "id": "loa", + "icon": "fas fa-door-open", + "displayName": "LoA Settings" + }, + { + "id": "ra", + "icon": "fa-user-tie", + "displayName": "RA Settings" + }, + { + "id": "logging", + "icon": "fa-solid fa-clipboard-list", + "displayName": "Requests Log" + } + ], + "content": [ + { + "name": "enableStatusSystem", + "category": "base", + "humanName": "Enable Status System", + "description": "Enabling this unlocks the Leave of Absence (LoA) and Reduced Activity (RA) system, allowing staff to request these statuses and have them tracked.", + "type": "boolean", + "default": false, + "elementToggle": true + }, + { + "name": "enableLoa", + "category": "loa", + "humanName": "Enable LoA System", + "description": "If enabled, staff can request a Leave of Absence (LoA).", + "type": "boolean", + "default": true + }, + { + "name": "loaRole", + "category": "loa", + "humanName": "LoA Role", + "description": "Role given to users when they are on a Leave of Absence. This is optional, but recommended to easily identify who is on LoA.", + "type": "roleID", + "allowNull": true, + "default": "", + "dependsOn": "enableLoa" + }, + { + "name": "loaMaxDays", + "category": "loa", + "humanName": "Maximum LoA Duration (days)", + "description": "The maximum duration for a Leave of Absence in days. This limits how long staff can request to be on LoA for.", + "type": "integer", + "default": 60, + "minValue": 1, + "dependsOn": "enableLoa" + }, + { + "name": "requireLoaApproval", + "category": "loa", + "humanName": "Require Approval for LoA?", + "description": "If enabled, LoA requests will require approval from staff who have supervisor permissions or higher.", + "type": "boolean", + "default": true, + "dependsOn": "enableLoa" + }, + { + "name": "enableRa", + "category": "ra", + "humanName": "Enable RA System", + "description": "If enabled, staff can request Reduced Activity (RA) status for when they are still working but at a reduced load.", + "type": "boolean", + "default": true + }, + { + "name": "raRole", + "category": "ra", + "humanName": "RA Role", + "description": "Role given to users when they are on Reduced Activity. This is optional, but recommended to easily identify who is on RA.", + "type": "roleID", + "allowNull": true, + "default": "", + "dependsOn": "enableRa" + }, + { + "name": "raMaxDays", + "category": "ra", + "humanName": "Maximum RA Duration (days)", + "description": "The maximum duration for RA in days. This limits how long staff can request to be on RA for.", + "type": "integer", + "default": 30, + "minValue": 1, + "dependsOn": "enableRa" + }, + { + "name": "requireRaApproval", + "category": "ra", + "humanName": "Require Approval for RA?", + "description": "If enabled, RA requests will require approval from staff who have supervisor permissions or higher.", + "type": "boolean", + "default": true, + "dependsOn": "enableRa" + }, + { + "name": "statusLogChannel", + "category": "logging", + "humanName": "Status Request Channel", + "description": "Channel where requests are sent for approval.", + "type": "channelID", + "allowNull": true, + "channelTypes": [ + "GUILD_TEXT", + "GUILD_NEWS" + ], + "default": "" + }, + { + "name": "logStatusChanges", + "category": "logging", + "humanName": "Log status changes", + "description": "If enabled, any changes in staff status (going on/off LoA or RA) will be logged in the configured channel.", + "type": "boolean", + "default": true + }, + { + "name": "statusChangeLogChannel", + "category": "logging", + "humanName": "Status Change Log Channel", + "description": "Channel where status changes are logged. By default this uses your main log channel, but you can set a separate channel here.", + "type": "channelID", + "allowNull": true, + "channelTypes": [ + "GUILD_TEXT", + "GUILD_NEWS" + ], + "default": "", + "dependsOn": "logStatusChanges" + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/events/botReady.js b/modules/staff-management-system/events/botReady.js new file mode 100644 index 00000000..e2d42cab --- /dev/null +++ b/modules/staff-management-system/events/botReady.js @@ -0,0 +1,131 @@ +const schedule = require('node-schedule'); +const { localize } = require('../../../src/functions/localize'); +const { Op } = require('sequelize'); +const {scheduleStatusExpiry} = require('../commands/staff-status.js'); +const { initActivityCheckAutomation } = require('../staff-management'); +const suspension_check_job = 'staff-management-checks'; + +module.exports.run = async (client) => { + const guild = client.guilds.cache.get(client.guildID); + try { + const LoaRequest = client.models['staff-management-system']['LoaRequest']; + const activeRequests = await LoaRequest.findAll({ + where: { status: 'APPROVED' } + }); + + for (const req of activeRequests) { + scheduleStatusExpiry(client, req); + } + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-sched-fail', { + error: e.message + })); + } + + if (guild) { + try { + await checkExpiredSuspensions(client, guild); + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-err-exp-susp', { + error: e.message + })); + } + } + + try { + initActivityCheckAutomation(client); + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-sched-fail', { + error: e.message + })); + } + + const existingJob = schedule.scheduledJobs[suspension_check_job]; + if (existingJob) existingJob.cancel(); + + schedule.scheduleJob(suspension_check_job, '0 * * * *', async () => { + if (!client.botReadyAt) return; + + const guild = client.guilds.cache.get(client.guildID); + if (!guild) return; + + try { + await checkExpiredSuspensions(client, guild); + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-err-exp-susp', { + error: e.message + })); + } + }); +}; + +async function checkExpiredSuspensions(client, guild) { + const Infraction = client.models['staff-management-system']['Infraction']; + const StaffProfile = client.models['staff-management-system']['StaffProfile']; + const config = client.configurations['staff-management-system']['infractions']; + const now = new Date(); + + const expiredSuspensions = await Infraction.findAll({ + where: { + type: 'Suspension', + active: true, + expiresAt: { + [Op.not]: null, + [Op.lte]: now + } + } + }); + + for (const susp of expiredSuspensions) { + const member = await guild.members.fetch(susp.userId).catch(() => null); + const profile = await StaffProfile.findByPk(susp.userId); + + try { + let rolesToRestore = []; + + if (profile?.suspendedRoles) { + try { + const parsed = JSON.parse(profile.suspendedRoles); + if (Array.isArray(parsed)) rolesToRestore = parsed; + } catch (e) { + client.logger.warn( + `[Staff Management] Failed to parse suspendedRoles for ${susp.userId}: ${e.message}` + ); + } + } + + if (member) { + if (rolesToRestore.length > 0) { + await member.roles.add(rolesToRestore).catch(e => { + client.logger.warn( + `Failed to restore roles for ${member.user.tag}: ${e.message}` + ); + }); + } + + if (config.suspensionRole) { + await member.roles.remove(config.suspensionRole).catch(() => {}); + } + } + + await susp.update({ active: false }); + + if (profile) { + await profile.update({ + isSuspended: false, + suspendedRoles: null + }); + } + + if (member) { + client.logger.info(localize('staff-management-system', 'log-susp-end', { + tag: member.user.tag + })); + } + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-susp-err', { + error: e.message + })); + } + } +} \ No newline at end of file diff --git a/modules/staff-management-system/events/guildMemberRemove.js b/modules/staff-management-system/events/guildMemberRemove.js new file mode 100644 index 00000000..795715d8 --- /dev/null +++ b/modules/staff-management-system/events/guildMemberRemove.js @@ -0,0 +1,52 @@ +const { Op } = require('sequelize'); +const { localize } = require('../../../src/functions/localize'); + +module.exports.run = async (client, member) => { + if (member.guild.id !== client.guildID) return; + + const StaffShift = client.models['staff-management-system']['StaffShift']; + const StaffProfile = client.models['staff-management-system']['StaffProfile']; + + try { + const profile = await StaffProfile.findByPk(member.id); + const openShifts = await StaffShift.findAll({ + where: { + userId: member.id, + endTime: null + } + }); + + for (const openShift of openShifts) { + const now = new Date(); + let effectiveStart = new Date(openShift.startTime); + + if (profile?.onBreak && profile.breakStartTime) { + const breakStartedAt = new Date(profile.breakStartTime); + if (!Number.isNaN(breakStartedAt.getTime()) && breakStartedAt <= now) { + effectiveStart = new Date( + effectiveStart.getTime() + (now.getTime() - breakStartedAt.getTime()) + ); + } + } + + const duration = Math.max(0, Math.floor((now.getTime() - effectiveStart.getTime()) / 1000)); + + await openShift.update({ + endTime: now, + duration + }); + } + + await StaffProfile.update( + { + onDuty: false, + onBreak: false, + breakStartTime: null + }, + { where: { userId: member.id } } + ); + + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-leave-err', { error: e.message })); + } +}; \ No newline at end of file diff --git a/modules/staff-management-system/events/interactionCreate.js b/modules/staff-management-system/events/interactionCreate.js new file mode 100644 index 00000000..804f7400 --- /dev/null +++ b/modules/staff-management-system/events/interactionCreate.js @@ -0,0 +1,583 @@ +const { + getConfig, + checkStaffPermissions, + applyFooter, + generateReviewHistoryResponse, + generatePromotionHistoryResponse, + generateInfractionHistoryResponse, + generateUserPanel, + generatePanelInfractions, + generatePanelPromotions, + generatePanelReviews, + generatePanelStatus, + generatePanelActivity, + generatePanelShifts, + generatePanelDeletion, + executeDataDeletion, + generatePanelSubpage +} = require('../staff-management'); +const { + handleStatusEnd, + scheduleStatusExpiry, + handleStatusEndSubmit, + handleStatusExtend, + handleStatusExtendSubmit, + handleStatusHistPage, + sendStatusDm, + logStatusChange +} = require('../commands/staff-status.js'); +const { localize } = require('../../../src/functions/localize'); +const dutyHandlers = require('../commands/duty.js').buttonHandlers; +const { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, MessageFlags, ModalBuilder, TextInputBuilder, TextInputStyle } = require('discord.js'); + +module.exports.run = async (client, interaction) => { + if (!client.botReadyAt) return; + if (!interaction.guild || interaction.guild.id !== client.guildID) return; + if (!interaction.customId || (!interaction.customId.startsWith('staff-mgmt_') && !interaction.customId.startsWith('duty-mgmt_'))) return; + + try { + const parts = interaction.customId.split('_'); + const action = parts[1]; + + // ----- Duty manage handlers ----- + if (interaction.customId.startsWith('duty-mgmt_')) { + const dutyAction = parts[1]; + + if (interaction.isStringSelectMenu() && dutyAction === 'dropdown') { + await interaction.deferUpdate(); + return await dutyHandlers.handleDutyDropdown(client, interaction, parts[2], interaction.values[0]); + } + + if (['start', 'break', 'end', 'hist', 'lb', 'admin-forceend', 'admin-voidactive'].includes(dutyAction)) { + await interaction.deferUpdate(); + } + + if (dutyAction === 'start') return await dutyHandlers.handleDutyStartButton(client, interaction); + if (dutyAction === 'break') return await dutyHandlers.handleDutyBreakButton(client, interaction); + if (dutyAction === 'end') return await dutyHandlers.handleDutyEndButton(client, interaction); + if (dutyAction === 'hist') return await dutyHandlers.handleDutyHistPageButton(client, interaction); + if (dutyAction === 'lb') return await dutyHandlers.handleDutyLbPageButton(client, interaction); + if (dutyAction === 'admin-forceend') return await dutyHandlers.handleDutyAdminForceEnd(client, interaction); + if (dutyAction === 'admin-voidactive') return await dutyHandlers.handleDutyAdminVoidActive(client, interaction); + if (dutyAction === 'admin-voidall') return await dutyHandlers.handleDutyAdminVoidAll(client, interaction); + if (dutyAction === 'admin-voidall-submit') return await dutyHandlers.handleDutyAdminVoidAllSubmit(client, interaction); + if (dutyAction === 'admin-addtime') return await dutyHandlers.handleDutyAdminAddTimeButton(client, interaction); + if (dutyAction === 'admin-addtime-submit') return await dutyHandlers.handleDutyAdminAddTimeSubmit(client, interaction); + return; + } + + // ----- Review history pagination ----- + if (action === 'rev-page') { + await interaction.deferUpdate(); + const targetUser = await client.users.fetch(parts[2]).catch(() => null); + if (!targetUser) return interaction.followUp({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const payload = await generateReviewHistoryResponse(client, targetUser, parseInt(parts[3], 10)); + if (payload.content) return interaction.followUp({ ...payload, flags: MessageFlags.Ephemeral }); + return interaction.editReply(payload); + } + + // ----- LOA/RA handlers ----- + const loaActions = ['loa-end', 'loa-end-submit', 'loa-extend', 'loa-extend-submit', 'loa-hist']; + const raActions = ['ra-end', 'ra-end-submit', 'ra-extend', 'ra-extend-submit', 'ra-hist']; + + if (loaActions.includes(action) || raActions.includes(action)) { + const type = action.startsWith('loa-') ? 'LOA' : 'RA'; + const base = action.replace(/^(loa|ra)-/, ''); + + if (base === 'end') return handleStatusEnd(interaction, type); + if (base === 'end-submit') return handleStatusEndSubmit(client, interaction, type); + if (base === 'extend') return handleStatusExtend(interaction, type); + if (base === 'extend-submit') return handleStatusExtendSubmit(client, interaction, type); + if (base === 'hist') return handleStatusHistPage(client, interaction, type); + } + + // ----- Promotion history pagination ----- + if (action === 'prom-hist') { + await interaction.deferUpdate(); + const targetUser = await client.users.fetch(parts[2]).catch(() => null); + if (!targetUser) return interaction.followUp({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const payload = await generatePromotionHistoryResponse(client, targetUser, parseInt(parts[3], 10)); + if (payload.content) return interaction.followUp({ ...payload, flags: MessageFlags.Ephemeral }); + return interaction.editReply(payload); + } + + // ----- Infraction history pagination ----- + if (action === 'inf-hist') { + await interaction.deferUpdate(); + const targetUser = await client.users.fetch(parts[2]).catch(() => null); + if (!targetUser) return interaction.followUp({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const payload = await generateInfractionHistoryResponse(client, targetUser, parseInt(parts[3], 10)); + if (payload.content) return interaction.followUp({ ...payload, flags: MessageFlags.Ephemeral }); + return interaction.editReply(payload); + } + + // ----- User panel dropdown ----- + if (interaction.customId.startsWith('staff-mgmt_panel-menu_')) { + const targetId = interaction.customId.split('_')[2]; + await interaction.deferUpdate(); + const targetUser = await client.users.fetch(targetId).catch(() => null); + if (!targetUser) return interaction.followUp({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const selection = interaction.values[0]; + let payload; + if (selection === 'overview') payload = await generateUserPanel(client, targetUser); + else if (selection === 'infractions') payload = await generatePanelInfractions(client, targetUser, 1); + else if (selection === 'promotions') payload = await generatePanelPromotions(client, targetUser, 1); + else if (selection === 'reviews') payload = await generatePanelReviews(client, targetUser, 1); + else if (selection === 'status') payload = await generatePanelStatus(client, targetUser, 1); + else if (selection === 'activity') payload = await generatePanelActivity(client, targetUser, 1); + else if (selection === 'shifts') payload = await generatePanelShifts(client, targetUser); + else if (selection === 'deletion') payload = await generatePanelDeletion(client, targetUser); + + return interaction.editReply(payload); + } + + // ----- User panel deletion dropdown ----- + if (interaction.customId.startsWith('staff-mgmt_delete-menu_')) { + const targetId = interaction.customId.split('_')[2]; + const selection = interaction.values[0]; + + if (selection === 'back') { + const targetUser = await client.users.fetch(targetId).catch(() => null); + if (!targetUser) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const payload = await generateUserPanel(client, targetUser); + return interaction.update(payload); + } + + const confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + const modal = new ModalBuilder() + .setCustomId(`staff-mgmt_del-confirm_${targetId}_${selection}`) + .setTitle(localize('staff-management-system', 'mod-del-title')); + modal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('confirm') + .setLabel(localize('staff-management-system', 'mod-del-lbl')) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(confirmPhrase) + .setRequired(true) + ) + ); + return interaction.showModal(modal); + } + + // ----- Data deletion modal submission ----- + if (interaction.isModalSubmit() && interaction.customId.startsWith('staff-mgmt_del-confirm_')) { + await interaction.deferReply({flags: MessageFlags.Ephemeral}); + const configuration = getConfig(client, 'configuration'); + + if (!checkStaffPermissions(interaction.member, configuration, 'management')) { + return interaction.editReply({ + content: localize('staff-management-system', 'del-no-perm') + }); + } + + const parts = interaction.customId.split('_'); + const targetId = parts[2]; + const selection = parts.slice(3).join('_'); + + const confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + + if (interaction.fields.getTextInputValue('confirm').trim() !== confirmPhrase) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-conf-fail') + }); + } + + if (selection === 'del_all') { + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'del-all-title')) + .setDescription(localize('staff-management-system', 'del-all-desc')) + .setColor('DarkRed') + ); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`staff-mgmt_del-all-confirm_${targetId}`) + .setLabel(localize('staff-management-system', 'btn-conf-del')) + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`staff-mgmt_del-all-cancel_${targetId}`) + .setLabel(localize('staff-management-system', 'btn-cancel')) + .setStyle(ButtonStyle.Secondary) + ); + + await interaction.editReply({ + embeds: [embed.toJSON()], + components: [row.toJSON()] + }); + + const reply = await interaction.fetchReply(); + const collector = reply.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 30000, + max: 1, + filter: (btnInt) => btnInt.user.id === interaction.user.id + }); + + collector.on('collect', async (btnInt) => { + if (!checkStaffPermissions(btnInt.member, configuration, 'management')) { + return btnInt.reply({ + content: localize('staff-management-system', 'del-no-perm'), + flags: MessageFlags.Ephemeral + }); + } + + if (btnInt.customId.includes('cancel')) { + await btnInt.update({ + content: localize('staff-management-system', 'succ-del-canc'), + embeds: [], + components: [] + }); + return; + } + + if (btnInt.customId.includes('confirm')) { + await executeDataDeletion(client, targetId, 'del_all'); + + client.logger.info(localize('staff-management-system', 'log-del-all', { + target: targetId, + admin: btnInt.user.id + })); + + const targetUser = await client.users.fetch(targetId).catch(() => null); + if (targetUser) { + const payload = await generateUserPanel(client, targetUser); + await interaction.message.edit(payload).catch(()=>{}); + } + + await btnInt.update({ + content: localize('staff-management-system', 'succ-del-all'), + embeds: [], + components: [] + }); + } + }); + + collector.on('end', async (_collected, reason) => { + if (reason === 'time') { + await interaction.editReply({ + content: localize('staff-management-system', 'err-del-time'), + embeds: [], + components: [] + }).catch(()=>{}); + } + }); + return; + } + + await executeDataDeletion(client, targetId, selection); + client.logger.info(localize('staff-management-system', 'log-del-type', { + type: selection, + target: targetId, + admin: interaction.user.id + })); + const targetUser = await client.users.fetch(targetId).catch(() => null); + if (targetUser) { + const payload = await generateUserPanel(client, targetUser); + await interaction.message.edit(payload).catch(()=>{}); + } + + return interaction.editReply({ + content: localize('staff-management-system', 'succ-del-tgt') + }); + } + + // ----- User panel buttons ----- + if (interaction.customId.startsWith('staff-mgmt_panel-')) { + const parts = interaction.customId.split('_'); + const targetId = parts[2]; + const page = parseInt(parts[3], 10); + + const targetUser = await client.users.fetch(targetId).catch(() => null); + if (!targetUser) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const typeMap = { + 'inf': 'infractions', + 'prom': 'promotions', + 'rev': 'reviews', + 'stat': 'status', + 'act': 'activity' + }; + const fullType = typeMap[parts[1].split('-')[1]]; + + if (fullType) { + const payload = await generatePanelSubpage(client, targetUser, fullType, page); + if (payload) return interaction.update(payload); + } + } + + // ----- Status buttons ----- + const LoARequest = client.models['staff-management-system']['LoaRequest']; + const StaffProfile = client.models['staff-management-system']['StaffProfile']; + const config = client.configurations['staff-management-system']['configuration']; + const statusConfig = client.configurations['staff-management-system']['status']; + + if (action === 'approve' || action === 'deny') { + const isSupervisor = interaction.member.roles.cache.some(r => config.supervisorRoles.includes(r.id)) || + interaction.member.roles.cache.some(r => config.managementRoles.includes(r.id)) || + interaction.member.permissions.has('Administrator'); + + if (!isSupervisor) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-perm'), + flags: MessageFlags.Ephemeral + }); + + const request = await LoARequest.findByPk(parts[2]); + if (!request) return interaction.reply({ + content: localize('staff-management-system', 'err-no-req'), + flags: MessageFlags.Ephemeral + }); + if (request.status !== 'PENDING') return interaction.reply({ + content: localize('staff-management-system', 'err-req-hndl', {status: request.status}), + flags: MessageFlags.Ephemeral + }); + + if (action === 'deny') { + const modal = new ModalBuilder() + .setCustomId(`staff-mgmt_loa-deny_${parts[2]}`) + .setTitle(localize('staff-management-system', 'mod-deny-req')); + modal.addComponents( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('reason') + .setLabel(localize('staff-management-system', 'general-rsn')) + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + ) + ); + return interaction.showModal(modal); + } + + if (action === 'approve') { + await interaction.deferUpdate(); + await request.update({ + status: 'APPROVED', + approverId: interaction.user.id + }); + await StaffProfile.upsert({ + userId: request.userId, + activityStatus: request.type + }); + scheduleStatusExpiry(client, request); + + const member = await interaction.guild.members.fetch(request.userId).catch(() => null); + if (member) { + const roleId = request.type === 'LOA' + ? statusConfig.loaRole + : statusConfig.raRole; + if (roleId) await member.roles.add(roleId).catch(() => {}); + await sendStatusDm(member.user, request.type, 'approved', { + approver: interaction.user.tag, + endDate: request.endDate + }); + } + + await logStatusChange(client, request.type, 'start', { + userId: request.userId, + startDate: request.startDate, + endDate: request.endDate, + reason: request.reason, + approverId: interaction.user.id + }); + + const embed = EmbedBuilder + .from(interaction.message.embeds[0]) + .setColor('Green') + .addFields({ + name: localize('staff-management-system', 'general-stat'), + value: localize('staff-management-system', 'req-appr-by', { + user: interaction.user.tag + }) + }); + return interaction.editReply({ + embeds: [embed.toJSON()], + components: [] + }); + } + } + + // ----- Deny modal submission ----- + if (interaction.isModalSubmit() && action === 'loa-deny') { + const configuration = getConfig(client, 'configuration'); + + if (!checkStaffPermissions(interaction.member, configuration, 'supervisor')) { + return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-perm'), + flags: MessageFlags.Ephemeral + }); + } + + const reason = interaction.fields.getTextInputValue('reason'); + const request = await LoARequest.findByPk(parts[2]); + if (!request) { + return interaction.reply({ + content: localize('staff-management-system', 'err-no-req'), + flags: MessageFlags.Ephemeral + }); + } + if (request.status !== 'PENDING') { + return interaction.reply({ + content: localize('staff-management-system', 'err-req-hndl', {status: request.status}), + flags: MessageFlags.Ephemeral + }); + } + + await request.update({ + status: 'DENIED', + approverId: interaction.user.id, + rejectionReason: reason + }); + + const member = await interaction.guild.members.fetch(request.userId).catch(() => null); + if (member) { + await sendStatusDm(member.user, request.type, 'denied', { + denier: interaction.user.tag, + reason + }); + } + + const embed = EmbedBuilder + .from(interaction.message.embeds[0]) + .setColor('Red') + .addFields( + { + name: localize('staff-management-system', 'general-stat'), + value: localize('staff-management-system', 'req-deny-by', { + user: interaction.user.tag + }) + }, + { + name: localize('staff-management-system', 'general-rsn'), + value: reason + } + ); + + await interaction.message.edit({ + embeds: [embed.toJSON()], + components: [] + }).catch(() => {}); + + return interaction.reply({ + content: localize('staff-management-system', 'req-deny-by', { + user: interaction.user.tag + }), + flags: MessageFlags.Ephemeral + }); + } + + // ----- Profile edit submission ----- + if (interaction.isModalSubmit() && action === 'profile-edit') { + const nickname = interaction.fields.getTextInputValue('nickname'); + const intro = interaction.fields.getTextInputValue('intro'); + + const Profile = client.models['staff-management-system']['StaffProfile']; + await Profile.upsert({ + userId: interaction.user.id, + customNickname: nickname || null, + customIntro: intro || null + }); + return interaction.reply({ + content: localize('staff-management-system', 'succ-prof-upd'), + flags: MessageFlags.Ephemeral + }); + } + + // ----- Activity checks button ----- + if (action === 'ac-respond') { + const ActivityCheck = client.models['staff-management-system']['ActivityCheck']; + const ActivityCheckResponse = client.models['staff-management-system']['ActivityCheckResponse']; + + const activeCheck = await ActivityCheck.findOne({ + where: { + status: 'ACTIVE', + messageId: interaction.message.id + } + }); + + if (!activeCheck) return interaction.reply({ + content: localize('staff-management-system', 'err-ac-alr-end'), + flags: MessageFlags.Ephemeral + }); + + const targetRoles = JSON.parse(activeCheck.targetRoles || '[]'); + const hasRole = targetRoles.length === 0 || interaction.member.roles.cache.some(r => targetRoles.includes(r.id)); + if (!hasRole) return interaction.reply({ + content: localize('staff-management-system', 'err-ac-not-req'), + flags: MessageFlags.Ephemeral + }); + + const existingResponse = await ActivityCheckResponse.findOne({ + where: { + activityCheckId: activeCheck.id, + userId: interaction.user.id + } + }); + + if (existingResponse) return interaction.reply({ + content: localize('staff-management-system', 'info-ac-alr-conf'), + flags: MessageFlags.Ephemeral + }); + + try { + await ActivityCheckResponse.create({ + activityCheckId: activeCheck.id, + userId: interaction.user.id + }); + } catch (e) { + if (e.name === 'SequelizeUniqueConstraintError') { + return interaction.reply({ + content: localize('staff-management-system', 'info-ac-alr-conf'), + flags: MessageFlags.Ephemeral + }); + } + throw e; + } + + return interaction.reply({ + content: localize('staff-management-system', 'succ-ac-log'), + flags: MessageFlags.Ephemeral + }); + } + + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-int-error', { error: e.stack })); + if (!interaction.replied && !interaction.deferred) { + try { + await interaction.reply({ + content: localize('staff-management-system', 'err-internal'), + flags: MessageFlags.Ephemeral + }); } catch (err) {} + } else { + try { + await interaction.followUp({ + content: localize('staff-management-system', 'err-internal'), + flags: MessageFlags.Ephemeral + }); } catch (err) {} + } + } +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/ActivityCheck.js b/modules/staff-management-system/models/ActivityCheck.js new file mode 100644 index 00000000..5d0dacea --- /dev/null +++ b/modules/staff-management-system/models/ActivityCheck.js @@ -0,0 +1,46 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementActivityCheck extends Model { + static init(sequelize) { + return super.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + messageId: { + type: DataTypes.STRING, + allowNull: false + }, + channelId: { + type: DataTypes.STRING, + allowNull: false + }, + endTime: { + type: DataTypes.DATE, + allowNull: false + }, + targetRoles: { + type: DataTypes.TEXT, + allowNull: false + }, + respondedUsers: { + type: DataTypes.TEXT, + defaultValue: '[]' + }, + status: { + type: DataTypes.STRING, + defaultValue: 'ACTIVE' + } + }, { + tableName: 'staff_management_activity_checks', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'ActivityCheck', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/ActivityCheckResponse.js b/modules/staff-management-system/models/ActivityCheckResponse.js new file mode 100644 index 00000000..3a3a1f30 --- /dev/null +++ b/modules/staff-management-system/models/ActivityCheckResponse.js @@ -0,0 +1,36 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementActivityCheckResponse extends Model { + static init(sequelize) { + return super.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + activityCheckId: { + type: DataTypes.INTEGER, + allowNull: false + }, + userId: { + type: DataTypes.STRING, + allowNull: false + } + }, { + tableName: 'staff_management_activity_check_responses', + timestamps: true, + sequelize, + indexes: [ + { + unique: true, + fields: ['activityCheckId', 'userId'] + } + ] + }); + } +}; + +module.exports.config = { + name: 'ActivityCheckResponse', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/Infraction.js b/modules/staff-management-system/models/Infraction.js new file mode 100644 index 00000000..2822e9b6 --- /dev/null +++ b/modules/staff-management-system/models/Infraction.js @@ -0,0 +1,54 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementInfraction extends Model { + static init(sequelize) { + return super.init({ + caseId: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + userId: { + type: DataTypes.STRING, + allowNull: false + }, + issuerId: { + type: DataTypes.STRING, + allowNull: false + }, + type: { + type: DataTypes.STRING, + allowNull: false + }, + reason: { + type: DataTypes.TEXT, + allowNull: true + }, + durationDays: { + type: DataTypes.INTEGER, + allowNull: true + }, + active: { + type: DataTypes.BOOLEAN, + defaultValue: true + }, + messageUrl: { + type: DataTypes.STRING, + allowNull: true + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: true + } + }, { + tableName: 'staff_management_infractions', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'Infraction', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/LoaRequest.js b/modules/staff-management-system/models/LoaRequest.js new file mode 100644 index 00000000..83f71288 --- /dev/null +++ b/modules/staff-management-system/models/LoaRequest.js @@ -0,0 +1,54 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementLoaRequest extends Model { + static init(sequelize) { + return super.init({ + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + userId: { + type: DataTypes.STRING, + allowNull: false + }, + type: { + type: DataTypes.STRING, + allowNull: false + }, + reason: { + type: DataTypes.TEXT, + allowNull: false + }, + startDate: { + type: DataTypes.DATE, + allowNull: false + }, + endDate: { + type: DataTypes.DATE, + allowNull: false + }, + status: { + type: DataTypes.STRING, + defaultValue: "PENDING" + }, + approverId: { + type: DataTypes.STRING, + allowNull: true + }, + rejectionReason: { + type: DataTypes.TEXT, + allowNull: true + } + }, { + tableName: 'staff_management_loa_requests', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'LoaRequest', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/Promotion.js b/modules/staff-management-system/models/Promotion.js new file mode 100644 index 00000000..491dbe45 --- /dev/null +++ b/modules/staff-management-system/models/Promotion.js @@ -0,0 +1,42 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementPromotion extends Model { + static init(sequelize) { + return super.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.STRING, + allowNull: false + }, + issuerId: { + type: DataTypes.STRING, + allowNull: false + }, + newRole: { + type: DataTypes.STRING, + allowNull: false + }, + reason: { + type: DataTypes.TEXT, + allowNull: true + }, + messageUrl: { + type: DataTypes.STRING, + allowNull: true + } + }, { + tableName: 'staff_management_promotions', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'Promotion', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/StaffProfile.js b/modules/staff-management-system/models/StaffProfile.js new file mode 100644 index 00000000..0f66976b --- /dev/null +++ b/modules/staff-management-system/models/StaffProfile.js @@ -0,0 +1,63 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementProfile extends Model { + static init(sequelize) { + return super.init({ + userId: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false + }, + points: { + type: DataTypes.INTEGER, + defaultValue: 0, + allowNull: false + }, + onDuty: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + lastClockIn: { + type: DataTypes.DATE, + allowNull: true + }, + activityStatus: { + type: DataTypes.STRING, + defaultValue: 'ACTIVE' + }, + isSuspended: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + suspendedRoles: { + type: DataTypes.TEXT, + allowNull: true + }, + customNickname: { + type: DataTypes.STRING, + allowNull: true + }, + customIntro: { + type: DataTypes.STRING(1024), + allowNull: true + }, + onBreak: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + breakStartTime: { + type: DataTypes.DATE, + allowNull: true + } + }, { + tableName: 'staff_management_profiles', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'StaffProfile', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/StaffReview.js b/modules/staff-management-system/models/StaffReview.js new file mode 100644 index 00000000..1c2d379b --- /dev/null +++ b/modules/staff-management-system/models/StaffReview.js @@ -0,0 +1,43 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementReview extends Model { + static init(sequelize) { + return super.init({ + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + targetId: { + type: DataTypes.STRING, + allowNull: false + }, + authorId: { + type: DataTypes.STRING, + allowNull: false + }, + stars: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { min: 1, max: 5 } + }, + comment: { + type: DataTypes.TEXT, + allowNull: true + }, + messageUrl: { + type: DataTypes.STRING, + allowNull: true + } + }, { + tableName: 'staff_management_reviews', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'StaffReview', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/StaffShift.js b/modules/staff-management-system/models/StaffShift.js new file mode 100644 index 00000000..9be88163 --- /dev/null +++ b/modules/staff-management-system/models/StaffShift.js @@ -0,0 +1,42 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementShift extends Model { + static init(sequelize) { + return super.init({ + userId: { + type: DataTypes.STRING, + allowNull: false + }, + startTime: { + type: DataTypes.DATE, + allowNull: false + }, + endTime: { + type: DataTypes.DATE, + allowNull: true + }, + duration: { + type: DataTypes.INTEGER, + allowNull: true + }, + type: { + type: DataTypes.STRING, + defaultValue: "Staff" + }, + breakCount: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + } + }, { + tableName: 'staff_management_shifts', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'StaffShift', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/module.json b/modules/staff-management-system/module.json new file mode 100644 index 00000000..0d5c0613 --- /dev/null +++ b/modules/staff-management-system/module.json @@ -0,0 +1,28 @@ +{ + "name": "staff-management-system", + "author": { + "scnxOrgID": "148", + "name": "Kevin", + "link": "https://github.com/Kevinking500" + }, + "fa-icon": "far fa-gear looks", + "openSourceURL": "https://github.com/Kevinking500/CustomDCBot/tree/main/modules/staff-management-system", + "commands-dir": "/commands", + "events-dir": "/events", + "models-dir": "/models", + "config-example-files": [ + "configs/configuration.json", + "configs/infractions.json", + "configs/promotions.json", + "configs/reviews.json", + "configs/shifts.json", + "configs/status.json", + "configs/profiles.json", + "configs/activity-checks.json" + ], + "tags": [ + "moderation" + ], + "humanReadableName": "Staff Management System", + "description": "A powerful, highly customizable staff management system designed to track activity, moderate personnel, and maintain detailed staff records seamlessly." +} diff --git a/modules/staff-management-system/staff-management.js b/modules/staff-management-system/staff-management.js new file mode 100644 index 00000000..9d96d2f7 --- /dev/null +++ b/modules/staff-management-system/staff-management.js @@ -0,0 +1,1762 @@ +/** + * Logic for the Staff Management module + * @module staff-management + * @author itskevinnn + */ +const { ModalBuilder, TextInputBuilder, TextInputStyle, EmbedBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } = require('discord.js'); +const { Op } = require('sequelize'); +const schedule = require('node-schedule'); +const { embedTypeV2, safeSetFooter } = require('../../src/functions/helpers'); +const { localize } = require('../../src/functions/localize'); + +// --- Local helpers --- +const getConfig = (client, file) => client.configurations['staff-management-system'][file]; +const getSafeChannelId = (val) => Array.isArray(val) && val.length > 0 // Helper to get safe channel ID from config + ? val[0] + : (typeof val === 'string' + ? val + : null +); +const parseDurationToDays = (input) => { + if (!input) return null; + const match = input.toString().match(/^(\d+)([dDwWmM])?$/); + if (!match) return null; + const value = parseInt(match[1], 10); + const unit = match[2]?.toLowerCase() || 'd'; + return unit === 'm' + ? value * 30 + : (unit === 'w' + ? value * 7 + : value + ); +}; + +const applyFooter = (client, embed) => { + safeSetFooter(embed, client); + if (!(client.strings && client.strings.disableFooterTimestamp)) { + embed.setTimestamp(); + } + return embed; +}; + +function checkStaffPermissions(member, config, level = 'staff') { + if (!member) return false; + if (member.permissions?.has('Administrator')) return true; + + const roleMap = { + staff: [ + ...(config?.staffRoles || []), + ...(config?.supervisorRoles || []), + ...(config?.managementRoles || []) + ], + supervisor: [ + ...(config?.supervisorRoles || []), + ...(config?.managementRoles || []) + ], + management: [ + ...(config?.managementRoles || []) + ] + }; + + const allowedRoles = roleMap[level] || roleMap.staff; + return member.roles?.cache?.some(role => allowedRoles.includes(role.id)) || false; +} + +const buildPaginationRow = (backId, countId, nextId, page, totalPages) => { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(backId) + .setLabel(localize('helpers', 'back')) + .setStyle(ButtonStyle.Primary) + .setDisabled(page <= 1), + new ButtonBuilder() + .setCustomId(countId) + .setLabel(`${page}/${totalPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(nextId) + .setLabel(localize('helpers', 'next')) + .setStyle(ButtonStyle.Primary) + .setDisabled(page >= totalPages) + ); +}; + +function formatDuration(seconds) { + if (!seconds || seconds <= 0) return localize('staff-management-system', 'time-zero'); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + const parts = []; + if (h > 0) parts.push(`${h} ${localize('staff-management-system', h !== 1 + ? 'time-hours' + : 'time-hour' + )}`); + if (m > 0) parts.push(`${m} ${localize('staff-management-system', m !== 1 + ? 'time-mins' + : 'time-min' + )}`); + if (s > 0) parts.push(`${s} ${localize('staff-management-system', s !== 1 + ? 'time-secs' + : 'time-sec' + )}`); + return parts.join(', ') || localize('staff-management-system', 'time-zero'); +} + +// ---------- Infractions ---------- +async function issueInfraction(client, interaction, targetMember, type, reason, expiryInput) { + await interaction.deferReply({ephemeral: true}); + const config = getConfig(client, 'infractions'); + if (!config?.enableInfractions) return interaction.editReply({ + content: localize('staff-management-system', 'err-feat-disabled', {feature: 'Infractions'}) + }); + + if (targetMember.id === interaction.user.id) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-self-infract') + }); + } + + if (type.toLowerCase() === 'suspension') { + return interaction.editReply({ + content: localize('staff-management-system', 'err-use-susp') + }); + } + + let expiresAt = null; + if (expiryInput) { + const days = parseDurationToDays(expiryInput); + if (!days) return interaction.editReply({ + content: localize('staff-management-system', 'err-inv-dur') + }); + expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + } + + const record = await client.models['staff-management-system']['Infraction'].create({ + userId: targetMember.id, + issuerId: interaction.user.id, + type, reason, expiresAt, + active: true + }); + + const placeholders = { + '%user%': targetMember.user.toString(), + '%user-avatar%': targetMember.user.displayAvatarURL({ + dynamic: true, + format: 'png', + size: 1024 + }) || '', + '%issuer-mention%': interaction.user.toString(), + '%issuer-name%': interaction.user.username, + '%issuer-avatar%': interaction.user.displayAvatarURL({ + dynamic: true, + format: 'png', + size: 1024 + }) || '', + '%type%': type, + '%reason%': reason, + '%case-id%': record.caseId.toString(), + '%end-date%': expiresAt + ? `` + : localize('staff-management-system', 'label-never') + }; + + const channelId = getSafeChannelId(config.infractionLogChannel); + if (channelId) { + const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); + if (channel) { + let template = config.infractionMessage; + if (typeof template === 'string') { + try { + template = JSON.parse(template); + } catch (e) { + } + } else if (typeof template === 'object') { + template = JSON.parse(JSON.stringify(template)); + } + + if (template && template.embeds && !template._schema) template._schema = 'v3'; + let msgOpts = await embedTypeV2(template, placeholders); + if (msgOpts?.content?.trim() === '') delete msgOpts.content; + + if (msgOpts?.embeds?.length > 0) { + const parsedEmbed = EmbedBuilder.from(msgOpts.embeds[0]); + applyFooter(client, parsedEmbed); + msgOpts.embeds[0] = parsedEmbed.toJSON(); + } + + const sentMsg = await channel.send(msgOpts).catch(()=>{}); + if (sentMsg) await record.update({ messageUrl: sentMsg.url }); + } + } + + if (config.dmInfractedUser && config.infractionDmMessage) { + let dmTemplate = config.infractionDmMessage; + if (typeof dmTemplate === 'string') { + try { + dmTemplate = JSON.parse(dmTemplate); + } catch (e) { + } + } else if (typeof dmTemplate === 'object') { + dmTemplate = JSON.parse(JSON.stringify(dmTemplate)); + } + + if (dmTemplate && dmTemplate.embeds && !dmTemplate._schema) dmTemplate._schema = 'v3'; + const dmOpts = await embedTypeV2(dmTemplate, placeholders); + if (dmOpts?.content?.trim() === '') delete dmOpts.content; + + if (dmOpts) { + try { + await targetMember.user.send(dmOpts); + } catch (e) { + client.logger.warn(localize('staff-management-system', 'log-infract-dm-fail', { + user: targetMember.user.tag, + error: e.message + })); + } + } + } + + await interaction.editReply({ + content: localize('staff-management-system', 'succ-infract', { + type, + caseId: record.caseId, + user: targetMember.user.tag + }) + }); +} + +// ---------- Suspensions ---------- +async function issueSuspension(client, interaction, targetMember, durationInput, reason) { + await interaction.deferReply({ephemeral: true}); + const config = getConfig(client, 'infractions'); + if (!config?.enableInfractions) + return interaction.editReply({ + content: localize('staff-management-system', 'err-feat-disabled', { + feature: 'Infractions' + }) + }); + + if (!config?.enableSuspensions) + return interaction.editReply({ + content: localize('staff-management-system', 'err-feat-disabled', { + feature: 'Suspensions' + }) + }); + + if (targetMember.id === interaction.user.id) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-self-infract') + }); + } + + const durationDays = parseDurationToDays(durationInput); + if (!durationDays) + return interaction.editReply({ + content: localize('staff-management-system', 'err-inv-dur') + }); + + const expiresAt = new Date(Date.now() + durationDays * 24 * 60 * 60 * 1000); + const durationString = `${durationDays} ${localize('staff-management-system', 'label-days')}`; + + let rolesToRemove = []; + const hierarchyRole = interaction.guild.roles.cache.get(config.suspensionHierarchyRole); + if (hierarchyRole) { + rolesToRemove = targetMember.roles.cache + .filter(r => r.position >= hierarchyRole.position && r.id !== interaction.guild.id && !r.managed) + .map(r => r.id); + + if (rolesToRemove.length) { + await targetMember.roles.remove(rolesToRemove).catch(() => {}); + } + } + + await client.models['staff-management-system']['StaffProfile'].upsert({ + userId: targetMember.id, + isSuspended: true, + suspendedRoles: JSON.stringify(rolesToRemove) + }); + if (config.suspensionRole) await targetMember.roles.add(config.suspensionRole).catch(() => {}); + + const record = await client.models['staff-management-system']['Infraction'].create({ + userId: targetMember.id, + issuerId: interaction.user.id, + type: 'Suspension', + reason, durationDays, expiresAt, + active: true + }); + + const placeholders = { + '%user%': targetMember.user.toString(), + '%user-avatar%': targetMember.user.displayAvatarURL({ + dynamic: true, + format: 'png', + size: 1024 + }) || '', + '%issuer-mention%': interaction.user.toString(), + '%issuer-name%': interaction.user.username, + '%issuer-avatar%': interaction.user.displayAvatarURL({ + dynamic: true, + format: 'png', + size: 1024 + }) || '', + '%duration%': durationString, + '%reason%': reason, + '%case-id%': record.caseId.toString(), + '%end-date%': `` + }; + + const channelId = getSafeChannelId(config.infractionLogChannel); + if (channelId) { + const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); + if (channel) { + let template = config.suspensionMessage; + if (typeof template === 'string') { + try { + template = JSON.parse(template); + } catch (e) { + } + } else if (typeof template === 'object') { + template = JSON.parse(JSON.stringify(template)); + } + + if (template && template.embeds && !template._schema) template._schema = 'v3'; + let msgOpts = await embedTypeV2(template, placeholders); + if (msgOpts?.content?.trim() === '') delete msgOpts.content; + + if (msgOpts?.embeds?.length > 0) { + const parsedEmbed = EmbedBuilder.from(msgOpts.embeds[0]); + applyFooter(client, parsedEmbed); + msgOpts.embeds[0] = parsedEmbed.toJSON(); + } + + const sentMsg = await channel.send(msgOpts).catch(()=>{}); + if (sentMsg) await record.update({ messageUrl: sentMsg.url }); + } + } + + if (config.dmInfractedUser && config.suspensionDmMessage) { + let dmTemplate = config.suspensionDmMessage; + if (typeof dmTemplate === 'string') { + try { + dmTemplate = JSON.parse(dmTemplate); + } catch (e) { + } + } else if (typeof dmTemplate === 'object') { + dmTemplate = JSON.parse(JSON.stringify(dmTemplate)); + } + + if (dmTemplate && dmTemplate.embeds && !dmTemplate._schema) dmTemplate._schema = 'v3'; + const dmOpts = await embedTypeV2(dmTemplate, placeholders); + if (dmOpts?.content?.trim() === '') delete dmOpts.content; + + if (dmOpts) { + try { + await targetMember.user.send(dmOpts); + } catch (e) { + client.logger.warn(localize('staff-management-system', 'log-susp-dm-fail', { + user: targetMember.user.tag, + error: e.message + })); + } + } + } + + await interaction.editReply({ + content: localize('staff-management-system', 'succ-susp', { + caseId: record.caseId, + user: targetMember.user.tag, + duration: durationString + }) + }); +} + +async function resolveInfractionReference(client, reference) { + const Infraction = client.models['staff-management-system']['Infraction']; + const value = reference?.trim(); + + if (!value) return null; + + if (/^\d+$/.test(value)) { + return await Infraction.findByPk(parseInt(value, 10)); + } + + try { + const parsed = new URL(value); + const validHosts = ['discord.com', 'canary.discord.com', 'ptb.discord.com']; + + if (!validHosts.includes(parsed.hostname)) return null; + + const parts = parsed.pathname.split('/').filter(Boolean); + if (parts.length !== 4 || parts[0] !== 'channels') return null; + + return await Infraction.findOne({ + where: {messageUrl: value} + }); + } catch (e) { + return null; + } +} + +// ----- Infractions voiding ----- +async function voidInfraction(client, interaction, reference) { + await interaction.deferReply({ephemeral: true}); + const config = getConfig(client, 'infractions'); + if (!config?.enableInfractions) return interaction.editReply({ + content: localize('staff-management-system', 'err-feat-disabled', { + feature: 'Infractions' + }) + }); + + const canManage = checkStaffPermissions(interaction.member, getConfig(client, 'configuration'), 'supervisor'); + if (!canManage) return interaction.editReply({ + content: localize('staff-management-system', 'err-gen-no-perm') + }); + + const record = await resolveInfractionReference(client, reference); + if (!record) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-no-case-ref', {reference}) + }); + } + if (!record.active) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-case-inact', {caseId: record.caseId}) + }); + } + + if (record.type.toLowerCase() === 'suspension') { + const Profile = client.models['staff-management-system']['StaffProfile']; + const profile = await Profile.findOne({ + where: {userId: record.userId} + }); + const member = await interaction.guild.members.fetch(record.userId).catch(() => null); + + if (member && profile && profile.isSuspended) { + try { + const rolesToRestore = JSON.parse(profile.suspendedRoles || '[]'); + if (rolesToRestore.length > 0) await member.roles.add(rolesToRestore); + if (config.suspensionRole) await member.roles.remove(config.suspensionRole); + await profile.update({ isSuspended: false, suspendedRoles: '[]' }); + } catch (e) { + return interaction.editReply({ + content: localize('staff-management-system', 'succ-void-fail', {caseId: record.caseId}) + }); + } + } + } + await record.update({active: false}); + await interaction.editReply({ + content: localize('staff-management-system', 'succ-void', {caseId: record.caseId}) + }); +} + +// ----- Generates infractions history embed ----- +async function generateInfractionHistoryResponse(client, targetUser, page = 1) { + const limit = 5; + const offset = (page - 1) * limit; + const {count, rows} = await client.models['staff-management-system']['Infraction'].findAndCountAll({ + where: {userId: targetUser.id}, + order: [['createdAt', 'DESC']], + limit, offset + }); + + if (count === 0) + return { + content: localize('staff-management-system', 'info-clean-rec', { + username: targetUser.username + }), + flags: MessageFlags.Ephemeral + }; + + const totalPages = Math.ceil(count / limit) || 1; + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'rec-title', { username: targetUser.username })) + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setColor('Red') + ); + + const desc = rows.map(r => { + const link = r.messageUrl + ? ` • [Jump](${r.messageUrl})` + : ''; + const statusIcon = r.active + ? '🔴' + : localize('staff-management-system', 'icon-voided'); + const expiry = r.expiresAt + ? `\n**${localize('staff-management-system', 'label-exp')}:** ` + : ''; + + return `**${statusIcon} ${localize('staff-management-system', 'label-case')} #${r.caseId} - ${r.type}**\n**${localize('staff-management-system', 'label-date')}:** \n**${localize('staff-management-system', 'label-iss')}:** <@${r.issuerId}>\n**${localize('staff-management-system', 'general-rsn')}:** ${r.reason}${expiry}${link}`; + }).join('\n\n'); + + embed.setDescription(desc); + embed.addFields({ + name: '\u200b', value: localize('staff-management-system', 'page-count', { + page, + total: totalPages + }) }); + + const row = buildPaginationRow( + `staff-mgmt_inf-hist_${targetUser.id}_${page - 1}`, + 'inf_hist_count', + `staff-mgmt_inf-hist_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { embeds: [embed.toJSON()], components: [row.toJSON()] }; +} + +// ----- Gets infraction history ----- +async function getInfractionHistory(client, interaction, targetUser) { + await interaction.deferReply({ephemeral: true}); + const response = await generateInfractionHistoryResponse(client, targetUser, 1); + if (response.content && response.content.startsWith('ℹ️')) return interaction.editReply(response); + await interaction.editReply({ + ...response + }); +} + +// ---------- Promotions ---------- +async function promoteUser(client, interaction, targetMember, newRole, reason) { + await interaction.deferReply({ephemeral: true}); + const config = getConfig(client, 'promotions'); + if (!config?.enablePromotions) return interaction.editReply({ + content: localize('staff-management-system', 'err-feat-disabled', {feature: 'Promotions'}) + }); + + if (targetMember.id === interaction.user.id) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-self-promo') + }); + } + + const finalReason = reason && reason.trim() !== '' + ? reason + : localize('staff-management-system', 'none-provided'); + const channelOverride = interaction.options.getChannel('channel'); + + if (config.autoAddRole) { + if (interaction.guild.members.me.roles.highest.position <= newRole.position) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-role-hier') + }); + } + try { + await targetMember.roles.add(newRole); + } catch (e) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-add-role', {e: e.message}) + }); } + } + + const record = await client.models['staff-management-system']['Promotion'].create({ + userId: targetMember.id, + issuerId: interaction.user.id, + newRole: newRole.id, + reason: finalReason + }); + + const placeholders = { + '%user-mention%': targetMember.user.toString(), + '%new-role-name%': newRole.name, + '%new-role-mention%': newRole.toString(), + '%promoter-mention%': interaction.user.toString(), + '%promoter-name%': interaction.user.username, + '%reason%': finalReason, + '%user-avatar%': targetMember.user.displayAvatarURL({ + dynamic: true, + format: 'png', + size: 1024 + }) || '', + '%promoter-avatar%': interaction.user.displayAvatarURL({ + dynamic: true, + format: 'png', + size: 1024 + }) || '' + }; + + const targetChannelId = channelOverride + ? channelOverride.id + : getSafeChannelId(config.promotionsChannel); + + if (targetChannelId) { + const channel = await interaction.guild.channels.fetch(targetChannelId).catch(() => null); + if (channel) { + let embedTemplate = config.promotionMessage; + if (typeof embedTemplate === 'string') { + try { + embedTemplate = JSON.parse(embedTemplate); + } + catch (e) {} } else if (typeof embedTemplate === 'object') { + embedTemplate = JSON.parse(JSON.stringify(embedTemplate)); + } + + if (embedTemplate && embedTemplate.embeds && !embedTemplate._schema) embedTemplate._schema = 'v3'; + let msgOpts = await embedTypeV2(embedTemplate, placeholders); + if (msgOpts?.content?.trim() === '') delete msgOpts.content; + + if (msgOpts.embeds && msgOpts.embeds.length > 0) { + const parsedEmbed = EmbedBuilder.from(msgOpts.embeds[0]); + applyFooter(client, parsedEmbed); + msgOpts.embeds[0] = parsedEmbed.toJSON(); + } + + const sentMessage = await channel + .send(msgOpts) + .catch(e => { + client.logger.error(localize('staff-management-system', 'log-promo-msg-error', { + e: e.message, + })); + return null; + }); + + if (sentMessage) await record.update({messageUrl: sentMessage.url}); + } + } + + if (config.dmPromotedUser && config.promotionDmMessage) { + let dmTemplate = config.promotionDmMessage; + if (typeof dmTemplate === 'string') { + try { + dmTemplate = JSON.parse(dmTemplate); + } catch (e) { + } + } else if (typeof dmTemplate === 'object') { + dmTemplate = JSON.parse(JSON.stringify(dmTemplate)); + } + + if (dmTemplate && dmTemplate.embeds && !dmTemplate._schema) dmTemplate._schema = 'v3'; + const dmOpts = await embedTypeV2(dmTemplate, placeholders); + if (dmOpts?.content?.trim() === '') delete dmOpts.content; + + if (dmOpts) { + try { + await targetMember.user.send(dmOpts); + } catch (e) { + client.logger.warn(localize('staff-management-system', 'log-promo-dm-fail', { + user: targetMember.user.tag, + error: e.message + })); + } + } + } + + await interaction.editReply({ + content: localize('staff-management-system', 'succ-promo', { + user: targetMember.user.tag, + role: newRole.name + }) + }); +} + +// ----- Generates promotion history & embed ----- +async function generatePromotionHistoryResponse(client, targetUser, page = 1) { + const Promotion = client.models['staff-management-system']['Promotion']; + const limit = 5; + const offset = (page - 1) * limit; + + const {count, rows} = await Promotion.findAndCountAll({ + where: { + userId: targetUser.id + }, + order: [['createdAt', 'DESC']], + limit, + offset + }); + if (count === 0) return { + content: localize('staff-management-system', 'info-no-promo', {username: targetUser.username}), + flags: MessageFlags.Ephemeral + }; + + const totalPages = Math.ceil(count / limit) || 1; + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'prom-hist-title', { username: targetUser.username })) + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setColor('Gold') + ); + + const desc = rows.map((r, i) => { + const link = r.messageUrl ? ` • [Jump](${r.messageUrl})` : ''; + return `**${offset + i + 1}. **\n**${localize('staff-management-system', 'label-role')}:** <@&${r.newRole}>\n**${localize('staff-management-system', 'label-prom-by')}:** <@${r.issuerId}>\n**${localize('staff-management-system', 'general-rsn')}:** ${r.reason}${link}`; + }).join('\n\n'); + + embed.setDescription(desc); + embed.addFields({ name: '\u200b', value: localize('staff-management-system', 'page-count', { page, total: totalPages }) }); + + const row = buildPaginationRow( + `staff-mgmt_prom-hist_${targetUser.id}_${page - 1}`, + 'prom_hist_count', + `staff-mgmt_prom-hist_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function getPromotionHistory(client, interaction, targetUser) { + await interaction.deferReply({ephemeral: true}); + const response = await generatePromotionHistoryResponse(client, targetUser, 1); + if (response.content && response.content.startsWith('ℹ️')) return interaction.editReply(response); + + await interaction.editReply({ + ...response + }); +} + +// ---------- User Panel ---------- +async function generatePanelSubpage(client, targetUser, type, page) { + if (type === 'infractions') return await generatePanelInfractions(client, targetUser, page); + if (type === 'promotions') return await generatePanelPromotions(client, targetUser, page); + if (type === 'reviews') return await generatePanelReviews(client, targetUser, page); + if (type === 'status') return await generatePanelStatus(client, targetUser, page); + if (type === 'activity') return await generatePanelActivity(client, targetUser, page); + return null; +} + +// Overview page +async function generateUserPanel(client, targetUser) { + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'panel-title', { + username: targetUser.username + })) + .setDescription(localize('staff-management-system', 'panel-desc', { + mention: targetUser.toString(), + id: targetUser.id + })) + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setColor('Blurple') + ); + + const menu = new StringSelectMenuBuilder() + .setCustomId(`staff-mgmt_panel-menu_${targetUser.id}`) + .setPlaceholder(localize('staff-management-system', 'panel-ph')) + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-over')) + .setValue('overview') + .setEmoji('🏠'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-act')) + .setValue('activity') + .setEmoji('📋'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-inf')) + .setValue('infractions') + .setEmoji('⚠️'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-prom')) + .setValue('promotions') + .setEmoji('🎉'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-rev')) + .setValue('reviews') + .setEmoji('⭐'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-shi')) + .setValue('shifts') + .setEmoji('⏱️'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-sta')) + .setValue('status') + .setEmoji('🌙'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-del')) + .setValue('deletion') + .setEmoji('🗑️') + ); + + const row = new ActionRowBuilder().addComponents(menu); + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +// Infractions page +async function generatePanelInfractions(client, targetUser, page = 1) { + const Infraction = client.models['staff-management-system']['Infraction']; + const allInfractions = await Infraction.findAll({ + where: {userId: targetUser.id} + }); + const count = allInfractions.length; + + let totalPages = 1; + if (count > 3) totalPages = 1 + Math.ceil((count - 3) / 5); + + const limit = page === 1 ? 3 : 5; + const offset = page === 1 ? 0 : 3 + ((page - 2) * 5); + + const typeCounts = {}; + allInfractions.forEach(inf => { typeCounts[inf.type] = (typeCounts[inf.type] || 0) + 1; }); + const typeStrings = Object.entries(typeCounts).map(([type, qty]) => `${type}: **${qty}**`).join('\n'); + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-inf-title', { username: targetUser.username })) + .setColor('Red') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + let desc = localize('staff-management-system', 'p-inf-desc', { + count: count, types: typeStrings || localize('staff-management-system', 'info-none') + }); + + const rows = await Infraction.findAll({ + where: {userId: targetUser.id}, + order: [['createdAt', 'DESC']], + limit, + offset + }); + + if (rows.length === 0) { + desc += localize('staff-management-system', 'p-no-hist'); + } else { + desc += rows.map(r => { + const statusIcon = r.active ? '🔴' : localize('staff-management-system', 'icon-voided'); + const expiry = r.expiresAt ? `\n**${localize('staff-management-system', 'label-exp')}:** ` : ''; + return `**${statusIcon} ${localize('staff-management-system', 'label-case')} #${r.caseId} - ${r.type}**\n**${localize('staff-management-system', 'label-date')}:** \n**${localize('staff-management-system', 'general-rsn')}:** ${r.reason}${expiry}`; + }).join('\n\n'); + } + + embed.setDescription(desc); + embed.addFields({ name: '\u200b', value: localize('staff-management-system', 'page-count', { page, total: totalPages }) }); + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'infractions').data.default = true; + + const paginationRow = buildPaginationRow( + `staff-mgmt_panel-inf_${targetUser.id}_${page - 1}`, + 'panel_inf_count', + `staff-mgmt_panel-inf_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON(), paginationRow.toJSON()] + }; +} + +// Promotions page +async function generatePanelPromotions(client, targetUser, page = 1) { + const Promotion = client.models['staff-management-system']['Promotion']; + const count = await Promotion.count({ + where: {userId: targetUser.id} + }); + + let totalPages = 1; + if (count > 3) totalPages = 1 + Math.ceil((count - 3) / 5); + + const limit = page === 1 + ? 3 + : 5; + const offset = page === 1 + ? 0 + : 3 + ((page - 2) * 5); + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-prom-title', { + username: targetUser.username + })) + .setColor('Gold') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + let desc = localize('staff-management-system', 'p-prom-desc', { count: count }); + const rows = await Promotion.findAll({ + where: {userId: targetUser.id}, + order: [['createdAt', 'DESC']], + limit, + offset + }); + + if (rows.length === 0) { + desc += localize('staff-management-system', 'p-no-hist'); + } else { + desc += rows.map(r => `**${localize('staff-management-system', 'label-role')}:** <@&${r.newRole}>\n**${localize('staff-management-system', 'label-prom-by')}:** <@${r.issuerId}>\n**${localize('staff-management-system', 'label-date')}:** \n**${localize('staff-management-system', 'general-rsn')}:** ${r.reason}`).join('\n\n'); + } + + embed.setDescription(desc); + embed.addFields({ name: '\u200b', value: localize('staff-management-system', 'page-count', { page, total: totalPages }) }); + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'promotions').data.default = true; + + const paginationRow = buildPaginationRow( + `staff-mgmt_panel-prom_${targetUser.id}_${page - 1}`, + 'panel_prom_count', + `staff-mgmt_panel-prom_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON(), paginationRow.toJSON()] + }; +} + +// Reviews page +async function generatePanelReviews(client, targetUser, page = 1) { + const Review = client.models['staff-management-system']['StaffReview']; + const allReviews = await Review.findAll({ + where: {targetId: targetUser.id} + }); + const count = allReviews.length; + + let totalPages = 1; + if (count > 3) totalPages = 1 + Math.ceil((count - 3) / 5); + + const limit = page === 1 ? 3 : 5; + const offset = page === 1 ? 0 : 3 + ((page - 2) * 5); + + const avg = count + ? (allReviews.reduce((a, b) => a + b.stars, 0) / count).toFixed(1) + : 0; + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-rev-title', { + username: targetUser.username + })) + .setColor('Gold') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + let desc = localize('staff-management-system', 'p-rev-desc', { count: count, avg: avg }); + + const rows = await Review.findAll({ + where: {targetId: targetUser.id}, + order: [['createdAt', 'DESC']], + limit, + offset + }); + if (rows.length === 0) desc += localize('staff-management-system', 'p-no-hist'); + else desc += rows.map(r => `**${"⭐".repeat(r.stars)}** ${localize('staff-management-system', 'label-by')} <@${r.authorId}>\n"${r.comment}"`).join('\n\n'); + + embed.setDescription(desc); + embed.addFields({ + name: '\u200b', + value: localize('staff-management-system', 'page-count', { + page, total: totalPages + }) + }); + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'reviews').data.default = true; + + const paginationRow = buildPaginationRow( + `staff-mgmt_panel-rev_${targetUser.id}_${page - 1}`, + 'panel_rev_count', + `staff-mgmt_panel-rev_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON(), paginationRow.toJSON()] + }; +} + +// Status page +async function generatePanelStatus(client, targetUser, page = 1) { + const LoaRequest = client.models['staff-management-system']['LoaRequest']; + const allStatuses = await LoaRequest.findAll({ + where: {userId: targetUser.id} + }); + const count = allStatuses.length; + + let totalPages = 1; + if (count > 3) totalPages = 1 + Math.ceil((count - 3) / 5); + const limit = page === 1 + ? 3 + : 5; + const offset = page === 1 + ? 0 + : 3 + ((page - 2) * 5); + + const activeStatus = allStatuses.find(s => ['APPROVED', 'PENDING'].includes(s.status) && new Date(s.endDate) > new Date()); + let activeText = localize('staff-management-system', 'info-none'); + if (activeStatus) { + activeText = `**${activeStatus.type}** (${activeStatus.status})\n${localize('staff-management-system', 'label-end')}: `; + } + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-sta-title', { + username: targetUser.username + })) + .setColor('Green') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + let desc = localize('staff-management-system', 'p-sta-desc', { + count: count, active: activeText + }); + + const rows = await LoaRequest.findAll({ + where: {userId: targetUser.id}, + order: [['createdAt', 'DESC']], + limit, + offset + }); + if (rows.length === 0) desc += localize('staff-management-system', 'p-no-hist'); + else { + const icons = { + APPROVED: '✅', + DENIED: '❌', + ENDED: '⏹️', + PENDING: '🕐' + }; + desc += rows.map(r => `**${icons[r.status] || '❓'} ${r.type} - ${r.status}**\n**${localize('staff-management-system', 'general-start')}:** \n**${localize('staff-management-system', 'general-end')}:** \n**${localize('staff-management-system', 'general-rsn')}:** ${r.reason}`).join('\n\n'); + } + + embed.setDescription(desc); + embed.addFields({ + name: '\u200b', + value: localize('staff-management-system', 'page-count', { + page, + total: totalPages + }) + }); + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'status').data.default = true; + + const paginationRow = buildPaginationRow( + `staff-mgmt_panel-stat_${targetUser.id}_${page - 1}`, + 'panel_stat_count', + `staff-mgmt_panel-stat_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON(), paginationRow.toJSON()] + }; +} + +// Activity checks page +async function generatePanelActivity(client, targetUser, page = 1) { + const ActivityCheck = client.models['staff-management-system']['ActivityCheck']; + const ActivityCheckResponse = client.models['staff-management-system']['ActivityCheckResponse']; + + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 90); + + const recentChecks = await ActivityCheck.findAll({ + where: { + createdAt: { [Op.gte]: cutoff } + }, + order: [['createdAt', 'DESC']] + }); + + if (recentChecks.length === 0) { + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-act-title', { + username: targetUser.username + })) + .setColor('Blue') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setDescription(localize('staff-management-system', 'p-act-desc', { count: 0 }) + localize('staff-management-system', 'p-no-hist')) + ); + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'activity').data.default = true; + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON()] + }; + } + + const checkIds = recentChecks.map(check => check.id); + const responses = await ActivityCheckResponse.findAll({ + where: { + activityCheckId: { [Op.in]: checkIds }, + userId: targetUser.id + }, + attributes: ['activityCheckId'] + }); + + const respondedCheckIds = new Set(responses.map(response => response.activityCheckId)); + const historyRows = recentChecks.filter(check => respondedCheckIds.has(check.id)); + + const count = historyRows.length; + let totalPages = 1; + if (count > 3) totalPages = 1 + Math.ceil((count - 3) / 5); + const limit = page === 1 + ? 3 + : 5; + const offset = page === 1 + ? 0 + : 3 + ((page - 2) * 5); + const paginatedRows = historyRows.slice(offset, offset + limit); + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-act-title', { + username: targetUser.username + })) + .setColor('Blue') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + let desc = localize('staff-management-system', 'p-act-desc', { count }); + + if (paginatedRows.length === 0) { + desc += localize('staff-management-system', 'p-no-hist'); + } else { + desc += paginatedRows.map(r => + `**${localize('staff-management-system', 'label-chk')} **\n` + + `**${localize('staff-management-system', 'label-end')}:** \n` + + `**${localize('staff-management-system', 'label-chan')}:** <#${r.channelId}>` + ).join('\n\n'); + } + + embed.setDescription(desc); + embed.addFields({ + name: '\u200b', + value: localize('staff-management-system', 'page-count', { page, total: totalPages }) + }); + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'activity').data.default = true; + + const paginationRow = buildPaginationRow( + `staff-mgmt_panel-act_${targetUser.id}_${page - 1}`, + 'panel_act_count', + `staff-mgmt_panel-act_${targetUser.id}_${page + 1}`, + page, + totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON(), paginationRow.toJSON()] + }; +} + +// Shifts page +async function generatePanelShifts(client, targetUser) { + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-shi-title', { + username: targetUser.username + })) + .setColor('Purple') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + try { + const Shift = client.models['staff-management-system']['StaffShift']; + const config = getConfig(client, 'shifts') || {}; + const shifts = await Shift.findAll({ + where: { + userId: targetUser.id, + endTime: {[Op.not]: null}, + duration: {[Op.not]: null} + } + }); + + const totalShifts = shifts.length; + const totalSeconds = shifts.reduce((sum, s) => sum + (parseInt(s.duration) || 0), 0); + + const breakdown = {}; + shifts.forEach(log => { + const t = log.type || 'Staff'; + breakdown[t] = (breakdown[t] || 0) + (parseInt(log.duration) || 0); + }); + const breakdownStr = Object.entries(breakdown).sort((a, b) => b[1] - a[1]).map(([type, sec]) => `• ${type}: ${formatDuration(sec)}`).join('\n') || localize('staff-management-system', 'info-none'); + + let quotaStr = localize('staff-management-system', 'no-quota-configured'); + const guild = client.guilds.cache.get(client.guildID); + const member = await guild?.members.fetch(targetUser.id).catch(() => null); + + if (member && config.enableQuotas && config.quotas) { + let bestQuota = null; + let highestPosition = -1; + for (const [roleId, hoursStr] of Object.entries(config.quotas)) { + const hours = parseFloat(hoursStr); + const role = guild.roles.cache.get(roleId); + if (role && member.roles.cache.has(roleId) && role.position > highestPosition) { + highestPosition = role.position; + bestQuota = { hours }; + } + } + + if (bestQuota) { + const timeframe = config.quotaTimeframe || 'Weekly'; + const cutoff = new Date(); + if (timeframe === 'Weekly') cutoff.setDate(cutoff.getDate() - 7); + else cutoff.setMonth(cutoff.getMonth() - 1); + + const recentShifts = await Shift.findAll({ + where: { + userId: targetUser.id, + startTime: {[Op.gt]: cutoff}, + endTime: {[Op.not]: null}, + duration: {[Op.not]: null} + } + }); + const recentSeconds = recentShifts.reduce((sum, s) => sum + (parseInt(s.duration) || 0), 0); + const requiredSeconds = bestQuota.hours * 3600; + const isMet = recentSeconds >= requiredSeconds; + + quotaStr = localize('staff-management-system', 'duty-quota-str', { + timeframe, + duration: formatDuration(recentSeconds), + hours: bestQuota.hours, + result: isMet + ? localize('staff-management-system', 'duty-quota-met') + : localize('staff-management-system', 'duty-quota-failed') + }); + } + } + + const allResults = await Shift.findAll({ + attributes: ['userId', [Shift.sequelize.fn('SUM', Shift.sequelize.col('duration')), 'totalDuration']], + where: { endTime: { [Op.not]: null }, duration: { [Op.not]: null } }, + group: ['userId'], + order: [[Shift.sequelize.literal('totalDuration'), 'DESC']] + }); + + const lbIndex = allResults.findIndex(p => p.userId === targetUser.id); + const lbRank = lbIndex !== -1 + ? `${lbIndex + 1} / ${allResults.length}` + : localize('staff-management-system', 'label-unranked'); + + embed.setDescription(localize('staff-management-system', 'panel-shifts-desc', { + totalShifts, + totalSeconds: formatDuration(totalSeconds), + lbRank, + breakdownStr, + quotaStr + })); + + } catch (e) { + client.logger.error(`[Staff Management] User panel error: ${e.stack}`); + embed.setDescription(localize('staff-management-system', 'err-shift-data-unavailable', { error: e.message })); + } + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'shifts').data.default = true; + + const historyBtnRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`duty-mgmt_hist_${targetUser.id}_1_All`) + .setLabel(localize('staff-management-system', 'btn-view-history')) + .setStyle(ButtonStyle.Secondary) + ); + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON(), historyBtnRow.toJSON()] + }; +} + +// Deletion page +async function generatePanelDeletion(client, targetUser) { + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'panel-deletion-title', { tag: targetUser.username })) + .setDescription(localize('staff-management-system', 'panel-deletion-desc', { mention: targetUser.toString() })) + .setColor('DarkRed') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + const menu = new StringSelectMenuBuilder() + .setCustomId(`staff-mgmt_delete-menu_${targetUser.id}`) + .setPlaceholder(localize('staff-management-system', 'panel-deletion-placeholder')) + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-back')) + .setValue('back') + .setEmoji('◀️'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-act')) + .setValue('del_activity') + .setEmoji('📋'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-inf')) + .setValue('del_infractions') + .setEmoji('⚠️'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-prom')) + .setValue('del_promotions') + .setEmoji('🎉'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-rev')) + .setValue('del_reviews') + .setEmoji('⭐'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-shifts')) + .setValue('del_shifts') + .setEmoji('⏱️'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-status')) + .setValue('del_status') + .setEmoji('🌙'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-all')) + .setValue('del_all') + .setEmoji('💥') + ); + + return { + embeds: [embed.toJSON()], + components: [new ActionRowBuilder().addComponents(menu).toJSON()] + }; +} + +async function executeDataDeletion(client, targetId, dataType) { + const models = client.models['staff-management-system']; + + if (['del_infractions', 'del_all'].includes(dataType)) { + await models.Infraction.destroy({ + where: { userId: targetId } + }); + } + + if (['del_promotions', 'del_all'].includes(dataType)) { + await models.Promotion.destroy({ + where: { userId: targetId } + }); + } + + if (['del_reviews', 'del_all'].includes(dataType)) { + await models.StaffReview.destroy({ + where: { targetId } + }); + } + + const profileUpdates = {}; + if (['del_shifts', 'del_all'].includes(dataType)) { + profileUpdates.onDuty = false; + profileUpdates.onBreak = false; + profileUpdates.breakStartTime = null; + profileUpdates.lastClockIn = null; + } + + if (['del_status', 'del_all'].includes(dataType)) { + profileUpdates.activityStatus = null; + } + + if (dataType === 'del_all') { + profileUpdates.customNickname = null; + profileUpdates.customIntro = null; + profileUpdates.isSuspended = false; + profileUpdates.suspendedRoles = null; + } + + if (Object.keys(profileUpdates).length > 0) { + const profile = await models.StaffProfile.findByPk(targetId); + if (profile) await profile.update(profileUpdates); + } + + if (['del_activity', 'del_all'].includes(dataType)) { + await models.ActivityCheckResponse.destroy({ + where: { userId: targetId } + }); + } +} + +// ---------- Activity Checks ---------- +async function startActivityCheck(client, interactionOrChannel, isAutomated = false) { + const config = getConfig(client, 'activity-checks'); + const ActivityCheck = client.models['staff-management-system']['ActivityCheck']; + + if (await ActivityCheck.findOne({ + where: {status: 'ACTIVE'} + })) { + return !isAutomated && interactionOrChannel.editReply + ? interactionOrChannel.editReply({content: localize('staff-management-system', 'err-ac-act')}) + : null; + } + + let rolesToCheck = config.targetRoles?.length + ? config.targetRoles + : (getConfig(client, 'configuration')?.staffRoles || []); + if (!rolesToCheck.length) return !isAutomated && interactionOrChannel.editReply + ? interactionOrChannel.editReply({ + content: localize('staff-management-system', 'err-ac-norole') + }) + : null; + + const targetChannel = isAutomated + ? interactionOrChannel + : (interactionOrChannel.options.getChannel('channel') || interactionOrChannel.guild.channels.cache.get(getSafeChannelId(config.sendingChannel)) || interactionOrChannel.channel); + if (!targetChannel) return !isAutomated && interactionOrChannel.editReply + ? interactionOrChannel.editReply({ + content: localize('staff-management-system', 'err-ac-invchan') + }) + : null; + + const durationHours = config.timeframe || 24; + const endTime = new Date(Date.now() + durationHours * 60 * 60 * 1000); + + let embedTemplate = typeof config.checkMessage === 'string' + ? JSON.parse(config.checkMessage) + : config.checkMessage; + let msgOpts = await embedTypeV2(embedTemplate, { + '%end-time%': ``, + '%duration%': durationHours.toString() + }); + + if (msgOpts?.content?.trim() === '') delete msgOpts.content; + msgOpts.components = [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId('staff-mgmt_ac-respond') + .setLabel(localize('staff-management-system', 'ac-confirm-btn')) + .setStyle(ButtonStyle.Success) + .setEmoji('✅') + ) + .toJSON() + ]; + + try { + const checkMessage = await targetChannel.send(msgOpts); + if (!isAutomated && interactionOrChannel.editReply) await interactionOrChannel.editReply({ + content: localize('staff-management-system', 'succ-ac-start', { + channel: targetChannel.id, + hours: durationHours + }) + }); + + const record = await ActivityCheck.create({ + messageId: checkMessage.id, + channelId: targetChannel.id, + endTime, + targetRoles: JSON.stringify(rolesToCheck), + status: 'ACTIVE' + }); + schedule.scheduleJob(endTime, async () => { + const currentCheck = await ActivityCheck.findByPk(record.id); + if (currentCheck && currentCheck.status === 'ACTIVE') await endActivityCheckProcess(client, currentCheck); + }); + } catch (e) { + if (!isAutomated && interactionOrChannel.editReply) interactionOrChannel.editReply({ + content: localize('staff-management-system', 'err-ac-perms', {channel: targetChannel.id}) + }); + } +} + +async function endActivityCheckProcess(client, activeCheck) { + await activeCheck.update({ status: 'ENDED' }); + const guild = client.guilds.cache.get(client.guildID); + if (!guild) return; + + try { + const msg = await guild.channels.cache.get(activeCheck.channelId)?.messages.fetch(activeCheck.messageId); + if (msg && msg.embeds.length > 0) { + const originalEmbed = EmbedBuilder + .from(msg.embeds[0]) + .setColor('#ed4245'); + originalEmbed + .setTitle(localize('staff-management-system', 'ac-title-end')); + await msg.edit({ + embeds: [originalEmbed.toJSON()], + components: [] + }); + } + } catch (e) {} + + const config = getConfig(client, 'activity-checks'); + const logChannel = guild.channels.cache.get(getSafeChannelId(config.logChannel) || getSafeChannelId(getConfig(client, 'configuration')?.generalLogChannel)); + if (!logChannel) return; + + const targetRoles = JSON.parse(activeCheck.targetRoles || '[]'); + const ActivityCheckResponse = client.models['staff-management-system']['ActivityCheckResponse']; + const responses = await ActivityCheckResponse.findAll({ + where: { activityCheckId: activeCheck.id }, + attributes: ['userId'] + }); + + const respondedUserIds = new Set(responses.map(response => response.userId)); + const StaffProfile = client.models['staff-management-system']['StaffProfile']; + const expectedMembers = guild.members.cache.filter(m => !m.user.bot && m.roles.cache.some(r => targetRoles.includes(r.id))); + const [responded, exceptions, failed] = [[], [], []]; + const expectedIds = [...expectedMembers.keys()]; + const profiles = await StaffProfile.findAll({ + where: { + userId: {[Op.in]: expectedIds} + } + }); + + expectedMembers.forEach(member => { + if (respondedUserIds.has(member.id)) return responded.push(member); + + let isException = false; + const prof = profiles.find(p => p.userId === member.id); + const isLoa = prof?.activityStatus === 'LOA'; + const isRa = prof?.activityStatus === 'RA'; + + if (config.exceptionsType === 'Only LoA' && isLoa) isException = true; + else if (config.exceptionsType === 'Only RA' && isRa) isException = true; + else if (config.exceptionsType === 'LoA and RA' && (isLoa || isRa)) isException = true; + else if (config.exceptionsType === 'Custom role(s)' && member.roles.cache.some(r => config.customExceptionRoles?.includes(r.id))) isException = true; + + isException + ? exceptions.push(member) + : failed.push(member); + }); + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'ac-res-title')) + .setColor('Blurple') + .addFields( + { + name: localize('staff-management-system', 'ac-f-res', { + count: responded.length } + ), + value: responded.length + ? responded.map(m => `<@${m.id}>`).join(', ').substring(0, 1024) + : localize('staff-management-system', 'info-none') + }, + { + name: localize('staff-management-system', 'ac-f-fail', { + count: failed.length + }), + value: failed.length + ? failed.map(m => `<@${m.id}>`).join(', ').substring(0, 1024) + : localize('staff-management-system', 'info-none') + }, + { + name: localize('staff-management-system', 'ac-f-exc', { + count: exceptions.length + }), + value: exceptions.length + ? exceptions.map(m => `<@${m.id}>`).join(', ').substring(0, 1024) + : localize('staff-management-system', 'info-none') + } + ) + ); + + const pingText = (config.pingResults && config.pingRoles?.length) + ? config.pingRoles.map(rId => `<@&${rId}>`).join(' ') + : null; + const finalMessage = { embeds: [embed.toJSON()] }; + if (pingText) finalMessage.content = pingText; + + await logChannel.send(finalMessage).catch((e) => { + client.logger.error(localize('staff-management-system', 'log-ac-send-fail', { + error: e.message + })); +}); +} + +function getIsoWeekNumber(date = new Date()) { + const tmp = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const day = tmp.getUTCDay() || 7; + + tmp.setUTCDate(tmp.getUTCDate() + 4 - day); + + const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1)); + return Math.ceil((((tmp - yearStart) / 86400000) + 1) / 7); +} + +function initActivityCheckAutomation(client) { + const config = getConfig(client, 'activity-checks'); + if (!config?.enableActivityChecks || !config?.automatedChecks) return; + + let cronString = config.automatedCheckInterval === 'Cronjob' + ? config.automatedCheckCronjob + : null; + if (!cronString) { + const dayMap = { + 'Monday': 1, + 'Tuesday': 2, + 'Wednesday': 3, + 'Thursday': 4, + 'Friday': 5, + 'Saturday': 6, + 'Sunday': 7 + }[config.automatedCheckWeekDay] || 1; + if (['Weekly', 'Biweekly'].includes(config.automatedCheckInterval)) cronString = `0 12 * * ${dayMap}`; + else if (config.automatedCheckInterval === 'Monthly') { + const startDay = [1, 8, 15, 22][(config.automatedCheckMonthWeek || 1) - 1]; + cronString = `0 12 ${startDay}-${startDay + 6} * ${dayMap}`; + } + } + if (!cronString) return; + + const jobName = 'automated-activity-check'; + const existingJob = schedule.scheduledJobs[jobName]; + if (existingJob) existingJob.cancel(); + schedule.scheduleJob(jobName, cronString, async () => { + if (config.automatedCheckInterval === 'Biweekly' && getIsoWeekNumber(new Date()) % 2 !== 0) { + return; + } + + const channel = client.guilds.cache.get(client.guildID)?.channels.cache.get(getSafeChannelId(config.sendingChannel)); + if (channel) { + client.logger.info(`[Activity Checks] Starting automated check.`); + await startActivityCheck(client, channel, true); + } + }); +} + +// ---------- Reviews ---------- +async function submitReview(client, interaction, targetUser, stars, comment) { + await interaction.deferReply({ephemeral: true}); + const config = getConfig(client, 'reviews'); + if (!config?.enableReviews) return interaction.editReply({ + content: localize('staff-management-system', 'err-feat-disabled', { + feature: 'Reviews' + }) + }); + + const targetMember = await interaction.guild.members.fetch(targetUser.id).catch(() => null); + if (!targetMember) return interaction.editReply({ + content: localize('staff-management-system', 'err-not-mem') + }); + if (!config.allowSelfRating && targetUser.id === interaction.user.id) return interaction.editReply({ + content: localize('staff-management-system', 'err-self-rate') + }); + + if (config.onlyAllowStaffReview !== false) { + const generalConfig = getConfig(client, 'configuration') || {}; + const staffRoles = Array.isArray(generalConfig.staffRoles) + ? generalConfig.staffRoles + : (generalConfig.staffRoles ? [generalConfig.staffRoles] : []); + + const hasStaffRole = staffRoles.length > 0 && targetMember.roles.cache.some(role => + staffRoles.includes(role.id) + ); + + if (!hasStaffRole) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-staff-rate') + }); + } + } + + const review = await client.models['staff-management-system']['StaffReview'].create({ + targetId: targetUser.id, + authorId: interaction.user.id, + stars, + comment + }); + const channelId = getSafeChannelId(config.reviewLogChannel); + + if (channelId) { + const channel = interaction.guild.channels.cache.get(channelId); + if (channel) { + let msgOpts = await embedTypeV2(config.ratingMessage, { + '%staff-mention%': targetUser.toString(), + '%reviewer-mention%': interaction.user.toString(), + '%stars%': '⭐'.repeat(stars), + '%rating%': stars.toString(), + '%comment%': comment, + '%staff-avatar%': targetUser.displayAvatarURL({dynamic: true}), + '%reviewer-avatar%': interaction.user.displayAvatarURL({dynamic: true}) + }); + if (msgOpts?.content?.trim() === '') delete msgOpts.content; + const sentMessage = await channel.send(msgOpts).catch(()=>{}); + if (sentMessage) await review.update({ messageUrl: sentMessage.url }); + } + } + await interaction.editReply({ + content: localize('staff-management-system', 'succ-review', { + tag: targetUser.tag, + stars + }) + }); +} + +async function generateReviewHistoryResponse(client, targetUser, page = 1) { + if (!getConfig(client, 'reviews')?.enableReviews) return { + content: localize('staff-management-system', 'err-feat-disabled', { + feature: 'Reviews' + }), + flags: MessageFlags.Ephemeral + }; + + const limit = 8; + const offset = (page - 1) * limit; + const Review = client.models['staff-management-system']['StaffReview']; + + const {count, rows} = await Review.findAndCountAll({ + where: {targetId: targetUser.id}, + order: [['createdAt', 'DESC']], + limit, + offset + }); + const allReviews = await Review.findAll({ + where: {targetId: targetUser.id}, + attributes: ['stars'] + }); + const avg = allReviews.length + ? (allReviews.reduce((a, b) => a + b.stars, 0) / allReviews.length).toFixed(1) + : 0; + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'rev-title', { username: targetUser.username })) + .setColor('Gold') + .setDescription(localize('staff-management-system', 'rev-desc', { avg, count: allReviews.length })) + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + embed.addFields({ + name: localize('staff-management-system', 'label-hist'), + value: rows.length > 0 + ? rows.map(r => `**${"⭐".repeat(r.stars)}** ${localize('staff-management-system', 'label-by')} <@${r.authorId}>${r.messageUrl + ? ` • [Jump](${r.messageUrl})` + : ''}\n"${r.comment}"`).join('\n\n') + : localize('staff-management-system', 'p-no-hist') }); + + const row = buildPaginationRow( + `staff-mgmt_rev-page_${targetUser.id}_${page - 1}`, + 'page_count_disabled', + `staff-mgmt_rev-page_${targetUser.id}_${page + 1}`, + page, + Math.ceil(count / limit) || 1 + ); + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function getReviewHistory(client, interaction, targetUser) { + await interaction.deferReply({ephemeral: true}); + const response = await generateReviewHistoryResponse(client, targetUser, 1); + if (response.content && response.content.startsWith('❌')) return interaction.editReply(response); + + await interaction.editReply({ + ...response + }); +} + +module.exports = { + getConfig, + getSafeChannelId, + parseDurationToDays, + applyFooter, + checkStaffPermissions, + buildPaginationRow, + formatDuration, + issueInfraction, + issueSuspension, + getInfractionHistory, + voidInfraction, + generateInfractionHistoryResponse, + promoteUser, + generatePromotionHistoryResponse, + getPromotionHistory, + generateUserPanel, + generatePanelInfractions, + generatePanelPromotions, + generatePanelActivity, + generatePanelReviews, + generatePanelStatus, + generatePanelShifts, + generatePanelDeletion, + executeDataDeletion, + generatePanelSubpage, + startActivityCheck, + initActivityCheckAutomation, + endActivityCheckProcess, + submitReview, + getReviewHistory, + generateReviewHistoryResponse +}; \ No newline at end of file diff --git a/modules/starboard/configs/config.json b/modules/starboard/configs/config.json index 7a6ea9ae..c7c9de16 100644 --- a/modules/starboard/configs/config.json +++ b/modules/starboard/configs/config.json @@ -1,227 +1,126 @@ { - "description": {}, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, - "filename": "config.json", - "content": [ - { - "name": "channelId", - "humanName": { - "en": "Starboard channel", - "de": "Starboard-Kanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "In which channel starred messages are sent", - "de": "In welchen Kanal gestarrte Nachrichten gesendet werden" - }, - "type": "channelID" - }, - { - "name": "emoji", - "humanName": { - "en": "Emoji" - }, - "default": { - "en": "⭐" - }, - "description": { - "en": "Which emoji should be used to star messages", - "de": "Mit welchem Emoji Nachrichten gestarrt werden sollen" - }, - "type": "emoji" - }, - { - "name": "message", - "humanName": { - "en": "Message", - "de": "Nachricht" - }, - "default": { - "en": { - "message": "**%stars%** %emoji% in %channelMention%", - "color": "#f5c91b", - "description": "%content%", - "image": "%image%", - "author": { - "name": "%displayName%", - "img": "%userAvatar%", - "url": "%link%" - } - } - }, - "description": { - "en": "This message gets send into the selected channel", - "de": "Diese Nachricht wird in den ausgewählten Kanal gesendet" - }, - "allowEmbed": true, - "type": "string", - "params": [ - { - "name": "stars", - "description": { - "en": "Amount of reactions on the message", - "de": "Anzahl der Reaktionen auf die Nachricht" - } - }, - { - "name": "content", - "description": { - "en": "The content of the starred message", - "de": "Der Inhalt der gestarrten Nachricht" - } - }, - { - "name": "link", - "description": { - "en": "A link to the starred message", - "de": "Ein Link zur gestarrten Nachricht" - } - }, - { - "name": "userID", - "description": { - "en": "The user ID of the author of the starred message", - "de": "Die Nutzer-ID des Autors der gestarrten Nachricht" - } - }, - { - "name": "userName", - "description": { - "en": "The username of the author of the starred message", - "de": "Der Benutzername des Autors der gestarrten Nachricht" - } - }, - { - "name": "displayName", - "description": { - "en": "The nickname of the author", - "de": "Der Nickname des Autors" - } - }, - { - "name": "userTag", - "description": { - "en": "The tag of the author of the starred message", - "de": "Der Tag des Autors der gestarrten Nachricht" - } - }, - { - "name": "userAvatar", - "description": { - "en": "The avatar URL of the message author", - "de": "Die Avatar-URL des Nachrichtenautors" - } - }, - { - "name": "channelName", - "description": { - "en": "The name of the channel the starred message was sent in", - "de": "Der Name des Kanals, in dem die gestarrte Nachricht gesendet wurde" - } - }, - { - "name": "channelMention", - "description": { - "en": "The channel mention of the channel the starred message was sent in", - "de": "Die Kanalerwähnung des Kanals, in dem die gestarrte Nachricht gesendet wurde" - } - }, - { - "name": "emoji", - "description": { - "en": "The set starboard emoji for lazy users", - "de": "Das festgelegte Starboard-Emoji für faule Nutzer" - } - }, - { - "name": "image", - "description": { - "en": "The first attachment or the first image url in the message", - "de": "Der erste Anhang oder die erste Bild-URL in der Nachricht" - } - } - ] - }, - { - "name": "excludedChannels", - "humanName": { - "en": "Excluded channels", - "de": "Ausgenommene Kanäle" - }, - "default": { - "en": [] - }, - "description": { - "en": "In which channels messages cannot be starred", - "de": "In welchen Kanälen Nachrichten nicht gestarrt werden können" - }, - "type": "array", - "content": "channelID" - }, - { - "name": "excludedRoles", - "humanName": { - "en": "Excluded roles", - "de": "Ausgenommene Rollen" - }, - "default": { - "en": [] - }, - "description": { - "en": "Users with these roles cannot star messages", - "de": "Nutzer mit diesen Rollen können keine Nachrichten starren" - }, - "type": "array", - "content": "roleID" - }, - { - "name": "minStars", - "humanName": { - "en": "Minimum stars", - "de": "Mindestanzahl Sterne" - }, - "default": { - "en": 3 - }, - "description": { - "en": "How many star reactions are needed for a message to land on the starboard", - "de": "Wie viele Star-Reaktionen benötigt werden, damit eine Nachricht auf dem Starboard landet" - }, - "type": "integer" - }, - { - "name": "starsPerHour", - "humanName": { - "en": "Stars per user per hour", - "de": "Sterne pro Nutzer pro Stunde" - }, - "default": { - "en": 5 - }, - "description": { - "en": "How many messages a user can star per hour", - "de": "Wie viele Nachrichten ein Nutzer pro Stunde starren kann" - }, - "type": "integer" - }, - { - "name": "selfStar", - "humanName": { - "en": "Self-Star" - }, - "default": { - "en": true - }, - "description": { - "en": "Whether users can star their own messages", - "de": "Ob Nutzer ihre eigenen Nachrichten starren können" - }, - "type": "boolean" - } - ] + "description": "Configure the starboard channel and reaction settings here", + "humanName": "Configuration", + "filename": "config.json", + "content": [ + { + "name": "channelId", + "humanName": "Starboard channel", + "default": "", + "description": "In which channel starred messages are sent", + "type": "channelID" + }, + { + "name": "emoji", + "humanName": "Emoji", + "default": "⭐", + "description": "Which emoji should be used to star messages", + "type": "emoji" + }, + { + "name": "message", + "humanName": "Message", + "default": { + "message": "**%stars%** %emoji% in %channelMention%", + "color": "#f5c91b", + "description": "%content%", + "image": "%image%", + "author": { + "name": "%displayName%", + "img": "%userAvatar%", + "url": "%link%" + } + }, + "description": "This message gets send into the selected channel", + "allowEmbed": true, + "type": "string", + "params": [ + { + "name": "stars", + "description": "Amount of reactions on the message" + }, + { + "name": "content", + "description": "The content of the starred message" + }, + { + "name": "link", + "description": "A link to the starred message" + }, + { + "name": "userID", + "description": "The user ID of the author of the starred message" + }, + { + "name": "userName", + "description": "The username of the author of the starred message" + }, + { + "name": "displayName", + "description": "The nickname of the author" + }, + { + "name": "userTag", + "description": "The tag of the author of the starred message" + }, + { + "name": "userAvatar", + "description": "The avatar URL of the message author" + }, + { + "name": "channelName", + "description": "The name of the channel the starred message was sent in" + }, + { + "name": "channelMention", + "description": "The channel mention of the channel the starred message was sent in" + }, + { + "name": "emoji", + "description": "The set starboard emoji for lazy users" + }, + { + "name": "image", + "description": "The first attachment or the first image url in the message" + } + ] + }, + { + "name": "excludedChannels", + "humanName": "Excluded channels", + "default": [], + "description": "In which channels messages cannot be starred", + "type": "array", + "content": "channelID" + }, + { + "name": "excludedRoles", + "humanName": "Excluded roles", + "default": [], + "description": "Users with these roles cannot star messages", + "type": "array", + "content": "roleID" + }, + { + "name": "minStars", + "humanName": "Minimum stars", + "default": 3, + "description": "How many star reactions are needed for a message to land on the starboard", + "type": "integer" + }, + { + "name": "starsPerHour", + "humanName": "Stars per user per hour", + "default": 5, + "description": "How many messages a user can star per hour", + "type": "integer" + }, + { + "name": "selfStar", + "humanName": "Self-Star", + "default": true, + "description": "Whether users can star their own messages", + "type": "boolean" + } + ] } \ No newline at end of file diff --git a/modules/starboard/handleStarboard.js b/modules/starboard/handleStarboard.js index 572d61b2..ded74bf6 100644 --- a/modules/starboard/handleStarboard.js +++ b/modules/starboard/handleStarboard.js @@ -1,4 +1,9 @@ -const {embedTypeV2, disableModule, formatDiscordUserName} = require('../../src/functions/helpers'); +const { + embedTypeV2, + disableModule, + formatDiscordUserName, + archiveDiscordAttachment +} = require('../../src/functions/helpers'); const {localize} = require('../../src/functions/localize'); const {Op} = require('sequelize'); @@ -70,7 +75,15 @@ module.exports = async (client, msgReaction, user, isReactionRemove = false) => return; } - let image = msg.attachments.size > 0 ? msg.attachments.first().url : null; + let image = null; + if (msg.attachments.size > 0) { + const firstAttachment = msg.attachments.first(); + image = await archiveDiscordAttachment(client, firstAttachment.url, { + displayName: `Starboard post by ${formatDiscordUserName(msg.author)} in #${msg.channel.name}`.slice(0, 100), + tags: ['starboard'], + uploaderDiscordID: msg.author.id + }); + } if (!image) { const matches = msg.content.match(/https?:\/\/.*\.(?:png|jpg|gif|jpeg|webp)/i); if (matches) image = matches[0]; diff --git a/modules/starboard/module.json b/modules/starboard/module.json index a4cbd144..038f14af 100644 --- a/modules/starboard/module.json +++ b/modules/starboard/module.json @@ -1,24 +1,20 @@ { "name": "starboard", - "humanReadableName": { - "en": "Starboard" - }, + "humanReadableName": "Starboard", "author": { "scnxOrgID": "60", "name": "TomatoCake", "link": "https://github.com/DEVTomatoCake" }, - "description": { - "en": "Let users highlight messages into a starboard channel by reacting.", - "de": "Lass Nutzer Nachrichten durch eine Reaktion in einem Starboard-Kanal hervorheben." - }, + "description": "Let users highlight messages into a starboard channel by reacting.", "events-dir": "/events", "models-dir": "/models", "config-example-files": [ "configs/config.json" ], + "fa-icon": "fas fa-star", "tags": [ "community" ], "openSourceURL": "https://github.com/DEVTomatoCake/ScootKit-CustomBot/tree/main/modules/starboard" -} \ No newline at end of file +} diff --git a/modules/status-roles/configs/config.json b/modules/status-roles/configs/config.json index 92c85945..d8f919ec 100644 --- a/modules/status-roles/configs/config.json +++ b/modules/status-roles/configs/config.json @@ -1,77 +1,37 @@ { - "description": { - "en": "Configure the function of the module here", - "de": "Stelle hier die Funktionen des Modules ein" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the function of the module here", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "words", - "humanName": { - "en": "Words", - "de": "Statusinhalt" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Words users should have in their status.", - "de": "Wörter, die Nutzer in ihrem Status haben sollen." - }, + "humanName": "Words", + "default": [], + "description": "Words users should have in their status.", "type": "array", "content": "string" }, { "name": "roles", - "humanName": { - "en": "Roles", - "de": "Rollen" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Roles to give to users with one of the words in their status", - "de": "Rollen, die an Nutzer mit einem der Wörter im Status vergeben werden sollen" - }, + "humanName": "Roles", + "default": [], + "description": "Roles to give to users with one of the words in their status", "type": "array", "content": "roleID" }, { "name": "remove", - "humanName": { - "en": "Remove all other roles", - "de": "Entferne alle anderen Rollen" - }, - "default": { - "en": false - }, - "description": { - "en": "Remove all other roles from users with one of the words in their status", - "de": "Entferne alle anderen Rollen von Nutzern mit einem der Wörter im Status" - }, + "humanName": "Remove all other roles", + "default": false, + "description": "Remove all other roles from users with one of the words in their status", "type": "boolean" }, { "name": "ignoreOfflineUsers", - "humanName": { - "en": "Do not remove roles from offline users", - "de": "Rollen von offline Nutzern nicht entfernen" - }, + "humanName": "Do not remove roles from offline users", "type": "boolean", - "default": { - "en": true - }, - "description": { - "en": "When users are offline, they don't have a status, leading to the role being removed. If enabled, the status role won't be removed from offline users, only users that have a different status. Recommended on servers with more than 500 members.", - "de": "Wenn Nutzer offline sind, haben sie keinen Status, was dazu führt, dass die Rolle entfernt wird. Wenn aktiviert, wird die Status-Rolle nicht von offline Nutzern entfernt, nur von Nutzern mit anderem Status. Empfohlen auf Servern mit 500+ Nutzern." - } + "default": true, + "description": "When users are offline, they don't have a status, leading to the role being removed. If enabled, the status role won't be removed from offline users, only users that have a different status. Recommended on servers with more than 500 members." } ] } \ No newline at end of file diff --git a/modules/status-roles/events/presenceUpdate.js b/modules/status-roles/events/presenceUpdate.js index 2d618452..6242144f 100644 --- a/modules/status-roles/events/presenceUpdate.js +++ b/modules/status-roles/events/presenceUpdate.js @@ -3,6 +3,7 @@ const {ActivityType} = require('discord.js'); module.exports.run = async function (client, oldPresence, newPresence) { if (!client.botReadyAt) return; + if (!newPresence.member) return; if (newPresence.member.guild.id !== client.guildID) return; const moduleConfig = client.configurations['status-roles']['config']; const roles = moduleConfig.roles; diff --git a/modules/status-roles/module.json b/modules/status-roles/module.json index f83b1f43..a7185f10 100644 --- a/modules/status-roles/module.json +++ b/modules/status-roles/module.json @@ -10,15 +10,10 @@ "config-example-files": [ "configs/config.json" ], + "fa-icon": "fa-solid fa-user-tag", "tags": [ "administration" ], - "humanReadableName": { - "en": "Status-roles", - "de": "Status-Rollen" - }, - "description": { - "en": "Simple module to reward users who have an invite to your server in their status!", - "de": "Einfaches Modul, um Nutzer zu belohnen, die einen Link zu deinem Server in ihrem Status haben!" - } -} \ No newline at end of file + "humanReadableName": "Status-roles", + "description": "Simple module to reward users who have an invite to your server in their status!" +} diff --git a/modules/sticky-messages/configs/sticky-messages.json b/modules/sticky-messages/configs/sticky-messages.json index 9722a565..4fffc79e 100644 --- a/modules/sticky-messages/configs/sticky-messages.json +++ b/modules/sticky-messages/configs/sticky-messages.json @@ -1,60 +1,30 @@ { - "description": { - "en": "Manage the sticky messages here", - "de": "Passe hier die Sticky-Nachrichten an" - }, - "humanName": { - "en": "Sticky messages", - "de": "Sticky-Nachrichten" - }, - "filename": "sticky-messages.json", - "configElements": true, - "content": [ - { - "name": "channelId", - "humanName": { - "en": "Channel", - "de": "Kanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "Channel-ID in which the message should get send", - "de": "Kanal-ID, in welchem die Nachricht gesendet werden soll" - }, - "type": "channelID" - }, - { - "name": "message", - "humanName": { - "en": "Message", - "de": "Nachricht" - }, - "default": { - "en": "" - }, - "description": { - "en": "Message that should get send", - "de": "Nachricht, die gesendet werden soll" - }, - "type": "string", - "allowEmbed": true - }, - { - "name": "respondBots", - "humanName": { - "en": "Respond to bots", - "de": "Antworten auf Bots" - }, - "default": { - "en": false - }, - "description": { - "en": "Whether your bot reacts to messages from other bots in the channel", - "de": "Ob dein Bot auf Nachrichten von anderen Bots in dem Kanal reagiert" - }, - "type": "boolean" - } - ] + "description": "Manage the sticky messages here", + "humanName": "Sticky messages", + "filename": "sticky-messages.json", + "configElements": true, + "content": [ + { + "name": "channelId", + "humanName": "Channel", + "default": "", + "description": "Channel-ID in which the message should get send", + "type": "channelID" + }, + { + "name": "message", + "humanName": "Message", + "default": "", + "description": "Message that should get send", + "type": "string", + "allowEmbed": true + }, + { + "name": "respondBots", + "humanName": "Respond to bots", + "default": false, + "description": "Whether your bot reacts to messages from other bots in the channel", + "type": "boolean" + } + ] } \ No newline at end of file diff --git a/modules/sticky-messages/events/messageCreate.js b/modules/sticky-messages/events/messageCreate.js index 540ed764..993164ed 100644 --- a/modules/sticky-messages/events/messageCreate.js +++ b/modules/sticky-messages/events/messageCreate.js @@ -14,8 +14,10 @@ async function deleteMessage(clientId, channel) { message = await channel.messages.fetch(channelData[channel.id].msg).catch(async () => { const msgs = await channel.messages.fetch({limit: 20}); message = msgs.find(m => m.author.id === clientId); + if (message) message.delete().catch(() => { + }); }); - if (message) message.delete().catch(() => { + if (message && message.deletable) message.delete().catch(() => { }); } diff --git a/modules/sticky-messages/module.json b/modules/sticky-messages/module.json index efe6db2f..2e4a9637 100644 --- a/modules/sticky-messages/module.json +++ b/modules/sticky-messages/module.json @@ -1,24 +1,19 @@ { "name": "sticky-messages", - "humanReadableName": { - "en": "Sticky messages", - "de": "Sticky-Nachrichten" - }, + "humanReadableName": "Sticky messages", "author": { "scnxOrgID": "60", "name": "TomatoCake", "link": "https://github.com/DEVTomatoCake" }, - "description": { - "en": "Let a set message always appear at the end of a channel.", - "de": "Lasse eine festgelegte Nachricht immer am Ende eines Kanals erscheinen." - }, + "description": "Let a set message always appear at the end of a channel.", "events-dir": "/events", "config-example-files": [ "configs/sticky-messages.json" ], + "fa-icon": "fas fa-thumbtack", "tags": [ "community" ], "openSourceURL": "https://github.com/DEVTomatoCake/ScootKit-CustomBot/tree/main/modules/sticky-messages" -} \ No newline at end of file +} diff --git a/modules/suggestions/commands/manage-suggestion.js b/modules/suggestions/commands/manage-suggestion.js index dc750532..fcf632da 100644 --- a/modules/suggestions/commands/manage-suggestion.js +++ b/modules/suggestions/commands/manage-suggestion.js @@ -53,6 +53,7 @@ module.exports.autoComplete = { */ async function autoCompleteSuggestionID(interaction) { const suggestions = await interaction.client.models['suggestions']['Suggestion'].findAll({ + where: {adminAnswer: null}, order: [['createdAt', 'DESC']] }); const returnValue = []; diff --git a/modules/suggestions/config.json b/modules/suggestions/config.json index f9eacca2..59c8eeaf 100644 --- a/modules/suggestions/config.json +++ b/modules/suggestions/config.json @@ -1,12 +1,6 @@ { - "description": { - "en": "Configure the function of the module here", - "de": "Stelle hier die Funktionen des Modules ein" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure the function of the module here", + "humanName": "Configuration", "filename": "config.json", "commandsWarnings": { "normal": [ @@ -16,435 +10,228 @@ "content": [ { "name": "suggestionChannel", - "humanName": { - "en": "Suggestion-Channel", - "de": "Vorschlagskanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "Channel in which this module should operate", - "de": "Kanal in dem dieses Modul arbeiten soll" - }, + "humanName": "Suggestion-Channel", + "default": "", + "description": "Channel in which this module should operate", "type": "channelID" }, { "name": "createSuggestionFromMessagesInChannel", - "humanName": { - "en": "Create suggestions from messages in channel", - "de": "Vorschläge von Nachrichten im Kanal erstellen" - }, - "default": { - "en": false, - "de": false - }, - "description": { - "en": "If enabled, the bot will create thread under each suggestion", - "de": "Wenn aktiviert, wird für jede Nachricht im Vorschlag-Kanal ein Vorschlag erstellt" - }, + "humanName": "Create suggestions from messages in channel", + "default": false, + "description": "If enabled, the bot will create thread under each suggestion", "type": "boolean" }, { "name": "reactions", - "humanName": { - "en": "Reactions", - "de": "Reaktionen" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Emojis with which the bot should react to a new suggestion", - "de": "Emojis mit denen der Bot auf neue Vorschläge reagieren soll" - }, + "humanName": "Reactions", + "default": [], + "description": "Emojis with which the bot should react to a new suggestion", "type": "array", "content": "emoji" }, { "name": "allowUserComment", - "humanName": { - "en": "User-Comments in Threads", - "de": "Nutzerkommentare in Threads" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "If enabled, the bot will create thread under each suggestion", - "de": "Wenn aktiviert erstellt der Bot immer einen neuen Thread unter Vorschlägen" - }, + "humanName": "User-Comments in Threads", + "default": true, + "description": "If enabled, the bot will create thread under each suggestion", "type": "boolean" }, { "name": "threadName", "dependsOn": "allowUserComment", - "humanName": { - "en": "Thread-Name" - }, - "default": { - "en": "Comments", - "de": "Kommentare" - }, - "description": { - "en": "Name of the thread", - "de": "Name des Threads" - }, + "humanName": "Thread-Name", + "default": "Comments", + "description": "Name of the thread", "type": "string" }, { "name": "successfullySubmitted", - "humanName": { - "en": "\"Successfully submitted\"-Message", - "de": "\"Erfolgreich eingereicht\"-Nachricht" - }, - "default": { - "en": "Suggestion %id% submitted successfully.", - "de": "Vorschlag %id% erfolgreich eingereicht." - }, - "description": { - "en": "This message gets send if a suggestion is submitted successfully.", - "de": "Diese Nachricht wird gesendet, wenn ein Vorschlag erfolgreich eingereicht wurde" - }, + "humanName": "\"Successfully submitted\"-Message", + "default": "Suggestion %id% submitted successfully.", + "description": "This message gets send if a suggestion is submitted successfully.", "type": "string", "allowEmbed": true, "params": [ { "name": "id", - "description": { - "en": "ID of the suggestion", - "de": "ID des Vorschlags" - } + "description": "ID of the suggestion" } ] }, { "name": "notifyRole", - "humanName": { - "en": "Notification-Role", - "de": "Benachrichtigungsrolle" - }, - "default": { - "en": "" - }, - "description": { - "en": "If set, this role gets pinged when a new suggestion gets created", - "de": "Wenn eine Rolle gesetzt ist, wird diese gepingt wenn ein neuer Vorschlag erstellt wird" - }, + "humanName": "Notification-Role", + "default": "", + "description": "If set, this role gets pinged when a new suggestion gets created", "type": "roleID", "allowNull": true }, { "name": "sendPNNotifications", - "humanName": { - "en": "Send DM-Notifications", - "de": "PN-Benachrichtigungen senden" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "If enabled the creator and all commentators get a notification when something changes on a suggestion", - "de": "Wenn diese Option aktiviert ist, wird der Ersteller benachrichtigt, wenn sich etwas an einem Vorschlag ändert" - }, + "humanName": "Send DM-Notifications", + "default": true, + "description": "If enabled the creator and all commentators get a notification when something changes on a suggestion", "type": "boolean" }, { "name": "teamChange", - "humanName": { - "en": "DM-Status-Notification", - "de": "PN-Statusbenachrichtigung" - }, - "default": { - "en": "Hi, a suggestion you are subscribed to got updated by a team member - read it here %url%", - "de": "Hi, ein von dir abonnierter Vorschlag wurde von einem Teammitglied beantwortet - lese ihn hier %url%" - }, - "description": { - "en": "This message gets send to the creator and all commentators when a suggestion gets updated and sendPNNotifications is enabled", - "de": "Diese Nachricht wird an den Ersteller und alle Nutzer, die einen Kommentar geschrieben haben, gesendet, wenn ein Vorschlag aktualisiert wird und \"PN-Benachrichtigungen senden\" aktiviert ist" - }, + "humanName": "DM-Status-Notification", + "default": "Hi, a suggestion you are subscribed to got updated by a team member - read it here %url%", + "description": "This message gets send to the creator and all commentators when a suggestion gets updated and sendPNNotifications is enabled", "type": "string", "dependsOn": "sendPNNotifications", "allowEmbed": true, "params": [ { "name": "url", - "description": { - "en": "URL to the suggestion", - "de": "URL zum Vorschlag" - } + "description": "URL to the suggestion" }, { "name": "title", - "description": { - "en": "Title of the suggestion", - "de": "Titel des Vorschlags" - } + "description": "Title of the suggestion" } ] }, { "name": "unansweredSuggestion", - "humanName": { - "en": "Unanswered Suggestion-Message", - "de": "Unbeantwortete Vorschlags-Nachricht" - }, + "humanName": "Unanswered Suggestion-Message", "default": { - "en": { - "title": "Suggestion #%id%", - "description": "%suggestion%", - "color": "#F1C40F", - "thumbnail": "%avatarURL%", - "author": { - "name": "%tag%", - "img": "%avatarURL%" - }, - "fields": [ - { - "name": "Suggestion-Status", - "value": "No admin answered to this suggestion yet" - } - ] - }, - "de": { - "title": "Vorschlag #%id%", - "description": "%suggestion%", - "color": "#F1C40F", - "thumbnail": "%avatarURL%", - "author": { - "name": "%tag%", - "img": "%avatarURL%" - }, - "fields": [ - { - "name": "Vorschlagsstatus", - "value": "Es hat noch kein Admin auf diesen Vorschlag geantwortet" - } - ] - } - }, - "description": { - "en": "This will be the messages that will get send when the user creates their suggestion and no admin has responded yet", - "de": "Das wird die Nachricht sein, die gesendet wird, wenn ein Nutzer seinen Vorschlag erstellt hat und noch kein Admin darauf geantwortet hat" - }, + "title": "Suggestion #%id%", + "description": "%suggestion%", + "color": "#F1C40F", + "thumbnail": "%avatarURL%", + "author": { + "name": "%tag%", + "img": "%avatarURL%" + }, + "fields": [ + { + "name": "Suggestion-Status", + "value": "No admin answered to this suggestion yet" + } + ] + }, + "description": "This will be the messages that will get send when the user creates their suggestion and no admin has responded yet", "type": "string", "allowEmbed": true, "params": [ { "name": "id", - "description": { - "en": "ID of the suggestion", - "de": "ID des Vorschlags" - } + "description": "ID of the suggestion" }, { "name": "suggestion", - "description": { - "en": "Content of the suggestion", - "de": "Inhalt des Vorschlags" - } + "description": "Content of the suggestion" }, { "name": "tag", - "description": { - "en": "Tag of the user who created this suggestion", - "de": "Tag des Users, der den Vorschlag erstellt hat" - } + "description": "Tag of the user who created this suggestion" }, { "name": "avatarURL", - "description": { - "en": "Avatar-URL of the user who created this suggestion", - "de": "Avatar-URL des Users, der den Vorschlag erstellt hat" - }, + "description": "Avatar-URL of the user who created this suggestion", "isImage": true } ] }, { "name": "deniedSuggestion", - "humanName": { - "en": "Denied Suggestion-Message", - "de": "Abgelehnte Vorschlags-Nachricht" - }, + "humanName": "Denied Suggestion-Message", "default": { - "en": { - "title": "Suggestion #%id%", - "description": "%suggestion%", - "color": "#E74C3C", - "thumbnail": "%avatarURL%", - "author": { - "name": "%tag%", - "img": "%avatarURL%" - }, - "fields": [ - { - "name": "Suggestion-Status: DENIED", - "value": "Denied by %adminUser% with the following reason: \"%adminMessage%\"" - } - ] - }, - "de": { - "title": "Vorschlag #%id%", - "description": "%suggestion%", - "color": "#E74C3C", - "thumbnail": "%avatarURL%", - "author": { - "name": "%tag%", - "img": "%avatarURL%" - }, - "fields": [ - { - "name": "Vorschlags-Status: ABGELEHNT", - "value": "Abgelehnt von %adminUser% mit folgendem Grund: \"%adminMessage%\"" - } - ] - } - }, - "description": { - "en": "The suggestion will be edited to this message, when an admin denies a suggestion", - "de": "Zu dieser Nachricht wird der Vorschlag editiert, wenn ein Admin den Vorschlag ablehnt" - }, + "title": "Suggestion #%id%", + "description": "%suggestion%", + "color": "#E74C3C", + "thumbnail": "%avatarURL%", + "author": { + "name": "%tag%", + "img": "%avatarURL%" + }, + "fields": [ + { + "name": "Suggestion-Status: DENIED", + "value": "Denied by %adminUser% with the following reason: \"%adminMessage%\"" + } + ] + }, + "description": "The suggestion will be edited to this message, when an admin denies a suggestion", "type": "string", "allowEmbed": true, "params": [ { "name": "id", - "description": { - "en": "ID of the suggestion", - "de": "ID des Vorschlags" - } + "description": "ID of the suggestion" }, { "name": "suggestion", - "description": { - "en": "Content of the suggestion", - "de": "Inhalt des Vorschlags" - } + "description": "Content of the suggestion" }, { "name": "tag", - "description": { - "en": "Tag of the user who created this suggestion", - "de": "Tag des Users, der den Vorschlag erstellt hat" - } + "description": "Tag of the user who created this suggestion" }, { "name": "avatarURL", - "description": { - "en": "Avatar-URL of the user who created this suggestion", - "de": "Avatar-URL des Users, der den Vorschlag erstellt hat" - }, + "description": "Avatar-URL of the user who created this suggestion", "isImage": true }, { "name": "adminUser", - "description": { - "en": "Mention of the administrator who denied this suggestion", - "de": "Erwähnung des Administrators, der den Vorschlag abgelehnt hat" - } + "description": "Mention of the administrator who denied this suggestion" }, { "name": "adminMessage", - "description": { - "en": "Message by administrator who denied this suggestion", - "de": "Nachricht des Administrators, der den Vorschlag abgelehnt hat" - } + "description": "Message by administrator who denied this suggestion" } ] }, { "name": "approvedSuggestion", - "humanName": { - "en": "Approved Suggestion-Message", - "de": "Angenommene Vorschlags-Nachricht" - }, + "humanName": "Approved Suggestion-Message", "default": { - "en": { - "title": "Suggestion #%id%", - "description": "%suggestion%", - "color": "#2ECC71", - "thumbnail": "%avatarURL%", - "author": { - "name": "%tag%", - "img": "%avatarURL%" - }, - "fields": [ - { - "name": "Suggestion-Status: APPROVED", - "value": "Approved by %adminUser% with the following reason: \"%adminMessage%\"" - } - ] - }, - "de": { - "title": "Vorschlag #%id%", - "description": "%suggestion%", - "color": "#2ECC71", - "thumbnail": "%avatarURL%", - "author": { - "name": "%tag%", - "img": "%avatarURL%" - }, - "fields": [ - { - "name": "Vorschlagsstatus: ANGENOMMEN", - "value": "Wurde von %adminUser% mit folgendem Grund angenommen: \"%adminMessage%\"" - } - ] - } - }, - "description": { - "en": "The suggestion will be edited to this message, when an admin approves a suggestion", - "de": "Zu dieser Nachricht wird der Vorschlag editiert, wenn ein Admin den Vorschlag annimt" - }, + "title": "Suggestion #%id%", + "description": "%suggestion%", + "color": "#2ECC71", + "thumbnail": "%avatarURL%", + "author": { + "name": "%tag%", + "img": "%avatarURL%" + }, + "fields": [ + { + "name": "Suggestion-Status: APPROVED", + "value": "Approved by %adminUser% with the following reason: \"%adminMessage%\"" + } + ] + }, + "description": "The suggestion will be edited to this message, when an admin approves a suggestion", "type": "string", "allowEmbed": true, "params": [ { "name": "id", - "description": { - "en": "ID of the suggestion", - "de": "ID des Vorschlags" - } + "description": "ID of the suggestion" }, { "name": "suggestion", - "description": { - "en": "Content of the suggestion", - "de": "Inhalt des Vorschlags" - } + "description": "Content of the suggestion" }, { "name": "tag", - "description": { - "en": "Tag of the user who created this suggestion", - "de": "Tag des Users, der den Vorschlag erstellt hat" - } + "description": "Tag of the user who created this suggestion" }, { "name": "avatarURL", - "description": { - "en": "Avatar-URL of the user who created this suggestion", - "de": "Avatar-URL des Users, der den Vorschlag erstellt hat" - }, + "description": "Avatar-URL of the user who created this suggestion", "isImage": true }, { "name": "adminUser", - "description": { - "en": "Mention of the administrator who approved this suggestion", - "de": "Erwähnung des Administrators, der den Vorschlag angenommen hat" - } + "description": "Mention of the administrator who approved this suggestion" }, { "name": "adminMessage", - "description": { - "en": "Message by administrator who approved this suggestion", - "de": "Nachricht des Administrators, der den Vorschlag angenommen hat" - } + "description": "Message by administrator who approved this suggestion" } ] } diff --git a/modules/suggestions/module.json b/modules/suggestions/module.json index 395821b4..202e130d 100644 --- a/modules/suggestions/module.json +++ b/modules/suggestions/module.json @@ -8,19 +8,14 @@ "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/suggestions", "commands-dir": "/commands", "models-dir": "/models", + "fa-icon": "far fa-lightbulb", "config-example-files": [ "config.json" ], "tags": [ "administration" ], - "humanReadableName": { - "en": "Suggestions", - "de": "Vorschläge" - }, + "humanReadableName": "Suggestions", "events-dir": "/events", - "description": { - "en": "Advanced module to manage suggestions on your guild", - "de": "Modul mit vielen Funktionen, um Vorschläge auf deinem Discord zu managen" - } -} \ No newline at end of file + "description": "Advanced module to manage suggestions on your guild" +} diff --git a/modules/team-list/config.json b/modules/team-list/config.json index 02ad5a74..4c1e39c7 100644 --- a/modules/team-list/config.json +++ b/modules/team-list/config.json @@ -1,59 +1,30 @@ { - "description": {}, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure your team list embeds and displayed roles here", + "humanName": "Configuration", "filename": "config.json", "configElements": true, "content": [ { "name": "channelID", - "humanName": { - "en": "Channel", - "de": "Kanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "Channel-ID to run all operations in it", - "de": "Kanal-ID, in welchem alle Aktionen ausgeführt werden" - }, + "humanName": "Channel", + "default": "", + "description": "Channel-ID to run all operations in it", "type": "channelID" }, { "name": "roles", - "humanName": { - "en": "Listed Roles", - "de": "Gelistete Rollen" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Roles that should be listed in the embed", - "de": "Jede Rolle, die im Embed gelistet werden soll" - }, + "humanName": "Listed Roles", + "default": [], + "description": "Roles that should be listed in the embed", "type": "array", "maxLength": 25, "content": "roleID" }, { "name": "descriptions", - "humanName": { - "en": "Descriptions of roles", - "de": "Beschreibung von Rollen" - }, - "default": { - "en": [], - "de": {} - }, - "description": { - "en": "Optional description of a listed role (Field 1: Role-ID, Field 2: Description)", - "de": "Optionale Beschreibung einer gelisteten Rolle (Feld 1: Rollen-ID, Feld 2: Beschreibung)" - }, + "humanName": "Descriptions of roles", + "default": {}, + "description": "Optional description of a listed role (Field 1: Role-ID, Field 2: Description)", "type": "keyed", "content": { "key": "roleID", @@ -62,29 +33,15 @@ }, { "name": "embed", - "humanName": { - "en": "Embed" - }, + "humanName": "Embed", "default": { - "en": { - "title": "Our staff", - "description": "Meet our staff here", - "color": "GREEN", - "thumbnail-url": "", - "img-url": "" - }, - "de": { - "title": "Unser Team", - "description": "Hier findest du alle unsere Teammitglieder", - "color": "GREEN", - "thumbnail-url": "", - "img-url": "" - } - }, - "description": { - "en": "Configuration of the member-embed", - "de": "Konfiguration des Partner-Embeds" + "title": "Our staff", + "description": "Meet our staff here", + "color": "GREEN", + "thumbnail-url": "", + "img-url": "" }, + "description": "Configuration of the member-embed", "type": "keyed", "content": { "key": "string", @@ -94,18 +51,9 @@ }, { "name": "nameOverwrites", - "humanName": { - "en": "Name-Overwrites", - "de": "Name-Overwrites" - }, - "default": { - "en": [], - "de": {} - }, - "description": { - "en": "optional; Allows to overwrite the displayed name of roles (Field 1: Role-ID, Field 2: Displayed Name)", - "de": "optional; Allows to overwrite the displayed name of a role (Feld 1: Rollen-ID, Feld 2: Angezeigter Name)" - }, + "humanName": "Name-Overwrites", + "default": {}, + "description": "optional; Allows to overwrite the displayed name of roles (Field 1: Role-ID, Field 2: Displayed Name)", "type": "keyed", "content": { "key": "roleID", @@ -114,33 +62,17 @@ }, { "name": "includeStatus", - "humanName": { - "en": "Include Online-Status of Staff-Members", - "de": "Online-Status von Teammitgliedern anzeigen" - }, - "description": { - "en": "If enabled, the current online status will be displayed in the staffmember-list", - "de": "Wenn aktiviert, wird der aktuelle Status in der Teammitglieder-Liste angezeigt" - }, + "humanName": "Include Online-Status of Staff-Members", + "description": "If enabled, the current online status will be displayed in the staffmember-list", "type": "boolean", - "default": { - "en": false - } + "default": false }, { "name": "onlineShowHighestRole", - "humanName": { - "en": "Only list the highest role of a user?", - "de": "Nur die höchste Rolle eines Nutzers anzeigen?" - }, - "description": { - "en": "If enabled, a staff member will only be listed under their highest role in the list.", - "de": "Wenn aktiviert, wird ein Teammitglied nur unter seiner höchsten Rolle in der Liste angezeigt." - }, + "humanName": "Only list the highest role of a user?", + "description": "If enabled, a staff member will only be listed under their highest role in the list.", "type": "boolean", - "default": { - "en": false - } + "default": false } ] -} \ No newline at end of file +} diff --git a/modules/team-list/events/botReady.js b/modules/team-list/events/botReady.js index 9cdcfef3..433a7edf 100644 --- a/modules/team-list/events/botReady.js +++ b/modules/team-list/events/botReady.js @@ -1,8 +1,8 @@ const isEqual = require('is-equal'); const { - disableModule, truncate, - parseEmbedColor + parseEmbedColor, + safeSetFooter } = require('../../../src/functions/helpers'); const {localize} = require('../../../src/functions/localize'); const {MessageEmbed} = require('discord.js'); @@ -32,26 +32,31 @@ let lastSavedEmbed = {}; */ async function updateEmbedsIfNeeded(client) { const channels = client.configurations['team-list']['config']; - for (const channelConfig of channels) { + for (let configIndex = 0; configIndex < channels.length; configIndex++) { + const channelConfig = channels[configIndex]; const embed = new MessageEmbed() - .setColor(parseEmbedColor(channelConfig.embed.color)) - .setTitle(channelConfig.embed.title) - .setDescription(channelConfig.embed.description) - .setTimestamp() - .setFooter({text: client.strings.footer, iconURL: client.strings.footerImgUrl}); + .setColor(parseEmbedColor(channelConfig.embed.color)); + safeSetFooter(embed, client); + + if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); + if (channelConfig.embed.description) embed.setDescription(channelConfig.embed.description); + if (channelConfig.embed.title) embed.setTitle(channelConfig.embed.title); if (channelConfig.embed['thumbnail-url']) embed.setThumbnail(channelConfig.embed['thumbnail-url']); if (channelConfig.embed['img-url']) embed.setImage(channelConfig.embed['img-url']); const channel = await client.channels.fetch(channelConfig['channelID']).catch(() => { }); - if (!channel) return disableModule('team-list', localize('team-list', 'channel-not-found', {c: channelConfig['channelID']})); - const messages = (await channel.messages.fetch()).filter(msg => msg.author.id === client.user.id); + if (!channel) { + client.logger.error(`[team-list] Could not find channel with id ${channelConfig['channelID']}`); + continue; + } + const guildMembers = client.guild.members.cache; const roles = (await channel.guild.roles.fetch()).filter(f => channelConfig.roles.includes(f.id)).sort((a, b) => a.position < b.position ? 1 : -1); const listedUserIDs = []; - let i = 0; + let fieldCount = 0; for (const role of roles.values()) { let userString = ''; for (const member of guildMembers.filter(m => m.roles.cache.has(role.id)).values()) { @@ -61,16 +66,40 @@ async function updateEmbedsIfNeeded(client) { } if (userString === '') userString = localize('team-list', 'no-users-with-role', {r: role.toString()}); else if (!channelConfig.includeStatus) userString = userString.substring(0, userString.length - 2); - i++; + fieldCount++; embed.addField(channelConfig['nameOverwrites'][role.id] || role.name, truncate((channelConfig['descriptions'][role.id] ? `${channelConfig['descriptions'][role.id]}\n` : '') + userString, 1024)); } - if (i === 0) embed.addField('⚠️', localize('team-list', 'no-roles-selected')); + if (fieldCount === 0) embed.addField('⚠️', localize('team-list', 'no-roles-selected')); - if (isEqual(lastSavedEmbed[channelConfig['channelID']], embed.toJSON())) continue; - lastSavedEmbed[channelConfig['channelID']] = embed.toJSON(); + const cacheKey = `${channelConfig['channelID']}-${configIndex}`; + if (isEqual(lastSavedEmbed[cacheKey], embed.toJSON())) continue; + lastSavedEmbed[cacheKey] = embed.toJSON(); - if (messages.last()) await messages.last().edit({embeds: [embed]}); - else channel.send({embeds: [embed]}); + const [messageData] = await client.models['team-list']['TeamListMessage'].findOrCreate({ + where: { + channelID: channel.id, + configIndex + }, + defaults: { + channelID: channel.id, + configIndex + } + }); + + let message = messageData.messageID ? await channel.messages.fetch(messageData.messageID).catch(() => { + }) : null; + + try { + if (message) { + await message.edit({embeds: [embed]}); + } else { + message = await channel.send({embeds: [embed]}); + messageData.messageID = message.id; + await messageData.save(); + } + } catch (e) { + client.logger.error(`[team-list] Failed to send/edit message in channel ${channelConfig['channelID']}: ${e.message}`); + } } } \ No newline at end of file diff --git a/modules/team-list/models/TeamListMessage.js b/modules/team-list/models/TeamListMessage.js new file mode 100644 index 00000000..bfc5f506 --- /dev/null +++ b/modules/team-list/models/TeamListMessage.js @@ -0,0 +1,28 @@ +const { + DataTypes, + Model +} = require('sequelize'); + +module.exports = class TeamListMessage extends Model { + static init(sequelize) { + return super.init({ + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + channelID: DataTypes.STRING, + messageID: DataTypes.STRING, + configIndex: DataTypes.INTEGER + }, { + tableName: 'team-list_message', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + 'name': 'TeamListMessage', + 'module': 'team-list' +}; diff --git a/modules/team-list/module.json b/modules/team-list/module.json index 11a6e39f..72aa9446 100644 --- a/modules/team-list/module.json +++ b/modules/team-list/module.json @@ -7,6 +7,7 @@ "link": "https://github.com/SCDerox" }, "events-dir": "/events", + "models-dir": "/models", "config-example-files": [ "config.json" ], @@ -14,12 +15,6 @@ "administration" ], "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/team-list", - "humanReadableName": { - "en": "Staff-List", - "de": "Teammitglieder-Liste" - }, - "description": { - "en": "List all your staff members and explain team roles in always up-to-date embed", - "de": "Liste alle deine Teammitglieder und erkläre sie in einem immer aktuellem Embed" - } -} \ No newline at end of file + "humanReadableName": "Staff-List", + "description": "List all your staff members and explain team roles in always up-to-date embed" +} diff --git a/modules/temp-channels/channel-settings.js b/modules/temp-channels/channel-settings.js index 75d8c86b..63981d8a 100644 --- a/modules/temp-channels/channel-settings.js +++ b/modules/temp-channels/channel-settings.js @@ -30,30 +30,47 @@ module.exports.channelMode = async function (interaction, callerInfo) { publicTemp = false; } if (publicTemp) { - await vchann.lockPermissions(); - await vchann.permissionOverwrites.delete(vchann.guild.roles.everyone); + await vchann.permissionOverwrites.create(interaction.guild.members.me, { + 'CONNECT': true, + 'VIEW_CHANNEL': true, + 'MANAGE_CHANNELS': true + }); await interaction.editReply(embedType(moduleConfig['modeSwitched'], {'%mode%': 'public'}, {ephemeral: true})); - - } else if (!publicTemp) { - - await vchann.lockPermissions(); - const guildRoles = await interaction.guild.roles.fetch(); - for (const [, role] of guildRoles) { - await vchann.permissionOverwrites.create(role, {'CONNECT': false}); - } - await vchann.permissionOverwrites.create(interaction.guild.members.me, {'CONNECT': true}); - await vchann.permissionOverwrites.create(interaction.member, {'CONNECT': true}); + } else { + await vchann.permissionOverwrites.create(vchann.guild.roles.everyone, { + 'CONNECT': false, + 'VIEW_CHANNEL': false + }); + await vchann.permissionOverwrites.create(interaction.guild.members.me, { + 'CONNECT': true, + 'VIEW_CHANNEL': true + }); + await vchann.permissionOverwrites.create(interaction.member, { + 'CONNECT': true, + 'VIEW_CHANNEL': true + }); if (allowedUsers.at(0) !== '') { for (const user of allowedUsers) { - await vchann.permissionOverwrites.create(interaction.guild.members.cache.get(user), {'CONNECT': true}); + const member = interaction.guild.members.cache.get(user); + if (member) await vchann.permissionOverwrites.create(member, { + 'CONNECT': true, + 'VIEW_CHANNEL': true + }); } } - interaction.editReply(embedType(moduleConfig['modeSwitched'], {'%mode%': 'private'}, {ephemeral: true})); + for (const roleId of (moduleConfig['privateBypassRoles'] || [])) { + await vchann.permissionOverwrites.create(roleId, { + 'CONNECT': true, + 'VIEW_CHANNEL': true + }).catch(() => { + }); + } + await interaction.editReply(embedType(moduleConfig['modeSwitched'], {'%mode%': 'private'}, {ephemeral: true})); } vc.isPublic = publicTemp; - await vc.save; + await vc.save(); }; /** @@ -75,8 +92,10 @@ module.exports.userAdd = async function (interaction, callerInfo) { let addedUser = null; if (callerInfo === 'command') { addedUser = interaction.options.getUser('user'); - } - if (callerInfo === 'modal') { + } else if (callerInfo === 'select') { + addedUser = await client.users.fetch(interaction.values[0]).catch(() => null); + if (!addedUser) return interaction.editReply(localize('temp-channels', 'user-not-found')); + } else if (callerInfo === 'modal') { const addedUserString = interaction.fields.getTextInputValue('add-modal-input'); try { addedUser = interaction.guild.members.cache.find(member => formatDiscordUserName(member.user).replaceAll('@', '') === addedUserString).user; @@ -90,11 +109,13 @@ module.exports.userAdd = async function (interaction, callerInfo) { } } - if (allowedUsers === '') { - allowedUsers = addedUser.id; - } else { - allowedUsers = allowedUsers + ',' + addedUser.id; + const existingUsers = (allowedUsers || '').split(',').filter(u => u.trim() !== ''); + if (existingUsers.includes(addedUser.id)) { + await interaction.editReply(embedType(moduleConfig['userAdded'], {'%user%': formatDiscordUserName(addedUser)}, {ephemeral: true})); + return; } + existingUsers.push(addedUser.id); + allowedUsers = existingUsers.join(','); vc.allowedUsers = allowedUsers; await vc.save(); const vchann = interaction.guild.channels.cache.get(vc.id); @@ -120,12 +141,14 @@ module.exports.userRemove = async function (interaction, callerInfo) { ] } }); - let allowedUsers = vc.allowedUsers.split(','); + let allowedUsers = (vc.allowedUsers || '').split(',').filter(u => u.trim() !== ''); let removedUser = null; if (callerInfo === 'command') { removedUser = interaction.options.getUser('user'); - } - if (callerInfo === 'modal') { + } else if (callerInfo === 'select') { + removedUser = await client.users.fetch(interaction.values[0]).catch(() => null); + if (!removedUser) return interaction.editReply(localize('temp-channels', 'user-not-found')); + } else if (callerInfo === 'modal') { const removedUserString = interaction.fields.getTextInputValue('remove-modal-input'); try { removedUser = interaction.guild.members.cache.find(member => formatDiscordUserName(member.user).replaceAll('@', '') === removedUserString).user; @@ -145,7 +168,14 @@ module.exports.userRemove = async function (interaction, callerInfo) { await vc.save(); const vchann = interaction.guild.channels.cache.get(vc.id); try { - await vchann.permissionOverwrites.delete(removedUser); + if (vc.isPublic) { + await vchann.permissionOverwrites.delete(removedUser); + } else { + await vchann.permissionOverwrites.create(removedUser, { + 'CONNECT': false, + 'VIEW_CHANNEL': false + }); + } } catch (e) { console.log(e); } @@ -171,16 +201,33 @@ module.exports.usersList = async function (interaction) { ] } }); - const allowedUsersArray = vc.allowedUsers.split(','); + if (!vc) { + interaction.editReply(embedType(moduleConfig['notInChannel'], {}, {ephemeral: true})); + return; + } + if (!vc.allowedUsers || vc.allowedUsers.trim() === '') { + interaction.editReply(embedType(localize('temp-channels', 'no-added-user'), {}, {ephemeral: true})); + return; + } + const allowedUsersArray = vc.allowedUsers.split(',').filter(u => u.trim() !== ''); let allowedUsers = ''; for (const user of allowedUsersArray) { allowedUsers = allowedUsers + '\n • <@' + user + '>'; } - if (allowedUsersArray.at(0) === '') { - interaction.editReply(localize('temp-channels', 'no-added-user')); + if (allowedUsersArray.length === 0) { + interaction.editReply(embedType(localize('temp-channels', 'no-added-user'), {}, {ephemeral: true})); return; } - interaction.editReply(moduleConfig['listUsers'] + ' ' + allowedUsers); + const listMsg = moduleConfig['listUsers']; + const hasParam = typeof listMsg === 'string' ? listMsg.includes('%users%') : JSON.stringify(listMsg).includes('%users%'); + if (hasParam) { + interaction.editReply(embedType(listMsg, {'%users%': allowedUsers}, {ephemeral: true})); + } else { + const result = embedType(listMsg, {}, {ephemeral: true}); + if (result.content) result.content += ' ' + allowedUsers; + else if (result.embeds && result.embeds[0]) result.embeds[0].description = (result.embeds[0].description || '') + '\n' + allowedUsers; + interaction.editReply(result); + } }; module.exports.channelEdit = async function (interaction, callerInfo) { @@ -202,7 +249,7 @@ module.exports.channelEdit = async function (interaction, callerInfo) { if (callerInfo === 'command') { if (interaction.options.getInteger('user-limit') >= 0) { if (interaction.options.getInteger('user-limit') < 0 || interaction.options.getInteger('user-limit') > 99) { - interaction.editReply(localize('temp-channels', 'edit-error')); + interaction.editReply(embedType(moduleConfig['edit-error'], {}, {ephemeral: true})); return; } vcLimit = interaction.options.getInteger('user-limit'); @@ -210,7 +257,7 @@ module.exports.channelEdit = async function (interaction, callerInfo) { } else vcLimit = vchann.userLimit; if (interaction.options.getInteger('bitrate')) { if (interaction.options.getInteger('bitrate') <= 8000 || interaction.options.getInteger('bitrate') >= interaction.guild.maximumBitrate) { - interaction.editReply(localize('temp-channels', 'edit-error')); + interaction.editReply(embedType(moduleConfig['edit-error'], {}, {ephemeral: true})); return; } vcBitrate = interaction.options.getInteger('bitrate'); @@ -227,30 +274,23 @@ module.exports.channelEdit = async function (interaction, callerInfo) { } if (callerInfo === 'modal') { if (isNaN(interaction.fields.getTextInputValue('edit-modal-limit-input'))) { - interaction.editReply(localize('temp-channels', 'edit-error')); + interaction.editReply(embedType(moduleConfig['edit-error'], {}, {ephemeral: true})); return; } if (interaction.fields.getTextInputValue('edit-modal-limit-input') < 0 || interaction.fields.getTextInputValue('edit-modal-limit-input') > 99) { - interaction.editReply(localize('temp-channels', 'edit-error')); - return; - } - if (isNaN(interaction.fields.getTextInputValue('edit-modal-bitrate-input'))) { - interaction.editReply(localize('temp-channels', 'edit-error')); - return; - } - if (interaction.fields.getTextInputValue('edit-modal-bitrate-input') <= 8000 || interaction.fields.getTextInputValue('edit-modal-bitrate-input') >= interaction.guild.maximumBitrate) { - interaction.editReply(localize('temp-channels', 'edit-error')); + interaction.editReply(embedType(moduleConfig['edit-error'], {}, {ephemeral: true})); return; } vcLimit = interaction.fields.getTextInputValue('edit-modal-limit-input'); - vcBitrate = interaction.fields.getTextInputValue('edit-modal-bitrate-input'); + const bitrateValues = interaction.fields.getStringSelectValues('edit-modal-bitrate-input'); + vcBitrate = parseInt(bitrateValues[0]); vcName = interaction.fields.getTextInputValue('edit-modal-name-input'); - const nsfwInput = interaction.fields.getTextInputValue('edit-modal-nsfw-input'); - vcNsfw = (nsfwInput === 'true'); + const nsfwValues = interaction.fields.getStringSelectValues('edit-modal-nsfw-input'); + vcNsfw = (nsfwValues[0] === 'true'); edited++; } @@ -259,7 +299,7 @@ module.exports.channelEdit = async function (interaction, callerInfo) { try { vchann.edit({userLimit: vcLimit, nsfw: vcNsfw, name: vcName, bitrate: vcBitrate}); } catch (e) { - interaction.editReply(localize('temp-channels', 'edit-error')); + interaction.editReply(embedType(moduleConfig['edit-error'], {}, {ephemeral: true})); } } else { interaction.editReply(localize('temp-channels', 'nothing-changed')); diff --git a/modules/temp-channels/commands/temp-channel.js b/modules/temp-channels/commands/temp-channel.js index 44ed867a..59b4bf8a 100644 --- a/modules/temp-channels/commands/temp-channel.js +++ b/modules/temp-channels/commands/temp-channel.js @@ -1,4 +1,5 @@ const {localize} = require('../../../src/functions/localize'); +const {embedType} = require('../../../src/functions/helpers'); const {client} = require('../../../main'); const {Op} = require('sequelize'); const {channelMode, userAdd, userRemove, usersList, channelEdit} = require('../channel-settings'); @@ -15,7 +16,7 @@ module.exports.beforeSubcommand = async function (interaction) { }); if (!vc) { - interaction.editReply(interaction.client.configurations['temp-channels']['config']['notInChannel']); + interaction.editReply(embedType(interaction.client.configurations['temp-channels']['config']['notInChannel'], {}, {ephemeral: true})); interaction.cancel = true; } else interaction.cancel = false; }; diff --git a/modules/temp-channels/config.json b/modules/temp-channels/config.json index 844a52f6..cef7546f 100644 --- a/modules/temp-channels/config.json +++ b/modules/temp-channels/config.json @@ -1,400 +1,344 @@ { - "description": {}, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Configure temporary voice channel creation settings here", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "channelID", - "humanName": { - "en": "Channel", - "de": "Kanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "Set the channel here where users have to join to create their temp-channel", - "de": "Gebe hier die ID des Channels ein, in welchem Nutzer joinen müssen, um einen neuen Channel zu erstellen" - }, + "humanName": "Channel", + "default": "", + "description": "Set the channel here where users have to join to create their temp-channel", "type": "channelID", "content": [ "GUILD_VOICE" - ] - }, - { - "name": "allowUserToChangeName", - "humanName": { - "en": "Allow editing the channel", - "de": "Kanaländerungen erlauben" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "If enabled the user has the permission to change the name and settings of the voice channel via both, the Discord-integrated menus and the corresponding /-commands", - "de": "Wenn aktiviert erhält der Ersteller des Channel die Permission \"MANAGE_CHANNEL\" auf diesem Channel, sowie Zugriff auf die entsprechenden Befehle" - }, - "type": "boolean" - }, - { - "name": "timeout", - "humanName": { - "en": "Deletion timeout", - "de": "Löschverzögerung" - }, - "default": { - "en": 3, - "de": 3 - }, - "description": { - "en": "Set a timeout here in which the bot should wait before deleting the voice channel (in seconds)", - "de": "Die Anzahl von Sekunden nach einem Channel-Leave, die der Bot warten soll, bevor er einen Channel löscht" - }, - "type": "integer", - "allowNull": true + ], + "category": "general" }, { "name": "category", - "humanName": { - "en": "Category", - "de": "Kategorie" - }, - "default": { - "en": "" - }, - "description": { - "en": "You can set a category here in which the new channel should be created", - "de": "Gebe hier die ID der Kategorie an, in welcher neue Temp-Channel erstellt werden sollen" - }, + "humanName": "Category", + "default": "", + "description": "You can set a category here in which the new channel should be created", "type": "channelID", "content": [ "GUILD_CATEGORY" - ] + ], + "category": "general" }, { "name": "channelname_format", - "humanName": { - "en": "Channel name", - "de": "Kanalname" - }, - "default": { - "en": "⏳ %username%", - "de": "⏳ %username%" - }, - "description": { - "en": "Change the format of the channel name here", - "de": "Du kannst das Format des Kanalnamens hier bearbeiten" - }, + "humanName": "Channel name", + "default": "⏳ %username%", + "description": "Change the format of the channel name here", "type": "string", "params": [ { "name": "username", - "description": { - "en": "Username of the user", - "de": "Nutzername des Nutzers" - } + "description": "Username of the user" }, { "name": "nickname", - "description": { - "en": "Nickname of the member", - "de": "Nickname des Mitglieds" - } + "description": "Nickname of the member" }, { "name": "number", - "description": { - "en": "The current number of the channel", - "de": "Aktuelle Nummer des Kanals" - } + "description": "The current number of the channel" }, { "name": "tag", - "description": { - "en": "Tag of the user", - "de": "Tag des Nutzers" - } + "description": "Tag of the user" } - ] + ], + "category": "general" + }, + { + "name": "timeout", + "humanName": "Deletion timeout", + "default": 3, + "description": "Set a timeout here in which the bot should wait before deleting the voice channel (in seconds)", + "type": "integer", + "allowNull": true, + "category": "general" + }, + { + "name": "publicChannels", + "humanName": "Default to public channels", + "default": true, + "description": "If enabled, new temp channels start public (synced with category). If disabled, channels start private (only the creator can join).", + "type": "boolean", + "category": "permissions" + }, + { + "name": "allowUserToChangeMode", + "humanName": "Allow change of channel mode", + "default": true, + "description": "If enabled the user has the permission to change the access-mode of the voice channel", + "type": "boolean", + "category": "permissions" + }, + { + "name": "privateBypassRoles", + "humanName": "Private Mode Bypass Roles", + "default": [], + "description": "Roles that can always join and see private temporary channels, regardless of who created them.", + "type": "array", + "content": "roleID", + "category": "permissions" + }, + { + "name": "allowUserToChangeName", + "humanName": "Allow editing the channel", + "default": true, + "description": "If enabled the user has the permission to change the name and settings of the voice channel via both, the Discord-integrated menus and the corresponding /-commands", + "type": "boolean", + "category": "permissions" }, { "name": "create_no_mic_channel", - "humanName": { - "en": "Create no-mic-channel", - "de": "No-Mic-Kanal erstellen" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "If enabled the bot will create a new channel for each voice channel which can be only seen by users in the voice channel", - "de": "Wenn aktiviert wird ein No-Mic-Textchannel für jeden Temp-Channel erstellt, auf welchen nur Nutzer Zugriff haben, die im VC sind" - }, - "type": "boolean" + "humanName": "Create no-mic-channel", + "default": false, + "description": "If enabled the bot will create a separate text channel for each voice channel, visible only to users in the voice channel. Note: Discord now has built-in text-in-voice channels, so this is usually not needed.", + "type": "boolean", + "category": "features" }, { "name": "noMicChannelMessage", - "humanName": { - "en": "no-mic-channel-message", - "de": "No-Mic-Kanal-Nachricht" - }, - "default": { - "en": "Welcome to your no-mic-channel - you can only see this channel if you are in the connected voicechat", - "de": "Willkommen im deinem No-Mic-Kanal! Dieser wurde zu deinem Temp-Kanal erstellt, damit du mit Leuten chatten kannst, die kein Mikrofon haben. Beachte, dass dieser Channel nur von Nutzern gesehen werden kann, die im Sprachkanal mit dir sind. Beachte außerdem, dass dieser Channel gelöscht wird, wenn dein VC nicht mehr in Benutzung ist." - }, - "description": { - "en": "You can set a message here that should be send in the no-mic-channel when created", - "de": "Hier kannst du eine Nachricht festlegen, welche in einem No-Mic-Channel gesendet werden soll." - }, + "humanName": "No-Mic Channel Message", + "default": "Welcome to your no-mic-channel - you can only see this channel if you are in the connected voicechat", + "description": "You can set a message here that should be send in the no-mic-channel when created", "type": "string", - "allowEmbed": true + "allowEmbed": true, + "dependsOn": "create_no_mic_channel", + "category": "features" + }, + { + "name": "useNoMic", + "humanName": "No-Mic Channel for Settings", + "default": true, + "description": "If enabled the settings menu will be sent into the no-mic-channel. If no-mic-channels aren't enabled, the menu will instead be sent to Discord's integrated text-in-voice channels", + "type": "boolean", + "category": "features" + }, + { + "name": "settingsChannel", + "humanName": "Settings channel", + "default": "", + "description": "You can set a channel here in which the settings menu should be created. Leave this field empty, if you don't want to use this feature.", + "type": "channelID", + "content": [ + "GUILD_TEXT" + ], + "allowNull": true, + "category": "features" }, { "name": "send_dm", - "humanName": { - "en": "Send DM", - "de": "PN senden" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "Should the bot send a direct message to a user when a new channel is created for them?", - "de": "Sollte beim Erstellen eines Temp-Channels eine PN an den Nutzer geschrieben werden?" - }, - "type": "boolean" + "humanName": "Send DM", + "default": true, + "description": "Should the bot send a direct message to a user when a new channel is created for them?", + "type": "boolean", + "category": "messages" }, { "name": "dm", - "humanName": { - "en": "DM", - "de": "Privatnachricht" - }, - "default": { - "en": "I have created and moved you to your new voice-channel - have fun ^^", - "de": "Tach - ich habe dir nen eigenen Channel erstellt und dich verschoben - Dieser wird nach Inaktivität gelöscht - Have fun^^" - }, - "description": { - "en": "Set the message that should get send to the user if they join the voice channel", - "de": "Hier kannst du die Nachricht festlegen, die an den Nutzer geschrieben soll (wenn aktiviert)" - }, + "humanName": "DM Message Content", + "default": "I have created and moved you to your new voice-channel - have fun ^^", + "description": "The direct message content sent to the user when their temporary channel is created.", "type": "string", "allowEmbed": true, + "dependsOn": "send_dm", "params": [ { "name": "channelname", - "description": { - "en": "Name of the channel", - "de": "Name des Kanals" - } + "description": "Name of the channel" } - ] - }, - { - "name": "publicChannels", - "humanName": { - "en": "Public channels", - "de": "Öffentliche Channel" - }, - "default": { - "en": true - }, - "description": { - "en": "Should the permissions for channels created by the bot be synced with their category?", - "de": "Sollen die Berechtigungen für vom Bot erstellte Kanäle mit deren Kategorie synchronisiert werden?" - }, - "type": "boolean" - }, - { - "name": "allowUserToChangeMode", - "humanName": { - "en": "Allow change of channel mode", - "de": "Kanaländerungen erlauben" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "If enabled the user has the permission to change the access-mode of the voice chanel", - "de": "Wenn aktiviert erhält der Ersteller des Channel die Möglichkeit die Zugriffsberechtigungen für den Kanal festzulegen" - }, - "type": "boolean" + ], + "category": "messages" }, { "name": "notInChannel", - "humanName": {}, - "default": { - "de": "Du musst in deinem Temp-Channel sein um das zu tun", - "en": "You have to be in your temp-channel to do this" - }, - "description": { - "en": "This message gets sent to a user, who tries to edit their channel, while not being in it", - "de": "Diese Nachricht wird an Nutzer gesendet, die versuchen ihren Kanal zu bearbeiten, während sie sich nicht darin befinden" - }, - "type": "string" + "humanName": "Not in Channel Message", + "default": "You have to be in your temp-channel to do this", + "description": "This message gets sent to a user who tries to edit their channel while not being in it.", + "type": "string", + "allowEmbed": true, + "category": "messages" }, { "name": "modeSwitched", - "humanName": {}, - "default": { - "en": "The access-mode of your channel has been switched to %mode%", - "de": "Der Zugriffsmodus deines Kanals wurde auf %mode% geändert" - }, - "description": { - "en": "This message gets sent to a user, after they changed the mode of their channel", - "de": "Diese Nachricht wird an Nutzer gesendet, nachdem sie ihren Kanal bearbeitet haben" - }, + "humanName": "Mode Switched Message", + "default": "The access-mode of your channel has been switched to %mode%", + "description": "This message gets sent to a user, after they changed the mode of their channel", "type": "string", + "allowEmbed": true, "params": [ { "name": "mode", - "description": { - "en": "Mode of the channel", - "de": "Modus des Kanals" - } + "description": "Mode of the channel" } - ] + ], + "category": "messages" }, { "name": "userAdded", - "humanName": {}, - "default": { - "en": "The user %user% has beed added to your channel. They can now access it whenever they like to", - "de": "Der Nutzer %user% wurde zu deinem Kanal hinzugefügt. Er/Sie hat nun uneingeschränkten Zugang dazu" - }, - "description": { - "en": "This message gets sent to a user, after they added an user to their channel", - "de": "Diese Nachricht wird an Nutzer gesendet, nachdem sie einen Nutzer zu ihrem Kanal hinzugefügt haben" - }, + "humanName": "User Added Message", + "default": "the user %user% has been added to your channel. They can now access it whenever they like to", + "description": "This message gets sent to a user, after they added an user to their channel", "type": "string", + "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "The user, that was added", - "de": "Der hinzugefügte Nutzer" - } + "description": "The user, that was added" } - ] + ], + "category": "messages" }, { "name": "userRemoved", - "humanName": {}, - "default": { - "en": "The user %user% has beed removed from your channel. They can no longer access it, while your channel is private", - "de": "Der Nutzer %user% wurde von deinem Kanal entfernt. Er/Sie hat nun keinen Zugriff mehr, während dein Kanal privat ist" - }, - "description": { - "en": "This message gets sent to a user, after they removed an user from their channel", - "de": "Diese Nachricht wird an Nutzer gesendet, nachdem sie einen Nutzer von ihrem Kanal entfernt haben" - }, + "humanName": "User Removed Message", + "default": "the user %user% has been removed from your channel. They can no longer access it, while your channel is private", + "description": "This message gets sent to a user, after they removed an user from their channel", "type": "string", + "allowEmbed": true, "params": [ { "name": "user", - "description": { - "en": "The user, that was removed", - "de": "Der Nutzer, der entfernt wurde" - } + "description": "The user, that was removed" } - ] + ], + "category": "messages" }, { "name": "listUsers", - "humanName": {}, - "default": { - "en": "Here is a list of all the users that have access to your channel:", - "de": "Hier ist eine Liste aller Nutzer mit Zugang zu deinem Kanal:" - }, - "description": { - "de": "Die Nachricht die gesendet wird, wenn ein Nutzer eine Liste der Nutzer mit Zugang zu seinem Temp-Channel anfragt. Dieser Nachricht folgt automatisch eine Liste der Nutzer.", - "en": "The message to be sent, if a user requests a list of the users with access to their channel. This is automatically followed by a list of the users' tags." - }, - "type": "string" + "humanName": "List Users Message", + "default": "Here is a list of all the users that have access to your channel: %users%", + "description": "The message to be sent when a user requests a list of users with access to their channel.", + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "users", + "description": "List of users with access" + } + ], + "category": "messages" }, { "name": "channelEdited", - "humanName": {}, - "default": { - "en": "Your channel was edited", - "de": "Dein Kanal wurde bearbeitet" - }, - "description": { - "en": "The message to be sent, if a user edited their channel", - "de": "Die Nachricht, die gesendet wird, wenn ein Nutzer seinen Kanal bearbeitet" - }, - "type": "string" + "humanName": "Channel Edited Message", + "default": "Your channel was edited", + "description": "The message to be sent when a user edits their channel.", + "type": "string", + "allowEmbed": true, + "category": "messages" }, { "name": "edit-error", - "humanName": {}, - "default": { - "en": "An error occurred while editing your channel. One or more of your settings could not be applied. This could be due to missing permissions or an invalid value", - "de": "Beim Bearbeiten des Kanals ist ein Fehler aufgetreten. Eine oder mehr deiner Einstellungen konnten nicht angewendet werden. Dies kann an fehlenden Rechten oder einem ungültigen Eingabewert liegen" - }, - "description": { - "en": "The message to be sent, if a user edited their channel, but it failed", - "de": "Die Nachricht, die gesendet wird, wenn das Bearbeiten eines Kanals fehlschlägt" - }, - "type": "string" + "humanName": "Edit Error Message", + "default": "An error occurred while editing your channel. One or more of your settings could not be applied. This could be due to missing permissions or an invalid value", + "description": "The message sent when a channel edit fails.", + "type": "string", + "allowEmbed": true, + "category": "messages" }, { - "name": "settingsChannel", - "humanName": { - "de": "Einstellungskanal", - "en": "Settings channel" - }, - "default": { - "en": "" - }, - "description": { - "en": "You can set a channel here in which the settings menu should be created. Leave this field empty, if you don't want to use this feature.", - "de": "Gebe hier die ID des Kanals an, in welcher das Einstellungsmenü erstellt werden soll. Lass dieses Feld leer, wenn du diese Funktion nicht verwenden willst." - }, + "name": "settingsMessage", + "humanName": "Settings Panel Message", + "default": "Change the Settings of your temporary channel here", + "description": "Set the message that should get send in the channel specified above to let the users change the settings of their temp-channels", + "type": "string", + "allowEmbed": true, + "params": [], + "category": "messages" + }, + { + "name": "enableMaxActiveChannels", + "humanName": "Enable channel limit", + "default": false, + "description": "If enabled, the bot will limit the number of temporary channels that can exist at the same time.", + "type": "boolean", + "category": "limits" + }, + { + "name": "maxActiveChannels", + "humanName": "Maximum active channels", + "default": 10, + "description": "Maximum number of temp channels that can exist at the same time.", + "type": "integer", + "dependsOn": "enableMaxActiveChannels", + "category": "limits" + }, + { + "name": "maxActiveChannelsMessage", + "humanName": "Channel Limit Reached Message", + "default": "⚠️ The maximum number of temporary channels has been reached. Please try again later.", + "description": "This message is sent via DM when a user tries to create a temp channel but the limit has been reached.", + "type": "string", + "allowEmbed": true, + "dependsOn": "enableMaxActiveChannels", + "category": "limits" + }, + { + "name": "enableArchiving", + "humanName": "Enable channel archiving", + "default": false, + "description": "If enabled, empty temp channels will be moved to an archive category instead of being deleted. Channels are restored when the creator rejoins the trigger channel.", + "type": "boolean", + "category": "archiving" + }, + { + "name": "archiveCategory", + "humanName": "Archive category", + "dependsOn": "enableArchiving", + "default": "", + "description": "Category where archived temp channels are moved to. Make this category hidden from regular users.", "type": "channelID", "content": [ - "GUILD_TEXT" + "GUILD_CATEGORY" ], - "allowNull": true + "category": "archiving" }, { - "name": "useNoMic", - "humanName": { - "de": "No-Mic-Channel für Einstellungen verwenden" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "If enabled the settings menu will be sent into the no-mic-channel. If no-mic-channels aren't enabled, the menu will instead be sent to Discord's integrated text-in-voice channels", - "de": "Wenn aktiviert wird das Einstellungsmenü in den No-Mic-Channel gesendet. Wenn No-Mic-Channels nicht aktiviert sind, wird es stattdessen in die in Sprachkanälen integrierten Textkanäle gesendet." - }, - "type": "boolean" + "name": "archiveDeleteAfterHours", + "humanName": "Delete archived channels after (hours)", + "dependsOn": "enableArchiving", + "default": 168, + "description": "Hours after which archived channels are permanently deleted. Set to 0 to never auto-delete. Default: 168 (7 days).", + "type": "integer", + "category": "archiving" + } + ], + "categories": [ + { + "id": "general", + "icon": "fas fa-gears", + "displayName": "General" }, { - "name": "settingsMessage", - "humanName": { - "de": "Einstellungsnachricht" - }, - "default": { - "en": "Change the Settings of your temporary channel here", - "de": "Ändere die Einstellungen deines Temp-Channels hier" - }, - "description": { - "en": "Set the message that should get send in the channel specified above to let the users change the settings of their temp-channels", - "de": "Hier kannst du die Nachricht festlegen, die in den weiter oben festgelegten Kanal gesendet werden soll, damit Nutzer ihre Temp-Channels bearbeiten können" - }, - "type": "string", - "allowEmbed": true, - "params": [] + "id": "permissions", + "icon": "fas fa-lock", + "displayName": "Permissions & Mode" + }, + { + "id": "features", + "icon": "fas fa-star", + "displayName": "Features" + }, + { + "id": "messages", + "icon": "fas fa-comment-dots", + "displayName": "Messages" + }, + { + "id": "limits", + "icon": "fa-solid fa-shield", + "displayName": "Limits" + }, + { + "id": "archiving", + "icon": "fa-regular fa-clock-rotate-left", + "displayName": "Archiving" } ] } \ No newline at end of file diff --git a/modules/temp-channels/events/botReady.js b/modules/temp-channels/events/botReady.js index 50c303b5..0bb8e50f 100644 --- a/modules/temp-channels/events/botReady.js +++ b/modules/temp-channels/events/botReady.js @@ -1,11 +1,54 @@ const {migrate} = require('../../../src/functions/helpers'); const {client} = require('../../../main'); +const { + migrationStart, + migrationEnd +} = require('../../../main'); const {sendMessage} = require('../channel-settings'); const {localize} = require('../../../src/functions/localize'); +const {scheduleJob} = require('node-schedule'); +const {Op} = require('sequelize'); + module.exports.run = async function () { - const settingsChannel = client.channels.cache.get(client.configurations['temp-channels']['config']['settingsChannel']); + const moduleConfig = client.configurations['temp-channels']['config']; + const settingsChannel = client.channels.cache.get(moduleConfig['settingsChannel']); await migrate('temp-channels', 'TempChannelV1', 'TempChannel'); + // Migration V2: add archivedAt column + const dbVersionV2 = await client.models['DatabaseSchemeVersion'].findOne({ + where: { + model: 'temp-channels_TempChannel', + version: 'V2' + } + }); + if (!dbVersionV2) { + migrationStart(); + try { + client.logger.info('[temp-channels] Running V2 migration (adding archivedAt field)...'); + const data = await client.models['temp-channels']['TempChannel'].findAll({ + attributes: ['id', 'creatorID', 'noMicChannel', 'allowedUsers', 'isPublic'] + }).catch(() => []); + await client.models['temp-channels']['TempChannel'].sync({force: true}); + for (const tc of data) { + await client.models['temp-channels']['TempChannel'].create({ + id: tc.id, + creatorID: tc.creatorID, + noMicChannel: tc.noMicChannel, + allowedUsers: tc.allowedUsers, + isPublic: tc.isPublic, + archivedAt: null + }); + } + client.logger.info('[temp-channels] V2 migration complete.'); + await client.models['DatabaseSchemeVersion'].upsert({ + model: 'temp-channels_TempChannel', + version: 'V2' + }); + } finally { + migrationEnd(); + } + } + // Cleanup orphaned temp channels on startup const tempChannels = await client.models['temp-channels']['TempChannel'].findAll(); let cleanedCount = 0; @@ -19,6 +62,9 @@ module.exports.run = async function () { continue; } + // Skip archived channels — they're supposed to be empty + if (tempChannel.archivedAt) continue; + if (dcChannel.members.size === 0) { await dcChannel.delete(`[temp-channels] ${localize('temp-channels', 'removed-audit-log-reason')}`).catch(() => {}); await tempChannel.destroy(); @@ -33,6 +79,38 @@ module.exports.run = async function () { client.logger.info(`[temp-channels] Cleaned up ${cleanedCount} empty or orphaned temp channel(s) on startup`); } + // Schedule archive cleanup job (every hour) + if (moduleConfig.enableArchiving && moduleConfig.archiveDeleteAfterHours > 0) { + const archiveCleanupJob = scheduleJob('0 * * * *', async () => { + const cutoff = new Date(Date.now() - moduleConfig.archiveDeleteAfterHours * 3600000); + const expiredChannels = await client.models['temp-channels']['TempChannel'].findAll({ + where: { + archivedAt: { + [Op.ne]: null, + [Op.lt]: cutoff + } + } + }); + for (const tc of expiredChannels) { + try { + const dcChannel = await client.channels.fetch(tc.id).catch(() => null); + if (dcChannel) await dcChannel.delete('[temp-channels] Archived channel expired').catch(() => { + }); + if (tc.noMicChannel) { + const noMic = await client.channels.fetch(tc.noMicChannel).catch(() => null); + if (noMic) await noMic.delete('[temp-channels] Archived no-mic channel expired').catch(() => { + }); + } + await tc.destroy(); + } catch (e) { + client.logger.warn(`[temp-channels] Failed to delete expired archive ${tc.id}: ${e.message}`); + } + } + if (expiredChannels.length > 0) client.logger.info(`[temp-channels] Deleted ${expiredChannels.length} expired archived channel(s)`); + }); + client.jobs.push(archiveCleanupJob); + } + if (settingsChannel) { await sendMessage(settingsChannel); } diff --git a/modules/temp-channels/events/interactionCreate.js b/modules/temp-channels/events/interactionCreate.js index c4944c56..f1bd7450 100644 --- a/modules/temp-channels/events/interactionCreate.js +++ b/modules/temp-channels/events/interactionCreate.js @@ -1,6 +1,14 @@ -const {ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle} = require('discord.js'); +const { + ActionRowBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + LabelBuilder, + UserSelectMenuBuilder +} = require('discord.js'); const {usersList, channelMode, userAdd, userRemove, channelEdit} = require('../channel-settings'); const {localize} = require('../../../src/functions/localize'); +const {embedType} = require('../../../src/functions/helpers'); const {Op} = require('sequelize'); module.exports.run = async function (client, interaction) { @@ -19,50 +27,41 @@ module.exports.run = async function (client, interaction) { if (interaction.customId === 'tempc-add') { if (!vc) { - interaction.reply({ - ephemeral: true, - content: interaction.client.configurations['temp-channels']['config']['notInChannel'] - }); + interaction.reply(embedType(interaction.client.configurations['temp-channels']['config']['notInChannel'], {}, {ephemeral: true})); return; } - const modal = new ModalBuilder() - .setCustomId('tempc-add-modal') - .setTitle(localize('temp-channels', 'add-modal-title')); - const userInput = new TextInputBuilder() - .setCustomId('add-modal-input') - .setLabel(localize('temp-channels', 'add-modal-prompt')) - .setStyle(TextInputStyle.Short) - .setPlaceholder(localize('temp-channels', 'edit-modal-username-placeholder')); - const actionRow = new ActionRowBuilder().addComponents(userInput); - modal.addComponents(actionRow); - await interaction.showModal(modal); + const selectMenu = new UserSelectMenuBuilder() + .setCustomId('tempc-add-select') + .setPlaceholder(localize('temp-channels', 'add-modal-prompt')) + .setMinValues(1) + .setMaxValues(1); + await interaction.reply({ + ephemeral: true, + content: localize('temp-channels', 'add-modal-prompt'), + components: [new ActionRowBuilder().addComponents(selectMenu)] + }); + return; } if (interaction.customId === 'tempc-remove') { if (!vc) { - interaction.reply({ - ephemeral: true, - content: interaction.client.configurations['temp-channels']['config']['notInChannel'] - }); + interaction.reply(embedType(interaction.client.configurations['temp-channels']['config']['notInChannel'], {}, {ephemeral: true})); return; } - const modal = new ModalBuilder() - .setCustomId('tempc-remove-modal') - .setTitle(localize('temp-channels', 'remove-modal-title')); - const userInput = new TextInputBuilder() - .setCustomId('remove-modal-input') - .setLabel(localize('temp-channels', 'remove-modal-prompt')) - .setStyle(TextInputStyle.Short) - .setPlaceholder(localize('temp-channels', 'edit-modal-username-placeholder')); - const actionRow = new ActionRowBuilder().addComponents(userInput); - modal.addComponents(actionRow); - await interaction.showModal(modal); + const selectMenu = new UserSelectMenuBuilder() + .setCustomId('tempc-remove-select') + .setPlaceholder(localize('temp-channels', 'remove-modal-prompt')) + .setMinValues(1) + .setMaxValues(1); + await interaction.reply({ + ephemeral: true, + content: localize('temp-channels', 'remove-modal-prompt'), + components: [new ActionRowBuilder().addComponents(selectMenu)] + }); + return; } if (interaction.customId === 'tempc-list') { if (!vc) { - interaction.reply({ - ephemeral: true, - content: interaction.client.configurations['temp-channels']['config']['notInChannel'] - }); + interaction.reply(embedType(interaction.client.configurations['temp-channels']['config']['notInChannel'], {}, {ephemeral: true})); return; } await interaction.deferReply({ephemeral: true}); @@ -70,10 +69,7 @@ module.exports.run = async function (client, interaction) { } if (interaction.customId === 'tempc-private') { if (!vc) { - interaction.reply({ - ephemeral: true, - content: interaction.client.configurations['temp-channels']['config']['notInChannel'] - }); + interaction.reply(embedType(interaction.client.configurations['temp-channels']['config']['notInChannel'], {}, {ephemeral: true})); return; } await interaction.deferReply({ephemeral: true}); @@ -81,10 +77,7 @@ module.exports.run = async function (client, interaction) { } if (interaction.customId === 'tempc-public') { if (!vc) { - interaction.reply({ - ephemeral: true, - content: interaction.client.configurations['temp-channels']['config']['notInChannel'] - }); + interaction.reply(embedType(interaction.client.configurations['temp-channels']['config']['notInChannel'], {}, {ephemeral: true})); return; } await interaction.deferReply({ephemeral: true}); @@ -92,32 +85,44 @@ module.exports.run = async function (client, interaction) { } if (interaction.customId === 'tempc-edit') { if (!vc) { - interaction.reply({ - ephemeral: true, - content: interaction.client.configurations['temp-channels']['config']['notInChannel'] - }); + interaction.reply(embedType(interaction.client.configurations['temp-channels']['config']['notInChannel'], {}, {ephemeral: true})); return; } const vchann = interaction.guild.channels.cache.get(vc.id); const modal = new ModalBuilder() .setCustomId('tempc-edit-modal') .setTitle(localize('temp-channels', 'edit-modal-title')); - const nsfwInput = new TextInputBuilder() - .setCustomId('edit-modal-nsfw-input') + const nsfwLabel = new LabelBuilder() .setLabel(localize('temp-channels', 'edit-modal-nsfw-prompt')) - .setRequired(true) - .setStyle(TextInputStyle.Short) - .setPlaceholder(localize('temp-channels', 'edit-modal-nsfw-placeholder')) - .setValue(vchann.nsfw.toString()); + .setStringSelectMenuComponent(c => c + .setCustomId('edit-modal-nsfw-input') + .addOptions( + { + label: localize('temp-channels', 'edit-modal-nsfw-off'), + value: 'false', + default: vchann.nsfw === false + }, + { + label: localize('temp-channels', 'edit-modal-nsfw-on'), + value: 'true', + default: vchann.nsfw === true + } + )); - const bitrateInput = new TextInputBuilder() - .setCustomId('edit-modal-bitrate-input') + const bitrateLabel = new LabelBuilder() .setLabel(localize('temp-channels', 'edit-modal-bitrate-prompt')) - .setRequired(true) - .setStyle(TextInputStyle.Short) - .setPlaceholder(localize('temp-channels', 'edit-modal-bitrate-placeholder')) - .setValue(vchann.bitrate.toString()); + .setStringSelectMenuComponent(c => { + c.setCustomId('edit-modal-bitrate-input'); + for (const b of [8000, 16000, 32000, 64000, 96000, 128000, 256000, 384000].filter(b => b <= interaction.guild.maximumBitrate)) { + c.addOptions({ + label: `${b / 1000} kbps`, + value: b.toString(), + default: vchann.bitrate === b + }); + } + return c; + }); const limitInput = new TextInputBuilder() .setCustomId('edit-modal-limit-input') @@ -135,8 +140,8 @@ module.exports.run = async function (client, interaction) { .setPlaceholder(localize('temp-channels', 'edit-modal-name-placeholder')) .setValue(vchann.name); - const nsfwRow = new ActionRowBuilder().addComponents(nsfwInput); - const bitrateRow = new ActionRowBuilder().addComponents(bitrateInput); + const nsfwRow = nsfwLabel; + const bitrateRow = bitrateLabel; const limitRow = new ActionRowBuilder().addComponents(limitInput); const nameRow = new ActionRowBuilder().addComponents(nameInput); modal.addComponents(bitrateRow); @@ -156,10 +161,7 @@ module.exports.run = async function (client, interaction) { }); if (interaction.customId === 'tempc-add-modal') { if (!vc) { - interaction.reply({ - ephemeral: true, - content: interaction.client.configurations['temp-channels']['config']['notInChannel'] - }); + interaction.reply(embedType(interaction.client.configurations['temp-channels']['config']['notInChannel'], {}, {ephemeral: true})); return; } await interaction.deferReply({ephemeral: true}); @@ -167,10 +169,7 @@ module.exports.run = async function (client, interaction) { } if (interaction.customId === 'tempc-remove-modal') { if (!vc) { - interaction.reply({ - ephemeral: true, - content: interaction.client.configurations['temp-channels']['config']['notInChannel'] - }); + interaction.reply(embedType(interaction.client.configurations['temp-channels']['config']['notInChannel'], {}, {ephemeral: true})); return; } await interaction.deferReply({ephemeral: true}); @@ -178,14 +177,34 @@ module.exports.run = async function (client, interaction) { } if (interaction.customId === 'tempc-edit-modal') { if (!vc) { - interaction.reply({ - ephemeral: true, - content: interaction.client.configurations['temp-channels']['config']['notInChannel'] - }); + interaction.reply(embedType(interaction.client.configurations['temp-channels']['config']['notInChannel'], {}, {ephemeral: true})); return; } await interaction.deferReply({ephemeral: true}); await channelEdit(interaction, 'modal'); } + } else if (interaction.isUserSelectMenu()) { + const vc = await client.models['temp-channels']['TempChannel'].findOne({ + where: { + [Op.and]: [ + {id: interaction.member.voice ? interaction.member.voice.channelId : null}, + {creatorID: interaction.member.id} + ] + } + }); + if (!vc) { + return interaction.reply({ + ephemeral: true, + ...embedType(interaction.client.configurations['temp-channels']['config']['notInChannel'], {}, {ephemeral: true}) + }); + } + if (interaction.customId === 'tempc-add-select') { + await interaction.deferReply({ephemeral: true}); + await userAdd(interaction, 'select'); + } + if (interaction.customId === 'tempc-remove-select') { + await interaction.deferReply({ephemeral: true}); + await userRemove(interaction, 'select'); + } } -}; +}; \ No newline at end of file diff --git a/modules/temp-channels/events/voiceStateUpdate.js b/modules/temp-channels/events/voiceStateUpdate.js index ea4925b5..98ce0574 100644 --- a/modules/temp-channels/events/voiceStateUpdate.js +++ b/modules/temp-channels/events/voiceStateUpdate.js @@ -9,29 +9,67 @@ module.exports.run = async function (client, oldState, newState) { if (!client.botReadyAt) return; const moduleConfig = client.configurations['temp-channels']['config']; + // Handle channel leave — delete or archive if (oldState.channel) { const oldChannel = await client.models['temp-channels']['TempChannel'].findOne({ - where: { - id: oldState.channel.id - } + where: {id: oldState.channel.id} }); - if (oldChannel) { + if (oldChannel && !oldChannel.archivedAt) { setTimeout(async () => { try { const dcOldChannel = await client.channels.fetch(oldChannel.id).catch(() => null); if (dcOldChannel && dcOldChannel.members.size === 0) { - if (oldChannel.noMicChannel) { - const noMicChannel = await client.channels.fetch(oldChannel.noMicChannel).catch(() => null); - if (noMicChannel) { - await noMicChannel.delete(`[temp-channels] ${localize('temp-channels', 'removed-audit-log-reason')}`).catch((e) => { - client.logger.warn(`[temp-channels] Failed to delete no-mic channel ${oldChannel.noMicChannel}: ${e.message}`); + if (moduleConfig.enableArchiving && moduleConfig.archiveCategory) { + // Archive: move to archive category, strip permissions + await dcOldChannel.setParent(moduleConfig.archiveCategory, { + lockPermissions: false, + reason: '[temp-channels] Archiving empty temp channel' + }).catch(() => { + }); + await dcOldChannel.permissionOverwrites.set([ + { + id: dcOldChannel.guild.roles.everyone, + deny: ['CONNECT', 'VIEW_CHANNEL'] + }, + { + id: dcOldChannel.guild.members.me, + allow: ['CONNECT', 'VIEW_CHANNEL', 'MANAGE_CHANNELS'] + } + ], '[temp-channels] Archiving channel'); + if (oldChannel.noMicChannel) { + const noMicChannel = await client.channels.fetch(oldChannel.noMicChannel).catch(() => null); + if (noMicChannel) { + await noMicChannel.setParent(moduleConfig.archiveCategory, { + lockPermissions: false, + reason: '[temp-channels] Archiving no-mic channel' + }).catch(() => { + }); + await noMicChannel.permissionOverwrites.set([ + { + id: noMicChannel.guild.roles.everyone, + deny: ['VIEW_CHANNEL'] + }, + { + id: noMicChannel.guild.members.me, + allow: ['VIEW_CHANNEL'] + } + ], '[temp-channels] Archiving no-mic channel').catch(() => { + }); + } + } + oldChannel.archivedAt = new Date(); + await oldChannel.save(); + } else { + // Delete channel + if (oldChannel.noMicChannel) { + const noMicChannel = await client.channels.fetch(oldChannel.noMicChannel).catch(() => null); + if (noMicChannel) await noMicChannel.delete(`[temp-channels] ${localize('temp-channels', 'removed-audit-log-reason')}`).catch(() => { }); } + await dcOldChannel.delete(`[temp-channels] ${localize('temp-channels', 'removed-audit-log-reason')}`).catch(() => { + }); + await oldChannel.destroy(); } - await dcOldChannel.delete(`[temp-channels] ${localize('temp-channels', 'removed-audit-log-reason')}`).catch((e) => { - client.logger.warn(`[temp-channels] Failed to delete temp channel ${oldChannel.id}: ${e.message}`); - }); - await oldChannel.destroy(); } else if (!dcOldChannel) { await oldChannel.destroy(); } @@ -42,6 +80,7 @@ module.exports.run = async function (client, oldState, newState) { } } + // No-mic channel visibility sync if (moduleConfig['create_no_mic_channel']) { const possibleExistingChannel = await client.models['temp-channels']['TempChannel'].findOne({ where: { @@ -51,7 +90,7 @@ module.exports.run = async function (client, oldState, newState) { ] } }); - if (possibleExistingChannel) { + if (possibleExistingChannel && !possibleExistingChannel.archivedAt) { const existingNoMicChannel = await newState.guild.channels.cache.get(possibleExistingChannel.noMicChannel); if (existingNoMicChannel) await existingNoMicChannel.permissionOverwrites.create(newState.member, { 'VIEW_CHANNEL': newState.channel && newState.channel.id === possibleExistingChannel.id @@ -62,15 +101,103 @@ module.exports.run = async function (client, oldState, newState) { if (!newState.channel) return; if (newState.channel.id === moduleConfig['channelID']) { - const alreadyExistingChannel = await client.models['temp-channels']['TempChannel'].findOne({ - where: { - creatorID: newState.member.user.id - } - }); - if (alreadyExistingChannel) return newState.setChannel(alreadyExistingChannel.id, `[temp-channels] ` + localize('temp-channels', 'move-audit-log-reason')).catch(() => { - newState.setChannel(null, '[temp-channels] ' + localize('temp-channels', 'disconnect-audit-log-reason')); - alreadyExistingChannel.destroy(); + // Check for existing channel (active or archived) + const existingChannel = await client.models['temp-channels']['TempChannel'].findOne({ + where: {creatorID: newState.member.user.id} }); + + if (existingChannel) { + // Restore from archive if needed + if (existingChannel.archivedAt) { + const dcChannel = await client.channels.fetch(existingChannel.id).catch(() => null); + if (dcChannel) { + await dcChannel.setParent(moduleConfig['category'] || null, { + lockPermissions: false, + reason: '[temp-channels] Restoring archived channel' + }).catch(() => { + }); + // Re-apply permissions based on saved mode + if (!existingChannel.isPublic) { + await dcChannel.permissionOverwrites.create(dcChannel.guild.roles.everyone, { + 'CONNECT': false, + 'VIEW_CHANNEL': false + }); + await dcChannel.permissionOverwrites.create(dcChannel.guild.members.me, { + 'CONNECT': true, + 'VIEW_CHANNEL': true, + 'MANAGE_CHANNELS': true + }); + await dcChannel.permissionOverwrites.create(newState.member, { + 'CONNECT': true, + 'VIEW_CHANNEL': true, + 'MANAGE_CHANNELS': moduleConfig['allowUserToChangeName'] + }); + const allowedUsers = (existingChannel.allowedUsers || '').split(',').filter(u => u && u !== newState.member.user.id); + for (const userId of allowedUsers) { + const member = newState.guild.members.cache.get(userId); + if (member) await dcChannel.permissionOverwrites.create(member, { + 'CONNECT': true, + 'VIEW_CHANNEL': true + }).catch(() => { + }); + } + for (const roleId of (moduleConfig['privateBypassRoles'] || [])) { + await dcChannel.permissionOverwrites.create(roleId, { + 'CONNECT': true, + 'VIEW_CHANNEL': true + }).catch(() => { + }); + } + } else { + await dcChannel.lockPermissions().catch(() => { + }); + await dcChannel.permissionOverwrites.create(dcChannel.guild.members.me, { + 'CONNECT': true, + 'VIEW_CHANNEL': true, + 'MANAGE_CHANNELS': true + }); + if (moduleConfig['allowUserToChangeName']) await dcChannel.permissionOverwrites.create(newState.member, {'MANAGE_CHANNELS': true}); + } + if (existingChannel.noMicChannel) { + const noMicChannel = await client.channels.fetch(existingChannel.noMicChannel).catch(() => null); + if (noMicChannel) { + await noMicChannel.setParent(moduleConfig['category'] || null, { + lockPermissions: false, + reason: '[temp-channels] Restoring archived no-mic channel' + }).catch(() => { + }); + } + } + existingChannel.archivedAt = null; + await existingChannel.save(); + return newState.setChannel(dcChannel.id, '[temp-channels] ' + localize('temp-channels', 'move-audit-log-reason')); + } else { + await existingChannel.destroy(); + } + } else { + // Active channel exists, move user there + return newState.setChannel(existingChannel.id, '[temp-channels] ' + localize('temp-channels', 'move-audit-log-reason')).catch(() => { + newState.setChannel(null, '[temp-channels] ' + localize('temp-channels', 'disconnect-audit-log-reason')); + existingChannel.destroy(); + }); + } + } + + // Channel limit check + if (moduleConfig.enableMaxActiveChannels && moduleConfig.maxActiveChannels > 0) { + const activeCount = await client.models['temp-channels']['TempChannel'].count({where: {archivedAt: null}}); + if (activeCount >= moduleConfig.maxActiveChannels) { + await newState.setChannel(null, '[temp-channels] Channel limit reached').catch(() => { + }); + if (moduleConfig.maxActiveChannelsMessage) { + await newState.member.user.send(embedType(moduleConfig.maxActiveChannelsMessage, {})).catch(() => { + }); + } + return; + } + } + + // Create new channel const n = await client.models['temp-channels']['TempChannel'].count({}) + 1; const newChannel = await newState.guild.channels.create({ name: moduleConfig['channelname_format'] @@ -98,23 +225,49 @@ module.exports.run = async function (client, oldState, newState) { parent: moduleConfig['category'], topic: localize('temp-channels', 'no-mic-channel-topic', {u: formatDiscordUserName(newState.member.user)}), reason: '[temp-channels] ' + localize('temp-channels', 'created-audit-log-reason', {u: formatDiscordUserName(newState.member.user)}), - permissionOverwrites: [ - { - id: everyoneRole, - deny: ['VIEW_CHANNEL'] - } - ] + permissionOverwrites: [{ + id: everyoneRole, + deny: ['VIEW_CHANNEL'] + }] }); - await noMicChannel.permissionOverwrites.create(newState.member, { - 'VIEW_CHANNEL': true - }, { + await noMicChannel.permissionOverwrites.create(newState.member, {'VIEW_CHANNEL': true}, { reason: '[temp-channels] ' + localize('temp-channels', 'created-audit-log-reason', {u: formatDiscordUserName(newState.member.user)}) }); await noMicChannel.send(embedType(moduleConfig['noMicChannelMessage'])).then(m => m.pin()); - if (moduleConfig['useNoMic']) { - await sendMessage(noMicChannel); + if (moduleConfig['useNoMic']) await sendMessage(noMicChannel); + } + + // Apply private permissions if default is private + if (!moduleConfig['publicChannels']) { + await newChannel.permissionOverwrites.create(newState.guild.roles.everyone, { + 'CONNECT': false, + 'VIEW_CHANNEL': false + }, { + reason: '[temp-channels] ' + localize('temp-channels', 'permission-update-audit-log-reason') + }); + await newChannel.permissionOverwrites.create(newState.guild.members.me, { + 'CONNECT': true, + 'VIEW_CHANNEL': true, + 'MANAGE_CHANNELS': true + }, { + reason: '[temp-channels] ' + localize('temp-channels', 'permission-update-audit-log-reason') + }); + await newChannel.permissionOverwrites.create(newState.member, { + 'CONNECT': true, + 'VIEW_CHANNEL': true, + 'MANAGE_CHANNELS': moduleConfig['allowUserToChangeName'] + }, { + reason: '[temp-channels] ' + localize('temp-channels', 'permission-update-audit-log-reason') + }); + for (const roleId of (moduleConfig['privateBypassRoles'] || [])) { + await newChannel.permissionOverwrites.create(roleId, { + 'CONNECT': true, + 'VIEW_CHANNEL': true + }, {reason: '[temp-channels] Private bypass role'}).catch(() => { + }); } } + await client.models['temp-channels']['TempChannel'].create({ creatorID: newState.member.user.id, id: newChannel.id, @@ -122,10 +275,6 @@ module.exports.run = async function (client, oldState, newState) { allowedUsers: newState.member.user.id, isPublic: moduleConfig['publicChannels'] }); - if (moduleConfig['useNoMic']) { - if (!moduleConfig['create_no_mic_channel']) { - await sendMessage(newChannel); - } - } + if (moduleConfig['useNoMic'] && !moduleConfig['create_no_mic_channel']) await sendMessage(newChannel); } }; \ No newline at end of file diff --git a/modules/temp-channels/locales.json b/modules/temp-channels/locales.json new file mode 100644 index 00000000..3b105afc --- /dev/null +++ b/modules/temp-channels/locales.json @@ -0,0 +1,29 @@ +{ + "en": { + "temp-channels": { + "removed-audit-log-reason": "Removed temp channel, because no one was in it", + "permission-update-audit-log-reason": "Updated permissions, to make sure only people in the VC can see the no-mic-channel", + "created-audit-log-reason": "Created Temp-Channel for %u", + "move-audit-log-reason": "Moved user to their voice channel", + "no-mic-channel-topic": "Welcome to %u's no-mic-channel. You will see this channel as long as you are connected to this temp-channel.", + "disconnect-audit-log-reason": "The old channel of the user could not be found - disconnecting them - hopefully they join again", + "command-description": "Manage your temp-channel", + "mode-subcommand-description": "Change the mode of your channel", + "public-option-description": "local public-option-description", + "add-subcommand-description": "Add users, that will be able to join your channel, while it is private", + "remove-subcommand-description": "Remove users from you channel", + "add-user-option-description": "The user to be added", + "remove-user-option-description": "The user to be removed", + "list-subcommand-description": "List the users with access to your channel", + "edit-subcommand-description": "Edit various settings of yout channel", + "user-limit-option-description": "Change the user-limit of your channel", + "bitrate-option-description": "Change the bitrate of your channel (min. 8000)", + "name-option-description": "Change the name of your channel", + "nsfw-option-description": "Change, whether your channel is age-restricted or not", + "no-added-user": "There are no users to be displayed here", + "nothing-changed": "Your channel already had these settings.", + "no-disconnect": "Couldn't disconnect the user from your channel. This could be due to missing permissions, or the user not being in your voice-channel", + "edit-error": "An error occurred while editing your channel. one or more of your settings couldn't be applied. This could be due to missing permissions or an invalid value." + } + } +} \ No newline at end of file diff --git a/modules/temp-channels/models/SettingsMessage.js b/modules/temp-channels/models/SettingsMessage.js new file mode 100644 index 00000000..4c3a3540 --- /dev/null +++ b/modules/temp-channels/models/SettingsMessage.js @@ -0,0 +1,25 @@ +const { + DataTypes, + Model +} = require('sequelize'); + +module.exports = class TempChannelSettingsMessage extends Model { + static init(sequelize) { + return super.init({ + channelID: { + type: DataTypes.STRING, + primaryKey: true + }, + messageID: DataTypes.STRING + }, { + tableName: 'temp-channel_settings_message', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + 'name': 'SettingsMessage', + 'module': 'temp-channels' +}; \ No newline at end of file diff --git a/modules/temp-channels/models/TempChannel.js b/modules/temp-channels/models/TempChannel.js index 4858794b..f757e7a8 100644 --- a/modules/temp-channels/models/TempChannel.js +++ b/modules/temp-channels/models/TempChannel.js @@ -10,7 +10,12 @@ module.exports = class TempChannel extends Model { creatorID: DataTypes.STRING, noMicChannel: DataTypes.STRING, allowedUsers: DataTypes.STRING, - isPublic: DataTypes.BOOLEAN + isPublic: DataTypes.BOOLEAN, + archivedAt: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null + } }, { tableName: 'temp-channel_TempChannelsv2', timestamps: true, diff --git a/modules/temp-channels/module.json b/modules/temp-channels/module.json index 05f7e1a3..e5b77333 100644 --- a/modules/temp-channels/module.json +++ b/modules/temp-channels/module.json @@ -8,6 +8,7 @@ "models-dir": "/models", "events-dir": "/events", "commands-dir": "/commands", + "fa-icon": "fas fa-hourglass-half", "config-example-files": [ "config.json" ], @@ -15,12 +16,6 @@ "community" ], "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/temp-channels", - "humanReadableName": { - "en": "Temporary channels", - "de": "Temporäre Channel" - }, - "description": { - "en": "Allow users to quickly create voice channels by joining a voice channel", - "de": "Erlaube es Nutzern, ihren eigenen Voice-Channel zu erstellen, indem sie einem VC joinen" - } -} \ No newline at end of file + "humanReadableName": "Temporary channels", + "description": "Allow users to quickly create voice channels by joining a voice channel" +} diff --git a/modules/tic-tak-toe/commands/tic-tac-toe.js b/modules/tic-tak-toe/commands/tic-tac-toe.js index 2523de5e..234ad037 100644 --- a/modules/tic-tak-toe/commands/tic-tac-toe.js +++ b/modules/tic-tak-toe/commands/tic-tac-toe.js @@ -39,7 +39,7 @@ module.exports.run = async function (interaction) { let endReason = null; let gameEndReasonType = null; let currentUser = randomElementFromArray([interaction.member, member]); - const a = rep.createMessageComponentCollector({componentType: ComponentType.Button}); + const a = rep.createMessageComponentCollector({componentType: ComponentType.Button, time: 300000}); setTimeout(() => { if (started || a.ended) return; endReason = localize('tic-tac-toe', 'invite-expired', {u: interaction.user.toString(), i: member.toString()}); @@ -224,10 +224,11 @@ module.exports.run = async function (interaction) { }); }); a.on('end', () => { - rep.edit({ + if (!ended) rep.edit({ content: endReason, components: [] - }); + }).catch(() => { + }); } ); }; diff --git a/modules/tic-tak-toe/module.json b/modules/tic-tak-toe/module.json index e23b26cc..e5f682ed 100644 --- a/modules/tic-tak-toe/module.json +++ b/modules/tic-tak-toe/module.json @@ -1,18 +1,13 @@ { "name": "tic-tak-toe", - "humanReadableName": { - "en": "Tic Tac Toe", - "de": "Tic-Tac-Toe" - }, + "humanReadableName": "Tic Tac Toe", "author": { "scnxOrgID": "1", "name": "SCDerox (SC Network Team)", "link": "https://github.com/SCDerox" }, - "description": { - "en": "Let your users play Tick-Tac-Toe against each other!", - "de": "Lasse Nutzer auf deinem Server Tick-Tac-Toe gegeneinander spielen" - }, + "fa-icon": "fa-solid fa-border-all", + "description": "Let your users play Tick-Tac-Toe against each other!", "commands-dir": "/commands", "noConfig": true, "releaseDate": "1641230658000", @@ -26,4 +21,4 @@ "fun" ], "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/tic-tak-toe" -} \ No newline at end of file +} diff --git a/modules/tickets/config.json b/modules/tickets/config.json index dd71b3ab..d10e46a1 100644 --- a/modules/tickets/config.json +++ b/modules/tickets/config.json @@ -1,53 +1,25 @@ { - "description": { - "en": "Manage the basic settings of this module here", - "de": "Passe die grundlegenden Optionen des Modules hier an" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Manage the basic settings of this module here", + "humanName": "Configuration", "configElementName": { - "de": { - "one": "Ticket-Kategorie", - "more": "Ticket-Kategorien" - }, - "en": { - "one": "Ticket-Category", - "more": "Ticket-Categories" - } + "one": "Ticket-Category", + "more": "Ticket-Categories" }, "configElements": true, "filename": "config.json", "content": [ { "name": "name", - "humanName": { - "en": "Name", - "de": "Name" - }, - "default": { - "en": "Support" - }, - "description": { - "en": "Name of the Ticket type. This will be shown to users", - "de": "Name des Tickettypen. Dieser wird Nutzern angezeigt" - }, + "humanName": "Name", + "default": "Support", + "description": "Name of the Ticket type. This will be shown to users", "type": "string" }, { "name": "ticket-create-category", - "humanName": { - "en": "Ticket create category", - "de": "Ticketerstellungs-Kategorie" - }, - "default": { - "en": "" - }, - "description": { - "en": "Category in which tickets should get created.", - "de": "Kategorie, in der Tickets erstellt werden sollen." - }, + "humanName": "Ticket create category", + "default": "", + "description": "Category in which tickets should get created.", "type": "channelID", "content": [ "GUILD_CATEGORY" @@ -55,17 +27,9 @@ }, { "name": "ticket-create-channel", - "humanName": { - "en": "Ticket create category", - "de": "Ticketerstellungs-Kanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "Channel in which a message with a \"Create Ticket\" button should get send", - "de": "Kanal in den eine Nachticht mit \"Ticket erstellen\" button gesendet werden soll" - }, + "humanName": "Ticket creation channel", + "default": "", + "description": "Channel in which a message with a \"Create Ticket\" button should get send", "type": "channelID", "content": [ "GUILD_TEXT" @@ -73,229 +37,116 @@ }, { "name": "ticketRoles", - "humanName": { - "en": "Ticket Roles", - "de": "Ticketrollen" - }, - "default": { - "en": [] - }, - "description": { - "de": "Nutzer, die in Tickets gepingt werden und diese sehen können", - "en": "Users who get pinged in the tickets and who can see tickets" - }, + "humanName": "Ticket Roles", + "default": [], + "description": "Users who get pinged in the tickets and who can see tickets", "type": "array", "content": "roleID" }, { "name": "logChannel", - "humanName": { - "en": "Log channel", - "de": "Log-Kanal" - }, - "default": { - "en": "" - }, - "description": { - "en": "Channel in which ticket logs should get send", - "de": "Kanal in den Ticket-Logs gesendet werden sollen" - }, + "humanName": "Log channel", + "default": "", + "description": "Channel in which ticket logs should get send", "type": "channelID" }, { "name": "ticket-create-message", - "humanName": { - "en": "Ticket created message", - "de": "Ticketerstellungs-Nachricht" - }, - "default": { - "en": "Click the big button below to contact our staff and create a ticket", - "de": "Klick auf den großen Button unter dieser Nachricht um unser Team zu kontaktieren und ein Ticket zu erstellen" - }, - "description": { - "en": "Message that gets send/edited in the ticket-create-channel", - "de": "Nachricht, die im Ticketerstellungs-Kanal gesendet/bearbeitet wird" - }, + "humanName": "Ticket created message", + "default": "Click the big button below to contact our staff and create a ticket", + "description": "Message that gets send/edited in the ticket-create-channel", "type": "string", "allowEmbed": true }, { "name": "sendUserDMAfterTicketClose", - "humanName": { - "en": "Send user DM after ticket is closed", - "de": "Nach schließen PN an Nutzer senden" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled users get a DM from the bot after someone closes the ticket", - "de": "Wenn diese Option aktiviert ist, bekommen Nutzer eine PN, wenn ihr Ticket geschlossen wird" - }, + "humanName": "Send user DM after ticket is closed", + "default": false, + "description": "If enabled users get a DM from the bot after someone closes the ticket", "type": "boolean" }, { "name": "userDM", - "humanName": { - "en": "User DM", - "de": "Nutzer PN" - }, - "default": { - "en": "Thanks for contacting our support for the ticket-category \"%type%\", here is your transcript: %transcriptURL%", - "de": "Danke, dass du unseren Support für die Kategorie \"%type%\" kontaktiert hast. Hier ist dein Transcript: %transcriptURL%" - }, - "description": { - "en": "This message gets send to the user if sendUserDMAfterTicketClose is enabled", - "de": "Diese Nachricht wird an den Nutzer gesendet, wenn die entsprechende Option aktiviert ist" - }, + "humanName": "User DM", + "default": "Thanks for contacting our support for the ticket-category \"%type%\", here is your transcript: %transcriptURL%", + "description": "This message gets send to the user if sendUserDMAfterTicketClose is enabled", "type": "string", "dependsOn": "sendUserDMAfterTicketClose", "allowEmbed": true, "params": [ { "name": "transcriptURL", - "description": { - "de": "URL zum Transcript", - "en": "URL to transcript" - } + "description": "URL to transcript" }, { "name": "type", - "description": { - "de": "Name des dieses Ticket Typen", - "en": "Name of this ticket type" - } + "description": "Name of this ticket type" } ] }, { "name": "creation-message", - "humanName": { - "en": "Ticket-Created Message", - "de": "Ticket-Erstellt Nachricht" - }, - "pro": true, + "humanName": "Ticket-Created Message", "type": "string", "allowEmbed": true, - "description": { - "en": "This message will get sent in new tickets. The close buttons will be added.", - "de": "Diese Nachricht wird in neue Tickets gesendet. Der Schließ-Knopf wird hinzugefügt." - }, + "description": "This message will get sent in new tickets. The close buttons will be added.", "default": { - "en": { - "title": "\uD83D\uDCE5 New ticket #%id%", - "color": "#2ECC71", - "message": "%rolePings%", - "fields": [ - { - "name": "\uD83D\uDC64 User", - "value": "%userMention%", - "inline": true - }, - { - "name": "☕ Ticket-Topic", - "value": "%ticketTopic%", - "inline": true - }, - { - "name": "ℹ\uFE0F Information", - "value": "Your issue got solved? Click the button below. You can always find this message pinned." - } - ] - }, - "de": { - "title": "\uD83D\uDCE5 Neues Ticket #%id%", - "color": "#2ECC71", - "message": "%rolePings%", - "fields": [ - { - "name": "\uD83D\uDC64 Nutzer", - "value": "%userMention%", - "inline": true - }, - { - "name": "☕ Ticket-Thema", - "value": "%ticketTopic%", - "inline": true - }, - { - "name": "ℹ\uFE0F Information", - "value": "Dein Problem wurde behoben? Klicke den Knopf unten. Du kannst diese Nachricht immer in den angepinnten Nachrichten finden." - } - ] - } + "title": "📥 New ticket #%id%", + "color": "#2ECC71", + "message": "%rolePings%", + "fields": [ + { + "name": "👤 User", + "value": "%userMention%", + "inline": true + }, + { + "name": "☕ Ticket-Topic", + "value": "%ticketTopic%", + "inline": true + }, + { + "name": "ℹ️ Information", + "value": "Your issue got solved? Click the button below. You can always find this message pinned." + } + ] }, "params": [ { "name": "id", - "description": { - "de": "Eindeutige Identifikationsnummer des Tickets", - "en": "Unique identification number of the ticket" - } + "description": "Unique identification number of the ticket" }, { "name": "userMention", - "description": { - "de": "Erwähnung des Nutzers, der das Ticket erstellt hat", - "en": "Mention of the user who created this ticket" - } + "description": "Mention of the user who created this ticket" }, { "name": "rolePings", - "description": { - "de": "Erwähnung der Rollen, die du im \"Ticket-Rollen\"-Feld eingestellt hast", - "en": "Mention of the roles you have selected in the \"Ticket roles\" field" - } + "description": "Mention of the roles you have selected in the \"Ticket roles\" field" }, { "name": "ticketTopic", - "description": { - "de": "Name des Ticket-Themas", - "en": "Name of the Ticket-Topic" - } + "description": "Name of the Ticket-Topic" }, { "name": "userTag", - "description": { - "de": "Tag des Nutzers, der das Ticket erstellt hat", - "en": "Tag of the user who created this ticket" - } + "description": "Tag of the user who created this ticket" } ] }, { "name": "ticket-create-button", - "humanName": { - "en": "Ticket create button", - "de": "Ticketerstellungs-Button" - }, - "default": { - "en": "Create ticket 🎫", - "de": "Ticket erstellen 🎫" - }, - "description": { - "en": "Button for creating a ticket", - "de": "Button zum Erstellen eines Tickets" - }, - "type": "string", - "pro": true + "humanName": "Ticket create button", + "default": "Create ticket 🎫", + "description": "Button for creating a ticket", + "type": "string" }, { "name": "ticket-close-button", - "humanName": { - "en": "Ticket close button", - "de": "Ticketschließungs-Button" - }, - "default": { - "en": "❎ Close ticket", - "de": "❎ Ticket schließen" - }, - "description": { - "en": "Button for closing a ticket", - "de": "Button um ein Ticket zu schließen" - }, - "type": "string", - "pro": true + "humanName": "Ticket close button", + "default": "❎ Close ticket", + "description": "Button for closing a ticket", + "type": "string" } ] -} \ No newline at end of file +} diff --git a/modules/tickets/events/interactionCreate.js b/modules/tickets/events/interactionCreate.js index 037cf2b0..2d2ee1eb 100644 --- a/modules/tickets/events/interactionCreate.js +++ b/modules/tickets/events/interactionCreate.js @@ -5,7 +5,8 @@ const { messageLogToStringToPaste, embedType, formatDiscordUserName, - parseEmbedColor + parseEmbedColor, + safeSetFooter } = require('../../../src/functions/helpers'); module.exports.run = async function (client, interaction) { @@ -50,27 +51,23 @@ module.exports.run = async function (client, interaction) { const logChannel = element.logChannel ? interaction.guild.channels.cache.get(element.logChannel) : client.logChannel; if (!logChannel) client.logger.error('[tickets] ' + localize('tickets', 'no-log-channel')); else { + const ticketEmbed = new MessageEmbed() + .setColor(parseEmbedColor('DARK_GREEN')) + .setTitle(localize('tickets', 'ticket-log-embed-title', {i: ticket.id})) + .setAuthor({ + name: client.user.username, + iconURL: client.user.avatarURL() + }) + .addField(localize('tickets', 'ticket-with-user'), `<@${ticket.userID}>`, true) + .addField(localize('tickets', 'ticket-type'), element.name, true) + .addField(localize('tickets', 'ticket-log'), localize('tickets', 'ticket-log-value', { + u: msgLog, + n: ticket.msgCount + }), true) + .addField(localize('tickets', 'closed-by'), interaction.user.toString(), true); + safeSetFooter(ticketEmbed, client); await logChannel.send({ - embeds: [ - new MessageEmbed() - .setColor(parseEmbedColor('DARK_GREEN')) - .setTitle(localize('tickets', 'ticket-log-embed-title', {i: ticket.id})) - .setFooter({ - text: client.strings.footer, - iconURL: client.strings.footerImgUrl - }) - .setAuthor({ - name: client.user.username, - iconURL: client.user.avatarURL() - }) - .addField(localize('tickets', 'ticket-with-user'), `<@${ticket.userID}>`, true) - .addField(localize('tickets', 'ticket-type'), element.name, true) - .addField(localize('tickets', 'ticket-log'), localize('tickets', 'ticket-log-value', { - u: msgLog, - n: ticket.msgCount - }), true) - .addField(localize('tickets', 'closed-by'), interaction.user.toString(), true) - ] + embeds: [ticketEmbed] }); } setTimeout(() => { diff --git a/modules/tickets/module.json b/modules/tickets/module.json index 772f3f6b..300a6de5 100644 --- a/modules/tickets/module.json +++ b/modules/tickets/module.json @@ -5,6 +5,7 @@ "name": "SCDerox (SC Network Team)", "link": "https://github.com/SCDerox" }, + "fa-icon": "fas fa-ticket-simple", "events-dir": "/events", "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/tickets", "models-dir": "/models", @@ -14,11 +15,6 @@ "tags": [ "support" ], - "humanReadableName": { - "en": "Ticket-System" - }, - "description": { - "en": "Let users create tickets to message your staff", - "de": "Lasse deine Nutzer durch Tickets mit deinem Team kommunizieren" - } -} \ No newline at end of file + "humanReadableName": "Ticket-System", + "description": "Let users create tickets to message your staff" +} diff --git a/modules/twitch-notifications/configs/config.json b/modules/twitch-notifications/configs/config.json index f5801ea4..35a31618 100644 --- a/modules/twitch-notifications/configs/config.json +++ b/modules/twitch-notifications/configs/config.json @@ -1,47 +1,30 @@ { - "description": {}, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Twitch API credentials and polling interval. Create an app at https://dev.twitch.tv/console/apps to get your Client ID and Secret.", + "humanName": "Configuration", "filename": "config.json", "hidden": true, "content": [ { "name": "twitchClientID", - "humanName": {}, - "default": { - "en": "me3ub5wbx2jxlhkvxrc6fbgp8wgixq" - }, - "description": { - "en": "ID of the Client, which is used to check if the Streamer is live" - }, - "hidden": true, + "humanName": "Twitch Client ID", + "default": "", + "description": "Client ID of your Twitch application (https://dev.twitch.tv/console/apps).", "type": "string" }, { "name": "clientSecret", - "humanName": {}, - "default": { - "en": "58v6r0v2oips1tldmfunrxp5m8xv6r" - }, - "description": { - "en": "Secret of the Twitch-Client, which is used to check if the Streamer is live" - }, - "hidden": true, + "humanName": "Twitch Client Secret", + "default": "", + "description": "Client Secret of your Twitch application.", "type": "string" }, { "name": "interval", - "humanName": {}, - "default": { - "en": 180 - }, - "description": { - "en": "Interval (in seconds) in which it is tested whether the streamer is live. This value must be higher than 60" - }, - "hidden": true, - "type": "integer" + "humanName": "Check interval (seconds)", + "default": 180, + "description": "How often (in seconds) the bot polls Twitch for stream updates. Must be at least 60 to stay within Twitch rate limits.", + "type": "integer", + "minValue": 60 } ] -} \ No newline at end of file +} diff --git a/modules/twitch-notifications/configs/streamers.json b/modules/twitch-notifications/configs/streamers.json index 26c2fe04..83cadb8e 100644 --- a/modules/twitch-notifications/configs/streamers.json +++ b/modules/twitch-notifications/configs/streamers.json @@ -1,153 +1,77 @@ { - "description": { - "en": "Configure here, where for what streamer which message should get send", - "de": "Stelle hier ein, bei welchem Streamer in welchen Channel eine Nachricht gesendet werden soll" - }, - "humanName": { - "en": "Streamers", - "de": "Streamers" - }, - "elementLimits": { - "STARTER": 2, - "ACTIVE_GUILD": 5, - "PRO": 15, - "UNLIMITED": 5, - "PROFESSIONAL": 15 - }, + "description": "Configure here, where for what streamer which message should get send", + "humanName": "Streamers", "filename": "streamers.json", "configElements": true, "content": [ { "name": "liveMessage", - "humanName": { - "en": "Live-Messages", - "de": "Live-Nachricht" - }, - "default": { - "en": "Hey, %streamer% is live on Twitch streaming %game%! Check it out: %url%", - "de": "Hi, %streamer% ist Live auf Twitch und streamt %game%! Jetzt anschauen: %url%" - }, - "description": { - "en": "Message that gets send if the streamer goes live", - "de": "Nachricht, die gesendet wird, wenn ein Streamer anfängt zu streamen" - }, + "humanName": "Live-Messages", + "default": "Hey, %streamer% is live on Twitch streaming %game%! Check it out: %url%", + "description": "Message that gets send if the streamer goes live", "type": "string", "allowEmbed": true, "params": [ { "name": "streamer", - "description": { - "en": "Name of the Streamer", - "de": "Name des Streamers" - } + "description": "Name of the Streamer" }, { "name": "game", - "description": { - "en": "Game which is streamed", - "de": "Spiel, welches gestreamt wird" - } + "description": "Game which is streamed" }, { "name": "url", - "description": { - "en": "Link to the stream", - "de": "Link zum Twitch-Stream" - } + "description": "Link to the stream" }, { "name": "title", - "description": { - "en": "Title of the Stream", - "de": "Titel des Streams" - } + "description": "Title of the Stream" }, { "name": "thumbnailUrl", - "description": { - "en": "The Link to the thumbnail of the Stream", - "de": "Link zum Thumbnail des Streams" - }, + "description": "The Link to the thumbnail of the Stream", "isImage": true } ] }, { "name": "liveMessageChannel", - "humanName": { - "en": "Channel" - }, - "default": { - "en": "" - }, - "description": { - "en": "Channel in which live-message should get sent", - "de": "Kanal, in welchen Benachrichtigung gesendet werden soll" - }, + "humanName": "Channel", + "default": "", + "description": "Channel in which live-message should get sent", "type": "channelID" }, { "name": "streamer", - "humanName": { - "en": "Streamer", - "de": "Streamer" - }, - "default": { - "en": "" - }, - "description": { - "en": "Streamer where a notification should send when they start streaming", - "de": "Steamer, bei denen eine Benachrichtigung gesendet werden soll, wenn sie anfangen, zu streamen" - }, + "humanName": "Streamer", + "default": "", + "description": "Streamer where a notification should send when they start streaming", "type": "string" }, { "name": "liveRole", - "humanName": { - "en": "Use Live-Role", - "de": "Live-Rolle Aktivieren" - }, - "default": { - "en": false - }, - "description": { - "en": "Should the Live-Role be activated?", - "de": "Soll die Live-Rolle aktiviert sein?" - }, + "humanName": "Use Live-Role", + "default": false, + "description": "Should the Live-Role be activated?", "type": "boolean" }, { "name": "id", - "humanName": { - "en": "Discord-User ID", - "de": "Discord-Benutzer ID" - }, - "default": { - "en": "" - }, - "description": { - "en": "ID of the Discord-Account of the Streamer", - "de": "ID des Discord-Accounts des Streamers" - }, + "humanName": "Discord-User ID", + "default": "", + "description": "ID of the Discord-Account of the Streamer", "type": "userID", "dependsOn": "liveRole" }, { "name": "role", - "humanName": { - "en": "Live Role", - "de": "Live Rolle" - }, - "default": { - "en": "" - }, - "description": { - "en": "ID of the Role that the Streamer should get, when live", - "de": "ID der Rolle, die der streamer bekommen soll, wenn er live ist" - }, + "humanName": "Live Role", + "default": "", + "description": "ID of the Role that the Streamer should get, when live", "type": "roleID", "allowNull": true, "dependsOn": "liveRole" } ] -} \ No newline at end of file +} diff --git a/modules/twitch-notifications/events/botReady.js b/modules/twitch-notifications/events/botReady.js index 11bbf958..633019e1 100644 --- a/modules/twitch-notifications/events/botReady.js +++ b/modules/twitch-notifications/events/botReady.js @@ -116,13 +116,16 @@ function twitchNotifications(client, apiClient) { module.exports.run = async (client) => { const config = client.configurations['twitch-notifications']['config']; - const ClientID = config['twitchClientID']; - const ClientSecret = config['clientSecret']; - const authProvider = new ClientCredentialsAuthProvider(ClientID, ClientSecret); + if (!config['twitchClientID'] || !config['clientSecret']) { + client.logger.error('[twitch-notifications] Missing twitchClientID or clientSecret in configs/config.json — module disabled. Create a Twitch app at https://dev.twitch.tv/console/apps to obtain credentials.'); + return; + } + + const authProvider = new ClientCredentialsAuthProvider(config['twitchClientID'], config['clientSecret']); const apiClient = new ApiClient({authProvider}); await twitchNotifications(client, apiClient); - const interval = config['interval'] * 1000; + const interval = (config['interval'] || 180) * 1000; const twitchCheckInterval = setInterval(() => { twitchNotifications(client, apiClient); }, interval); diff --git a/modules/twitch-notifications/module.json b/modules/twitch-notifications/module.json index f2603317..ee7d9e0c 100644 --- a/modules/twitch-notifications/module.json +++ b/modules/twitch-notifications/module.json @@ -1,5 +1,6 @@ { "name": "twitch-notifications", + "fa-icon": "fa-brands fa-twitch", "author": { "name": "jateute", "link": "https://github.com/jateute", @@ -15,12 +16,6 @@ "tags": [ "integrations" ], - "humanReadableName": { - "en": "Twitch-Notifications", - "de": "Twitch-Benachrichtigungen" - }, - "description": { - "en": "Module that sends a message to a channel, when a streamer goes live on Twitch", - "de": "Sendet eine Nachricht in einen ausgewählten Channel, wenn ein Streamer Live auf Twitch streamt" - } -} \ No newline at end of file + "humanReadableName": "Twitch-Notifications", + "description": "Module that sends a message to a channel, when a streamer goes live on Twitch" +} diff --git a/modules/uno/commands/uno.js b/modules/uno/commands/uno.js index 64d7c882..c9974d5c 100644 --- a/modules/uno/commands/uno.js +++ b/modules/uno/commands/uno.js @@ -401,13 +401,16 @@ module.exports.run = async function (interaction) { fetchReply: true, ephemeral: true }); - m.createMessageComponentCollector({componentType: ComponentType.Button}).on('collect', i => perPlayerHandler(i, p, game)); + m.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 1800000 + }).on('collect', i => perPlayerHandler(i, p, game)); }); } const timeout = setTimeout(startGame, 179000); - const collector = msg.createMessageComponentCollector({componentType: ComponentType.Button}); + const collector = msg.createMessageComponentCollector({componentType: ComponentType.Button, time: 1800000}); collector.on('collect', async i => { if (i.customId === 'uno-join') { if (game.players.some(p => p.id === i.user.id)) return i.reply({ @@ -451,7 +454,10 @@ module.exports.run = async function (interaction) { fetchReply: true, ephemeral: true }); - m.createMessageComponentCollector({componentType: ComponentType.Button}).on('collect', int => perPlayerHandler(int, player, game)); + m.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 1800000 + }).on('collect', int => perPlayerHandler(int, player, game)); } else if (i.customId === 'uno-uno') { const player = game.players.find(p => p.id === i.user.id); if (!player) return i.reply({content: localize('uno', 'not-in-game'), ephemeral: true}); diff --git a/modules/uno/module.json b/modules/uno/module.json index d65f1e4d..0872f815 100644 --- a/modules/uno/module.json +++ b/modules/uno/module.json @@ -1,17 +1,13 @@ { "name": "uno", - "humanReadableName": { - "en": "Uno" - }, + "humanReadableName": "Uno", + "fa-icon": "fa-solid fa-cards-blank", "author": { "scnxOrgID": "60", "name": "TomatoCake", "link": "https://github.com/DEVTomatoCake" }, - "description": { - "en": "Let your users play Uno against each other!", - "de": "Lasse Nutzer auf deinem Server Uno gegeneinander spielen" - }, + "description": "Let your users play Uno against each other!", "commands-dir": "/commands", "noConfig": true, "releaseDate": "0", @@ -19,4 +15,4 @@ "fun" ], "openSourceURL": "https://github.com/DEVTomatoCake/ScootKit-CustomBot/tree/main/modules/uno" -} \ No newline at end of file +} diff --git a/modules/welcomer/configs/channels.json b/modules/welcomer/configs/channels.json index 8a90cf41..fcd15431 100644 --- a/modules/welcomer/configs/channels.json +++ b/modules/welcomer/configs/channels.json @@ -1,43 +1,21 @@ { - "description": { - "en": "Configure here in which channel which message should get send", - "de": "Passe hier an, in welchen Kanälen welche Nachricht gesendet werden soll" - }, - "humanName": { - "en": "Channel", - "de": "Kanäle" - }, + "description": "Configure here in which channel which message should get send", + "humanName": "Channel", "filename": "channels.json", "configElements": true, "content": [ { "name": "channelID", - "humanName": { - "en": "Channel", - "de": "Channel" - }, - "default": { - "en": "" - }, - "description": { - "en": "Channel in which the message should get send", - "de": "Kanal in welchen die Nachricht gesendet werden soll" - }, + "humanName": "Channel", + "default": "", + "description": "Channel in which the message should get send", "type": "channelID" }, { "name": "type", - "humanName": { - "en": "Channel-Type", - "de": "Kanal-Typ" - }, - "default": { - "en": "" - }, - "description": { - "en": "This sets in which content the channel should get used", - "de": "Dies gibt an, in welchem Kontext dieser Kanal verwendet werden soll" - }, + "humanName": "Channel-Type", + "default": "", + "description": "This sets in which content the channel should get used", "type": "select", "content": [ "join", @@ -48,234 +26,129 @@ }, { "name": "randomMessages", - "humanName": { - "en": "Random messages?", - "de": "Zufällige Nachrichten?" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled the bot will randomly pick a messages instead of using the message option below", - "de": "Wenn aktiviert wird der Bot eine zufällige Nachricht aus deiner Konfiguration wählen, anstatt die unten" - }, + "humanName": "Random messages?", + "default": false, + "description": "If enabled the bot will randomly pick a messages instead of using the message option below", "type": "boolean" }, { "name": "message", - "humanName": { - "de": "Nachricht", - "en": "Message" - }, - "default": { - "en": "" - }, - "description": { - "en": "Message that should get send", - "de": "Nachricht, die gesendet wird" - }, + "humanName": "Message", + "default": "", + "description": "Message that should get send", "type": "string", "allowEmbed": true, "allowGeneratedImage": true, "params": [ { "name": "mention", - "description": { - "en": "Mentions the user", - "de": "Erwähnung des Nutzers" - } + "description": "Mentions the user" }, { "name": "memberProfilePictureUrl", - "description": { - "en": "URL of the user's avatar", - "de": "URL zum Avatar des Nutzers" - }, + "description": "URL of the user's avatar", "isImage": true }, { "name": "servername", - "description": { - "en": "Name of the guild", - "de": "Servername" - } + "description": "Name of the guild" }, { "name": "tag", - "description": { - "en": "Tag of the user", - "de": "Tag des Nutzers" - } + "description": "Tag of the user" }, { "name": "createdAt", - "description": { - "en": "Date when account was created", - "de": "Datum an dem der Account erstellt wurde" - } + "description": "Date when account was created" }, { "name": "memberProfileBannerUrl", - "description": { - "en": "URL of the banner's avatar", - "de": "URL zum Banner des Nutzers" - }, + "description": "URL of the banner's avatar", "isImage": true }, { "name": "joinedAt", - "description": { - "en": "Date when user joined guild", - "de": "Datum, an dem der Nutzer den Server betreten hat" - } + "description": "Date when user joined guild" }, { "name": "guildUserCount", - "description": { - "en": "Count of users on the guild", - "de": "Anzahl von Nutzern auf dem Server" - } + "description": "Count of users on the guild" }, { "name": "guildMemberCount", - "description": { - "en": "Count of members (without bots) on the guild", - "de": "Anzahl von Nutzern auf dem Server" - } + "description": "Count of members (without bots) on the guild" }, { "name": "boostCount", - "description": { - "en": "Total count of boosts", - "de": "Gesamte Anzahl an Boosts" - } + "description": "Total count of boosts" }, { "name": "guildLevel", - "description": { - "en": "Boost-Level of the guild after the boost", - "de": "Boost-Level nach dem Boost" - } + "description": "Boost-Level of the guild after the boost" }, { "name": "mention", - "description": { - "en": "Mention of the user who unboosted", - "de": "Erwähnung des Nutzers" - } + "description": "Mention of the user who unboosted" } ] }, { "name": "welcome-button", - "humanName": { - "en": "Welcome-Button (only if \"Channel-Type\" = \"join\")", - "de": "Willkommens-Knopf (nur wenn \"Channel-Type\" = \"join\")" - }, - "default": { - "en": false - }, - "description": { - "en": "If enabled, a welcome-button will be attached to the welcome message. When a user clicks on it, the bot will send a welcome-ping in a configured channel. The button can be pressed once.", - "de": "If enabled, a welcome-button will be attached to the welcome message. When a user clicks on it, the bot will send a welcome-ping in a configured channel. The button can be pressed once." - }, + "humanName": "Welcome-Button (only if \"Channel-Type\" = \"join\")", + "default": false, + "description": "If enabled, a welcome-button will be attached to the welcome message. When a user clicks on it, the bot will send a welcome-ping in a configured channel. The button can be pressed once.", "type": "boolean" }, { "name": "welcome-button-content", "dependsOn": "welcome-button", - "humanName": { - "en": "Welcome-Button-Content", - "de": "Willkommens-Knopf-Inhalt" - }, - "default": { - "en": "Say hi \uD83D\uDC4B", - "de": "Hallo sagen \uD83D\uDC4B" - }, - "description": { - "en": "Content of the welcome button", - "de": "Inhalt des Willkommens-Knopfes" - }, + "humanName": "Welcome-Button-Content", + "default": "Say hi 👋", + "description": "Content of the welcome button", "type": "string" }, { "name": "welcome-button-channel", "dependsOn": "welcome-button", - "humanName": { - "en": "Channel in which the welcome-button should send a message", - "de": "Kanal, in welchen der Willkommens-Knopf die Nachricht senden soll" - }, - "default": { - "en": "", - "de": "" - }, - "description": { - "en": "The bot will send the configured message in this channel when a user presses the button", - "de": "Der Bot wird die konfigurierte Nachricht in diesen Kanal senden, wenn jemand den Knopf drückt" - }, + "humanName": "Channel in which the welcome-button should send a message", + "default": "", + "description": "The bot will send the configured message in this channel when a user presses the button", "type": "channelID" }, { "name": "welcome-button-message", "dependsOn": "welcome-button", - "humanName": { - "en": "Welcome-Button-Message", - "de": "Willkommens-Knopf-Nachricht" - }, - "default": { - "en": "%clickUserMention% welcomes %userMention% :wave:", - "de": "%clickUserMention% begrüßt %userMention% :wave:" - }, + "humanName": "Welcome-Button-Message", + "default": "%clickUserMention% welcomes %userMention% :wave:", "allowEmbed": true, - "description": { - "en": "This is the message the bot will send in the configured channel when a user presses the button", - "de": "Der Bot wird in diesen Kanal die Nachricht senden, wenn ein Nutzer den Knopf drückt" - }, + "description": "This is the message the bot will send in the configured channel when a user presses the button", "type": "string", "params": [ { "name": "userMention", - "description": { - "en": "Mention of the user who joined the server", - "de": "Erwähnung des Nutzer, der den Server beigetreten hat" - } + "description": "Mention of the user who joined the server" }, { "name": "userTag", - "description": { - "en": "Tag of the user who joined the server", - "de": "Tag des Nutzer, der den Server beigetreten hat" - } + "description": "Tag of the user who joined the server" }, { "name": "userAvatarURL", "isImage": true, - "description": { - "en": "Avatar of the user who joined the server", - "de": "Avatar des Nutzer, der den Server beigetreten hat" - } + "description": "Avatar of the user who joined the server" }, { "name": "clickUserMention", - "description": { - "en": "Mention of the user who clicked the button", - "de": "Erwähnung des Nutzer, der den Knopf gedrückt hat" - } + "description": "Mention of the user who clicked the button" }, { "name": "clickUserTag", - "description": { - "en": "Tag of the user who clicked the button", - "de": "Tag des Nutzer, der den Knopf gedrückt hat" - } + "description": "Tag of the user who clicked the button" }, { "name": "clickUserAvatarURL", "isImage": true, - "description": { - "en": "Avatar of the user who clicked the button", - "de": "Avatar des Nutzer, der den Knopf gedrückt hat" - } + "description": "Avatar of the user who clicked the button" } ] } diff --git a/modules/welcomer/configs/config.json b/modules/welcomer/configs/config.json index 69e17a70..e53a6071 100644 --- a/modules/welcomer/configs/config.json +++ b/modules/welcomer/configs/config.json @@ -1,242 +1,153 @@ { - "description": { - "en": "Manage the basic settings of this module here", - "de": "Passe die grundlegenden Optionen des Modules hier an" - }, - "humanName": { - "en": "Configuration", - "de": "Konfiguration" - }, + "description": "Manage the basic settings of this module here", + "humanName": "Configuration", "filename": "config.json", "content": [ { "name": "give-roles-on-join", - "humanName": { - "en": "Give roles on join", - "de": "Nutzer Rollen beim Beitreten geben" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Roles to give to a new member", - "de": "Rollen, die neuen Mitgliedern gegeben werden sollen" - }, + "humanName": "Give roles on join", + "default": [], + "description": "Roles to give to a new member", "type": "array", - "content": "roleID" + "content": "roleID", + "category": "roles" }, { "name": "assign-roles-immediately", - "humanName": { - "en": "Immediately give roles, instead of waiting for rules acceptance?", - "de": "Rollen sofort geben statt Regelbestätigung abzuwarten?" - }, - "default": { - "en": true - }, - "description": { - "en": "If enabled, roles will be granted immediately when a user joins your server. Otherwise, no roles will be assigned to users before they complete the Discord onboarding.", - "de": "Wenn aktiviert, werden die Rollen sofort vergeben, wenn ein Nutzer deinem Server beitritt. Ansonsten werden Rollen erst zugewiesen, wenn das Discord onboarding abgeschlossen wurde." - }, - "type": "boolean" + "humanName": "Immediately give roles, instead of waiting for rules acceptance?", + "default": true, + "description": "If enabled, roles will be granted immediately when a user joins your server. Otherwise, no roles will be assigned to users before they complete the Discord onboarding.", + "type": "boolean", + "category": "roles" }, { "name": "not-send-messages-if-member-is-bot", - "humanName": { - "en": "Ignore bots?", - "de": "Bots ignorieren?" - }, - "default": { - "en": true, - "de": true - }, - "description": { - "en": "Should bots get ignored when they join (or leave) the server", - "de": "Sollen Bots ignoriert werden, wenn sie den Server beitreten (oder diesen verlassen)" - }, - "type": "boolean" + "humanName": "Ignore bots?", + "default": true, + "description": "Should bots get ignored when they join (or leave) the server", + "type": "boolean", + "category": "welcome" }, { "name": "give-roles-on-boost", - "humanName": { - "de": "Zusätzliche Rollen beim Boost geben", - "en": "Give additional roles to boosters" - }, - "default": { - "en": [], - "de": [] - }, - "description": { - "en": "Roles to give to members who boosts the server", - "de": "Rollen, die Booster haben sollen" - }, + "humanName": "Give additional roles to boosters", + "default": [], + "description": "Roles to give to members who boosts the server", "type": "array", - "content": "roleID" + "content": "roleID", + "category": "boost" }, { "name": "delete-welcome-message", - "humanName": { - "en": "Delete welcome message", - "de": "Willkommensnachricht löschen" - }, - "default": { - "en": true - }, - "description": { - "en": "Should their welcome message be deleted, if a user leaves the server within 7 days", - "de": "Soll die Willkommensnachricht eines Nutzers, der den Server innerhalb von 7 Tagen wieder verlässt gelöscht werden" - }, - "type": "boolean" + "humanName": "Delete welcome message", + "default": true, + "description": "Should their welcome message be deleted, if a user leaves the server within 7 days", + "type": "boolean", + "category": "welcome" }, { "name": "sendDirectMessageOnJoin", - "humanName": { - "en": "Send DM on join? (often experienced by users as spam)", - "de": "PN beim Beitreten schicken? (von Nutzern oft als Spam empfunden)" - }, + "humanName": "Send DM on join? (often experienced by users as spam)", "type": "boolean", - "default": { - "en": false - }, - "description": { - "en": "If enabled, a DM will be sent to new users. This is often experienced by them as spam and can decrease your new user retention metrics. Please note that not all users will receive this DM, as a huge chunk has DMs disabled.", - "de": "Wenn aktiviert, wird eine PN an neue Nutzer gesendet. Das wird often als Spam empfunden und kann die Anzahl an Nutzern erhöhen, die direkt nach dem Beitritt deinen Server verlassen. Bitte beachte, dass nicht alle Nutzer diese PN erhalten werden, da eine großer Anzahl diese deaktiviert hat." - } + "default": false, + "description": "If enabled, a DM will be sent to new users. This is often experienced by them as spam and can decrease your new user retention metrics. Please note that not all users will receive this DM, as a huge chunk has DMs disabled.", + "category": "welcome" }, { "name": "joinDM", "dependsOn": "sendDirectMessageOnJoin", - "humanName": { - "en": "Join DM Message", - "de": "Beitritt PN Nachricht" - }, + "humanName": "Join DM Message", "allowGeneratedImage": true, - "default": { - "en": "" - }, - "description": { - "en": "Message that should get send to new users via DMs", - "de": "Nachricht, die an neue Nutzer per PN geschickt werden soll" - }, + "default": "", + "description": "Message that should get send to new users via DMs", "type": "string", "allowEmbed": true, "params": [ { "name": "mention", - "description": { - "en": "Mentions the user", - "de": "Erwähnung des Nutzers" - } + "description": "Mentions the user" }, { "name": "memberProfilePictureUrl", - "description": { - "en": "URL of the user's avatar", - "de": "URL zum Avatar des Nutzers" - }, + "description": "URL of the user's avatar", "isImage": true }, { "name": "servername", - "description": { - "en": "Name of the guild", - "de": "Servername" - } + "description": "Name of the guild" }, { "name": "tag", - "description": { - "en": "Tag of the user", - "de": "Tag des Nutzers" - } + "description": "Tag of the user" }, { "name": "createdAt", - "description": { - "en": "Date when account was created", - "de": "Datum an dem der Account erstellt wurde" - } + "description": "Date when account was created" }, { "name": "tag", - "description": { - "en": "Tag of the user", - "de": "Tag des Nutzers" - } + "description": "Tag of the user" }, { "name": "memberProfilePictureUrl", - "description": { - "en": "URL of the user's avatar", - "de": "URL zum Avatar des Nutzers" - }, + "description": "URL of the user's avatar", "isImage": true }, { "name": "joinedAt", - "description": { - "en": "Date when user joined guild", - "de": "Datum, an dem der Nutzer den Server betreten hat" - } + "description": "Date when user joined guild" }, { "name": "guildUserCount", - "description": { - "en": "Count of users on the guild", - "de": "Anzahl von Nutzern auf dem Server" - } + "description": "Count of users on the guild" }, { "name": "guildMemberCount", - "description": { - "en": "Count of members (without bots) on the guild", - "de": "Anzahl von Nutzern auf dem Server" - } + "description": "Count of members (without bots) on the guild" }, { "name": "mention", - "description": { - "en": "Mention of the user who boosted", - "de": "Erwähnung des Nutzers" - } + "description": "Mention of the user who boosted" }, { "name": "boostCount", - "description": { - "en": "Total count of boosts", - "de": "Gesamte Anzahl an Boosts" - } + "description": "Total count of boosts" }, { "name": "guildLevel", - "description": { - "en": "Boost-Level of the guild after the boost", - "de": "Boost-Level nach dem Boost" - } + "description": "Boost-Level of the guild after the boost" }, { "name": "mention", - "description": { - "en": "Mention of the user who unboosted", - "de": "Erwähnung des Nutzers" - } + "description": "Mention of the user who unboosted" }, { "name": "boostCount", - "description": { - "en": "Total count of boosts", - "de": "Gesamte Anzahl an Boosts" - } + "description": "Total count of boosts" }, { "name": "guildLevel", - "description": { - "en": "Boost-Level of the guild after the unboost", - "de": "Boost-Level nach dem Boost" - } + "description": "Boost-Level of the guild after the unboost" } - ] + ], + "category": "welcome" + } + ], + "categories": [ + { + "id": "welcome", + "icon": "fas fa-door-open", + "displayName": "Welcome" + }, + { + "id": "roles", + "icon": "fa-solid fa-users", + "displayName": "Auto-Roles" + }, + { + "id": "boost", + "icon": "fas fa-star", + "displayName": "Boosts" } ] } \ No newline at end of file diff --git a/modules/welcomer/configs/random-messages.json b/modules/welcomer/configs/random-messages.json index 1227ecbf..adff7096 100644 --- a/modules/welcomer/configs/random-messages.json +++ b/modules/welcomer/configs/random-messages.json @@ -1,28 +1,14 @@ { - "description": { - "en": "Manage the randomly send messages here", - "de": "Passe hier die Nachrichten an, die zufällig gesendet werden sollen" - }, - "humanName": { - "en": "Random messages", - "de": "Zufällige Nachrichten" - }, + "description": "Manage the randomly send messages here", + "humanName": "Random messages", "filename": "random-messages.json", "configElements": true, "content": [ { "name": "type", - "humanName": { - "en": "Message-Type", - "de": "Nachricht-Type" - }, - "default": { - "en": "" - }, - "description": { - "en": "This sets in which content the message should get send", - "de": "Dies gibt an, in welchem Kontext diese Nachricht versendet werden soll" - }, + "humanName": "Message-Type", + "default": "", + "description": "This sets in which content the message should get send", "type": "select", "content": [ "join", @@ -33,134 +19,78 @@ }, { "name": "message", - "humanName": { - "en": "Message", - "de": "Nachricht" - }, + "humanName": "Message", "allowGeneratedImage": true, - "default": { - "en": "" - }, - "description": { - "en": "Message that should get send", - "de": "Nachricht, die gesendet werden soll" - }, + "default": "", + "description": "Message that should get send", "type": "string", "allowEmbed": true, "params": [ { "name": "mention", - "description": { - "en": "Mentions the user", - "de": "Erwähnung des Nutzers" - } + "description": "Mentions the user" }, { "name": "memberProfilePictureUrl", - "description": { - "en": "URL of the user's avatar", - "de": "URL zum Avatar des Nutzers" - }, + "description": "URL of the user's avatar", "isImage": true }, { "name": "servername", - "description": { - "en": "Name of the guild", - "de": "Servername" - } + "description": "Name of the guild" }, { "name": "tag", - "description": { - "en": "Tag of the user", - "de": "Tag des Nutzers" - } + "description": "Tag of the user" }, { "name": "createdAt", - "description": { - "en": "Date when account was created", - "de": "Datum an dem der Account erstellt wurde" - } + "description": "Date when account was created" }, { "name": "tag", - "description": { - "en": "Tag of the user", - "de": "Tag des Nutzers" - } + "description": "Tag of the user" }, { "name": "memberProfilePictureUrl", - "description": { - "en": "URL of the user's avatar", - "de": "URL zum Avatar des Nutzers" - }, + "description": "URL of the user's avatar", "isImage": true }, { "name": "joinedAt", - "description": { - "en": "Date when user joined guild", - "de": "Datum, an dem der Nutzer den Server betreten hat" - } + "description": "Date when user joined guild" }, { "name": "guildUserCount", - "description": { - "en": "Count of users on the guild", - "de": "Anzahl von Nutzern auf dem Server" - } + "description": "Count of users on the guild" }, { "name": "guildMemberCount", - "description": { - "en": "Count of members (without bots) on the guild", - "de": "Anzahl von Nutzern auf dem Server" - } + "description": "Count of members (without bots) on the guild" }, { "name": "mention", - "description": { - "en": "Mention of the user who boosted", - "de": "Erwähnung des Nutzers" - } + "description": "Mention of the user who boosted" }, { "name": "boostCount", - "description": { - "en": "Total count of boosts", - "de": "Gesamte Anzahl an Boosts" - } + "description": "Total count of boosts" }, { "name": "guildLevel", - "description": { - "en": "Boost-Level of the guild after the boost", - "de": "Boost-Level nach dem Boost" - } + "description": "Boost-Level of the guild after the boost" }, { "name": "mention", - "description": { - "en": "Mention of the user who unboosted", - "de": "Erwähnung des Nutzers" - } + "description": "Mention of the user who unboosted" }, { "name": "boostCount", - "description": { - "en": "Total count of boosts", - "de": "Gesamte Anzahl an Boosts" - } + "description": "Total count of boosts" }, { "name": "guildLevel", - "description": { - "en": "Boost-Level of the guild after the unboost", - "de": "Boost-Level nach dem Boost" - } + "description": "Boost-Level of the guild after the unboost" } ] } diff --git a/modules/welcomer/events/guildMemberAdd.js b/modules/welcomer/events/guildMemberAdd.js index e19641e6..0371ed9c 100644 --- a/modules/welcomer/events/guildMemberAdd.js +++ b/modules/welcomer/events/guildMemberAdd.js @@ -70,7 +70,7 @@ module.exports.run = async function (client, guildMember) { )); const memberModel = await moduleModel.findOne({ where: { - userId: guildMember.id, + userID: guildMember.id, channelID: sentMessage.channelId } }); diff --git a/modules/welcomer/events/guildMemberRemove.js b/modules/welcomer/events/guildMemberRemove.js index 6eba7e50..0b386494 100644 --- a/modules/welcomer/events/guildMemberRemove.js +++ b/modules/welcomer/events/guildMemberRemove.js @@ -50,7 +50,7 @@ module.exports.run = async function (client, guildMember) { if (!moduleConfig['delete-welcome-message']) return; const memberModels = await moduleModel.findAll({ where: { - userId: guildMember.id + userID: guildMember.id } }); for (const memberModel of memberModels) { @@ -77,7 +77,7 @@ async function timer(client, userId) { const model = client.models['welcomer']['User']; const timeModel = await model.findOne({ where: { - userId: userId + userID: userId } }); if (timeModel) { diff --git a/modules/welcomer/module.json b/modules/welcomer/module.json index 7abfcc93..c0e4b355 100644 --- a/modules/welcomer/module.json +++ b/modules/welcomer/module.json @@ -5,6 +5,7 @@ "name": "SCDerox (SC Network Team)", "link": "https://github.com/SCDerox" }, + "fa-icon": "fas fa-door-open", "openSourceURL": "https://github.com/SCNetwork/CustomDCBot/tree/main/modules/welcomer", "events-dir": "/events", "models-dir": "/models", @@ -16,12 +17,6 @@ "tags": [ "administration" ], - "humanReadableName": { - "en": "Welcome and Boosts", - "de": "Willkommen und Boosts" - }, - "description": { - "en": "Simple module to say \"Hi\" to new members, give them roles automatically and say \"thanks\" to users who boosted", - "de": "Einfaches Modul zum Begrüßen von neuen Usern, zum automatischen Vergeben von Rollen beim Joinen und zum Bedanken bei Boosts." - } -} \ No newline at end of file + "humanReadableName": "Welcome and Boosts", + "description": "Simple module to say \"Hi\" to new members, give them roles automatically and say \"thanks\" to users who boosted" +} diff --git a/package.json b/package.json index ed3d2874..331ee76f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "start": "node main.js", "test": "npx eslint ./", + "verify-configs": "node scripts/verify-config-defaults.js", "generate-config": "node generate-config.js", "generate-template": "node generate-template.js" }, @@ -30,7 +31,7 @@ "centra": "2.6.0", "discord-api-types": "0.38.37", "discord-logs": "2.2.1", - "discord.js": "14.25.1", + "discord.js": "14.26.2", "dotenv": "16.3.1", "erlpack": "github:discord/erlpack", "fparser": "3.1.0", diff --git a/scripts/verify-config-defaults.js b/scripts/verify-config-defaults.js new file mode 100644 index 00000000..5602291b --- /dev/null +++ b/scripts/verify-config-defaults.js @@ -0,0 +1,340 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const VALID_TYPES = new Set([ + 'string', 'emoji', 'imgURL', 'timezone', + 'boolean', 'integer', 'float', + 'channelID', 'roleID', 'userID', 'guildID', + 'array', 'keyed', 'select' +]); + +let errors = 0; +let warnings = 0; +let filesChecked = 0; +let fieldsChecked = 0; + +function report(level, filePath, fieldName, message) { + const prefix = level === 'error' ? '\x1b[31mERROR\x1b[0m' : '\x1b[33mWARN\x1b[0m'; + const loc = fieldName ? `${filePath} -> ${fieldName}` : filePath; + console.log(` ${prefix}: ${loc}: ${message}`); + if (level === 'error') errors++; + else warnings++; +} + +function isLocalizedObject(value) { + if (value === null || value === undefined) return false; + if (typeof value !== 'object' || Array.isArray(value)) return false; + if (!('en' in value)) return false; + return Object.keys(value).every(k => /^[a-z]{2,3}$/.test(k)); +} + +function resolveDefault(field) { + if (isLocalizedObject(field.default)) return field.default['en']; + return field.default; +} + +function isValidV2Embed(obj) { + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return false; + const validKeys = new Set([ + 'message', 'title', 'description', 'color', 'url', + 'image', 'thumbnail', 'author', 'fields', 'footer', + 'footerImgUrl', 'embedTimestamp', '_schema' + ]); + const hasEmbedKey = obj.title || obj.description || (obj.author && obj.author.name) || obj.image || obj.message; + if (!hasEmbedKey) return false; + + for (const key of Object.keys(obj)) { + if (!validKeys.has(key)) return false; + } + + if (obj.author) { + if (typeof obj.author !== 'object' || Array.isArray(obj.author)) return false; + const authorKeys = new Set(['name', 'img', 'url']); + for (const key of Object.keys(obj.author)) { + if (!authorKeys.has(key)) return false; + } + } + if (obj.fields) { + if (!Array.isArray(obj.fields)) return false; + for (const f of obj.fields) { + if (typeof f.name !== 'string' || typeof f.value !== 'string') return false; + } + } + return true; +} + +function isValidV3Message(obj) { + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return false; + return obj._schema === 'v3'; +} + +function isValidV4Message(obj) { + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return false; + return obj._schema === 'v4'; +} + +function looksLikeV3ButMissingSchema(obj) { + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return false; + if (obj._schema) return false; + // Has v3-specific keys like embeds, content (as top-level message content), buttons, linkButtons, attachmentURLs + return !!(obj.embeds || obj.buttons || obj.linkButtons || obj.attachmentURLs || + (obj.content && !obj.title && !obj.description)); +} + +function verifyField(filePath, field) { + fieldsChecked++; + const name = field.name; + + if (!name) { + report('error', filePath, '(unnamed)', 'Field is missing "name" property'); + return; + } + + if (typeof field.default === 'undefined') { + report('error', filePath, name, 'Missing "default" value'); + return; + } + + if (!field.type) { + report('error', filePath, name, 'Missing "type" property'); + return; + } + + if (!VALID_TYPES.has(field.type)) { + report('error', filePath, name, `Unknown type "${field.type}"`); + return; + } + + const def = resolveDefault(field); + + // allowNull fields with null default are valid + if (field.allowNull && (def === null || def === '')) return; + + switch (field.type) { + case 'boolean': + if (typeof def !== 'boolean') { + report('error', filePath, name, `Type is "boolean" but default is ${JSON.stringify(def)} (${typeof def})`); + } + break; + + case 'integer': + if (def !== '' && def !== null && def !== 0) { + if (typeof def !== 'number' || !Number.isInteger(def)) { + report('error', filePath, name, `Type is "integer" but default is ${JSON.stringify(def)} (${typeof def})`); + } + } + if (typeof def === 'number') { + if (field.maxValue !== undefined && def > field.maxValue) { + report('error', filePath, name, `Default ${def} exceeds maxValue ${field.maxValue}`); + } + if (field.minValue !== undefined && def < field.minValue) { + report('error', filePath, name, `Default ${def} is below minValue ${field.minValue}`); + } + } + break; + + case 'float': + if (def !== '' && def !== null && def !== 0) { + if (typeof def !== 'number') { + report('error', filePath, name, `Type is "float" but default is ${JSON.stringify(def)} (${typeof def})`); + } + } + if (typeof def === 'number') { + if (field.maxValue !== undefined && def > field.maxValue) { + report('error', filePath, name, `Default ${def} exceeds maxValue ${field.maxValue}`); + } + if (field.minValue !== undefined && def < field.minValue) { + report('error', filePath, name, `Default ${def} is below minValue ${field.minValue}`); + } + } + break; + + case 'string': + case 'emoji': + case 'imgURL': + case 'timezone': + if (field.allowEmbed && typeof def === 'object' && def !== null) { + // Embed message — validate schema + if (isValidV3Message(def) || isValidV4Message(def)) { + // v3/v4 with explicit _schema are fine + } else if (looksLikeV3ButMissingSchema(def)) { + report('error', filePath, name, `Default looks like a v3 message (has ${Object.keys(def).filter(k => ['embeds', 'content', 'buttons', 'linkButtons'].includes(k)).join(', ')}) but is missing "_schema": "v3" — will be parsed as v2`); + } else if (!isValidV2Embed(def)) { + report('error', filePath, name, `Default is an object (embed) but has invalid v2 message schema. Keys: ${JSON.stringify(Object.keys(def))}`); + } + } else if (typeof def !== 'string') { + if (field.allowEmbed) { + report('error', filePath, name, `Type is "${field.type}" (allowEmbed) but default is ${typeof def}, not a string or valid embed object`); + } else if (typeof def === 'object' && def !== null && !Array.isArray(def)) { + report('error', filePath, name, `Type is "${field.type}" but default is an object — missing "allowEmbed: true"?`); + } else { + report('error', filePath, name, `Type is "${field.type}" but default is ${JSON.stringify(def)} (${typeof def})`); + } + } + break; + + case 'array': + if (!Array.isArray(def)) { + report('error', filePath, name, `Type is "array" but default is ${JSON.stringify(def)} (${typeof def})`); + } + if (!field.content) { + report('warn', filePath, name, 'Array field is missing "content" (element type)'); + } + break; + + case 'keyed': + if (typeof def !== 'object' || def === null || Array.isArray(def)) { + report('error', filePath, name, `Type is "keyed" but default is ${JSON.stringify(def)} (${typeof def})`); + } + if (!field.content) { + report('warn', filePath, name, 'Keyed field is missing "content" (key/value types)'); + } + break; + + case 'select': + if (!field.content || !Array.isArray(field.content)) { + report('error', filePath, name, 'Select field is missing "content" options array'); + } else { + const options = typeof field.content[0] !== 'string' + ? field.content.map(f => f.value) + : field.content; + if (def !== '' && def !== null && !options.includes(def)) { + report('error', filePath, name, `Default "${def}" is not in select options: [${options.join(', ')}]`); + } + } + break; + + case 'channelID': + case 'roleID': + case 'userID': + case 'guildID': + // These are typically empty strings as defaults (filled at runtime) + if (def !== '' && def !== null && typeof def !== 'string') { + report('error', filePath, name, `Type is "${field.type}" but default is ${JSON.stringify(def)} (${typeof def})`); + } + break; + } + +} + +function verifyConfigFile(filePath) { + filesChecked++; + let data; + try { + data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch (e) { + report('error', filePath, null, `Failed to parse JSON: ${e.message}`); + return; + } + + const relPath = path.relative(process.cwd(), filePath); + + if (!data.content || !Array.isArray(data.content)) { + report('warn', relPath, null, 'No "content" array found — skipping field checks'); + return; + } + + if (!data.filename) { + report('warn', relPath, null, 'Missing "filename" property'); + } + + const fieldNames = new Set(data.content.map(f => f.name)); + + for (const field of data.content) { + verifyField(relPath, field); + + // Verify dependsOn references + if (field.dependsOn && !fieldNames.has(field.dependsOn)) { + report('error', relPath, field.name, `dependsOn references non-existent field "${field.dependsOn}"`); + } + if (field.dependsOnNot && !fieldNames.has(field.dependsOnNot)) { + report('error', relPath, field.name, `dependsOnNot references non-existent field "${field.dependsOnNot}"`); + } + + // Localized defaults are no longer supported + if (isLocalizedObject(field.default)) { + report('error', relPath, field.name, `Default uses deprecated localized format (keys: ${Object.keys(field.default).join(', ')}). Run the conversion script to migrate to external config-localizations`); + } + } + + // Check for multiple elementToggle fields + const toggleFields = data.content.filter(f => f.elementToggle); + if (toggleFields.length > 1) { + report('error', relPath, toggleFields.map(f => f.name).join(', '), `File has ${toggleFields.length} elementToggle fields — only one is supported. Use dependsOn for additional toggles`); + } + + // Check for duplicate field names + const seen = new Set(); + for (const field of data.content) { + if (field.name && seen.has(field.name)) { + report('error', relPath, field.name, 'Duplicate field name'); + } + seen.add(field.name); + } +} + +function discoverConfigFiles() { + const configFiles = []; + + // Core config-generator files + const generatorDir = path.join(__dirname, '..', 'config-generator'); + if (fs.existsSync(generatorDir)) { + for (const f of fs.readdirSync(generatorDir)) { + if (f.endsWith('.json')) { + configFiles.push(path.join(generatorDir, f)); + } + } + } + + // Module config files (discovered via module.json) + const modulesDir = path.join(__dirname, '..', 'modules'); + for (const moduleName of fs.readdirSync(modulesDir)) { + const moduleJsonPath = path.join(modulesDir, moduleName, 'module.json'); + if (!fs.existsSync(moduleJsonPath)) continue; + + let moduleJson; + try { + moduleJson = JSON.parse(fs.readFileSync(moduleJsonPath, 'utf-8')); + } catch { + report('error', `modules/${moduleName}/module.json`, null, 'Failed to parse module.json'); + continue; + } + + const exampleFiles = moduleJson['config-example-files'] || []; + for (const f of exampleFiles) { + const cfgPath = path.join(modulesDir, moduleName, f); + if (fs.existsSync(cfgPath)) { + configFiles.push(cfgPath); + } else { + report('error', `modules/${moduleName}/${f}`, null, 'Config example file listed in module.json but does not exist'); + } + } + } + + return configFiles; +} + +// Main +console.log('\n\x1b[1mVerifying config file default values...\x1b[0m\n'); + +const configFiles = discoverConfigFiles(); + +for (const filePath of configFiles) { + verifyConfigFile(filePath); +} + +console.log(`\n\x1b[1mResults:\x1b[0m ${filesChecked} files, ${fieldsChecked} fields checked`); +if (errors > 0) { + console.log(` \x1b[31m${errors} error(s)\x1b[0m`); +} +if (warnings > 0) { + console.log(` \x1b[33m${warnings} warning(s)\x1b[0m`); +} +if (errors === 0 && warnings === 0) { + console.log(' \x1b[32mAll checks passed!\x1b[0m'); +} + +console.log(''); +process.exit(errors > 0 ? 1 : 0); diff --git a/src/cli.js b/src/cli.js index 73c82b51..11e8bb23 100644 --- a/src/cli.js +++ b/src/cli.js @@ -37,6 +37,7 @@ module.exports.commands = [ if (inputElement.client.logChannel) await inputElement.client.logChannel.send('⚠️️ Configuration reloaded failed. Bot shutting down'); console.log('Reload failed. Exiting'); process.exit(0); + ; }); } }, diff --git a/src/commands/help.js b/src/commands/help.js index 36864556..e16f817b 100644 --- a/src/commands/help.js +++ b/src/commands/help.js @@ -17,9 +17,34 @@ const { MessageFlags } = require('discord.js'); const {localize} = require('../functions/localize'); +const { + loadConfigLocalization, + isLocalizedObject +} = require('../functions/configuration'); const SELECT_MENU_MAX = 25; +/** + * Resolve a module.json string (humanReadableName or description) for the current locale. + * Supports both old {en: ..., de: ...} format and new plain English string format. + */ +function resolveModuleString(client, moduleName, key, fallback) { + const value = client.modules[moduleName]['config'][key]; + if (typeof value === 'object' && value !== null && 'en' in value) { + return value[client.locale] || value['en'] || fallback; + } + if (typeof value === 'string') { + if (client.locale && client.locale !== 'en') { + const locData = loadConfigLocalization(client.locale); + if (locData[moduleName] && locData[moduleName]['_module'] && locData[moduleName]['_module'][key]) { + return locData[moduleName]['_module'][key]; + } + } + return value || fallback; + } + return fallback; +} + module.exports.run = async function (interaction) { const modules = {}; for (const command of interaction.client.commands) { @@ -29,21 +54,44 @@ module.exports.run = async function (interaction) { modules[command.module || 'none'].push(command); } + // Add custom slash commands as their own module group + const customCommands = (interaction.client.config || {}).customCommands || []; + const enabledCustomCommands = customCommands.filter(c => c.type === 'COMMAND' && c.enabled && c.slashCommandName && c.slashCommandDescription); + if (enabledCustomCommands.length > 0) { + modules['custom-commands'] = enabledCustomCommands.map(c => ({ + name: c.slashCommandName, + description: c.slashCommandDescription, + options: (c.slashCommandsOptions || []).map(o => ({ + type: o.type, + name: o.name, + description: o.description, + required: o.required + })) + })); + } + const moduleKeys = Object.keys(modules); const allSelectOptions = []; for (const mod of moduleKeys) { - const label = mod === 'none' - ? interaction.client.strings.helpembed.build_in - : (interaction.client.modules[mod]['config']['humanReadableName'][interaction.client.locale] || - interaction.client.modules[mod]['config']['humanReadableName']['en'] || mod); + let label, desc, emoji; + if (mod === 'none') { + label = interaction.client.strings.helpembed.build_in; + desc = localize('help', 'built-in-description'); + emoji = '⚙️'; + } else if (mod === 'custom-commands') { + label = localize('help', 'custom-commands-label'); + desc = localize('help', 'custom-commands-description'); + emoji = '🔧'; + } else { + label = resolveModuleString(interaction.client, mod, 'humanReadableName', mod); + desc = resolveModuleString(interaction.client, mod, 'description', ''); + emoji = '📦'; + } allSelectOptions.push({ label: truncate(label, 100), value: mod, - description: mod !== 'none' - ? truncate(interaction.client.modules[mod]['config']['description'][interaction.client.locale] || - interaction.client.modules[mod]['config']['description']['en'] || '', 100) - : localize('help', 'built-in-description'), - emoji: mod === 'none' ? '⚙️' : '📦' + description: truncate(desc, 100), + emoji }); } @@ -76,12 +124,19 @@ module.exports.run = async function (interaction) { let moduleList = ''; for (const mod of moduleKeys) { - const label = mod === 'none' - ? interaction.client.strings.helpembed.build_in - : (interaction.client.modules[mod]['config']['humanReadableName'][interaction.client.locale] || - interaction.client.modules[mod]['config']['humanReadableName']['en'] || mod); + let label, emoji; + if (mod === 'none') { + label = interaction.client.strings.helpembed.build_in; + emoji = '⚙️'; + } else if (mod === 'custom-commands') { + label = localize('help', 'custom-commands-label'); + emoji = '🔧'; + } else { + label = resolveModuleString(interaction.client, mod, 'humanReadableName', mod); + emoji = '📦'; + } const cmdNames = modules[mod].map(c => `\`/${c.name}\``).join(', '); - moduleList = moduleList + `${mod === 'none' ? '⚙️' : '📦'} **${label}**: ${truncate(cmdNames, 200)}\n`; + moduleList = moduleList + `${emoji} **${label}**: ${truncate(cmdNames, 200)}\n`; } headerContainer.addTextDisplayComponents(new TextDisplayBuilder().setContent(truncate(moduleList, 4000))); headerContainer.addSeparatorComponents(new SeparatorBuilder().setDivider(true).setSpacing(SeparatorSpacingSize.Small)); @@ -155,21 +210,26 @@ module.exports.run = async function (interaction) { * @returns {Promise} Array of V2 component objects */ async function buildModuleComponents(mod) { - const label = mod === 'none' - ? interaction.client.strings.helpembed.build_in - : (interaction.client.modules[mod]['config']['humanReadableName'][interaction.client.locale] || - interaction.client.modules[mod]['config']['humanReadableName']['en'] || mod); - const description = mod !== 'none' - ? (interaction.client.modules[mod]['config']['description'][interaction.client.locale] || - interaction.client.modules[mod]['config']['description']['en'] || '') - : ''; + let label, description; + if (mod === 'none') { + label = interaction.client.strings.helpembed.build_in; + description = ''; + } else if (mod === 'custom-commands') { + label = localize('help', 'custom-commands-label'); + description = localize('help', 'custom-commands-description'); + } else { + label = resolveModuleString(interaction.client, mod, 'humanReadableName', mod); + description = resolveModuleString(interaction.client, mod, 'description', ''); + } + + const emoji = mod === 'none' ? '⚙️' : mod === 'custom-commands' ? '🔧' : '📦'; const container = new ContainerBuilder() .setAccentColor(parseEmbedColor('GREEN')); const headerSection = new SectionBuilder() .addTextDisplayComponents( - new TextDisplayBuilder().setContent(`# ${mod === 'none' ? '⚙️' : '📦'} ${label}${description ? '\n*' + description + '*' : ''}`) + new TextDisplayBuilder().setContent(`# ${emoji} ${label}${description ? '\n*' + description + '*' : ''}`) ) .setThumbnailAccessory( new ThumbnailBuilder().setURL(interaction.client.user.displayAvatarURL()) diff --git a/src/commands/reload.js b/src/commands/reload.js index a7a26efb..410330ec 100644 --- a/src/commands/reload.js +++ b/src/commands/reload.js @@ -8,15 +8,16 @@ module.exports.run = async function (interaction) { ephemeral: true, content: localize('reload', 'reloading-config') }); - if (interaction.client.logChannel) interaction.client.logChannel.send('🔄 ' + localize('reload', 'reloading-config-with-name', {tag: formatDiscordUserName(interaction.user)})).then(() => { + if (interaction.client.logChannel) interaction.client.logChannel.send('🔄 ' + localize('reload', 'reloading-config-with-name', {tag: formatDiscordUserName(interaction.user)})).catch(() => { }); await reloadConfig(interaction.client).catch((async reason => { - if (interaction.client.logChannel) interaction.client.logChannel.send('⚠️️ ' + localize('reload', 'reload-failed')).then(() => { + if (interaction.client.logChannel) interaction.client.logChannel.send('⚠️️ ' + localize('reload', 'reload-failed')).catch(() => { }); await interaction.editReply({content: localize('reload', 'reload-failed-message', {reason})}); process.exit(0); + ; })).then(async (res) => { - if (interaction.client.logChannel) interaction.client.logChannel.send('✅ ' + localize('reload', 'reloaded-config', res)).then(() => { + if (interaction.client.logChannel) interaction.client.logChannel.send('✅ ' + localize('reload', 'reloaded-config', res)).catch(() => { }); await interaction.editReply(localize('reload', 'reload-successful-syncing-commands')); await syncCommandsIfNeeded(); diff --git a/src/discordjs-fix.js b/src/discordjs-fix.js index b70ef01d..e85a2a2c 100644 --- a/src/discordjs-fix.js +++ b/src/discordjs-fix.js @@ -35,10 +35,75 @@ Discord.Partials = Partials; if (EmbedBuilder && !EmbedBuilder.prototype.addField) { EmbedBuilder.prototype.addField = function (name, value, inline = false) { - return this.addFields({name, value, inline}); + return this.addFields({ + name: name || '\u200b', + value: value || '\u200b', + inline + }); }; } +const originalAddFields = EmbedBuilder.prototype.addFields; +EmbedBuilder.prototype.addFields = function (...fields) { + const normalized = fields.flat().map(f => ({ + ...f, + name: f.name || '\u200b', + value: f.value || '\u200b' + })); + return originalAddFields.call(this, normalized); +}; + +const originalSetDescription = EmbedBuilder.prototype.setDescription; +EmbedBuilder.prototype.setDescription = function (description) { + if (description === '') description = null; + return originalSetDescription.call(this, description); +}; + +const colorNames = { + 'YELLOW': 0xF1C40F, + 'GREEN': 0x2ECC71, + 'GOLD': 0xF1C40F, + 'PURPLE': 0x9B59B6, + 'LUMINOUS_VIVID_PINK': 0xE91E63, + 'FUCHSIA': 0xEB459E, + 'ORANGE': 0xE67E22, + 'DARK_AQUA': 0x11806A, + 'DARK_GREEN': 0x1F8B4C, + 'DARK_BLUE': 0x206694, + 'DARK_VIVID_PINK': 0xAD1457, + 'LIGHT_GREY': 0xBCC0C0, + 'GREYPLE': 0x99AAB5, + 'DARK_BUT_NOT_BLACK': 0x2C2F33, + 'NOT_QUITE_BLACK': 0x23272A, + 'DARK_NAVY': 0x2C3E50, + 'DARK_GOLD': 0xC27C0E, + 'DARK_RED': 0x992D22, + 'DARKER_GREY': 0x7F8C8D, + 'DARK_GREY': 0x979C9F, + 'DARK_ORANGE': 0xA84300, + 'DARK_PURPLE': 0x71368A, + 'GREY': 0x95A5A6, + 'NAVY': 0x34495E, + 'BLURPLE': 0x5865F2, + 'BLUE': 0x3498DB, + 'AQUA': 0x1ABC9C, + 'WHITE': 0xFFFFFF, + 'RED': 0xE74C3C +}; + +function resolveColor(color) { + if (typeof color !== 'string') return color; + const upper = color.toUpperCase(); + if (colorNames[upper]) return colorNames[upper]; + if (color.startsWith('#')) return parseInt(color.replace('#', ''), 16); + return color; +} + +const originalSetColor = EmbedBuilder.prototype.setColor; +EmbedBuilder.prototype.setColor = function (color) { + return originalSetColor.call(this, resolveColor(color)); +}; + const originalButtonSetStyle = ButtonBuilder.prototype.setStyle; ButtonBuilder.prototype.setStyle = function (style) { if (typeof style === 'string') { @@ -102,7 +167,14 @@ function normalizeMessageOptions(options) { const cloned = {...options}; if (cloned.components) cloned.components = normalizeComponents(cloned.components); if (cloned.embeds && Array.isArray(cloned.embeds)) { - cloned.embeds = cloned.embeds.map(e => e?.data ? e : (e instanceof EmbedBuilder ? e : new EmbedBuilder(e))); + cloned.embeds = cloned.embeds.map(e => { + if (e?.data || e instanceof EmbedBuilder) return e; + if (e && typeof e.color === 'string') e = { + ...e, + color: resolveColor(e.color) + }; + return new EmbedBuilder(e); + }); } return cloned; } diff --git a/src/events/botReady.js b/src/events/botReady.js index 32be5b4e..987d3ca8 100644 --- a/src/events/botReady.js +++ b/src/events/botReady.js @@ -1,6 +1,4 @@ module.exports.run = async (client) => { - await client.guild.members.fetch({withPresences: true}).catch(() => { - }); if (client.config.disableStatus) client.user.setActivity(null); else await client.user.setActivity(client.config.user_presence); }; \ No newline at end of file diff --git a/src/events/guildDelete.js b/src/events/guildDelete.js new file mode 100644 index 00000000..f7788d31 --- /dev/null +++ b/src/events/guildDelete.js @@ -0,0 +1,14 @@ +module.exports.run = async (client, guild) => { + if (!client.scnxSetup) return; + if (guild.id !== client.config.guildID) return; + client.logger.error(`Bot was removed from the configured guild (${guild.id}).`); + await require('../functions/scnx-integration').reportIssue(client, { + type: 'CORE_FAILURE', + errorDescription: 'bot_not_on_guild', + errorData: { + inviteURL: `https://discord.com/oauth2/authorize?client_id=${client.user.id}&guild_id=${client.config.guildID}&disable_guild_select=true&permissions=8&scope=bot%20applications.commands` + } + }); +}; + +module.exports.ignoreBotReadyCheck = true; diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 99f0554e..e527820f 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -18,7 +18,7 @@ module.exports.run = async (client, interaction) => { }); } if ((interaction.customId || '').startsWith('cc-') && client.scnxSetup) return require('../functions/scnx-integration').customCommandInteractionClick(interaction); - if (interaction.isSelectMenu() && interaction.customId === 'select-roles' && client.scnxSetup) return require('../functions/scnx-integration').handleSelectRoles(client, interaction); + if (interaction.isSelectMenu() && interaction.customId.startsWith('select-roles') && client.scnxSetup) return require('../functions/scnx-integration').handleSelectRoles(client, interaction); if (interaction.isButton() && interaction.customId.startsWith('srb-') && client.scnxSetup) return require('../functions/scnx-integration').handleRoleButton(client, interaction); if (!interaction.commandName) return; const command = client.commands.find(c => c.name.toLowerCase() === interaction.commandName.toLowerCase()); @@ -54,26 +54,26 @@ module.exports.run = async (client, interaction) => { if (group) return await command.autoComplete[group][subCommand][focusedOption](interaction); else return await command.autoComplete[subCommand][focusedOption](interaction); } catch (e) { - if (client.captureException) client.captureException(e, { + const sentryId = client.captureException ? client.captureException(e, { command: command.name, module: command.module, group, subCommand, focusedOption, userID: interaction.user.id - }); + }) : null; interaction.client.logger.error(localize('command', 'autcomplete-execution-failed', { e, f: focusedOption, c: command.name, g: group || '', s: subCommand || '' - })); + }) + (sentryId ? ` [Sentry: ${sentryId}]` : '')); interaction.respond([]); } } if (!interaction.isCommand()) return; - if (command.restricted === true && !client.config.botOperators.includes(interaction.user.id)) return interaction.reply(embedType(client.strings.not_enough_permissions)); + if (command.restricted === true && !client.config.botOperators.includes(interaction.user.id)) return interaction.reply(embedType(client.strings.not_enough_permissions || '⚠️ Not enough permissions', {}, {ephemeral: true})); client.logger.debug(localize('command', 'used', { tag: command.forceAnonymous ? '????????????' : formatDiscordUserName(interaction.user), @@ -82,7 +82,7 @@ module.exports.run = async (client, interaction) => { })); try { - if (command.options.filter(c => c.type === 'SUB_COMMAND').length === 0) return await command.run(interaction); + if (command.options.filter(c => c.type === 'SUB_COMMAND' || c.type === 'SUB_COMMAND_GROUP').length === 0) return await command.run(interaction); if (!command.subcommands) { interaction.client.logger.error(`Command ${interaction.commandName} has subcommands but does not use the subcommands handler (required).`); return interaction.reply({ @@ -103,6 +103,7 @@ module.exports.run = async (client, interaction) => { subCommand, userID: interaction.user.id }); + console.error(e, traceID); interaction.client.logger.error(localize('command', 'execution-failed', { e, c: command.name, diff --git a/src/functions/configuration.js b/src/functions/configuration.js index 2e500f74..eb47a0e4 100644 --- a/src/functions/configuration.js +++ b/src/functions/configuration.js @@ -13,6 +13,26 @@ const { const {localize} = require('./localize'); const isEqual = require('is-equal'); +// Config localization: load external translation files (cached) +const configLocalizationCache = {}; + +function loadConfigLocalization(locale) { + if (configLocalizationCache[locale]) return configLocalizationCache[locale]; + try { + configLocalizationCache[locale] = JSON.parse(fs.readFileSync(`${__dirname}/../../config-localizations/${locale}.json`, 'utf-8')); + } catch (e) { + configLocalizationCache[locale] = {}; + } + return configLocalizationCache[locale]; +} + +function isLocalizedObject(value) { + if (value === null || value === undefined) return false; + if (typeof value !== 'object' || Array.isArray(value)) return false; + if (!('en' in value)) return false; + return Object.keys(value).every(k => /^[a-z]{2,3}$/.test(k)); +} + const channelTypeMap = { GUILD_TEXT: ChannelType.GuildText, GUILD_CATEGORY: ChannelType.GuildCategory, @@ -81,6 +101,24 @@ async function checkConfigFile(file, moduleName) { return reject(`Not found config example file: ${file}`); } if (!exampleFile) return; + const locScope = builtIn ? '_core' : moduleName; + const locFileName = exampleFile.filename.replace('.json', ''); + + function resolveDefault(field) { + if (isLocalizedObject(field.default)) { + return field.default[client.locale] || field.default['en']; + } + if (['string', 'emoji', 'imgURL'].includes(field.type) && client.locale && client.locale !== 'en') { + const locData = loadConfigLocalization(client.locale); + const fileLocData = locData[locScope] && locData[locScope][locFileName]; + if (fileLocData && fileLocData.content && fileLocData.content[field.name] && + fileLocData.content[field.name].default !== undefined) { + return fileLocData.content[field.name].default; + } + } + return field.default; + } + let forceOverwrite = false; let configData = exampleFile.configElements ? [] : {}; try { @@ -98,7 +136,6 @@ async function checkConfigFile(file, moduleName) { if (typeof configData === 'object') configData = [configData]; else configData = []; } - if (exampleFile.elementLimits) configData = require('./scnx-integration').verifyLimitedConfigElementFile(client, exampleFile, configData); let skipOverwrite = false; if (exampleFile.skipContentCheck) newConfig = configData; @@ -110,12 +147,12 @@ async function checkConfigFile(file, moduleName) { const dependsOnNotField = field.dependsOnNot ? exampleFile.content.find(f => f.name === field.dependsOnNot) : null; if (field.dependsOn && !dependsOnField) return reject(`Depends-On-Field ${field.dependsOn} does not exist.`); if (field.dependsOnNot && !dependsOnNotField) return reject(`Depends-On-Field ${field.dependsOnNotField} does not exist.`); - if (dependsOnField && !(typeof object[dependsOnField.name] === 'undefined' ? (dependsOnField.default[client.locale] || dependsOnField.default['en']) : object[dependsOnField.name])) { - objectData[field.name] = configData[field.name] || (field.default[client.locale] || field.default['en']); // Otherwise disabled fields may be overwritten + if (dependsOnField && !(typeof object[dependsOnField.name] === 'undefined' ? resolveDefault(dependsOnField) : object[dependsOnField.name])) { + objectData[field.name] = configData[field.name] || resolveDefault(field); // Otherwise disabled fields may be overwritten continue; } - if (dependsOnNotField && (typeof object[dependsOnNotField.name] === 'undefined' ? (dependsOnNotField.default[client.locale] || dependsOnNotField.default['en']) : object[dependsOnNotField.name])) { - objectData[field.name] = configData[field.name] || (field.default[client.locale] || field.default['en']); // Otherwise disabled fields may be overwritten + if (dependsOnNotField && (typeof object[dependsOnNotField.name] === 'undefined' ? resolveDefault(dependsOnNotField) : object[dependsOnNotField.name])) { + objectData[field.name] = configData[field.name] || resolveDefault(field); // Otherwise disabled fields may be overwritten continue; } try { @@ -127,15 +164,18 @@ async function checkConfigFile(file, moduleName) { newConfig.push(objectData); } } else { + const elementToggleField = exampleFile.content.find(f => f.elementToggle); + const elementToggleValue = elementToggleField ? !!(typeof configData[elementToggleField.name] === 'undefined' ? resolveDefault(elementToggleField) : configData[elementToggleField.name]) : true; + if (!elementToggleValue) skipOverwrite = true; for (const field of exampleFile.content) { - if (exampleFile.content.find(f => f.elementToggle) && !configData[exampleFile.content.find(f => f.elementToggle).name]) { - skipOverwrite = true; + if (!elementToggleValue) { + newConfig[field.name] = configData[field.name] !== undefined ? configData[field.name] : resolveDefault(field); continue; } const dependsOnField = field.dependsOn ? exampleFile.content.find(f => f.name === field.dependsOn) : null; if (field.dependsOn && !dependsOnField) return reject(`Depends-On-Field ${field.dependsOn} does not exist.`); - if (dependsOnField && !(typeof configData[dependsOnField.name] === 'undefined' ? (dependsOnField.default[client.locale] || dependsOnField.default['en']) : configData[dependsOnField.name])) { - newConfig[field.name] = configData[field.name] || (field.default[client.locale] || field.default['en']); // Otherwise disabled fields may be overwritten + if (dependsOnField && !(typeof configData[dependsOnField.name] === 'undefined' ? resolveDefault(dependsOnField) : configData[dependsOnField.name])) { + newConfig[field.name] = configData[field.name] || resolveDefault(field); // Otherwise disabled fields may be overwritten continue; } try { @@ -157,16 +197,20 @@ async function checkConfigFile(file, moduleName) { const field = {...fieldData}; return new Promise(async (res, rej) => { if (!field.name) return rej('missing fieldname.'); - if (typeof field.default === 'undefined' || typeof field.default.en === 'undefined') { - console.log(field.default); + if (typeof field.default === 'undefined') { return rej('Missing default value on ' + field.name); } - if (typeof field.default !== 'object') return rej(`${field.name} has an invalid default value. The default value needs to be localized. A possible fix could be: default = "${JSON.stringify({en: field.default})}". If you want a default value for all languages, only set the "en" key.`); - field.default = field.default[client.locale] || field.default['en']; + if (isLocalizedObject(field.default)) { + // Old format: {en: ..., de: ...} — backwards compatible + field.default = field.default[client.locale] || field.default['en']; + } else { + // New format: plain value — resolve locale from external file + field.default = resolveDefault(field); + } if (typeof fieldValue === 'undefined') { fieldValue = field.default; return res(fieldValue); - } else if (field.type === 'keyed' && field.disableKeyEdits) for (const key in field.default) if (typeof fieldValue[key] === 'undefined') fieldValue[key] = field.default[key]; + } else if (field.type === 'keyed' && field.disableKeyEdits) for (const key in field.default) if (fieldValue[key] == null) fieldValue[key] = field.default[key]; if (field.allowNull && field.type !== 'boolean' && !fieldValue) return res(fieldValue); if (!await checkType(field, fieldValue)) { if (client.scnxSetup) await require('./scnx-integration').reportIssue(client, { @@ -192,7 +236,7 @@ async function checkConfigFile(file, moduleName) { if (typeof field.default[key] === 'undefined') delete fieldValue[key]; } for (const key in field.default) { - if (typeof fieldValue[key] === 'undefined') fieldValue[key] = field.default[key]; + if (fieldValue[key] == null) fieldValue[key] = field.default[key]; } } if (client.scnxSetup) fieldValue = require('./scnx-integration').setFieldValue(client, field, fieldValue); @@ -236,6 +280,8 @@ async function checkModuleConfig(moduleName, afterCheckEventFile = null) { } module.exports.loadAllConfigs = loadAllConfigs; +module.exports.loadConfigLocalization = loadConfigLocalization; +module.exports.isLocalizedObject = isLocalizedObject; /** * Check type of one field diff --git a/src/functions/helpers.js b/src/functions/helpers.js index 9848e0aa..47174a43 100644 --- a/src/functions/helpers.js +++ b/src/functions/helpers.js @@ -117,6 +117,15 @@ function getGlobalArgs() { globalArgs['%guildID%'] = guild.id; globalArgs['%guildIcon%'] = guild.iconURL() || ''; } + const now = new Date(); + globalArgs['%timestamp%'] = dateToDiscordTimestamp(now); + globalArgs['%shortTime%'] = dateToDiscordTimestamp(now, 't'); + globalArgs['%longTime%'] = dateToDiscordTimestamp(now, 'T'); + globalArgs['%shortDate%'] = dateToDiscordTimestamp(now, 'd'); + globalArgs['%longDate%'] = dateToDiscordTimestamp(now, 'D'); + globalArgs['%shortDateTime%'] = dateToDiscordTimestamp(now, 'f'); + globalArgs['%longDateTime%'] = dateToDiscordTimestamp(now, 'F'); + globalArgs['%relativeTime%'] = dateToDiscordTimestamp(now, 'R'); return globalArgs; } @@ -195,7 +204,7 @@ function embedType(input, args = {}, optionsToKeep = {}, mergeComponentsRows = [ let footer = null; if (!embedData.footer?.disabled) { const footerText = inputReplacer(args, embedData.footer?.text, true) || (client.strings && client.strings.footer); - const footerIconURL = embedData.footer?.iconURL || (client.strings && client.strings.footerImgUrl); + const footerIconURL = (embedData.footer?.iconURL || (client.strings && client.strings.footerImgUrl) || '').trim() || undefined; // Only create footer object if we have valid text if (footerText && footerText.trim().length > 0) { footer = { @@ -207,22 +216,22 @@ function embedType(input, args = {}, optionsToKeep = {}, mergeComponentsRows = [ const fields = []; for (const fieldData of embedData.fields || []) fields.push({ - name: inputReplacer(args, fieldData.name, true) || '\u200B', - value: inputReplacer(args, fieldData.value, true) || '\u200B', + name: truncate(inputReplacer(args, fieldData.name, true) || '\u200B', 256), + value: truncate(inputReplacer(args, fieldData.value, true) || '\u200B', 1024), inline: fieldData.inline }); const embed = new MessageEmbed({ - title: inputReplacer(args, embedData.title, true), - description: inputReplacer(args, embedData.description, true), + title: truncate(inputReplacer(args, embedData.title, true) || '', 256) || undefined, + description: truncate(inputReplacer(args, embedData.description, true) || '', 4096) || undefined, color: parseColor(embedData.color), - thumbnail: embedData.thumbnailURL ? {url: inputReplacer(args, embedData.thumbnailURL)} : null, - image: embedData.imageURL ? {url: inputReplacer(args, embedData.imageURL)} : null, + thumbnail: inputReplacer(args, embedData.thumbnailURL)?.trim() ? {url: inputReplacer(args, embedData.thumbnailURL).trim()} : null, + image: inputReplacer(args, embedData.imageURL)?.trim() ? {url: inputReplacer(args, embedData.imageURL).trim()} : null, timestamp: (embedData.footer?.hideTime || embedData.footer?.disabled || client.strings.disableFooterTimestamp) ? null : new Date(), author: embedData.author?.name ? { - name: inputReplacer(args, embedData.author.name), - iconURL: inputReplacer(args, embedData.author.imageURL, null), - url: inputReplacer(args, embedData.author.url, null) + name: truncate(inputReplacer(args, embedData.author.name), 256), + iconURL: inputReplacer(args, embedData.author.imageURL, null)?.trim() || null, + url: inputReplacer(args, embedData.author.url, null)?.trim() || null } : null, footer, fields @@ -232,7 +241,7 @@ function embedType(input, args = {}, optionsToKeep = {}, mergeComponentsRows = [ optionsToKeep.files = [...(optionsToKeep.files || [])]; for (const url of input.attachmentURLs || []) { - optionsToKeep.files.push({attachment: url}); + if (url && url.trim()) optionsToKeep.files.push({attachment: url.trim()}); } if (optionsToKeep.components) optionsToKeep.components = optionsToKeep.components.map(c => (typeof c.toJSON === 'function' ? c.toJSON() : c)); // polyfill for djs migration @@ -250,19 +259,22 @@ function embedTypeSchemaV2(input, args = {}, optionsToKeep = {}, mergeComponents if (client.scnxSetup) input = require('./scnx-integration').verifyEmbedType(client, input); if (input.title || input.description || (input.author || {}).name || input.image) { const emb = new MessageEmbed(); - if (input['title']) emb.setTitle(inputReplacer(args, input['title'])); - if (input['description']) emb.setDescription(inputReplacer(args, input['description'])); + if (input['title']) emb.setTitle(truncate(inputReplacer(args, input['title']), 256)); + if (input['description']) emb.setDescription(truncate(inputReplacer(args, input['description']), 4096)); if (input['color']) emb.setColor(parseColor(input['color'])); - if (input['url']) emb.setURL(input['url']); - if ((input['image'] || '').replaceAll(' ', '')) emb.setImage(inputReplacer(args, input['image'])); - if ((input['thumbnail'] || '').replaceAll(' ', '')) emb.setThumbnail(inputReplacer(args, input['thumbnail'])); + const resolvedURL = inputReplacer(args, input['url'])?.trim(); + if (resolvedURL) emb.setURL(resolvedURL); + const resolvedImage = inputReplacer(args, input['image'])?.trim(); + if (resolvedImage) emb.setImage(resolvedImage); + const resolvedThumbnail = inputReplacer(args, input['thumbnail'])?.trim(); + if (resolvedThumbnail) emb.setThumbnail(resolvedThumbnail); if (input['author'] && typeof input['author'] === 'object' && (input['author'] || {}).name) emb.setAuthor({ - name: inputReplacer(args, input['author']['name']), - iconURL: (input['author']['img'] || '').replaceAll(' ', '') ? inputReplacer(args, input['author']['img']) : null + name: truncate(inputReplacer(args, input['author']['name']), 256), + iconURL: (input['author']['img'] || '').trim() ? inputReplacer(args, input['author']['img']).trim() : null }); if (typeof input['fields'] === 'object') { input.fields.forEach(f => { - emb.addField(inputReplacer(args, f['name']), inputReplacer(args, f['value']), f['inline']); + emb.addField(truncate(inputReplacer(args, f['name']), 256), truncate(inputReplacer(args, f['value']), 1024), f['inline']); }); } if (!client.strings.disableFooterTimestamp && !input.embedTimestamp) emb.setTimestamp(); @@ -270,7 +282,7 @@ function embedTypeSchemaV2(input, args = {}, optionsToKeep = {}, mergeComponents // Safely set footer with null checks const footerText = input.footer ? inputReplacer(args, input.footer) : (client.strings && client.strings.footer); - const footerIconURL = input.footerImgUrl || (client.strings && client.strings.footerImgUrl); + const footerIconURL = (input.footerImgUrl || (client.strings && client.strings.footerImgUrl) || '').trim() || undefined; if (footerText && footerText.trim().length > 0) { emb.setFooter({ text: footerText, @@ -360,10 +372,10 @@ function buildV4Button(comp, args) { btn.setCustomId(`disabled-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`); } else if (action.type === 'linkButton') { btn.setStyle(ButtonStyle.Link); - if (comp.url) btn.setURL(inputReplacer(args, comp.url)); + if (comp.url) btn.setURL(inputReplacer(args, comp.url).trim()); } } else if (style === 5 && comp.url) { - btn.setURL(inputReplacer(args, comp.url)); + btn.setURL(inputReplacer(args, comp.url).trim()); } else if (comp.custom_id) { btn.setCustomId(comp.custom_id); } @@ -379,16 +391,16 @@ function buildV4Button(comp, args) { * @returns {StringSelectMenuBuilder|null} Built select menu or null if invalid * @private */ -function buildV4StringSelect(comp, args) { +function buildV4StringSelect(comp, args, counters) { if (!Array.isArray(comp.options) || comp.options.length === 0) return null; const select = new StringSelectMenuBuilder(); if (comp.scnx_action) { if (comp.scnx_action.type === 'roleElement') { - select.setCustomId('select-roles'); + select.setCustomId(`select-roles-${counters ? counters.roleSelect++ : 0}`); } else if (comp.scnx_action.type === 'customCommandElement') { - select.setCustomId('cc-select'); + select.setCustomId(`cc-select-${counters ? counters.ccSelect++ : 0}`); } } else if (comp.custom_id) { select.setCustomId(comp.custom_id); @@ -425,7 +437,7 @@ function buildV4StringSelect(comp, args) { * @returns {Object|null} A discord.js builder instance or null if invalid/skipped * @private */ -function buildV4Component(comp, args) { +function buildV4Component(comp, args, counters) { if (!comp || typeof comp !== 'object' || !comp.type) return null; try { @@ -450,7 +462,7 @@ function buildV4Component(comp, args) { if (!item.media || !item.media.url) continue; try { const galleryItem = new MediaGalleryItemBuilder() - .setURL(inputReplacer(args, item.media.url)); + .setURL(inputReplacer(args, item.media.url).trim()); if (item.description) galleryItem.setDescription(truncate(inputReplacer(args, item.description), 1024)); if (item.spoiler) galleryItem.setSpoiler(true); gallery.addItems(galleryItem); @@ -464,7 +476,7 @@ function buildV4Component(comp, args) { } case 13: { // File if (!comp.file || !comp.file.url) return null; - const file = new FileBuilder().setURL(inputReplacer(args, comp.file.url)); + const file = new FileBuilder().setURL(inputReplacer(args, comp.file.url).trim()); if (comp.spoiler) file.setSpoiler(true); return file; } @@ -474,7 +486,7 @@ function buildV4Component(comp, args) { const firstChild = comp.components[0]; if (firstChild && firstChild.type === 3) { // String select menu (max 1 per row) - const select = buildV4StringSelect(firstChild, args); + const select = buildV4StringSelect(firstChild, args, counters); if (!select) return null; row.addComponents(select); } else { @@ -509,7 +521,7 @@ function buildV4Component(comp, args) { if (comp.accessory.type === 11) { // Thumbnail if (comp.accessory.media && comp.accessory.media.url) { - const thumb = new ThumbnailBuilder().setURL(inputReplacer(args, comp.accessory.media.url)); + const thumb = new ThumbnailBuilder().setURL(inputReplacer(args, comp.accessory.media.url).trim()); if (comp.accessory.description) thumb.setDescription(truncate(inputReplacer(args, comp.accessory.description), 1024)); if (comp.accessory.spoiler) thumb.setSpoiler(true); section.setThumbnailAccessory(thumb); @@ -541,7 +553,7 @@ function buildV4Component(comp, args) { let addedChildren = 0; for (const child of comp.components) { try { - const built = buildV4Component(child, args); + const built = buildV4Component(child, args, counters); if (!built) continue; switch (child.type) { case 10: @@ -568,6 +580,10 @@ function buildV4Component(comp, args) { container.addSectionComponents(built); addedChildren++; break; + case 'dynamicImage': + container.addMediaGalleryComponents(built); + addedChildren++; + break; } } catch (e) { client.logger.error(`[embedType/v4] Failed to build container child (type ${child.type}): ${formatV4BuilderError(e)}`); @@ -576,6 +592,11 @@ function buildV4Component(comp, args) { if (addedChildren === 0) return null; return container; } + case 'dynamicImage': { // Placeholder for dynamic image - emits a MediaGallery component at this position + return new MediaGalleryBuilder().addItems( + new MediaGalleryItemBuilder().setURL('attachment://image.png') + ); + } default: return null; } @@ -600,19 +621,34 @@ function embedTypeSchemaV4(input, args = {}, optionsToKeep = {}, mergeComponents optionsToKeep.flags = existingFlags | MessageFlags.IsComponentsV2; const components = []; + + // Save any pre-existing components passed via optionsToKeep (e.g. giveaway buttons) to append last + const keepComponents = (optionsToKeep.components || []).map(c => typeof c.toJSON === 'function' ? c.toJSON() : c); + + const counters = {roleSelect: 0, ccSelect: 0}; for (const comp of input.components || []) { try { - const built = buildV4Component(comp, args); + const built = buildV4Component(comp, args, counters); if (built) components.push(built); } catch (e) { client.logger.error(`[embedType/v4] Failed to build top-level component (type ${(comp || {}).type}): ${formatV4BuilderError(e)}`); } } + // Check if a dynamicImage sentinel exists anywhere (including inside containers) + if ((input.components || []).some(function findSentinel(c) { + return c.type === 'dynamicImage' || (Array.isArray(c.components) && c.components.some(findSentinel)); + })) optionsToKeep._hasDynamicImagePlaceholder = true; + for (const row of mergeComponentsRows) { components.push(row); } + // Append pre-existing components from optionsToKeep at the bottom (e.g. giveaway buttons) + for (const kept of keepComponents) { + components.push(kept); + } + // Add SCNX branding for non-paid plans if (client.scnxSetup && !['PROFESSIONAL', 'PRO', 'ENTERPRISE'].includes(client.scnxData.plan)) { components.push(new TextDisplayBuilder().setContent('-# Powered by scnx.xyz \u26A1')); @@ -630,10 +666,16 @@ module.exports.embedTypeV2 = async function (input, args, otP, mergeComponentsRo let optionsToKeep = embedType(input, args, otP, mergeComponentsRows); if (!optionsToKeep.attachments && client.scnxSetup && (input.dynamicImage || {}).enabled) { optionsToKeep = await require('./scnx-integration').returnDynamicImages(input, optionsToKeep, args); - // For v4, dynamic image was added to files but embeds don't exist; add a File component to display it + // For v4, dynamic image was added to files but embeds don't exist; add a MediaGallery component to display it if ((input._schema || 'v2') === 'v4' && optionsToKeep.files && optionsToKeep.files.length > 0) { - if (!optionsToKeep.components) optionsToKeep.components = []; - optionsToKeep.components.push(new FileBuilder().setURL('attachment://image.png')); + // If a dynamicImage placeholder was placed in the components, the MediaGallery is already in position + if (!optionsToKeep._hasDynamicImagePlaceholder) { + if (!optionsToKeep.components) optionsToKeep.components = []; + optionsToKeep.components.push(new MediaGalleryBuilder().addItems( + new MediaGalleryItemBuilder().setURL('attachment://image.png') + )); + } + delete optionsToKeep._hasDynamicImagePlaceholder; } } return optionsToKeep; @@ -661,6 +703,33 @@ function formatDate(date, skipDiscordFormat = false) { module.exports.formatDate = formatDate; +/** + * Formats a duration (in milliseconds) as a short human-readable string, + * picking the largest meaningful unit. Localized via the `helpers` namespace. + * @param {number} ms Duration in milliseconds + * @return {string} e.g. "2 months", "5 days", "3 hours", "just now" + * @author Simon Csaba + */ +function formatDurationShort(ms) { + if (!Number.isFinite(ms) || ms < 60_000) return localize('helpers', 'duration-just-now'); + const units = [ + ['year', 365 * 24 * 60 * 60 * 1000], + ['month', 30 * 24 * 60 * 60 * 1000], + ['day', 24 * 60 * 60 * 1000], + ['hour', 60 * 60 * 1000], + ['minute', 60 * 1000] + ]; + for (const [unit, size] of units) { + const value = Math.floor(ms / size); + if (value >= 1) { + return localize('helpers', `duration-${unit}${value === 1 ? '' : 's'}`, {i: value}); + } + } + return localize('helpers', 'duration-just-now'); +} + +module.exports.formatDurationShort = formatDurationShort; + /** * Posts (encrypted) content to SC Network Paste * @param {String} content Content to post @@ -731,6 +800,7 @@ module.exports.messageLogToStringToPaste = messageLogToStringToPaste; * @return {string} Truncated string */ function truncate(string, length) { + if (!string) return string; return (string.length > length) ? string.substr(0, length - 3).trim() + '...' : string; } @@ -837,9 +907,10 @@ function compareArrays(array1, array2) { if (array1.length !== array2.length) return false; for (let i = 0, l = array1.length; i < l; i++) { - if (array1[i] instanceof Object) { - for (const key in array1[i]) { - if (array2[key] !== array1[key]) return false; + if (array1[i] instanceof Object || array2[i] instanceof Object) { + const keys = new Set([...Object.keys(array1[i] || {}), ...Object.keys(array2[i] || {})]); + for (const key of keys) { + if ((array1[i][key] ?? null) !== (array2[i][key] ?? null)) return false; } continue; } @@ -934,17 +1005,37 @@ async function lockChannel(channel, allowedRoles = [], reason = localize('main', permissions: Array.from(channel.permissionOverwrites.cache.values()) }); - for (const overwrite of channel.permissionOverwrites.cache.filter(e => e.allow.has(PermissionFlagsBits.SendMessages)).values()) { - if (overwrite.type === 'role' && channel.client.guild.members.me.roles.botRole?.id === overwrite.id) continue; + const allowedRoleSet = new Set(allowedRoles.map(r => typeof r === 'string' ? r : r.id || r)); + const botRoleId = channel.client.guild.members.me.roles.botRole?.id; + + for (const overwrite of channel.permissionOverwrites.cache.values()) { + if (overwrite.id === botRoleId) continue; if (overwrite.type === 'member' && channel.client.user.id === overwrite.id) continue; + if (allowedRoleSet.has(overwrite.id)) continue; + if (overwrite.deny.has(PermissionFlagsBits.SendMessages)) continue; await overwrite.edit({ SendMessages: false, SendMessagesInThreads: false }, reason); } - const everyoneRole = await channel.guild.roles.cache.find(r => r.name === '@everyone'); - if (channel.permissionsFor(everyoneRole).has(PermissionFlagsBits.ViewChannel)) await channel.permissionOverwrites.create(everyoneRole, { + // Also deny roles inheriting SendMessages from the parent category + if (channel.parent) { + for (const [id, catOverwrite] of channel.parent.permissionOverwrites.cache) { + if (catOverwrite.type !== 0) continue; // Only roles + if (id === botRoleId) continue; + if (allowedRoleSet.has(id)) continue; + if (channel.permissionOverwrites.cache.has(id)) continue; // Already handled above + if (!catOverwrite.allow.has(PermissionFlagsBits.SendMessages)) continue; + await channel.permissionOverwrites.create(id, { + SendMessages: false, + SendMessagesInThreads: false + }, {reason}); + } + } + + const everyoneRole = channel.guild.roles.everyone; + await channel.permissionOverwrites.create(everyoneRole, { SendMessages: false, SendMessagesInThreads: false }, {reason}); @@ -1024,14 +1115,30 @@ function disableModule(module, reason = null) { module.exports.disableModule = disableModule; +/** + * Checks whether a module is currently enabled. Prefer this over `client.models[X]` or + * `client.configurations[X]` as enabled-checks — models load for every module directory + * on disk regardless of enabled state, and configurations are only populated when the + * module is enabled. + * @param {Client} client + * @param {String} moduleName + * @returns {Boolean} + */ +function moduleEnabled(client, moduleName) { + return !!(client.modules[moduleName] && client.modules[moduleName].enabled); +} + +module.exports.moduleEnabled = moduleEnabled; + /** * Formates a number to make it human-readable * @param {Number|string} number + * @param {Intl.NumberFormatOptions} [options] * @returns {string} */ -module.exports.formatNumber = function (number) { - if (typeof number === 'string') number = parseInt(number); - return new Intl.NumberFormat(client.locale.split('_')[0], {}).format(number); +module.exports.formatNumber = function (number, options = {}) { + if (typeof number === 'string') number = parseFloat(number); + return new Intl.NumberFormat(client.locale.split('_')[0], options).format(number); }; /** @@ -1050,4 +1157,37 @@ module.exports.shuffleArray = function (input) { [array[i], array[j]] = [array[j], array[i]]; } return array; -} \ No newline at end of file +} + +/** + * Tries to archive a Discord CDN attachment into the guild's scnx file + * library and returns the full archival result. Returns null when the bot + * is running outside an scnx setup (OSS build — scnx-integration is not + * shipped), when archival is disabled, or on any failure. Use this when you + * need to know whether the returned URL will outlive Discord's signed TTL + * — e.g. persisting an attachment URL for later restoration. + * @param {Client} client + * @param {string} url Discord CDN URL + * @param {{displayName?: string, tags?: string[], uploaderDiscordID?: string}} meta + * @returns {Promise<{id: string, url: string, mediaCategory: string, duplicate?: boolean} | null>} + */ +module.exports.tryArchiveDiscordAttachment = async function (client, url, meta = {}) { + if (!client.scnxSetup) return null; + return require('./scnx-integration').archiveDiscordAttachment(client, url, meta); +}; + +/** + * Convenience wrapper around tryArchiveDiscordAttachment — always returns a + * URL. On success, the permanent scnx CDN URL; on any failure (disabled, + * OSS build, rate-limited, quota-exhausted, upstream error), the original + * Discord URL. Use this at display sites where the URL is only needed + * within Discord's signed-TTL window. + * @param {Client} client + * @param {string} url Discord CDN URL + * @param {{displayName?: string, tags?: string[], uploaderDiscordID?: string}} meta + * @returns {Promise} + */ +module.exports.archiveDiscordAttachment = async function (client, url, meta = {}) { + const result = await module.exports.tryArchiveDiscordAttachment(client, url, meta); + return result ? result.url : url; +}; \ No newline at end of file diff --git a/src/functions/localize.js b/src/functions/localize.js index 5b335eda..5b5aacad 100644 --- a/src/functions/localize.js +++ b/src/functions/localize.js @@ -34,7 +34,8 @@ function localize(file, string, replace = {}) { let rs = locals[client.locale][file][string]; if (!rs) rs = locals['en'][file][string]; if (!rs) throw new Error(`String ${file}/${string} not found`); - for (const key in replace) { + // Replace longest keys first to avoid e.g. %user replacing part of %username + for (const key of Object.keys(replace).sort((a, b) => b.length - a.length)) { rs = rs.replaceAll(`%${key}`, replace[key]); } return rs; diff --git a/src/global-params.json b/src/global-params.json new file mode 100644 index 00000000..d0396031 --- /dev/null +++ b/src/global-params.json @@ -0,0 +1,58 @@ +[ + { + "name": "botName", + "description": { + "en": "Display name of the bot", + "de": "Anzeigename des Bots" + } + }, + { + "name": "botID", + "description": { + "en": "User ID of the bot", + "de": "Nutzer-ID des Bots" + } + }, + { + "name": "botAvatar", + "description": { + "en": "URL of the bot's avatar", + "de": "URL des Bot-Avatars" + } + }, + { + "name": "botTag", + "description": { + "en": "Username and tag of the bot (e.g. Bot#1234)", + "de": "Nutzername und Tag des Bots (z.B. Bot#1234)" + } + }, + { + "name": "botMention", + "description": { + "en": "Mention of the bot (renders as a clickable @mention)", + "de": "Erwähnung des Bots (wird als klickbare @Erwähnung angezeigt)" + } + }, + { + "name": "guildName", + "description": { + "en": "Name of the server", + "de": "Name des Servers" + } + }, + { + "name": "guildID", + "description": { + "en": "ID of the server", + "de": "ID des Servers" + } + }, + { + "name": "guildIcon", + "description": { + "en": "URL of the server icon", + "de": "URL des Server-Icons" + } + } +]