├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── pull-request-build.yml │ └── push-build-deploy.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── config ├── beta.yml ├── default.yml ├── local.template.yml ├── main.yml └── template.yml ├── eslint.config.mjs ├── index.ts ├── mojirabot-logo.png ├── package-lock.json ├── package.json ├── restart.sh ├── src ├── BotConfig.ts ├── GuildConfig.ts ├── MojiraBot.ts ├── commands │ ├── BugCommand.ts │ ├── HelpCommand.ts │ ├── MentionCommand.ts │ ├── ModmailBanCommand.ts │ ├── ModmailUnbanCommand.ts │ ├── MooCommand.ts │ ├── PingCommand.ts │ ├── PollCommand.ts │ ├── SearchCommand.ts │ ├── SendCommand.ts │ ├── ShutdownCommand.ts │ ├── TipsCommand.ts │ └── commandHandlers │ │ ├── Command.ts │ │ ├── CommandExecutor.ts │ │ ├── DefaultCommandRegistry.ts │ │ ├── SlashCommand.ts │ │ ├── SlashCommandRegister.ts │ │ └── SlashCommandRegistry.ts ├── events │ ├── EventHandler.ts │ ├── EventRegistry.ts │ ├── discord │ │ └── ErrorEventHandler.ts │ ├── interaction │ │ └── InteractionEventHandler.ts │ ├── internal │ │ └── InternalProgressEventHandler.ts │ ├── mention │ │ └── MentionDeleteEventHandler.ts │ ├── message │ │ ├── MessageDeleteEventHandler.ts │ │ ├── MessageEventHandler.ts │ │ └── MessageUpdateEventHandler.ts │ ├── modmail │ │ ├── ModmailEventHandler.ts │ │ └── ModmailThreadEventHandler.ts │ ├── reaction │ │ ├── ReactionAddEventHandler.ts │ │ └── ReactionRemoveEventHandler.ts │ ├── request │ │ ├── RequestDeleteEventHandler.ts │ │ ├── RequestEventHandler.ts │ │ ├── RequestReactionRemovalEventHandler.ts │ │ ├── RequestReopenEventHandler.ts │ │ ├── RequestResolveEventHandler.ts │ │ ├── RequestUnresolveEventHandler.ts │ │ ├── RequestUpdateEventHandler.ts │ │ └── TestingRequestEventHandler.ts │ └── roles │ │ ├── RoleRemoveEventHandler.ts │ │ └── RoleSelectEventHandler.ts ├── mentions │ ├── Mention.ts │ ├── MentionRegistry.ts │ ├── MultipleMention.ts │ └── SingleMention.ts ├── permissions │ ├── AdminPermission.ts │ ├── AnyPermission.ts │ ├── ModeratorPermission.ts │ ├── OwnerPermission.ts │ ├── Permission.ts │ └── PermissionRegistry.ts ├── tasks │ ├── AddProgressMessageTask.ts │ ├── CachedFilterFeedTask.ts │ ├── FilterFeedTask.ts │ ├── MessageTask.ts │ ├── ResolveRequestMessageTask.ts │ ├── Task.ts │ ├── TaskScheduler.ts │ └── VersionFeedTask.ts ├── types │ └── discord.d.ts └── util │ ├── ChannelConfigUtil.ts │ ├── DiscordUtil.ts │ ├── LoggerUtil.ts │ ├── MarkdownUtil.ts │ ├── NewsUtil.ts │ ├── ReactionsUtil.ts │ ├── RequestsUtil.ts │ └── RoleSelectionUtil.ts ├── start.sh ├── stop.sh └── tsconfig.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## The Bug 11 | 12 | ## Example 13 | 14 | ## Expected behavior 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## The Problem 11 | 12 | ## Possible Solution 13 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | ## Approach 4 | 5 | ## Future work 6 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-build.yml: -------------------------------------------------------------------------------- 1 | name: Build (pull request) 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [18.x] 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - name: Set up cache for NPM modules 26 | uses: actions/cache@v4 27 | with: 28 | path: ~/.npm 29 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 30 | restore-keys: | 31 | ${{ runner.os }}-node- 32 | 33 | - name: Install dependencies 34 | run: npm install 35 | 36 | - name: Check code style 37 | run: npm run lint --if-present 38 | 39 | - name: Validate code 40 | run: npm run validate --if-present 41 | env: 42 | CI: true 43 | -------------------------------------------------------------------------------- /.github/workflows/push-build-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy 2 | 3 | permissions: 4 | id-token: write # Require write permission to Fetch an OIDC token. 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | # Allow manually triggering deployment 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | name: Build and deploy 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x] 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | 30 | - name: Set up cache for NPM modules 31 | uses: actions/cache@v4 32 | with: 33 | path: ~/.npm 34 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 35 | restore-keys: | 36 | ${{ runner.os }}-node- 37 | 38 | - name: Install dependencies 39 | run: npm i --no-audit 40 | 41 | - name: Compile to JavaScript 42 | run: npm run build 43 | env: 44 | CI: true 45 | 46 | - name: Rebuild better-sqlite3 47 | run: | 48 | npm rebuild better_sqlite3 49 | npm rebuild 50 | 51 | - name: Prune dependencies 52 | run: npm prune --production 53 | 54 | - name: Deploy 55 | uses: mojira/deploy@main 56 | with: 57 | azure_client_id: ${{ secrets.AZURE_CLIENT_ID }} 58 | azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} 59 | azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} 60 | bastion_name: ${{ secrets.BASTION_NAME }} 61 | resource_group: ${{ secrets.RESOURCE_GROUP }} 62 | resource_id: ${{ secrets.RESOURCE_ID }} 63 | ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} 64 | username: mojiradiscordbot 65 | artifact_paths: | 66 | bin 67 | node_modules 68 | config/default.* 69 | config/main.* 70 | *.sh 71 | artifact_destination: /home/mojiradiscordbot/mojira-discord-bot 72 | script: | 73 | cd mojira-discord-bot 74 | mv -f /home/mojiradiscordbot/mojira-discord-bot/main.yml /home/mojiradiscordbot/mojira-discord-bot/config/main.yml 75 | ./restart.sh main 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | node_modules 3 | 4 | .vscode/* 5 | .history 6 | **/.DS_Store 7 | .idea/ 8 | 9 | **/*.log 10 | settings.json 11 | config/local.yml 12 | config/local-*.yml 13 | 14 | db.sqlite 15 | 16 | screenlog.* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![](https://img.shields.io/github/issues/mojira/mojira-discord-bot)](https://github.com/mojira/mojira-discord-bot/issues) 3 | [![](https://img.shields.io/github/stars/mojira/mojira-discord-bot)](https://github.com/mojira/mojira-discord-bot/stargazers) 4 | [![](https://img.shields.io/github/license/mojira/mojira-discord-bot)](https://github.com/mojira/mojira-discord-bot/blob/master/LICENSE.md) 5 | 6 | # Mojira Discord Bot 7 | 8 | 9 |
10 |

11 | 12 | MojiraBot 13 | 14 | 15 |

MojiraBot

16 | 17 |

18 | A Discord bot for linking to Mojira tickets and various server moderation tasks. 19 |
20 | Report Bug 21 | · 22 | Request Feature 23 |

24 |

25 | 26 | ## About the project 27 | MojiraBot was written by [violine1101](https://github.com/violine1101) for use with [Node.js](https://nodejs.org), first in a single JavaScript file and later in [TypeScript](https://github.com/Microsoft/TypeScript/). Its purpose is to link to Mojira tickets and provide information about them inside of Discord. It also provides some moderation tools. 28 | 29 | ## Usage 30 | If you want to tinker around with the project on your local PC or run your own instance of the bot, you can simply go ahead! 31 | 32 | Here's a guide on how you can do that. 33 | 34 | ### Prerequisites 35 | You need to have installed Git, node.js, and NPM. 36 | 37 | In order to use a Discord bot, you need to create one on the Discord developer portal. 38 | 39 | ### Cloning the repository 40 | You can download this repository with the following command: 41 | 42 | ``` 43 | git clone https://github.com/mojira/mojira-discord-bot.git 44 | ``` 45 | 46 | ### Installation 47 | In order to install the dependencies, simply use this command: 48 | 49 | ``` 50 | npm install 51 | ``` 52 | 53 | ### Configuration 54 | There are multiple configuration files that are used to configure the bot. They are all located in the directory `config`. 55 | 56 | In order to run the bot, you need to set up a `local.yml` file for the bot's credentials. In order to do so, follow the steps outlined in the `local.template.yml` file. 57 | 58 | There are multiple deployment configurations for different instances of the bot. Currently the following configurations are available: 59 | - `main` – Main MojiraBot on the Mojira Discord server 60 | - `beta` – violine1101's beta testing bot 61 | 62 | You can create another configuration like that for your own bot. 63 | 64 | An overview / documentation of all config options can be found in `template.yml`. 65 | 66 | ### Running 67 | #### Testing / development 68 | For testing or development purposes, it is recommended to run the bot using the following command: 69 | 70 | ``` 71 | NODE_ENV= npm run bot 72 | ``` 73 | 74 | where `` is the name of a deployment configuration (`main` or `beta`). 75 | 76 | #### Deployment 77 | To deploy the bot, you need to run the following command: 78 | 79 | ``` 80 | ./start.sh 81 | ``` 82 | 83 | where `` is the name of a deployment configuration (`main` or `beta`). 84 | 85 | Note that the bot is started in a detached screen, which means you won't see any output and the bot is running in the background. 86 | 87 | You can stop the bot with `./stop.sh ` or restart it with `./restart.sh `. 88 | 89 | ### Minimal bot permissions 90 | For the bot to function properly, the minimal Discord permissions bitfield is `268790848`. 91 | 92 | ## Built with 93 | 94 | This project depends on the following projects, thanks to every developer who makes their code open-source! :heart: 95 | 96 | - [discord.js](https://github.com/discordjs/discord.js/) 97 | - [ESLint](https://github.com/eslint/eslint) 98 | - [jira.js](https://github.com/MrRefactoring/jira.js) 99 | - [JS-YAML](https://github.com/nodeca/js-yaml) 100 | - [log4js](https://github.com/log4js-node/log4js-node) 101 | - [node-config](https://github.com/lorenwest/node-config) 102 | - [TypeScript](https://github.com/Microsoft/TypeScript/) 103 | - [TypeScript ESLint](https://github.com/typescript-eslint/typescript-eslint/) 104 | 105 | ...and of course all the typings from [DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped/)! 106 | 107 | ## Contributing 108 | 109 | You're very welcome to contribute to this project! Please note that this project uses [TypeScript ESLint](https://github.com/typescript-eslint/typescript-eslint/) to ensure consistent code, you can execute `npm run lint` to fix lint warnings and errors automatically. 110 | 111 | ## Found a bug in Minecraft? 112 | 113 | Please head over to [bugs.mojang.com](https://bugs.mojang.com), search whether your bug is already reported and if not, create an account and click the red "Create" button on the top of the page. 114 | 115 | ## License 116 | 117 | Distributed under the GNU General Public License v3.0. See `LICENSE.md` for more information. 118 | -------------------------------------------------------------------------------- /config/beta.yml: -------------------------------------------------------------------------------- 1 | # Settings for violine1101's beta bot 2 | 3 | debug: true 4 | 5 | owners: 6 | - '417403221863301130' # violine1101 7 | 8 | homeChannel: '649027251010142228' 9 | 10 | modmailEnabled: true 11 | modmailChannel: '975742528546545704' 12 | 13 | request: 14 | channels: 15 | - '672114750477303837' 16 | internalChannels: 17 | - '911003147588223016' 18 | requestLimits: 19 | - 5 20 | logChannel: '681145896247099618' 21 | 22 | roleGroups: 23 | - prompt: Please select the role(s) you are interested in, so that we can add you to the appropriate channels. 24 | color: Red 25 | channel: '653602305417150475' 26 | message: '654300040239775784' 27 | radio: false 28 | roles: 29 | - id: '654297808286777404' 30 | title: Test 1 31 | desc: This is the very first role that you can select 32 | emoji: '651840398859304961' 33 | - id: '654297834241130507' 34 | title: Test 2 35 | desc: This is the second role you can select 36 | emoji: '651840436515897354' 37 | - id: '654297849902661673' 38 | title: Test 3 39 | desc: This is the third role you can select 40 | emoji: '651840478957797420' 41 | - id: '654297862867517441' 42 | title: Test 4 43 | emoji: '654297985835859978' 44 | - prompt: Radio test selection 45 | color: Blue 46 | channel: '653602305417150475' 47 | message: '797220404661059595' 48 | radio: true 49 | roles: 50 | - id: '654297808286777404' 51 | title: Test selection \#1 52 | desc: Lorem ipsum 53 | emoji: 1️⃣ 54 | - id: '654297834241130507' 55 | title: Test selection \#2 56 | desc: dolor sit amet 57 | emoji: 2️⃣ 58 | - id: '654297849902661673' 59 | title: Test selection \#3 60 | desc: consetetur sadipscing elitr 61 | emoji: 3️⃣ 62 | - id: '654297862867517441' 63 | title: Test selection \#4 64 | desc: sed diam nonumy eirmod 65 | emoji: 4️⃣ 66 | - prompt: Please select the project(s) you are interested in. 67 | desc: This will give you access to the appropriate channels. 68 | color: Green 69 | channel: '653602305417150475' 70 | message: '797480350384390175' 71 | radio: false 72 | roles: 73 | - id: '654297808286777404' 74 | title: Java Edition (MC) 75 | desc: _Windows, macOS and Linux_ 76 | emoji: '651840398859304961' 77 | - id: '654297834241130507' 78 | title: Bedrock Edition (MCPE) 79 | desc: |- 80 | _Android, iOS, Windows 10 (from the Microsoft Store), 81 | Xbox, Nintendo Switch, Playstation, Fire OS, and Gear VR_ 82 | emoji: '651840436515897354' 83 | - id: '654297849902661673' 84 | title: Minecraft Dungeons (MCD) 85 | desc: _Action-adventure title set in the Minecraft universe_ 86 | emoji: '651840478957797420' 87 | - id: '654297862867517441' 88 | title: Other projects 89 | desc: (BDS, MCL, REALMS, WEB) 90 | emoji: '654297985835859978' 91 | - prompt: Please select the pronoun(s) that you'd like to go by. 92 | desc: This is not mandatory, but we encourage people to use the appropriate pronouns when referring to each other. 93 | color: Blue 94 | channel: '653602305417150475' 95 | message: '797480367014936576' 96 | radio: false 97 | roles: 98 | - id: '654297808286777404' 99 | title: He/Him 100 | emoji: '🇭' 101 | - id: '654297834241130507' 102 | title: She/Her 103 | emoji: '🇸' 104 | - id: '654297849902661673' 105 | title: They/Them 106 | emoji: '🇹' 107 | - id: '654297862867517441' 108 | title: Other Pronoun 109 | desc: (please indicate in your nickname) 110 | emoji: '🇴' 111 | 112 | filterFeeds: 113 | - jql: updated > {{lastRun}} 114 | jqlRemoved: '' 115 | channel: '665904688616701953' 116 | publish: true 117 | interval: 30000 118 | filterFeedEmoji: '😀' 119 | title: '{{num}} tickets have been updated!' 120 | titleSingle: This ticket has just been updated! 121 | 122 | versionFeeds: 123 | #version-feed-test 124 | - projects: 125 | - MC 126 | channel: '816267831993040926' 127 | publish: true 128 | interval: 30000 129 | scope: 5 130 | versionFeedEmoji: '🎉' 131 | actions: 132 | - released 133 | - unreleased 134 | - renamed 135 | 136 | #feed-test 137 | - projects: 138 | - MC 139 | - MCD 140 | - MCPE 141 | channel: '665904688616701953' 142 | interval: 20000 143 | scope: 5 144 | actions: 145 | - created 146 | - archived 147 | - unarchived 148 | - released 149 | - unreleased 150 | - renamed 151 | -------------------------------------------------------------------------------- /config/default.yml: -------------------------------------------------------------------------------- 1 | # General settings 2 | 3 | debug: false 4 | 5 | logDirectory: logs 6 | 7 | ticketUrlsCauseEmbed: false 8 | 9 | quotedTicketsCauseEmbed: false 10 | 11 | forbiddenTicketPrefix: '!' 12 | 13 | requiredTicketPrefix: '' 14 | 15 | embedDeletionEmoji: '🗑️' 16 | 17 | maxSearchResults: 5 18 | 19 | projects: 20 | - BDS 21 | - MC 22 | - MCD 23 | - MCL 24 | - MCLG 25 | - MCPE 26 | - REALMS 27 | - WEB 28 | 29 | request: 30 | invalidTicketEmoji: ⏸ 31 | noLinkEmoji: ⛔ 32 | warningLifetime: 5000 33 | waitingEmoji: ⌛ 34 | 35 | invalidRequestJql: created > -24h 36 | 37 | suggestedEmoji: 38 | - ✅ 39 | - ☑️ 40 | - ❌ 41 | - 💬 42 | 43 | ignorePrependResponseMessageEmoji: ✅ 44 | ignoreResolutionEmoji: 💬 45 | 46 | resolveDelay: 10000 47 | progressMessageAddDelay: 10000 48 | prependResponseMessage: whenResolved 49 | prependResponseMessageInLog: false 50 | 51 | responseMessage: |- 52 | ``` 53 | {{author}} {{url}} 54 | {{message}} 55 | 56 | ``` 57 | -------------------------------------------------------------------------------- /config/local.template.yml: -------------------------------------------------------------------------------- 1 | # 1. Copy this file 2 | # 2. Rename the copy to `local.yml` 3 | # 3. Change the bot token, and your Jira email+personal access token if desired, and save the file 4 | # 5 | # You can add more configs if you want to. 6 | # 7 | # You can also use `local-beta.yml` for the beta bot configuration, or `local-main.yml` for the main configuration. 8 | # 9 | # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 10 | # Never commit any of the local files. 11 | # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 12 | 13 | token: 14 | jiraUser: 15 | jiraPat: 16 | -------------------------------------------------------------------------------- /config/main.yml: -------------------------------------------------------------------------------- 1 | # Settings for the offical Mojira Discord bot 2 | 3 | owners: 4 | - '87225001874325504' # urielsalis 5 | - '137290216691073024' # NeunEinser 6 | - '252267207264960513' # LateLag 7 | - '263098499858563072' # Sonicwave 8 | - '417403221863301130' # violine1101 9 | - '437088929485684737' # SPGoding 10 | 11 | homeChannel: '646317855234850818' 12 | 13 | modmailEnabled: true 14 | modmailChannel: '1122496663966199849' # modmail 15 | 16 | request: 17 | channels: 18 | - '648555618994618378' # java-requests 19 | - '648555751438155776' # bedrock-requests 20 | - '651161365091844116' # other-requests 21 | internalChannels: 22 | - '683038862024638474' # java-pending-requests 23 | - '683038914306506841' # bedrock-pending-requests 24 | - '683040112191340560' # other-pending-requests 25 | requestLimits: 26 | - 30 # Limit for # java-requests 27 | - 30 # Limit for # bedrock-requests 28 | - 45 # Limit for # other-requests 29 | testingRequestChannels: 30 | - '740188001052917801' 31 | - '807240396445843516' 32 | logChannel: '683039388825026562' 33 | 34 | roleGroups: 35 | - prompt: Please select the project(s) you are interested in. 36 | desc: This will give you access to the appropriate channels. 37 | color: Green 38 | channel: '648479533246316555' 39 | message: '692405794305736816' 40 | radio: false 41 | roles: 42 | - id: '648536573675044865' 43 | title: Java Edition (MC) 44 | desc: _Windows, macOS and Linux_ 45 | emoji: '648525192414494730' 46 | - id: '648536590481752074' 47 | title: Bedrock Edition (BDS, MCPE) 48 | desc: |- 49 | _Android, iOS, Windows 10/11, ChromeOS, 50 | Xbox, Nintendo Switch, Playstation, and Amazon Fire_ 51 | emoji: '648474430158405642' 52 | - id: '648536618113826847' 53 | title: Other projects (MCL, REALMS, WEB) 54 | desc: _Projects concerning both Java and Bedrock_ 55 | emoji: '648521149390520320' 56 | - id: '1275788781756223551' 57 | title: Archived projects (MCCE, MCE, MCD, MCLG) 58 | desc: _Read-only access to all archived channels_ 59 | emoji: '1276641238782705704' 60 | 61 | filterFeeds: 62 | #java-triage 63 | - jql: project = MC AND status changed BY 712020:d5700659-5369-4732-bae2-b41f1168d768 AFTER {{lastRun}} 64 | channel: '1275197626719141888' # new channel #java-triage 65 | publish: true 66 | interval: 300000 67 | title: '{{num}} tickets have just been triaged.' 68 | titleSingle: This ticket has just been triaged. 69 | cached: false 70 | 71 | #java-fixes 72 | - jql: project = MC AND resolved > {{lastRun}} AND resolution = Fixed AND fixVersion in unreleasedVersions() 73 | channel: '666349583227682819' 74 | publish: true 75 | interval: 30000 76 | filterFeedEmoji: '🎉' 77 | title: '{{num}} tickets have just been resolved as Fixed!' 78 | titleSingle: This ticket has just been resolved as Fixed! 79 | cached: false 80 | 81 | # bedrock-fixes 82 | - jql: project IN (MCPE, BDS) AND (resolution CHANGED TO Fixed AFTER {{lastRun}} OR fixVersion CHANGED AFTER {{lastRun}}) AND fixVersion != EMPTY 83 | channel: '974302728719314974' 84 | publish: true 85 | interval: 30000 86 | filterFeedEmoji: '🎉' 87 | title: '{{num}} tickets have just been marked as fixed!' 88 | titleSingle: This ticket has just been marked as fixed! 89 | cached: false 90 | 91 | versionFeeds: 92 | #java-fixes 93 | - projects: 94 | - name: MC 95 | id: 10400 96 | channel: '666349583227682819' 97 | publish: true 98 | interval: 10000 99 | scope: 5 100 | versionFeedEmoji: '🎉' 101 | actions: 102 | - released 103 | - unreleased 104 | 105 | #bedrock-fixes 106 | - projects: 107 | - name: MCPE 108 | id: 10200 109 | channel: '974302728719314974' 110 | publish: true 111 | interval: 10000 112 | scope: 5 113 | versionFeedEmoji: '🎉' 114 | actions: 115 | - released 116 | - unreleased 117 | 118 | #version-feed 119 | - projects: 120 | - name: BDS 121 | id: 11700 122 | - name: MC 123 | id: 10400 124 | - name: MCL 125 | id: 11101 126 | - name: MCPE 127 | id: 10200 128 | - name: REALMS 129 | id: 11402 130 | channel: '741600360619049000' 131 | publish: true 132 | interval: 10000 133 | scope: 5 134 | actions: 135 | - created 136 | - archived 137 | - unarchived 138 | - released 139 | - unreleased 140 | - renamed 141 | -------------------------------------------------------------------------------- /config/template.yml: -------------------------------------------------------------------------------- 1 | # Settings template / documentation 2 | 3 | # Whether or not the bot is in debug mode. 4 | # Optional; false by default. 5 | debug: 6 | 7 | # The directory to save logs to; false to disable saving log files. 8 | # Optional; false by default. 9 | logDirectory: 10 | 11 | # Your bot token used to log in to Discord with the bot. 12 | token: 13 | 14 | # Your Jira E-Mail and personal access token. 15 | # Optional; if not assigned a value, the bot will not attempt to log into Jira. 16 | jiraUser: 17 | jiraPat: 18 | 19 | # A list of user IDs for owner only commands. 20 | # Optional; empty by default. 21 | owners: 22 | - 23 | - 24 | - ... 25 | 26 | # The channel ID of the bot's home channel. 27 | homeChannel: 28 | 29 | # Whether or not bot DMs will be forwarded to an internal channel. 30 | modmailEnabled: 31 | 32 | # The channel ID of the channel modmail bot DMs will be sent to. 33 | modmailChannel: 34 | 35 | # Whether the bot should send an embed when a full URL to a ticket gets posted. 36 | ticketUrlsCauseEmbed: 37 | 38 | # Whether the bot should send an embed when a ticket gets posted inside of a quote. 39 | quotedTicketsCauseEmbed: 40 | 41 | # A prefix that prevents the bot from posting an embed for a mentioned ticket. 42 | # If this prefix is longer than 1, none of the characters can be used. 43 | # When omitted or empty, no prefix prevents embeds. 44 | forbiddenTicketPrefix: 45 | 46 | # Prefix that needs to preceed any mentioned ticket in order for the bot to post an embed 47 | # If this prefix is longer than 1, the entire string needs to prefix any mentioned ticket. 48 | # When omitted or empty, no prefix is required for posting embeds. 49 | requiredTicketPrefix: 50 | 51 | # An emoji or emoji ID which, when reacted to a bot embed, deletes it. 52 | embedDeletionEmoji: 53 | 54 | # The maximum number of results returned by the jira search command. 55 | maxSearchResults: 56 | 57 | # The projects the bot should be able to find tickets for. 58 | projects: 59 | - 60 | - 61 | - ... 62 | 63 | # Settings about channels that handle user requests. 64 | request: 65 | # The IDs of the server's request channels. 66 | # Optional; empty by default. 67 | channels: 68 | - 69 | - 70 | - ... 71 | 72 | # The IDs of the corresponding internal channels of the request channels. 73 | # All the messages sent in a request channel by users will be forwarded to the respective internal channel by the bot. 74 | # The length of this array MUST be the same as the length of `channels`. 75 | # You MAY use the same internal channel for multiple times. 76 | # Optional; empty by default. 77 | internalChannels: 78 | - 79 | - 80 | - ... 81 | 82 | # The number of allowed requests per user per day. 83 | # If the number of requests created by a single user exceeds this amount, new requests will not be forwarded to the internal channels. 84 | # The length of this array MUST be the same as the length of `channels`. 85 | # Setting to a negative number will remove the request limit 86 | # Optional; empty by default. 87 | requestLimits: 88 | - 89 | - 90 | - ... 91 | 92 | # The IDs of the server's testing request channels. 93 | # Optional; empty by default. 94 | testingRequestChannels: 95 | - 96 | - 97 | - ... 98 | 99 | # A channel which stores logs like ` resolved as .` 100 | logChannel: 101 | 102 | # An emoji or emoji ID which means that the request doesn't contain any valid ticket ID or ticket link. 103 | noLinkEmoji: 104 | 105 | # An emoji or emoji ID which means that the request contains a ticket that is invalid. 106 | invalidTicketEmoji: 107 | 108 | # The lifetime of the warning about an invalid request, in milliseconds. 109 | warningLifetime: 110 | 111 | # Jira search jql that marks a ticket as invalid to make requests for. 112 | invalidRequestJql: 113 | 114 | # An emoji or emoji ID which means that the request is waiting to be handled. 115 | # The bot will react to every user's request with this emoji. 116 | waitingEmoji: 117 | 118 | # An array of emojis or emoji IDs that volunteers may be interested in. 119 | # Note: volunteers are free to use emoji which aren't in the array. 120 | # This feature is only used to make work more efficient. 121 | # Optional; empty by default. 122 | suggestedEmoji: 123 | - 124 | - 125 | - ... 126 | 127 | # An emoji or emoji ID which, when used, doesn't trigger the response template message. 128 | ignorePrependResponseMessageEmoji: 129 | 130 | # An emoji or emoji ID which, when used, doesn't resolve the request. 131 | ignoreResolutionEmoji: 132 | 133 | # The amount of time in milliseconds between a volunteer reacts to the message and the bot deletes its message. 134 | resolveDelay: 135 | 136 | # The amount of time in milliseconds between a volunteer sends a message and the bot deletes the message and adds it to the internal request. 137 | progressMessageAddDelay: 138 | 139 | # If and when the message defined in `responseMessage` should be added to an entry in the internal requests channel. 140 | # Optional; never by default. 141 | prependResponseMessage: <'never' | 'whenResolved' | 'always'> 142 | 143 | # Whether the message defined in `responseMessage` should be added to the entry in the log channel. 144 | # Optional; false by default. 145 | prependResponseMessageInLog: 146 | 147 | # The response message to be send for easy copying a message to respond to a request. 148 | # Parameters are {{autor}}, {{url}} and {{message}}. 149 | # {{author}} will be replaced by "@DiscordUser#1234". 150 | # {{url}} will be replaced by the link to the message. 151 | # {{message}} will be replaced by a quote of the contents of the message. 152 | # Optional; empty string by default 153 | responseMessage: 154 | 155 | # A list of role groups that users can self-assign roles. 156 | roleGroups: 157 | # A list of self-assignable roles for this group. 158 | - roles: 159 | # The role's ID 160 | - id: 161 | 162 | # The role's name. 163 | title: 164 | 165 | # Optional; the role's description. 166 | desc: 167 | 168 | # The ID of an emoji representing this role. 169 | # This needs to be the ID of a custom emoji currently! 170 | emoji: 171 | 172 | - 173 | - ... 174 | # The prompt that should be shown for the role selection message. 175 | prompt: 176 | # Optional; Further explanation on the role selection. 177 | desc: 178 | # Color of the role selection embed. 179 | color: 180 | # The channel ID of which the role selection message is in. 181 | channel: 182 | # The message ID of this role selection message. 183 | message: 184 | # Whether or not users can only choose one role from this group. 185 | radio: 186 | 187 | # A list of feeds that should be sent when there are unknown tickets in a specific filter. 188 | filterFeeds: 189 | # The filter's JQL query. 190 | - jql: 191 | 192 | # The JQL query for which tickets should be allowed to be reposted that have been posted previously. 193 | # Optional; Only include if cached is set to true 194 | jqlRemoved: 195 | 196 | # The ID of the channel in which the feed should be sent. 197 | channel: 198 | 199 | # The interval of the check for this filter feed, in milliseconds. 200 | interval: 201 | 202 | # The emoji to react to all filter feed messages with. 203 | # Optional; none by default. 204 | filterFeedEmoji: 205 | 206 | # The title for this feed embed. 207 | # {{num}} will be replaced by the number of ticket(s). 208 | title: 209 | 210 | # The message accompanying this feed embed, in case there's only one ticket. 211 | # If this is not set, `title` will be used instead. 212 | titleSingle: 213 | 214 | # Whether the bot should automatically publish feed messages it posts, if the channel is an announcement channel. 215 | # Optional; false by default. 216 | publish: 217 | 218 | # Whether the bot should cache tickets in the filter feed, 219 | # If false, the variable {{lastRun}} should be included in the jql, but when true, the filter feed will be less efficient. 220 | # Optional; true by default. 221 | cached: 222 | 223 | - 224 | - ... 225 | 226 | # A list of feeds that should be sent when there has been a change to a version. 227 | versionFeeds: 228 | # The projects whose versions should be monitored. 229 | - projects: 230 | - 231 | - 232 | - ... 233 | 234 | # The ID of the channel in which the feed should be sent. 235 | channel: 236 | 237 | # The interval of the check for this version feed, in milliseconds. 238 | interval: 239 | 240 | # The emoji to react to all version feed messages with. 241 | # Optional; none by default. 242 | versionFeedEmoji: 243 | 244 | # How many versions should be monitored; only the x latest versions are monitored. 245 | scope: 246 | 247 | # A list of actions that should be included in the version feed. 248 | actions: 249 | - <'created' | 'released' | 'unreleased' | 'archived' | 'unarchived' | 'renamed'> 250 | - ... 251 | 252 | # Whether the bot should automatically publish feed messages it posts, if the channel is an announcement channel. 253 | # Optional; false by default. 254 | publish: 255 | 256 | - 257 | - ... 258 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 2 | import stylistic from "@stylistic/eslint-plugin"; 3 | import globals from "globals"; 4 | import tsParser from "@typescript-eslint/parser"; 5 | import path from "node:path"; 6 | import { fileURLToPath } from "node:url"; 7 | import js from "@eslint/js"; 8 | import { FlatCompat } from "@eslint/eslintrc"; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all 16 | }); 17 | 18 | export default [{ 19 | ignores: [ 20 | // don't ever lint node_modules 21 | "**/node_modules", 22 | // don't lint build output (make sure it's set to your correct build folder name) 23 | "**/bin" 24 | ], 25 | }, ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"), { 26 | files: ["**/*.ts"], 27 | 28 | plugins: { 29 | "@typescript-eslint": typescriptEslint, 30 | "@stylistic": stylistic, 31 | }, 32 | 33 | languageOptions: { 34 | globals: { 35 | ...globals.node, 36 | }, 37 | 38 | parser: tsParser, 39 | ecmaVersion: 5, 40 | sourceType: "module", 41 | 42 | parserOptions: { 43 | project: "tsconfig.json", 44 | }, 45 | }, 46 | 47 | rules: { 48 | "brace-style": ["warn", "1tbs", { 49 | allowSingleLine: true, 50 | }], 51 | 52 | "comma-dangle": ["warn", "always-multiline"], 53 | "comma-spacing": "warn", 54 | "comma-style": "warn", 55 | curly: ["warn", "multi-line", "consistent"], 56 | "dot-location": ["warn", "property"], 57 | "eol-last": "warn", 58 | "@stylistic/indent": ["warn", "tab"], 59 | "keyword-spacing": ["warn"], 60 | 61 | "max-nested-callbacks": ["warn", { 62 | max: 4, 63 | }], 64 | 65 | "max-statements-per-line": ["warn", { 66 | max: 2, 67 | }], 68 | 69 | "no-empty-function": "warn", 70 | "no-floating-decimal": "warn", 71 | "@typescript-eslint/no-floating-promises": "error", 72 | "no-inline-comments": "warn", 73 | "no-lonely-if": "warn", 74 | "no-multi-spaces": "warn", 75 | 76 | "no-multiple-empty-lines": ["warn", { 77 | max: 2, 78 | maxEOF: 1, 79 | maxBOF: 0, 80 | }], 81 | 82 | "no-shadow": "off", 83 | 84 | "@typescript-eslint/no-shadow": ["error", { 85 | allow: ["err", "resolve", "reject"], 86 | }], 87 | 88 | "no-trailing-spaces": ["warn"], 89 | "no-var": "error", 90 | "object-curly-spacing": ["warn", "always"], 91 | "prefer-const": "error", 92 | quotes: ["warn", "single"], 93 | "@stylistic/semi": ["warn"], 94 | "space-before-blocks": "warn", 95 | 96 | "space-before-function-paren": ["warn", { 97 | anonymous: "never", 98 | named: "never", 99 | asyncArrow: "always", 100 | }], 101 | 102 | "space-in-parens": ["warn", "always", { 103 | exceptions: ["empty"], 104 | }], 105 | 106 | "space-infix-ops": "warn", 107 | "space-unary-ops": "warn", 108 | "spaced-comment": "warn", 109 | "template-curly-spacing": ["warn", "always"], 110 | 111 | yoda: ["error", "never", { 112 | exceptRange: true, 113 | }], 114 | }, 115 | }]; 116 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import log4js from 'log4js'; 2 | import BotConfig from './src/BotConfig.js'; 3 | import MojiraBot from './src/MojiraBot.js'; 4 | 5 | log4js.configure( { 6 | appenders: { 7 | console: { type: 'console' }, 8 | }, 9 | categories: { 10 | default: { appenders: ['console'], level: 'info' }, 11 | }, 12 | } ); 13 | 14 | try { 15 | BotConfig.init(); 16 | 17 | const logConfig: log4js.Configuration = { 18 | appenders: { 19 | out: { type: 'stdout' }, 20 | }, 21 | categories: { 22 | default: { appenders: [ 'out' ], level: BotConfig.debug ? 'debug' : 'info' }, 23 | }, 24 | }; 25 | 26 | if ( BotConfig.logDirectory ) { 27 | logConfig.appenders.log = { 28 | type: 'file', 29 | filename: `${ BotConfig.logDirectory }/${ new Date().toJSON().replace( /[:.]/g, '_' ) }.log`, 30 | }; 31 | logConfig.categories.default.appenders.push( 'log' ); 32 | } 33 | 34 | log4js.configure( logConfig ); 35 | 36 | if ( BotConfig.debug ) { 37 | MojiraBot.logger.info( 'Debug mode is activated' ); 38 | } 39 | 40 | if ( BotConfig.logDirectory ) { 41 | MojiraBot.logger.info( `Writing log to ${ logConfig.appenders.log[ 'filename' ] }` ); 42 | } 43 | 44 | await MojiraBot.start(); 45 | } catch ( err ) { 46 | MojiraBot.logger.error( err ); 47 | } 48 | -------------------------------------------------------------------------------- /mojirabot-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mojira/mojira-discord-bot/1ad653b620b5f7c5a49cc06db514523944457824/mojirabot-logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mojira-discord-bot", 3 | "description": "A Discord bot used to link to Mojira bug reports and do various server moderation tasks.", 4 | "license": "GPL-3.0", 5 | "repository": "https://github.com/mojira/mojira-discord-bot", 6 | "type": "module", 7 | "scripts": { 8 | "bot": "tsc && node bin", 9 | "build": "tsc", 10 | "validate": "tsc --noEmit", 11 | "lint": "eslint . --max-warnings 0", 12 | "lintfix": "eslint . --fix" 13 | }, 14 | "dependencies": { 15 | "@discordjs/rest": "^2.4.0", 16 | "better-sqlite3": "^11.3.0", 17 | "config": "^3.3.12", 18 | "discord.js": "^14.16.3", 19 | "emoji-regex": "^10.4.0", 20 | "escape-string-regexp": "^5.0.0", 21 | "jira.js": "^2.20.1", 22 | "js-yaml": "^4.1.0", 23 | "log4js": "^6.4.0" 24 | }, 25 | "devDependencies": { 26 | "@stylistic/eslint-plugin": "^2.8.0", 27 | "@types/config": "^3.3.5", 28 | "@types/node": "^18.0.0", 29 | "@typescript-eslint/eslint-plugin": "^8.8.0", 30 | "@typescript-eslint/parser": "^8.8.0", 31 | "eslint": "^9.11.1", 32 | "typescript": "^5.6.2" 33 | }, 34 | "engines": { 35 | "node": ">=18.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /restart.sh: -------------------------------------------------------------------------------- 1 | ./stop.sh $1 2 | ./start.sh $1 3 | -------------------------------------------------------------------------------- /src/BotConfig.ts: -------------------------------------------------------------------------------- 1 | import { Client, ColorResolvable, Snowflake } from 'discord.js'; 2 | import { Version2Client as JiraClient } from 'jira.js'; 3 | import config from 'config'; 4 | import MojiraBot from './MojiraBot.js'; 5 | import Sqlite3 from 'better-sqlite3'; 6 | import { VersionChangeType } from './tasks/VersionFeedTask.js'; 7 | import SlashCommandRegister from './commands/commandHandlers/SlashCommandRegister.js'; 8 | 9 | function getOrDefault( configPath: string, defaultValue: T ): T { 10 | if ( !config.has( configPath ) ) MojiraBot.logger.debug( `config ${ configPath } not set, assuming default` ); 11 | return config.has( configPath ) ? config.get( configPath ) : defaultValue; 12 | } 13 | 14 | export enum PrependResponseMessageType { 15 | Never = 'never', 16 | WhenResolved = 'whenResolved', 17 | Always = 'always' 18 | } 19 | 20 | export class RequestConfig { 21 | public channels: Snowflake[]; 22 | public internalChannels: Snowflake[]; 23 | public requestLimits: number[]; 24 | public testingRequestChannels: Snowflake[]; 25 | public logChannel: Snowflake; 26 | 27 | public invalidTicketEmoji: string; 28 | public noLinkEmoji: string; 29 | public warningLifetime: number; 30 | public invalidRequestJql: string; 31 | public waitingEmoji: string; 32 | public suggestedEmoji: string[]; 33 | public ignorePrependResponseMessageEmoji: string; 34 | public ignoreResolutionEmoji: string; 35 | public resolveDelay: number; 36 | public progressMessageAddDelay: number; 37 | public prependResponseMessage: PrependResponseMessageType; 38 | public prependResponseMessageInLog: boolean; 39 | public responseMessage: string; 40 | 41 | constructor() { 42 | this.channels = getOrDefault( 'request.channels', [] ); 43 | this.internalChannels = this.channels.length ? config.get( 'request.internalChannels' ) : getOrDefault( 'request.internalChannels', [] ); 44 | this.requestLimits = this.channels.length ? config.get( 'request.requestLimits' ) : getOrDefault( 'request.requestLimits', [] ); 45 | this.testingRequestChannels = getOrDefault( 'request.testingRequestChannels', [] ); 46 | this.logChannel = config.get( 'request.logChannel' ); 47 | 48 | if ( this.channels.length !== this.internalChannels.length ) { 49 | throw new Error( 'There are not exactly as many Request channels and ' ); 50 | } 51 | 52 | this.invalidTicketEmoji = config.get( 'request.invalidTicketEmoji' ); 53 | this.noLinkEmoji = config.get( 'request.noLinkEmoji' ); 54 | this.warningLifetime = config.get( 'request.warningLifetime' ); 55 | this.invalidRequestJql = config.get( 'request.invalidRequestJql' ); 56 | this.waitingEmoji = config.get( 'request.waitingEmoji' ); 57 | this.suggestedEmoji = getOrDefault( 'request.suggestedEmoji', [] ); 58 | this.ignorePrependResponseMessageEmoji = config.get( 'request.ignorePrependResponseMessageEmoji' ); 59 | this.ignoreResolutionEmoji = config.get( 'request.ignoreResolutionEmoji' ); 60 | 61 | this.resolveDelay = config.get( 'request.resolveDelay' ); 62 | this.progressMessageAddDelay = config.get( 'request.progressMessageAddDelay' ); 63 | this.prependResponseMessage = getOrDefault( 'request.prependResponseMessage', PrependResponseMessageType.Never ); 64 | this.prependResponseMessageInLog = getOrDefault( 'request.prependResponseMessageInLog', false ); 65 | this.responseMessage = getOrDefault( 'request.responseMessage', '' ); 66 | } 67 | } 68 | 69 | export interface RoleConfig { 70 | emoji: Snowflake; 71 | title: string; 72 | desc?: string; 73 | id: Snowflake; 74 | } 75 | 76 | export interface RoleGroupConfig { 77 | roles: RoleConfig[]; 78 | prompt: string; 79 | desc?: string; 80 | color: ColorResolvable; 81 | channel: Snowflake; 82 | message?: Snowflake; 83 | radio?: boolean; 84 | } 85 | 86 | export interface FilterFeedConfig { 87 | jql: string; 88 | jqlRemoved?: string; 89 | channel: Snowflake; 90 | interval: number; 91 | filterFeedEmoji: string | Snowflake; 92 | title: string; 93 | titleSingle?: string; 94 | publish?: boolean; 95 | cached?: boolean; 96 | } 97 | 98 | export interface VersionConfig { 99 | name: string; 100 | id: number; 101 | } 102 | 103 | export interface VersionFeedConfig { 104 | projects: VersionConfig[]; 105 | channel: Snowflake; 106 | interval: number; 107 | versionFeedEmoji: string | Snowflake; 108 | scope: number; 109 | actions: VersionChangeType[]; 110 | publish?: boolean; 111 | } 112 | 113 | export default class BotConfig { 114 | public static debug: boolean; 115 | public static logDirectory: false | string; 116 | 117 | private static token: string; 118 | private static jiraPat?: string; 119 | private static jiraUser?: string; 120 | 121 | public static owners: Snowflake[]; 122 | 123 | public static homeChannel: Snowflake; 124 | 125 | public static modmailEnabled: boolean; 126 | public static modmailChannel: Snowflake; 127 | 128 | public static ticketUrlsCauseEmbed: boolean; 129 | public static quotedTicketsCauseEmbed: boolean; 130 | public static requiredTicketPrefix: string; 131 | public static forbiddenTicketPrefix: string; 132 | 133 | public static embedDeletionEmoji: string; 134 | 135 | public static maxSearchResults: number; 136 | 137 | public static projects: string[]; 138 | 139 | public static request: RequestConfig; 140 | 141 | public static roleGroups: RoleGroupConfig[]; 142 | 143 | public static filterFeeds: FilterFeedConfig[]; 144 | public static versionFeeds: VersionFeedConfig[]; 145 | 146 | public static database: Sqlite3.Database; 147 | 148 | public static init(): void { 149 | this.debug = getOrDefault( 'debug', false ); 150 | this.logDirectory = getOrDefault( 'logDirectory', false ); 151 | 152 | this.token = config.get( 'token' ); 153 | this.jiraUser = getOrDefault( 'jiraUser', undefined ); 154 | this.jiraPat = getOrDefault( 'jiraPat', undefined ); 155 | 156 | this.owners = getOrDefault( 'owners', [] ); 157 | 158 | this.homeChannel = config.get( 'homeChannel' ); 159 | 160 | this.modmailEnabled = getOrDefault( 'modmailEnabled', false ); 161 | this.modmailChannel = getOrDefault( 'modmailChannel', '' ); 162 | 163 | this.ticketUrlsCauseEmbed = getOrDefault( 'ticketUrlsCauseEmbed', false ); 164 | this.quotedTicketsCauseEmbed = getOrDefault( 'quotedTicketsCauseEmbed', false ); 165 | 166 | this.forbiddenTicketPrefix = getOrDefault( 'forbiddenTicketPrefix', '' ); 167 | this.requiredTicketPrefix = getOrDefault( 'requiredTicketPrefix', '' ); 168 | 169 | this.embedDeletionEmoji = getOrDefault( 'embedDeletionEmoji', '' ); 170 | 171 | this.maxSearchResults = config.get( 'maxSearchResults' ); 172 | 173 | this.projects = config.get( 'projects' ); 174 | 175 | this.request = new RequestConfig(); 176 | 177 | this.roleGroups = getOrDefault( 'roleGroups', [] ); 178 | 179 | this.filterFeeds = config.get( 'filterFeeds' ); 180 | this.versionFeeds = config.get( 'versionFeeds' ); 181 | 182 | this.database = new Sqlite3( './db.sqlite' ); 183 | } 184 | 185 | public static async login( client: Client ): Promise { 186 | try { 187 | await client.login( this.token ); 188 | await SlashCommandRegister.registerCommands( client, this.token ); 189 | } catch ( err ) { 190 | MojiraBot.logger.error( err ); 191 | return false; 192 | } 193 | return true; 194 | } 195 | 196 | public static jiraLogin(): void { 197 | // TODO: integrate newErrorHandling from Jira.js 198 | MojiraBot.jira = new JiraClient( { 199 | host: 'https://mojira.atlassian.net', 200 | authentication: this.jiraPat === undefined || this.jiraUser === undefined ? undefined : { 201 | basic: { 202 | email: this.jiraUser, 203 | apiToken: this.jiraPat, 204 | }, 205 | }, 206 | } ); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/GuildConfig.ts: -------------------------------------------------------------------------------- 1 | export default class GuildConfiguration { 2 | // nothing here so far 3 | } 4 | -------------------------------------------------------------------------------- /src/MojiraBot.ts: -------------------------------------------------------------------------------- 1 | import { Client, ClientUser, FetchMessagesOptions, GatewayIntentBits, Message, Partials, Snowflake, TextChannel } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import { Version2Client as JiraClient } from 'jira.js'; 4 | import BotConfig from './BotConfig.js'; 5 | import ErrorEventHandler from './events/discord/ErrorEventHandler.js'; 6 | import EventRegistry from './events/EventRegistry.js'; 7 | import MessageDeleteEventHandler from './events/message/MessageDeleteEventHandler.js'; 8 | import MessageEventHandler from './events/message/MessageEventHandler.js'; 9 | import MessageUpdateEventHandler from './events/message/MessageUpdateEventHandler.js'; 10 | import ReactionAddEventHandler from './events/reaction/ReactionAddEventHandler.js'; 11 | import ReactionRemoveEventHandler from './events/reaction/ReactionRemoveEventHandler.js'; 12 | import RequestEventHandler from './events/request/RequestEventHandler.js'; 13 | import RequestResolveEventHandler from './events/request/RequestResolveEventHandler.js'; 14 | import FilterFeedTask from './tasks/FilterFeedTask.js'; 15 | import CachedFilterFeedTask from './tasks/CachedFilterFeedTask.js'; 16 | import TaskScheduler from './tasks/TaskScheduler.js'; 17 | import VersionFeedTask from './tasks/VersionFeedTask.js'; 18 | import DiscordUtil from './util/DiscordUtil.js'; 19 | import { RoleSelectionUtil } from './util/RoleSelectionUtil.js'; 20 | import InteractionEventHandler from './events/interaction/InteractionEventHandler.js'; 21 | 22 | /** 23 | * Core class of MojiraBot 24 | * 25 | * @author violine1101 26 | * @since 2019-12-02 27 | */ 28 | export default class MojiraBot { 29 | public static logger = log4js.getLogger( 'MojiraBot' ); 30 | 31 | public static client: Client = new Client( { 32 | partials: [ 33 | Partials.Message, 34 | Partials.Reaction, 35 | Partials.User, 36 | Partials.Channel, 37 | ], 38 | intents: [ 39 | // TODO: We might not need all of these intents 40 | GatewayIntentBits.Guilds, 41 | GatewayIntentBits.GuildBans, 42 | GatewayIntentBits.GuildEmojisAndStickers, 43 | GatewayIntentBits.GuildIntegrations, 44 | GatewayIntentBits.GuildWebhooks, 45 | GatewayIntentBits.GuildInvites, 46 | GatewayIntentBits.GuildVoiceStates, 47 | GatewayIntentBits.GuildMessages, 48 | GatewayIntentBits.GuildMessageReactions, 49 | GatewayIntentBits.GuildMessageTyping, 50 | GatewayIntentBits.DirectMessages, 51 | GatewayIntentBits.DirectMessageReactions, 52 | GatewayIntentBits.DirectMessageTyping, 53 | GatewayIntentBits.MessageContent, 54 | ], 55 | allowedMentions: { 56 | parse: ['users'], 57 | }, 58 | } ); 59 | 60 | private static running = false; 61 | private static botUser: ClientUser; 62 | 63 | public static jira: JiraClient; 64 | 65 | public static async start(): Promise { 66 | if ( this.running ) { 67 | this.logger.error( 'MojiraBot is still running. You can only start a bot that is not currently running.' ); 68 | return; 69 | } 70 | 71 | // Ensure graceful shutdown 72 | process.on( 'SIGTERM', async () => { 73 | this.logger.info( 'The bot process has been terminated (SIGTERM).' ); 74 | 75 | await MojiraBot.shutdown(); 76 | } ); 77 | 78 | process.on( 'SIGINT', async () => { 79 | this.logger.info( 'The bot process has been terminated (SIGINT).' ); 80 | 81 | await MojiraBot.shutdown(); 82 | } ); 83 | 84 | try { 85 | BotConfig.jiraLogin(); 86 | const loginResult = await BotConfig.login( this.client ); 87 | if ( !loginResult || !this.client.user ) return; 88 | 89 | BotConfig.database.exec( 'CREATE TABLE IF NOT EXISTS modmail_bans (\'user\' varchar)' ); 90 | BotConfig.database.exec( 'CREATE TABLE IF NOT EXISTS modmail_threads (\'user\' varchar, \'thread\' varchar)' ); 91 | 92 | this.botUser = this.client.user; 93 | this.running = true; 94 | this.logger.info( `MojiraBot has been started successfully. Logged in as ${ this.botUser.tag }` ); 95 | 96 | // Register events. 97 | EventRegistry.setClient( this.client ); 98 | EventRegistry.add( new ErrorEventHandler() ); 99 | 100 | for ( const group of BotConfig.roleGroups ) { 101 | const channel = await DiscordUtil.getChannel( group.channel ); 102 | if ( channel && channel instanceof TextChannel ) { 103 | try { 104 | try { 105 | await RoleSelectionUtil.updateRoleSelectionMessage( group ); 106 | } catch ( error ) { 107 | MojiraBot.logger.error( error ); 108 | } 109 | } catch ( err ) { 110 | this.logger.error( err ); 111 | } 112 | } 113 | } 114 | 115 | const requestChannels: TextChannel[] = []; 116 | const internalChannels = new Map(); 117 | const requestLimits = new Map(); 118 | 119 | if ( BotConfig.request.channels ) { 120 | for ( let i = 0; i < BotConfig.request.channels.length; i++ ) { 121 | const requestChannelId = BotConfig.request.channels[i]; 122 | const internalChannelId = BotConfig.request.internalChannels[i]; 123 | const requestLimit = BotConfig.request.requestLimits[i]; 124 | try { 125 | const requestChannel = await DiscordUtil.getChannel( requestChannelId ); 126 | const internalChannel = await DiscordUtil.getChannel( internalChannelId ); 127 | if ( requestChannel instanceof TextChannel && internalChannel instanceof TextChannel ) { 128 | requestChannels.push( requestChannel ); 129 | internalChannels.set( requestChannelId, internalChannelId ); 130 | requestLimits.set( requestChannelId, requestLimit ); 131 | 132 | // https://stackoverflow.com/questions/55153125/fetch-more-than-100-messages 133 | const allMessages: Message[] = []; 134 | let lastId: Snowflake | undefined; 135 | let continueSearch = true; 136 | 137 | while ( continueSearch ) { 138 | const options: FetchMessagesOptions = { limit: 50 }; 139 | if ( lastId ) { 140 | options.before = lastId; 141 | } 142 | const messages = await internalChannel.messages.fetch( options ); 143 | allMessages.push( ...messages.values() ); 144 | lastId = messages.last()?.id; 145 | if ( messages.size !== 50 || !lastId ) { 146 | continueSearch = false; 147 | } 148 | } 149 | this.logger.info( `Fetched ${ allMessages.length } messages from "${ internalChannel.name }"` ); 150 | 151 | // Resolve pending resolved requests 152 | const handler = new RequestResolveEventHandler( this.botUser.id ); 153 | for ( const message of allMessages ) { 154 | message.reactions.cache.forEach( async reaction => { 155 | const users = await reaction.users.fetch(); 156 | const user = [...users.values()].find( v => v.id !== this.botUser.id ); 157 | if ( user ) { 158 | try { 159 | await handler.onEvent( reaction, user ); 160 | } catch ( error ) { 161 | MojiraBot.logger.error( error ); 162 | } 163 | } 164 | } ); 165 | } 166 | } 167 | } catch ( err ) { 168 | this.logger.error( err ); 169 | } 170 | } 171 | 172 | const newRequestHandler = new RequestEventHandler( internalChannels, requestLimits ); 173 | for ( const requestChannel of requestChannels ) { 174 | this.logger.info( `Catching up on requests from #${ requestChannel.name }...` ); 175 | 176 | let lastId: Snowflake | undefined = undefined; 177 | 178 | let pendingRequests: Message[] = []; 179 | 180 | let foundLastBotReaction = false; 181 | while ( !foundLastBotReaction ) { 182 | let fetchedMessages = await requestChannel.messages.fetch( { before: lastId } ); 183 | 184 | if ( fetchedMessages.size === 0 ) break; 185 | 186 | fetchedMessages = fetchedMessages.sort( ( a: Message, b: Message ) => { 187 | return a.createdAt < b.createdAt ? -1 : 1; 188 | } ); 189 | 190 | for ( const messageId of fetchedMessages.keys() ) { 191 | const message = fetchedMessages.get( messageId ); 192 | const hasBotReaction = message.reactions.cache.find( reaction => reaction.me ) !== undefined; 193 | const hasReactions = message.reactions.cache.size > 0; 194 | 195 | if ( hasBotReaction ) { 196 | foundLastBotReaction = true; 197 | } else if ( !hasReactions ) { 198 | pendingRequests.push( message ); 199 | } 200 | } 201 | 202 | lastId = fetchedMessages.firstKey(); 203 | } 204 | 205 | pendingRequests = pendingRequests.sort( ( a: Message, b: Message ) => { 206 | return a.createdAt < b.createdAt ? -1 : 1; 207 | } ); 208 | 209 | for ( const message of pendingRequests ) { 210 | try { 211 | await newRequestHandler.onEvent( message ); 212 | } catch ( error ) { 213 | MojiraBot.logger.error( error ); 214 | } 215 | } 216 | } 217 | 218 | this.logger.info( 'Fully caught up on requests.' ); 219 | } 220 | 221 | EventRegistry.add( new ReactionAddEventHandler( this.botUser.id, internalChannels, requestLimits ) ); 222 | EventRegistry.add( new ReactionRemoveEventHandler( this.botUser.id ) ); 223 | EventRegistry.add( new MessageEventHandler( this.botUser.id, internalChannels, requestLimits ) ); 224 | EventRegistry.add( new MessageUpdateEventHandler( this.botUser.id, internalChannels ) ); 225 | EventRegistry.add( new MessageDeleteEventHandler( this.botUser.id, internalChannels ) ); 226 | EventRegistry.add( new InteractionEventHandler( this.client ) ); 227 | 228 | // #region Schedule tasks. 229 | // Filter feed tasks. 230 | for ( const config of BotConfig.filterFeeds ) { 231 | const channel = await DiscordUtil.getChannel( config.channel ); 232 | if ( channel === undefined || !channel.isSendable() ) continue; 233 | 234 | if ( config.cached ) { 235 | TaskScheduler.addTask( 236 | new CachedFilterFeedTask( config, channel ), 237 | config.interval 238 | ); 239 | } else { 240 | TaskScheduler.addTask( 241 | new FilterFeedTask( config, channel ), 242 | config.interval 243 | ); 244 | } 245 | } 246 | 247 | // Version feed tasks. 248 | for ( const config of BotConfig.versionFeeds ) { 249 | const channel = await DiscordUtil.getChannel( config.channel ); 250 | if ( channel === undefined || !channel.isSendable() ) continue; 251 | 252 | TaskScheduler.addTask( 253 | new VersionFeedTask( config, channel ), 254 | config.interval 255 | ); 256 | } 257 | // #endregion 258 | 259 | // TODO Change to custom status when discord.js#3552 is merged into current version of package 260 | try { 261 | this.botUser.setActivity( '/help' ); 262 | } catch ( error ) { 263 | MojiraBot.logger.error( error ); 264 | } 265 | 266 | const homeChannel = await DiscordUtil.getChannel( BotConfig.homeChannel ); 267 | 268 | if ( homeChannel instanceof TextChannel ) { 269 | await ( homeChannel as TextChannel ).send( 'Hey, I have been restarted!' ); 270 | } 271 | } catch ( err ) { 272 | this.logger.error( `MojiraBot could not be started: ${ err }` ); 273 | await this.shutdown(); 274 | } 275 | } 276 | 277 | public static async shutdown(): Promise { 278 | if ( !this.running ) { 279 | this.logger.error( 'MojiraBot is not running yet. You can only shut down a running bot.' ); 280 | return; 281 | } 282 | 283 | this.logger.info( 'Initiating graceful shutdown...' ); 284 | 285 | try { 286 | TaskScheduler.clearAll(); 287 | await this.client.destroy(); 288 | this.running = false; 289 | this.logger.info( 'MojiraBot has been successfully shut down.' ); 290 | 291 | log4js.shutdown( ( err ) => { 292 | if ( err ) { 293 | console.log( err ); 294 | } 295 | process.exit(); 296 | } ); 297 | } catch ( err ) { 298 | this.logger.error( `MojiraBot could not be shut down: ${ err }` ); 299 | } 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /src/commands/BugCommand.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, ChatInputCommandInteraction } from 'discord.js'; 2 | import Command from './commandHandlers/Command.js'; 3 | import { MentionRegistry } from '../mentions/MentionRegistry.js'; 4 | import BotConfig from '../BotConfig.js'; 5 | import { ChannelConfigUtil } from '../util/ChannelConfigUtil.js'; 6 | import SlashCommand from './commandHandlers/SlashCommand.js'; 7 | 8 | export default class BugCommand extends SlashCommand { 9 | public slashCommandBuilder = this.slashCommandBuilder 10 | .setName( 'bug' ) 11 | .setDescription( 'Creates a embed with info from a ticket in Jira.' ) 12 | .addStringOption( option => 13 | option.setName( 'ticket-id' ) 14 | .setDescription( 'The ID of the ticket.' ) 15 | .setRequired( true ) 16 | ); 17 | 18 | public async run( interaction: ChatInputCommandInteraction ): Promise { 19 | if ( interaction.channel === null ) return false; 20 | 21 | if ( ChannelConfigUtil.commandsDisabled( interaction.channel ) ) return false; 22 | 23 | const tickets = interaction.options.getString( 'ticket-id' )?.split( /\s+/ig ); 24 | 25 | if ( tickets == null ) return false; 26 | 27 | const ticketRegex = new RegExp( `\\s*((?:${ BotConfig.projects.join( '|' ) })-\\d+)\\s*` ); 28 | 29 | for ( const ticket of tickets ) { 30 | if ( !ticketRegex.test( ticket ) ) { 31 | try { 32 | await interaction.reply( { content: `'${ ticket }' is not a valid ticket ID.`, ephemeral: true } ); 33 | } catch ( err ) { 34 | Command.logger.log( err ); 35 | return false; 36 | } 37 | return true; 38 | } 39 | } 40 | 41 | const mention = MentionRegistry.getMention( tickets, interaction.channel ); 42 | 43 | let embed: EmbedBuilder; 44 | try { 45 | embed = await mention.getEmbed(); 46 | } catch ( err ) { 47 | try { 48 | await interaction.reply( { content: err, ephemeral: true } ); 49 | } catch ( err ) { 50 | Command.logger.log( err ); 51 | return false; 52 | } 53 | return true; 54 | } 55 | 56 | if ( embed === undefined ) return false; 57 | 58 | embed.setFooter( { text: interaction.user.tag, iconURL: interaction.user.avatarURL() ?? undefined } ) 59 | .setTimestamp( interaction.createdAt ); 60 | 61 | try { 62 | await interaction.reply( { embeds: [embed] } ); 63 | } catch ( err ) { 64 | Command.logger.error( err ); 65 | return false; 66 | } 67 | 68 | return true; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/commands/HelpCommand.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, EmbedBuilder } from 'discord.js'; 2 | import BotConfig from '../BotConfig.js'; 3 | import SlashCommand from './commandHandlers/SlashCommand.js'; 4 | 5 | export default class HelpCommand extends SlashCommand { 6 | public readonly slashCommandBuilder = this.slashCommandBuilder 7 | .setName( 'help' ) 8 | .setDescription( 'Sends a help message.' ); 9 | 10 | public async run( interaction: ChatInputCommandInteraction ): Promise { 11 | try { 12 | const embed = new EmbedBuilder(); 13 | embed.setTitle( '<:mojira:821162280905211964> **MojiraBot help** <:mojira:821162280905211964>' ) 14 | .setDescription( `This is a bot that links to a Mojira ticket when its ticket number is mentioned. 15 | Currently, the following projects are supported: ${ BotConfig.projects.join( ', ' ) } 16 | To prevent the bot from linking a ticket, preface the ticket number with an exclamation mark. 17 | 18 | This bot is continuously being worked on and this will receive more features in the future. 19 | It is not possible to invite this bot to other servers yet. 20 | If you have any issues, feel free to ping <@417403221863301130>. 21 | 22 | (For help with the bug tracker or this Discord server, use \`/tips\`)`.replace( /\t/g, '' ) ) 23 | .addFields( { 24 | name: 'Bot Commands', 25 | value: `\`/help\` - Sends this message. 26 | 27 | \`/ping\` - Sends a message to check if the bot is running. 28 | 29 | \`/search \` - Searches for text and returns the results from the bug tracker. 30 | 31 | \`/tips\` - Sends helpful info on how to use the bug tracker and this Discord server.`, 32 | } ) 33 | .setFooter( { text: interaction.user.tag, iconURL: interaction.user.avatarURL() ?? undefined } ); 34 | await interaction.reply( { embeds: [embed], ephemeral: true } ); 35 | } catch { 36 | return false; 37 | } 38 | 39 | return true; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/MentionCommand.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, Message } from 'discord.js'; 2 | import Command from './commandHandlers/Command.js'; 3 | import { MentionRegistry } from '../mentions/MentionRegistry.js'; 4 | import BotConfig from '../BotConfig.js'; 5 | import { ChannelConfigUtil } from '../util/ChannelConfigUtil.js'; 6 | import DiscordUtil from '../util/DiscordUtil.js'; 7 | 8 | export default class MentionCommand extends Command { 9 | public static get ticketPattern(): string { 10 | return `(?(?:${ BotConfig.projects.join( '|' ) })-\\d+)`; 11 | } 12 | 13 | /** 14 | * @returns A NEW regex object every time. You have to store it as a variable if you use `exec` on it, otherwise you will encounter infinite loops. 15 | */ 16 | public static getTicketIdRegex(): RegExp { 17 | return new RegExp( `(?<=^|[^${ BotConfig.forbiddenTicketPrefix }])(?<=${ BotConfig.requiredTicketPrefix })(${ MentionCommand.ticketPattern })`, 'g' ); 18 | } 19 | 20 | /** 21 | * @returns A NEW regex object every time. You have to store it as a variable if you use `exec` on it, otherwise you will encounter infinite loops. 22 | */ 23 | public static getTicketLinkRegex(): RegExp { 24 | return new RegExp( `https?://bugs\\.mojang\\.com/(?:browse|projects/\\w+/issues)/${ MentionCommand.ticketPattern }`, 'g' ); 25 | } 26 | 27 | public test( messageText: string ): boolean | string[] { 28 | 29 | // replace all issues posted in the form of a link from the search either with a mention or remove them 30 | if ( !BotConfig.ticketUrlsCauseEmbed || BotConfig.requiredTicketPrefix ) { 31 | messageText = messageText.replace( 32 | MentionCommand.getTicketLinkRegex(), 33 | BotConfig.ticketUrlsCauseEmbed ? `${ BotConfig.requiredTicketPrefix }$1` : '' 34 | ); 35 | } 36 | 37 | if ( !BotConfig.quotedTicketsCauseEmbed ) { 38 | messageText = messageText 39 | .split( '\n' ) 40 | .filter( line => !line.startsWith( '> ' ) ) 41 | .join( '\n' ); 42 | } 43 | 44 | let ticketMatch: RegExpExecArray | null; 45 | const ticketIdRegex = MentionCommand.getTicketIdRegex(); 46 | const ticketMatches: Set = new Set(); 47 | 48 | while ( ( ticketMatch = ticketIdRegex.exec( messageText ) ) !== null ) { 49 | ticketMatches.add( ticketMatch[1] ); 50 | } 51 | 52 | return ticketMatches.size ? Array.from( ticketMatches ) : false; 53 | } 54 | 55 | public async run( message: Message, args: string[] ): Promise { 56 | if ( ChannelConfigUtil.mentionsDisabled( message.channel ) ) return false; 57 | 58 | if ( !message.channel.isSendable() ) { 59 | return false; 60 | } 61 | 62 | const mention = MentionRegistry.getMention( args, message.channel ); 63 | 64 | let embed: EmbedBuilder; 65 | try { 66 | embed = await mention.getEmbed(); 67 | } catch ( jiraError ) { 68 | try { 69 | Command.logger.info( `Error when retreiving issue information: ${ jiraError.message }` ); 70 | await message.channel.send( `${ message.author } ${ jiraError.message }` ); 71 | } catch ( discordError ) { 72 | Command.logger.error( discordError ); 73 | } 74 | return false; 75 | } 76 | 77 | if ( embed === undefined ) return false; 78 | let messageAuthorNormalizedTag: string; 79 | if ( message.author.discriminator === '0' ) { 80 | messageAuthorNormalizedTag = message.author.username; 81 | } else { 82 | messageAuthorNormalizedTag = message.author.tag; 83 | } 84 | embed.setFooter( { text: messageAuthorNormalizedTag, iconURL: message.author.avatarURL() ?? undefined } ) 85 | .setTimestamp( message.createdAt ); 86 | 87 | try { 88 | await DiscordUtil.sendMentionMessage( message, { embeds: [embed] } ); 89 | } catch ( err ) { 90 | Command.logger.error( err ); 91 | return false; 92 | } 93 | 94 | if ( message.deletable ) { 95 | const matchesTicketId = message.content.match( new RegExp( `^\\s*${ BotConfig.requiredTicketPrefix }${ MentionCommand.ticketPattern }\\s*$` ) ); 96 | const matchesTicketUrl = message.content.match( new RegExp( `^\\s*https?://bugs.mojang.com/browse/${ MentionCommand.ticketPattern }\\s*$` ) ); 97 | 98 | if ( matchesTicketId || ( BotConfig.ticketUrlsCauseEmbed && matchesTicketUrl ) ) { 99 | try { 100 | await message.delete(); 101 | } catch ( err ) { 102 | Command.logger.error( err ); 103 | } 104 | } 105 | } 106 | 107 | return true; 108 | } 109 | 110 | public asString( args: string[] ): string { 111 | return '[mention] ' + args.join( ', ' ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/commands/ModmailBanCommand.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction } from 'discord.js'; 2 | import BotConfig from '../BotConfig.js'; 3 | import PermissionRegistry from '../permissions/PermissionRegistry.js'; 4 | import SlashCommand from './commandHandlers/SlashCommand.js'; 5 | 6 | export default class ModmailBanCommand extends SlashCommand { 7 | public readonly slashCommandBuilder = this.slashCommandBuilder 8 | .setName( 'modmailban' ) 9 | .setDescription( 'Ban a user from using the modmail system.' ) 10 | .addUserOption( option => 11 | option.setName( 'user' ) 12 | .setDescription( 'The user to ban.' ) 13 | .setRequired( true ) 14 | ); 15 | 16 | public readonly permissionLevel = PermissionRegistry.ADMIN_PERMISSION; 17 | 18 | public async run( interaction: ChatInputCommandInteraction ): Promise { 19 | 20 | const args = interaction.options.getUser( 'user', true ); 21 | 22 | try { 23 | BotConfig.database.prepare( 24 | `INSERT INTO modmail_bans (user) 25 | VALUES (?)` 26 | ).run( args.id ); 27 | } catch { 28 | return false; 29 | } 30 | 31 | try { 32 | await interaction.reply( `Banned user ${ args.toString() } from ModMail.` ); 33 | } catch { 34 | return false; 35 | } 36 | 37 | return true; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/commands/ModmailUnbanCommand.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction } from 'discord.js'; 2 | import BotConfig from '../BotConfig.js'; 3 | import PermissionRegistry from '../permissions/PermissionRegistry.js'; 4 | import SlashCommand from './commandHandlers/SlashCommand.js'; 5 | 6 | export default class ModmailUnbanCommand extends SlashCommand { 7 | public readonly slashCommandBuilder = this.slashCommandBuilder 8 | .setName( 'modmailunban' ) 9 | .setDescription( 'Unbans a user from using the modmail system.' ) 10 | .addUserOption( option => 11 | option.setName( 'user' ) 12 | .setDescription( 'The user to unban.' ) 13 | .setRequired( true ) 14 | ); 15 | 16 | public readonly permissionLevel = PermissionRegistry.ADMIN_PERMISSION; 17 | 18 | public async run( interaction: ChatInputCommandInteraction ): Promise { 19 | 20 | const args = interaction.options.getUser( 'user', true ); 21 | 22 | try { 23 | const unban = BotConfig.database.prepare( 24 | `DELETE FROM modmail_bans 25 | WHERE user = ?` 26 | ).run( args.id ); 27 | if ( unban.changes == 0 ) { 28 | await interaction.reply( { content: 'User was never banned.', ephemeral: true } ); 29 | 30 | return true; 31 | } 32 | } catch { 33 | return false; 34 | } 35 | 36 | try { 37 | await interaction.reply( `${ args.toString() } has been unbanned from using modmail` ); 38 | } catch { 39 | return false; 40 | } 41 | 42 | return true; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/commands/MooCommand.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, Message } from 'discord.js'; 2 | import { SingleMention } from '../mentions/SingleMention.js'; 3 | import { ReactionsUtil } from '../util/ReactionsUtil.js'; 4 | import SlashCommand from './commandHandlers/SlashCommand.js'; 5 | 6 | export default class MooCommand extends SlashCommand { 7 | public readonly slashCommandBuilder = this.slashCommandBuilder 8 | .setName( 'moo' ) 9 | .setDescription( 'Mooooo.' ); 10 | 11 | public async run( interaction: ChatInputCommandInteraction ): Promise { 12 | try { 13 | if ( interaction.channel === null ) return false; 14 | const mention = new SingleMention( 'MC-772', interaction.channel ); 15 | const embed = await mention.getEmbed(); 16 | embed.setFooter( { text: interaction.user.tag, iconURL: interaction.user.avatarURL() ?? undefined } ); 17 | const message = await interaction.reply( { embeds: [embed], fetchReply: true } ); 18 | if ( message instanceof Message ) { 19 | await ReactionsUtil.reactToMessage( message, ['🐮', '🐄', '🥛'] ); 20 | } 21 | } catch { 22 | return false; 23 | } 24 | 25 | return true; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/commands/PingCommand.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction } from 'discord.js'; 2 | import SlashCommand from './commandHandlers/SlashCommand.js'; 3 | 4 | export default class PingCommand extends SlashCommand { 5 | public readonly slashCommandBuilder = this.slashCommandBuilder 6 | .setName( 'ping' ) 7 | .setDescription( 'Check if MojiraBot is online.' ); 8 | 9 | public async run( interaction: ChatInputCommandInteraction ): Promise { 10 | let message; 11 | 12 | try { 13 | message = await interaction.reply( { content: `${ interaction.user.toString() } Pong!`, fetchReply: true } ); 14 | } catch { 15 | return false; 16 | } 17 | 18 | try { 19 | await message.react( '🏓' ); 20 | } catch { 21 | return false; 22 | } 23 | 24 | return true; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/commands/PollCommand.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, Message, EmbedBuilder } from 'discord.js'; 2 | import Command from './commandHandlers/Command.js'; 3 | import emojiRegex from 'emoji-regex'; 4 | import PermissionRegistry from '../permissions/PermissionRegistry.js'; 5 | import { ReactionsUtil } from '../util/ReactionsUtil.js'; 6 | import SlashCommand from './commandHandlers/SlashCommand.js'; 7 | 8 | interface PollOption { 9 | emoji: string; 10 | emojiName?: string; 11 | rawEmoji: string; 12 | text: string; 13 | } 14 | 15 | export default class PollCommand extends SlashCommand { 16 | public readonly slashCommandBuilder = this.slashCommandBuilder 17 | .setName( 'poll' ) 18 | .setDescription( 'Create a poll.' ) 19 | .addStringOption( option => 20 | option.setName( 'title' ) 21 | .setDescription( 'The title of the poll.' ) 22 | .setRequired( true ) 23 | ) 24 | .addStringOption( option => 25 | option.setName( 'choices' ) 26 | .setDescription( 'The choices to include in the poll, separated by the \'~\' character.' ) 27 | .setRequired( true ) 28 | ); 29 | 30 | 31 | public readonly permissionLevel = PermissionRegistry.MODERATOR_PERMISSION; 32 | 33 | private async sendSyntaxMessage( interaction: ChatInputCommandInteraction, additionalInfo?: string ): Promise { 34 | try { 35 | if ( additionalInfo != undefined ) { 36 | additionalInfo += '\n'; 37 | } else { 38 | additionalInfo = ''; 39 | } 40 | 41 | await interaction.reply( { 42 | content: `${ additionalInfo }Choice syntax: 43 | \`\`\` 44 | []~ []~... 45 | \`\`\``.replace( /\t/g, '' ), 46 | ephemeral: true, 47 | } ); 48 | } catch ( err ) { 49 | Command.logger.error( err ); 50 | } 51 | } 52 | 53 | private async sendPollMessage( interaction: ChatInputCommandInteraction, title: string, options: PollOption[] ): Promise { 54 | const embed = new EmbedBuilder(); 55 | embed.setTitle( 'Poll' ) 56 | .setFooter( { text: interaction.user.tag, iconURL: interaction.user.avatarURL() ?? undefined } ) 57 | .setTimestamp( interaction.createdAt ) 58 | .setColor( 'Green' ); 59 | 60 | if ( title ) { 61 | embed.setDescription( title ); 62 | } 63 | 64 | if ( !options.length ) { 65 | options.push( { 66 | emoji: '👍', 67 | rawEmoji: '👍', 68 | text: 'Yes', 69 | } ); 70 | options.push( { 71 | emoji: '👎', 72 | rawEmoji: '👎', 73 | text: 'No', 74 | } ); 75 | } 76 | 77 | for ( const option of options ) { 78 | embed.addFields( { name: option.emoji, value: option.text, inline: true } ); 79 | } 80 | 81 | let poll = await interaction.reply( { embeds: [embed], allowedMentions: { parse: [] }, fetchReply: true } ); 82 | 83 | if ( poll instanceof Array ) { 84 | if ( poll.length == 0 ) { 85 | Command.logger.error( 'No message returned from posted poll message!' ); 86 | return; 87 | } 88 | poll = poll[0]; 89 | } 90 | 91 | const reactions = options.map( option => option.rawEmoji ); 92 | 93 | if ( poll instanceof Message ) { 94 | await ReactionsUtil.reactToMessage( poll, reactions ); 95 | } 96 | } 97 | 98 | public async run( interaction: ChatInputCommandInteraction ): Promise { 99 | const pollOptions = interaction.options.getString( 'choices' )?.split( '~' ); 100 | 101 | if ( pollOptions == null ) return false; 102 | 103 | const options: PollOption[] = []; 104 | 105 | for ( const option of pollOptions ) { 106 | if ( /^\s*$/.test( option ) ) { 107 | continue; 108 | } 109 | 110 | const optionArgs = /^\s*(\S+)\s+(.+)\s*$/.exec( option ); 111 | 112 | const customEmoji = /^/; 113 | const unicodeEmoji = emojiRegex(); 114 | 115 | if ( !optionArgs ) { 116 | await this.sendSyntaxMessage( interaction, 'Incorrect choice syntax.' ); 117 | return true; 118 | } 119 | 120 | const emoji = optionArgs[1]; 121 | if ( customEmoji.test( emoji ) || unicodeEmoji.test( emoji ) ) { 122 | let emojiName = emoji; 123 | let rawEmoji = emoji; 124 | const emojiMatch = customEmoji.exec( emoji ); 125 | if ( emojiMatch ) { 126 | emojiName = emojiMatch[1]; 127 | rawEmoji = emojiMatch[2]; 128 | } 129 | options.push( { 130 | emoji: emoji, 131 | emojiName: emojiName, 132 | rawEmoji: rawEmoji, 133 | text: optionArgs[2], 134 | } ); 135 | } else { 136 | await this.sendSyntaxMessage( interaction, `**Error:** ${ emoji } is not a valid emoji.` ); 137 | return true; 138 | } 139 | } 140 | 141 | const title = interaction.options.getString( 'title' ); 142 | 143 | if ( title == null ) return false; 144 | 145 | await this.sendPollMessage( interaction, title, options ); 146 | 147 | return true; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/commands/SearchCommand.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, escapeMarkdown, ChatInputCommandInteraction } from 'discord.js'; 2 | import SlashCommand from './commandHandlers/SlashCommand.js'; 3 | import BotConfig from '../BotConfig.js'; 4 | import MojiraBot from '../MojiraBot.js'; 5 | import { ChannelConfigUtil } from '../util/ChannelConfigUtil.js'; 6 | 7 | export default class SearchCommand extends SlashCommand { 8 | public readonly slashCommandBuilder = this.slashCommandBuilder 9 | .setName( 'search' ) 10 | .setDescription( 'Search for issues in Jira.' ) 11 | .addStringOption( option => 12 | option.setName( 'query' ) 13 | .setDescription( 'The query to search for.' ) 14 | .setRequired( true ) 15 | ); 16 | 17 | public async run( interaction: ChatInputCommandInteraction ): Promise { 18 | const plainArgs = interaction.options.getString( 'query' )?.replace( /"|<|>/g, '' ); 19 | 20 | if ( plainArgs == null ) return false; 21 | 22 | try { 23 | const embed = new EmbedBuilder(); 24 | const searchFilter = `(description ~ "${ plainArgs }" OR summary ~ "${ plainArgs }") AND project in (${ BotConfig.projects.join( ', ' ) })`; 25 | const searchResults = await MojiraBot.jira.issueSearch.searchForIssuesUsingJql( { 26 | jql: searchFilter, 27 | maxResults: BotConfig.maxSearchResults, 28 | fields: [ 'key', 'summary' ], 29 | } ); 30 | 31 | if ( !searchResults.issues ) { 32 | embed.setTitle( `No results found for "${ escapeMarkdown( plainArgs ) }"` ); 33 | await interaction.reply( { embeds: [embed], ephemeral: true } ); 34 | return true; 35 | } 36 | 37 | embed.setTitle( '**Results:**' ); 38 | embed.setFooter( { text: interaction.user.tag, iconURL: interaction.user.avatarURL() ?? undefined } ); 39 | 40 | for ( const issue of searchResults.issues ) { 41 | embed.addFields( { 42 | name: issue.key, 43 | value: `[${ issue.fields.summary }](https://bugs.mojang.com/browse/${ issue.key })`, 44 | } ); 45 | } 46 | 47 | const escapedJql = encodeURIComponent( searchFilter ).replace( /\(/g, '%28' ).replace( /\)/g, '%29' ); 48 | embed.setDescription( `__[See all results](https://bugs.mojang.com/issues/?jql=${ escapedJql })__` ); 49 | 50 | if ( interaction.channel !== null && ChannelConfigUtil.publicSearch( interaction.channel ) ) { 51 | await interaction.reply( { embeds: [embed], ephemeral: false } ); 52 | } else { 53 | await interaction.reply( { embeds: [embed], ephemeral: true } ); 54 | } 55 | } catch { 56 | const embed = new EmbedBuilder(); 57 | embed.setTitle( `No results found for "${ escapeMarkdown( plainArgs ) }"` ); 58 | await interaction.reply( { embeds: [embed], ephemeral: true } ); 59 | return false; 60 | } 61 | 62 | return true; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/commands/SendCommand.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, TextChannel, NewsChannel, ChatInputCommandInteraction } from 'discord.js'; 2 | import PermissionRegistry from '../permissions/PermissionRegistry.js'; 3 | import SlashCommand from './commandHandlers/SlashCommand.js'; 4 | 5 | export default class SendCommand extends SlashCommand { 6 | public readonly slashCommandBuilder = this.slashCommandBuilder 7 | .setName( 'send' ) 8 | .setDescription( 'Send a message to a channel as the bot.' ) 9 | .addChannelOption( option => 10 | option.setName( 'channel' ) 11 | .setDescription( 'The channel to send the message to.' ) 12 | .setRequired( true ) 13 | ) 14 | .addStringOption( option => 15 | option.setName( 'message-type' ) 16 | .setDescription( 'The type of message to send. Either text or embed.' ) 17 | .setRequired( true ) 18 | .addChoices( 19 | { name: 'text', value: 'text' }, 20 | { name: 'embed', value: 'embed' } 21 | ) 22 | ) 23 | .addStringOption( option => 24 | option.setName( 'message' ) 25 | .setDescription( 'The message to send.' ) 26 | .setRequired( true ) 27 | ); 28 | 29 | public readonly permissionLevel = PermissionRegistry.OWNER_PERMISSION; 30 | 31 | public async run( interaction: ChatInputCommandInteraction ): Promise { 32 | const channel = interaction.options.getChannel( 'channel' ); 33 | const messageType = interaction.options.getString( 'message-type' ); 34 | const content = interaction.options.getString( 'message' ); 35 | 36 | if ( channel == null || messageType == null || content == null ) return false; 37 | 38 | if ( channel instanceof TextChannel || channel instanceof NewsChannel ) { 39 | if ( messageType === 'text' ) { 40 | try { 41 | await channel.send( content ); 42 | await interaction.reply( { content: 'Message sent.' } ); 43 | } catch { 44 | return false; 45 | } 46 | } else if ( messageType === 'embed' ) { 47 | try { 48 | const embed = new EmbedBuilder(); 49 | embed.setDescription( content ); 50 | await channel.send( { embeds: [embed] } ); 51 | await interaction.reply( { content: 'Message sent.' } ); 52 | } catch { 53 | return false; 54 | } 55 | } 56 | } else { 57 | await interaction.reply( { content: `**Error:** ${ channel.name } is not a valid channel. `, ephemeral: true } ); 58 | return true; 59 | } 60 | 61 | return true; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/commands/ShutdownCommand.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction } from 'discord.js'; 2 | import MojiraBot from '../MojiraBot.js'; 3 | import PermissionRegistry from '../permissions/PermissionRegistry.js'; 4 | import SlashCommand from './commandHandlers/SlashCommand.js'; 5 | 6 | export default class ShutdownCommand extends SlashCommand { 7 | public readonly slashCommandBuilder = this.slashCommandBuilder 8 | .setName( 'shutdown' ) 9 | .setDescription( 'Shutdown MojiraBot.' ); 10 | 11 | public readonly permissionLevel = PermissionRegistry.OWNER_PERMISSION; 12 | 13 | public async run( interaction: ChatInputCommandInteraction ): Promise { 14 | try { 15 | await interaction.reply( { content: 'Shutting down MojiraBot...' } ); 16 | await MojiraBot.shutdown(); 17 | } catch { 18 | return false; 19 | } 20 | 21 | return true; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/commands/TipsCommand.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, EmbedBuilder } from 'discord.js'; 2 | import SlashCommand from './commandHandlers/SlashCommand.js'; 3 | 4 | export default class TipsCommand extends SlashCommand { 5 | public readonly slashCommandBuilder = this.slashCommandBuilder 6 | .setName( 'tips' ) 7 | .setDescription( 'Get some tips on how to use the Mojira Discord Server.' ); 8 | 9 | public async run( interaction: ChatInputCommandInteraction ): Promise { 10 | try { 11 | const embed = new EmbedBuilder(); 12 | embed.setDescription( `__Welcome to the Mojira Discord Server!__ 13 | 14 | For help with using the bug tracker, there is an article on the Minecraft website that you can read: . 15 | 16 | How to use this server: 17 | Start by choosing which bug tracker projects you would like to be a part of in <#648479533246316555>. 18 | Afterwards, you can use corresponding request channels in each project to make requests for changes to tickets on the bug tracker, like resolutions and adding affected versions. 19 | The moderators and helpers of the bug tracker will then be able to see the requests and resolve them.`.replace( /\t/g, '' ) ) 20 | .setFooter( { text: interaction.user.tag, iconURL: interaction.user.avatarURL() ?? undefined } ); 21 | await interaction.reply( { embeds: [embed], ephemeral: true } ); 22 | } catch { 23 | return false; 24 | } 25 | 26 | return true; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/commandHandlers/Command.ts: -------------------------------------------------------------------------------- 1 | import { Message, GuildMember } from 'discord.js'; 2 | import Permission from '../../permissions/Permission.js'; 3 | import PermissionRegistry from '../../permissions/PermissionRegistry.js'; 4 | import log4js from 'log4js'; 5 | 6 | /** 7 | * Interface for bot commands 8 | * 9 | * @author violine1101 10 | * @since 2019-12-04 11 | */ 12 | export default abstract class Command { 13 | public static logger = log4js.getLogger( 'CommandExecutor' ); 14 | 15 | public readonly permissionLevel: Permission = PermissionRegistry.ANY_PERMISSION; 16 | 17 | public checkPermission( member: GuildMember ): boolean { 18 | return this.permissionLevel.checkPermission( member ); 19 | } 20 | 21 | /** 22 | * @returns `false` if this is not a valid command 23 | * @returns `true` if it is a valid command but doesn't have any arguments 24 | * @returns string (or list) of arguments if it is a valid command 25 | * 26 | * @param messageText The text that came with the message 27 | */ 28 | public abstract test( messageText: string ): boolean | string | string[]; 29 | public abstract run( message: Message, args?: string | string[] ): Promise; 30 | 31 | public abstract asString( args?: string | string[] ): string; 32 | } 33 | -------------------------------------------------------------------------------- /src/commands/commandHandlers/CommandExecutor.ts: -------------------------------------------------------------------------------- 1 | import Command from './Command.js'; 2 | import { Message } from 'discord.js'; 3 | import DefaultCommandRegistry from './DefaultCommandRegistry.js'; 4 | 5 | export default class CommandExecutor { 6 | public static async checkCommands( message: Message ): Promise { 7 | for ( const commandName in DefaultCommandRegistry ) { 8 | const command = DefaultCommandRegistry[commandName] as Command; 9 | 10 | if ( message.member && command.checkPermission( message.member ) ) { 11 | const commandTestResult = command.test( message.content ); 12 | 13 | if ( commandTestResult === false ) continue; 14 | 15 | const args = commandTestResult === true ? '' : commandTestResult; 16 | 17 | Command.logger.info( `User ${ message.author.tag } ran command ${ command.asString( args ) }` ); 18 | return await command.run( message, args ); 19 | } 20 | } 21 | 22 | return false; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/commands/commandHandlers/DefaultCommandRegistry.ts: -------------------------------------------------------------------------------- 1 | import MentionCommand from '../MentionCommand.js'; 2 | 3 | export default class DefaultCommandRegistry { 4 | public static MENTION_COMMAND = new MentionCommand(); 5 | } 6 | -------------------------------------------------------------------------------- /src/commands/commandHandlers/SlashCommand.ts: -------------------------------------------------------------------------------- 1 | import Permission from '../../permissions/Permission.js'; 2 | import PermissionRegistry from '../../permissions/PermissionRegistry.js'; 3 | import log4js from 'log4js'; 4 | import { ChatInputCommandInteraction, GuildMember } from 'discord.js'; 5 | import { SlashCommandBuilder } from '@discordjs/builders'; 6 | 7 | export default abstract class SlashCommand { 8 | public static logger = log4js.getLogger( 'SlashCommandExecutor' ); 9 | 10 | public slashCommandBuilder: SlashCommandBuilder = new SlashCommandBuilder(); 11 | 12 | public readonly permissionLevel: Permission = PermissionRegistry.ANY_PERMISSION; 13 | 14 | public checkPermission( member: GuildMember ): boolean { 15 | return this.permissionLevel.checkPermission( member ); 16 | } 17 | 18 | public abstract run( interaction: ChatInputCommandInteraction ): Promise; 19 | 20 | public asString( interaction: ChatInputCommandInteraction ): string { 21 | return interaction.toString(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/commands/commandHandlers/SlashCommandRegister.ts: -------------------------------------------------------------------------------- 1 | import SlashCommand from './SlashCommand.js'; 2 | import SlashCommandRegistry from './SlashCommandRegistry.js'; 3 | import { REST } from '@discordjs/rest'; 4 | import { Routes } from 'discord-api-types/v9'; 5 | import { Client, Collection, RESTPostAPIApplicationCommandsJSONBody, ChatInputCommandInteraction, GuildMember } from 'discord.js'; 6 | import { SlashCommandJsonData } from '../../types/discord.js'; 7 | import { ChannelConfigUtil } from '../../util/ChannelConfigUtil.js'; 8 | 9 | export default class SlashCommandRegister { 10 | public static async registerCommands( client: Client, token: string ) { 11 | client.guilds.cache.forEach( async fetchedGuild => { 12 | await fetchedGuild.fetch(); 13 | 14 | const commands: RESTPostAPIApplicationCommandsJSONBody[] = []; 15 | 16 | client.commands = new Collection(); 17 | 18 | for ( const commandName in SlashCommandRegistry ) { 19 | const command = SlashCommandRegistry[commandName] as SlashCommand; 20 | 21 | // FIXME: This stores a function in a map, and could be refactored. 22 | // E.g. we could only store the `command` here, and move the logic elsewhere. Does that work? 23 | const jsonData: SlashCommandJsonData = { 24 | data: command.slashCommandBuilder, 25 | async execute( interaction: ChatInputCommandInteraction ) { 26 | SlashCommand.logger.info( `User ${ interaction.user.tag } ran command ${ command.asString( interaction ) }` ); 27 | 28 | const member = interaction.member instanceof GuildMember ? interaction.member : await fetchedGuild.members.fetch( interaction.user ); 29 | 30 | if ( command.checkPermission( member ) ) { 31 | if ( interaction.channel !== null && ChannelConfigUtil.commandsDisabled( interaction.channel ) ) { 32 | await interaction.reply( { content: 'Commands are not allowed in this channel.', ephemeral: true } ); 33 | } else if ( !await command.run( interaction ) ) { 34 | await interaction.reply( { content: 'An error occurred while running this command.', ephemeral: true } ); 35 | } 36 | } else { 37 | await interaction.reply( { content: 'You do not have permission to use this command.', ephemeral: true } ); 38 | } 39 | }, 40 | }; 41 | 42 | client.commands.set( command.slashCommandBuilder.name, jsonData ); 43 | commands.push( jsonData.data.toJSON() ); 44 | SlashCommand.logger.info( `Registered command ${ commandName } for guild '${ fetchedGuild.name }'` ); 45 | } 46 | 47 | const rest = new REST( { version: '9' } ).setToken( token ); 48 | 49 | if ( client.user != null ) { 50 | rest.put( Routes.applicationGuildCommands( client.user.id, fetchedGuild.id ), { body: commands } ) 51 | .then( () => SlashCommand.logger.info( `Successfully registered all slash commands for guild '${ fetchedGuild.name }'.` ) ) 52 | .catch( error => SlashCommand.logger.error( `An error occurred while registering slash commands for guild '${ fetchedGuild.name }'`, error ) ); 53 | } 54 | } ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/commands/commandHandlers/SlashCommandRegistry.ts: -------------------------------------------------------------------------------- 1 | import BugCommand from '../BugCommand.js'; 2 | import HelpCommand from '../HelpCommand.js'; 3 | import MooCommand from '../MooCommand.js'; 4 | import ModmailBanCommand from '../ModmailBanCommand.js'; 5 | import ModmailUnbanCommand from '../ModmailUnbanCommand.js'; 6 | import PingCommand from '../PingCommand.js'; 7 | import PollCommand from '../PollCommand.js'; 8 | import SearchCommand from '../SearchCommand.js'; 9 | import SendCommand from '../SendCommand.js'; 10 | import ShutdownCommand from '../ShutdownCommand.js'; 11 | import TipsCommand from '../TipsCommand.js'; 12 | 13 | export default class SlashCommandRegistry { 14 | public static BUG_COMMAND = new BugCommand(); 15 | public static HELP_COMMAND = new HelpCommand(); 16 | public static MODMAIL_BAN_COMMAND = new ModmailBanCommand(); 17 | public static MODMAIL_UNBAN_COMMAND = new ModmailUnbanCommand(); 18 | public static MOO_COMMAND = new MooCommand(); 19 | public static PING_COMMAND = new PingCommand(); 20 | public static POLL_COMMAND = new PollCommand(); 21 | public static SEARCH_COMMAND = new SearchCommand(); 22 | public static SEND_COMMAND = new SendCommand(); 23 | public static SHUTDOWN_COMMAND = new ShutdownCommand(); 24 | public static TIPS_COMMAND = new TipsCommand(); 25 | } 26 | -------------------------------------------------------------------------------- /src/events/EventHandler.ts: -------------------------------------------------------------------------------- 1 | import { ClientEvents } from 'discord.js'; 2 | 3 | /** 4 | * Interface for Discord events 5 | * 6 | * @author Bemoty 7 | * @since 2019-03-29 8 | */ 9 | export default interface EventHandler { 10 | eventName: K; 11 | onEvent: ( ...args: ClientEvents[K] ) => void | Promise; 12 | } 13 | -------------------------------------------------------------------------------- /src/events/EventRegistry.ts: -------------------------------------------------------------------------------- 1 | import { Client, ClientEvents } from 'discord.js'; 2 | import EventHandler from './EventHandler.js'; 3 | 4 | export default class EventRegistry { 5 | private static client: Client; 6 | 7 | public static add( handler: EventHandler ): void { 8 | if ( this.client == undefined ) { 9 | throw 'Event handlers cannot be added to a nonexisting Discord client'; 10 | } 11 | 12 | this.client.on( handler.eventName, handler.onEvent ); 13 | } 14 | 15 | public static setClient( client: Client ): void { 16 | this.client = client; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/events/discord/ErrorEventHandler.ts: -------------------------------------------------------------------------------- 1 | import MojiraBot from '../../MojiraBot.js'; 2 | import EventHandler from '../EventHandler.js'; 3 | 4 | export default class ErrorEventHandler implements EventHandler<'error'> { 5 | public readonly eventName = 'error'; 6 | 7 | public onEvent = ( errorEvent: Error ): void => { 8 | MojiraBot.logger.error( `An unexpected connection error occurred: ${ errorEvent.message }` ); 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/events/interaction/InteractionEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { Client, Interaction } from 'discord.js'; 2 | import EventHandler from '../EventHandler.js'; 3 | import SlashCommand from '../../commands/commandHandlers/SlashCommand.js'; 4 | 5 | export default class InteractionEventHandler implements EventHandler<'interactionCreate'> { 6 | public readonly eventName = 'interactionCreate'; 7 | 8 | private readonly botUser: Client; 9 | 10 | constructor( botUser: Client ) { 11 | this.botUser = botUser; 12 | } 13 | 14 | // This syntax is used to ensure that `this` refers to the `InteractionEventHandler` object 15 | public onEvent = async ( interaction: Interaction ): Promise => { 16 | // Execute commands 17 | if ( interaction.isChatInputCommand() ) { 18 | const command = this.botUser.commands.get( interaction.commandName ); 19 | 20 | if ( !command ) return; 21 | 22 | try { 23 | await command.execute( interaction ); 24 | } catch ( error ) { 25 | SlashCommand.logger.error( error ); 26 | } 27 | } 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/events/internal/InternalProgressEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { Message, Snowflake } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import BotConfig from '../../BotConfig.js'; 4 | import AddProgressMessageTask from '../../tasks/AddProgressMessageTask.js'; 5 | import TaskScheduler from '../../tasks/TaskScheduler.js'; 6 | import DiscordUtil from '../../util/DiscordUtil.js'; 7 | import EventHandler from '../EventHandler.js'; 8 | 9 | export default class InternalProgressEventHandler implements EventHandler<'messageCreate'> { 10 | public readonly eventName = 'messageCreate'; 11 | 12 | private logger = log4js.getLogger( 'InternalProgressEventHandler' ); 13 | 14 | private isValidId( id: string ): id is Snowflake { 15 | return /^\d{18,}$/.test( id ); 16 | } 17 | 18 | // This syntax is used to ensure that `this` refers to the `InternalProgressEventHandler` object 19 | public onEvent = async ( origin: Message ): Promise => { 20 | if ( !origin.channel.isSendable() ) { 21 | return; 22 | } 23 | 24 | const messageId = origin.content.split( /\s/ )[0]; 25 | if ( !this.isValidId( messageId ) ) { 26 | try { 27 | const error = await origin.channel.send( `${ origin.author.toString() } ${ messageId } is not a valid message ID!` ); 28 | await DiscordUtil.deleteWithDelay( error, BotConfig.request.warningLifetime ); 29 | } catch ( err ) { 30 | this.logger.error( err ); 31 | } 32 | return; 33 | } 34 | 35 | let progressedRequest: Message; 36 | 37 | try { 38 | progressedRequest = await origin.channel.messages.fetch( messageId ); 39 | } catch ( err ) { 40 | const error = await origin.channel.send( `${ origin.author.toString() } ${ messageId } could not be found!` ); 41 | 42 | await DiscordUtil.deleteWithDelay( error, BotConfig.request.warningLifetime ); 43 | 44 | this.logger.error( err ); 45 | 46 | return; 47 | } 48 | 49 | try { 50 | TaskScheduler.addOneTimeMessageTask( 51 | origin, 52 | new AddProgressMessageTask( progressedRequest ), 53 | BotConfig.request.progressMessageAddDelay || 0 54 | ); 55 | } catch ( err ) { 56 | this.logger.error( err ); 57 | } 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/events/mention/MentionDeleteEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { MessageReaction, User } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import EventHandler from '../EventHandler.js'; 4 | 5 | export default class MentionDeleteEventHandler implements EventHandler<'messageReactionAdd'> { 6 | public readonly eventName = 'messageReactionAdd'; 7 | 8 | private logger = log4js.getLogger( 'MentionDeleteEventHandler' ); 9 | 10 | public onEvent = async ( { message }: MessageReaction, user: User ): Promise => { 11 | this.logger.info( `User ${ user.tag } is attempting to delete message '${ message.id }'` ); 12 | 13 | const footer = message.embeds[0]?.footer?.text; 14 | if ( footer === undefined ) return; 15 | 16 | const userTag = footer.match( /.{3,32}#[0-9]{4}/ ); 17 | 18 | if ( userTag !== null && user.tag === userTag[0] ) { 19 | try { 20 | await message.delete(); 21 | } catch ( error ) { 22 | this.logger.error( error ); 23 | } 24 | } 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/events/message/MessageDeleteEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageType, PartialMessage, Snowflake } from 'discord.js'; 2 | import BotConfig from '../../BotConfig.js'; 3 | import DiscordUtil from '../../util/DiscordUtil.js'; 4 | import EventHandler from '../EventHandler.js'; 5 | import RequestDeleteEventHandler from '../request/RequestDeleteEventHandler.js'; 6 | 7 | export default class MessageDeleteEventHandler implements EventHandler<'messageDelete'> { 8 | public readonly eventName = 'messageDelete'; 9 | 10 | private readonly botUserId: string; 11 | 12 | private readonly requestDeleteEventHandler: RequestDeleteEventHandler; 13 | 14 | constructor( botUserId: string, internalChannels: Map ) { 15 | this.botUserId = botUserId; 16 | 17 | this.requestDeleteEventHandler = new RequestDeleteEventHandler( internalChannels ); 18 | } 19 | 20 | // This syntax is used to ensure that `this` refers to the `MessageDeleteEventHandler` object 21 | public onEvent = async ( message: Message | PartialMessage ): Promise => { 22 | message = await DiscordUtil.fetchMessage( message ); 23 | 24 | if ( 25 | // Don't handle non-default messages 26 | ( message.type !== MessageType.Default && message.type !== MessageType.Reply ) 27 | 28 | // Don't handle webhooks 29 | || message.webhookId 30 | 31 | // Don't handle own messages 32 | || message.author.id === this.botUserId 33 | ) return; 34 | 35 | if ( BotConfig.request.channels && BotConfig.request.channels.includes( message.channel.id ) ) { 36 | // The deleted message is in a request channel 37 | await this.requestDeleteEventHandler.onEvent( message ); 38 | } 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/events/message/MessageEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageType, Snowflake, DMChannel } from 'discord.js'; 2 | import BotConfig from '../../BotConfig.js'; 3 | import CommandExecutor from '../../commands/commandHandlers/CommandExecutor.js'; 4 | import DiscordUtil from '../../util/DiscordUtil.js'; 5 | import EventHandler from '../EventHandler.js'; 6 | import RequestEventHandler from '../request/RequestEventHandler.js'; 7 | import TestingRequestEventHandler from '../request/TestingRequestEventHandler.js'; 8 | import InternalProgressEventHandler from '../internal/InternalProgressEventHandler.js'; 9 | import ModmailEventHandler from '../modmail/ModmailEventHandler.js'; 10 | import ModmailThreadEventHandler from '../modmail/ModmailThreadEventHandler.js'; 11 | 12 | export default class MessageEventHandler implements EventHandler<'messageCreate'> { 13 | public readonly eventName = 'messageCreate'; 14 | 15 | private readonly botUserId: Snowflake; 16 | 17 | private readonly requestEventHandler: RequestEventHandler; 18 | private readonly testingRequestEventHandler: TestingRequestEventHandler; 19 | private readonly internalProgressEventHandler: InternalProgressEventHandler; 20 | private readonly modmailEventHandler: ModmailEventHandler; 21 | private readonly modmailThreadEventHandler: ModmailThreadEventHandler; 22 | 23 | constructor( botUserId: Snowflake, internalChannels: Map, requestLimits: Map ) { 24 | this.botUserId = botUserId; 25 | 26 | this.requestEventHandler = new RequestEventHandler( internalChannels, requestLimits ); 27 | this.testingRequestEventHandler = new TestingRequestEventHandler(); 28 | this.internalProgressEventHandler = new InternalProgressEventHandler(); 29 | this.modmailEventHandler = new ModmailEventHandler(); 30 | this.modmailThreadEventHandler = new ModmailThreadEventHandler(); 31 | } 32 | 33 | // This syntax is used to ensure that `this` refers to the `MessageEventHandler` object 34 | public onEvent = async ( message: Message ): Promise => { 35 | message = await DiscordUtil.fetchMessage( message ); 36 | 37 | if ( 38 | // Don't reply to webhooks 39 | message.webhookId 40 | 41 | // Don't reply to own messages 42 | || message.author.id === this.botUserId 43 | 44 | // Don't reply to non-default messages 45 | || ( message.type !== MessageType.Default && message.type !== MessageType.Reply ) 46 | ) return; 47 | 48 | // Only true if the message is in a DM channel 49 | if ( message.partial ) { 50 | await message.fetch(); 51 | } 52 | 53 | if ( BotConfig.request.channels && BotConfig.request.channels.includes( message.channel.id ) ) { 54 | // This message is in a request channel 55 | await this.requestEventHandler.onEvent( message ); 56 | 57 | // Don't reply in request channels 58 | return; 59 | } else if ( BotConfig.request.testingRequestChannels && BotConfig.request.testingRequestChannels.includes( message.channel.id ) ) { 60 | // This message is in a testing request channel 61 | await this.testingRequestEventHandler.onEvent( message ); 62 | 63 | // We want the bot to create embeds in testing channels if someone only posts only a ticket ID 64 | // so that people know what the issue is about 65 | } else if ( BotConfig.request.internalChannels && BotConfig.request.internalChannels.includes( message.channel.id ) ) { 66 | // This message is in an internal channel 67 | await this.internalProgressEventHandler.onEvent( message ); 68 | 69 | // Don't reply in internal request channels 70 | return; 71 | } else if ( message.channel instanceof DMChannel && BotConfig.modmailEnabled ) { 72 | // This message is in a DM channel and modmail is enabled 73 | await this.modmailEventHandler.onEvent( message ); 74 | 75 | // Don't reply in DM channels 76 | return; 77 | } else if ( message.channel.isThread() && message.channel.parent?.id == BotConfig.modmailChannel && BotConfig.modmailEnabled ) { 78 | // This message is in the modmail channel and is in a thread 79 | await this.modmailThreadEventHandler.onEvent( message ); 80 | 81 | // Don't reply in modmail threads 82 | return; 83 | } 84 | 85 | await CommandExecutor.checkCommands( message ); 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /src/events/message/MessageUpdateEventHandler.ts: -------------------------------------------------------------------------------- 1 | import EventHandler from '../EventHandler.js'; 2 | import { Message, MessageType, Snowflake } from 'discord.js'; 3 | import BotConfig from '../../BotConfig.js'; 4 | import RequestUpdateEventHandler from '../request/RequestUpdateEventHandler.js'; 5 | import DiscordUtil from '../../util/DiscordUtil.js'; 6 | 7 | export default class MessageUpdateEventHandler implements EventHandler<'messageUpdate'> { 8 | public readonly eventName = 'messageUpdate'; 9 | 10 | private readonly botUserId: string; 11 | 12 | private readonly requestUpdateEventHandler: RequestUpdateEventHandler; 13 | 14 | constructor( botUserId: string, internalChannels: Map ) { 15 | this.botUserId = botUserId; 16 | 17 | this.requestUpdateEventHandler = new RequestUpdateEventHandler( internalChannels ); 18 | } 19 | 20 | // This syntax is used to ensure that `this` refers to the `MessageUpdateEventHandler` object 21 | public onEvent = async ( oldMessage: Message, newMessage: Message ): Promise => { 22 | oldMessage = await DiscordUtil.fetchMessage( oldMessage ); 23 | newMessage = await DiscordUtil.fetchMessage( newMessage ); 24 | 25 | if ( 26 | // Don't handle non-default messages 27 | ( oldMessage.type !== MessageType.Default && oldMessage.type !== MessageType.Reply ) 28 | 29 | // Don't handle webhooks 30 | || oldMessage.webhookId 31 | 32 | // Don't handle own messages 33 | || oldMessage.author.id === this.botUserId 34 | ) return; 35 | 36 | if ( BotConfig.request.channels && BotConfig.request.channels.includes( oldMessage.channel.id ) ) { 37 | // The updated message is in a request channel 38 | await this.requestUpdateEventHandler.onEvent( oldMessage, newMessage ); 39 | } 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/events/modmail/ModmailEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { Message, TextChannel } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import BotConfig from '../../BotConfig.js'; 4 | import DiscordUtil from '../../util/DiscordUtil.js'; 5 | import EventHandler from '../EventHandler.js'; 6 | 7 | export default class ModmailEventHandler implements EventHandler<'messageCreate'> { 8 | public readonly eventName = 'messageCreate'; 9 | 10 | private logger = log4js.getLogger( 'ModmailEventHandler' ); 11 | 12 | // This syntax is used to ensure that `this` refers to the `ModmailEventHandler` object 13 | public onEvent = async ( origin: Message ): Promise => { 14 | if ( !origin.channel.isSendable() ) { 15 | return; 16 | } 17 | 18 | const modmailChannel = await DiscordUtil.getChannel( BotConfig.modmailChannel ); 19 | 20 | const banStatus = BotConfig.database.prepare( 'SELECT user FROM modmail_bans WHERE user = ?' ).get( origin.author.id ); 21 | 22 | const previousThread = BotConfig.database.prepare( 'SELECT thread FROM modmail_threads WHERE user = ?' ).get( origin.author.id ); 23 | 24 | if ( modmailChannel instanceof TextChannel && banStatus === undefined ) { 25 | if ( previousThread ) { 26 | const thread = modmailChannel.threads.cache.find( t => t.id == previousThread.thread ); 27 | if ( thread && !thread.archived ) { 28 | try { 29 | await thread.send( `${ origin.author }: ${ origin.content }` ); 30 | if ( origin.attachments ) { 31 | origin.attachments.forEach( async file => { 32 | await thread.send( { 33 | files: [ { 34 | attachment: file.url, 35 | name: file.name ?? undefined, 36 | } ], 37 | } ); 38 | } ); 39 | } 40 | await origin.react( '📬' ); 41 | return; 42 | } catch ( e ) { 43 | this.logger.error( e ); 44 | 45 | return; 46 | } 47 | } else { 48 | await BotConfig.database.prepare( 49 | `DELETE FROM modmail_threads 50 | WHERE user = ?` 51 | ).run( origin.author.id ); 52 | } 53 | } 54 | try { 55 | const start = await modmailChannel.send( `${ origin.author }: ${ origin.content }` ); 56 | await origin.react( '📬' ); 57 | 58 | const newThread = await start.startThread( { 59 | name: origin.author.tag, 60 | autoArchiveDuration: 1440, 61 | } ); 62 | 63 | BotConfig.database.prepare( 64 | `INSERT INTO modmail_threads (user, thread) 65 | VALUES (?, ?)` 66 | ).run( [ origin.author.id, newThread.id ] ); 67 | 68 | if ( origin.attachments ) { 69 | origin.attachments.forEach( async file => { 70 | await newThread.send( { 71 | files: [ { 72 | attachment: file.url, 73 | name: file.name ?? undefined, 74 | } ], 75 | } ); 76 | } ); 77 | } 78 | } catch ( e ) { 79 | this.logger.error( e ); 80 | 81 | return; 82 | } 83 | } else if ( banStatus !== undefined ) { 84 | try { 85 | await origin.channel.send( 'We\'re sorry, but you have been banned from sending any further modmail messages.' ); 86 | await origin.react( '❌' ); 87 | } catch ( e ) { 88 | this.logger.error( e ); 89 | 90 | return; 91 | } 92 | } 93 | }; 94 | } 95 | -------------------------------------------------------------------------------- /src/events/modmail/ModmailThreadEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import BotConfig from '../../BotConfig.js'; 4 | import EventHandler from '../EventHandler.js'; 5 | 6 | export default class ModmailThreadEventHandler implements EventHandler<'messageCreate'> { 7 | public readonly eventName = 'messageCreate'; 8 | 9 | private logger = log4js.getLogger( 'ModmailThreadEventHandler' ); 10 | 11 | // This syntax is used to ensure that `this` refers to the `ModmailThreadEventHandler` object 12 | public onEvent = async ( origin: Message ): Promise => { 13 | const id = BotConfig.database.prepare( 'SELECT user FROM modmail_threads WHERE thread = ?' ).get( origin.channel.id ); 14 | 15 | if ( !origin.channel.isThread() || id === undefined ) return; 16 | 17 | const user = await origin.channel.fetchStarterMessage().then( msg => msg?.mentions.users.first() ); 18 | 19 | if ( user ) { 20 | try { 21 | if ( origin.content ) { 22 | await user.send( origin.content ); 23 | } 24 | 25 | if ( origin.attachments ) { 26 | origin.attachments.forEach( async file => { 27 | await user.send( { 28 | files: [ { 29 | attachment: file.url, 30 | name: file.name ?? undefined, 31 | } ], 32 | } ); 33 | } ); 34 | } 35 | 36 | await origin.react( '📬' ); 37 | } catch ( err ) { 38 | this.logger.error( err ); 39 | 40 | return; 41 | } 42 | } 43 | return; 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/events/reaction/ReactionAddEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { MessageReaction, Snowflake, User } from 'discord.js'; 2 | import BotConfig from '../../BotConfig.js'; 3 | import DiscordEventHandler from '../EventHandler.js'; 4 | import RequestEventHandler from '../request/RequestEventHandler.js'; 5 | import RequestReopenEventHandler from '../request/RequestReopenEventHandler.js'; 6 | import RequestResolveEventHandler from '../request/RequestResolveEventHandler.js'; 7 | import RequestReactionRemovalEventHandler from '../request/RequestReactionRemovalEventHandler.js'; 8 | import RoleSelectEventHandler from '../roles/RoleSelectEventHandler.js'; 9 | import MentionDeleteEventHandler from '../mention/MentionDeleteEventHandler.js'; 10 | import DiscordUtil from '../../util/DiscordUtil.js'; 11 | 12 | export default class ReactionAddEventHandler implements DiscordEventHandler<'messageReactionAdd'> { 13 | public readonly eventName = 'messageReactionAdd'; 14 | 15 | private readonly botUserId: Snowflake; 16 | 17 | private readonly roleSelectHandler = new RoleSelectEventHandler(); 18 | private readonly requestResolveEventHandler: RequestResolveEventHandler; 19 | private readonly requestReactionRemovalEventHandler = new RequestReactionRemovalEventHandler(); 20 | private readonly requestReopenEventHandler: RequestReopenEventHandler; 21 | private readonly mentionDeleteEventHandler = new MentionDeleteEventHandler(); 22 | 23 | constructor( botUserId: Snowflake, internalChannels: Map, requestLimits: Map ) { 24 | this.botUserId = botUserId; 25 | 26 | const requestEventHandler = new RequestEventHandler( internalChannels, requestLimits ); 27 | this.requestResolveEventHandler = new RequestResolveEventHandler( botUserId ); 28 | this.requestReopenEventHandler = new RequestReopenEventHandler( botUserId, requestEventHandler ); 29 | } 30 | 31 | // This syntax is used to ensure that `this` refers to the `ReactionAddEventHandler` object 32 | public onEvent = async ( reaction: MessageReaction, user: User ): Promise => { 33 | // Do not react to own reactions 34 | if ( user.id === this.botUserId ) return; 35 | 36 | reaction = await DiscordUtil.fetchReaction( reaction ); 37 | user = await DiscordUtil.fetchUser( user ); 38 | 39 | const message = await DiscordUtil.fetchMessage( reaction.message ); 40 | 41 | if ( BotConfig.roleGroups.find( g => g.message === message.id ) ) { 42 | // Handle role selection 43 | return this.roleSelectHandler.onEvent( reaction, user ); 44 | } else if ( BotConfig.request.internalChannels.includes( message.channel.id ) ) { 45 | // Handle resolving user request 46 | return this.requestResolveEventHandler.onEvent( reaction, user ); 47 | } else if ( BotConfig.request.channels.includes( message.channel.id ) ) { 48 | // Handle removing user reactions in the request channels 49 | return this.requestReactionRemovalEventHandler.onEvent( reaction, user ); 50 | } else if ( BotConfig.request.logChannel.includes( message.channel.id ) ) { 51 | // Handle reopening a user request 52 | return this.requestReopenEventHandler.onEvent( reaction, user ); 53 | } else if ( reaction.message.author?.id === this.botUserId && reaction.emoji.name === BotConfig.embedDeletionEmoji ) { 54 | // Handle deleting bot embed 55 | return this.mentionDeleteEventHandler.onEvent( reaction, user ); 56 | } 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /src/events/reaction/ReactionRemoveEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { MessageReaction, User } from 'discord.js'; 2 | import BotConfig from '../../BotConfig.js'; 3 | import EventHandler from '../EventHandler.js'; 4 | import RequestUnresolveEventHandler from '../request/RequestUnresolveEventHandler.js'; 5 | import RoleRemoveEventHandler from '../roles/RoleRemoveEventHandler.js'; 6 | import DiscordUtil from '../../util/DiscordUtil.js'; 7 | 8 | export default class ReactionRemoveEventHandler implements EventHandler<'messageReactionRemove'> { 9 | public readonly eventName = 'messageReactionRemove'; 10 | 11 | private readonly botUserId: string; 12 | 13 | private readonly roleRemoveHandler = new RoleRemoveEventHandler(); 14 | private readonly requestUnresolveEventHandler: RequestUnresolveEventHandler; 15 | 16 | constructor( botUserId: string ) { 17 | this.botUserId = botUserId; 18 | this.requestUnresolveEventHandler = new RequestUnresolveEventHandler( this.botUserId ); 19 | } 20 | 21 | // This syntax is used to ensure that `this` refers to the `ReactionRemoveEventHandler` object 22 | public onEvent = async ( reaction: MessageReaction, user: User ): Promise => { 23 | if ( user.id === this.botUserId ) return; 24 | 25 | reaction = await DiscordUtil.fetchReaction( reaction ); 26 | user = await DiscordUtil.fetchUser( user ); 27 | 28 | const message = await DiscordUtil.fetchMessage( reaction.message ); 29 | 30 | if ( BotConfig.roleGroups.find( g => g.message === message.id ) ) { 31 | // Handle role removal 32 | return this.roleRemoveHandler.onEvent( reaction, user ); 33 | } else if ( BotConfig.request.internalChannels.includes( message.channel.id ) ) { 34 | // Handle unresolving user request 35 | return this.requestUnresolveEventHandler.onEvent( reaction, user ); 36 | } 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/events/request/RequestDeleteEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { Message, PartialMessage, Snowflake, TextChannel } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import EventHandler from '../EventHandler.js'; 4 | import { RequestsUtil } from '../../util/RequestsUtil.js'; 5 | import TaskScheduler from '../../tasks/TaskScheduler.js'; 6 | import DiscordUtil from '../../util/DiscordUtil.js'; 7 | 8 | export default class RequestDeleteEventHandler implements EventHandler<'messageDelete'> { 9 | public readonly eventName = 'messageDelete'; 10 | 11 | private logger = log4js.getLogger( 'RequestDeleteEventHandler' ); 12 | 13 | /** 14 | * A map from request channel IDs to internal channel objects. 15 | */ 16 | private readonly internalChannels: Map; 17 | 18 | constructor( internalChannels: Map ) { 19 | this.internalChannels = internalChannels; 20 | } 21 | 22 | // This syntax is used to ensure that `this` refers to the `RequestDeleteEventHandler` object 23 | public onEvent = async ( origin: Message | PartialMessage ): Promise => { 24 | origin = await DiscordUtil.fetchMessage( origin ); 25 | 26 | this.logger.info( `User ${ origin.author.tag }'s request ${ origin.id } in channel ${ origin.channel.id } was deleted` ); 27 | 28 | const internalChannelId = this.internalChannels.get( origin.channel.id ); 29 | if ( internalChannelId === undefined ) return; 30 | 31 | const internalChannel = await DiscordUtil.getChannel( internalChannelId ); 32 | 33 | if ( internalChannel && internalChannel instanceof TextChannel ) { 34 | for ( const [, internalMessage] of internalChannel.messages.cache ) { 35 | const result = await RequestsUtil.getOriginIds( internalMessage ); 36 | if ( !result ) { 37 | continue; 38 | } 39 | if ( result.channelId === origin.channel.id && result.messageId === origin.id ) { 40 | TaskScheduler.clearMessageTasks( internalMessage ); 41 | if ( internalMessage.deletable ) { 42 | try { 43 | await internalMessage.delete(); 44 | } catch ( error ) { 45 | this.logger.error( error ); 46 | } 47 | } 48 | } 49 | } 50 | } 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/events/request/RequestEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, GuildChannel, Message, MessageType, Snowflake, TextChannel } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import BotConfig, { PrependResponseMessageType } from '../../BotConfig.js'; 4 | import DiscordUtil from '../../util/DiscordUtil.js'; 5 | import { ReactionsUtil } from '../../util/ReactionsUtil.js'; 6 | import { RequestsUtil } from '../../util/RequestsUtil.js'; 7 | import EventHandler from '../EventHandler.js'; 8 | 9 | export default class RequestEventHandler implements EventHandler<'messageCreate'> { 10 | public readonly eventName = 'messageCreate'; 11 | 12 | private logger = log4js.getLogger( 'RequestEventHandler' ); 13 | 14 | /** 15 | * A map from request channel IDs to internal channel objects. 16 | */ 17 | private readonly internalChannels: Map; 18 | 19 | /** 20 | * A map from request channel IDs to request limit numbers. 21 | */ 22 | private readonly requestLimits: Map; 23 | 24 | constructor( internalChannels: Map, requestLimits: Map ) { 25 | this.internalChannels = internalChannels; 26 | this.requestLimits = requestLimits; 27 | } 28 | 29 | // This syntax is used to ensure that `this` refers to the `RequestEventHandler` object 30 | public onEvent = async ( origin: Message, forced?: boolean ): Promise => { 31 | // we need this because this method gets invoked directly on bot startup instead of via the general MessageEventHandler 32 | if ( origin.type !== MessageType.Default ) { 33 | return; 34 | } 35 | 36 | if ( !origin.channel.isSendable() ) { 37 | return; 38 | } 39 | 40 | if ( origin.channel instanceof GuildChannel ) { 41 | this.logger.info( `${ origin.author.tag } posted request ${ origin.id } in #${ origin.channel.name }` ); 42 | } 43 | 44 | try { 45 | await origin.reactions.removeAll(); 46 | } catch ( error ) { 47 | this.logger.error( error ); 48 | } 49 | 50 | const tickets = RequestsUtil.getTicketIdsFromString( origin.content ); 51 | 52 | if ( BotConfig.request.noLinkEmoji && !tickets.length ) { 53 | try { 54 | await origin.react( BotConfig.request.noLinkEmoji ); 55 | } catch ( error ) { 56 | this.logger.error( error ); 57 | } 58 | 59 | try { 60 | const warning = await origin.channel.send( `${ origin.author }, your request (<${ origin.url }>) doesn't contain any valid ticket reference. If you'd like to add it you can edit your message.` ); 61 | await DiscordUtil.deleteWithDelay( warning, BotConfig.request.warningLifetime ); 62 | } catch ( error ) { 63 | this.logger.error( error ); 64 | } 65 | 66 | return; 67 | } 68 | 69 | if ( BotConfig.request.invalidRequestJql ) { 70 | if ( !await RequestsUtil.checkTicketValidity( tickets ) ) { 71 | try { 72 | await origin.react( BotConfig.request.invalidTicketEmoji ); 73 | } catch ( error ) { 74 | this.logger.error( error ); 75 | } 76 | 77 | try { 78 | const warning = await origin.channel.send( `${ origin.author }, your request (<${ origin.url }>) contains a ticket that is less than 24 hours old. Please wait until it is at least one day old before making a request.` ); 79 | await DiscordUtil.deleteWithDelay( warning, BotConfig.request.warningLifetime ); 80 | } catch ( error ) { 81 | this.logger.error( error ); 82 | } 83 | return; 84 | } 85 | } 86 | 87 | const requestLimit = this.requestLimits.get( origin.channel.id ); 88 | const internalChannelId = this.internalChannels.get( origin.channel.id ); 89 | if ( internalChannelId === undefined ) return; 90 | 91 | const internalChannel = await DiscordUtil.getChannel( internalChannelId ); 92 | 93 | if ( !forced && requestLimit && requestLimit >= 0 && internalChannel instanceof TextChannel ) { 94 | // Check for 24 hour rolling window request limit 95 | const internalChannelUserMessages = internalChannel.messages.cache 96 | .filter( message => message.embeds.length > 0 && message.embeds[0].author?.name === origin.author.tag ) 97 | .filter( message => { 98 | // Check if message is at most 24 hours old 99 | if ( message.embeds[0].timestamp === null ) return false; 100 | const messageTimestamp = new Date( message.embeds[0].timestamp ).getTime(); 101 | return new Date().getTime() - messageTimestamp <= 86400000; 102 | } ); 103 | if ( internalChannelUserMessages.size >= requestLimit ) { 104 | try { 105 | await origin.react( BotConfig.request.invalidTicketEmoji ); 106 | } catch ( error ) { 107 | this.logger.error( error ); 108 | } 109 | 110 | try { 111 | const warning = await origin.channel.send( `${ origin.author }, you have posted a lot of requests today that are still pending. Please wait for these requests to be resolved before posting more.` ); 112 | await DiscordUtil.deleteWithDelay( warning, BotConfig.request.warningLifetime ); 113 | } catch ( error ) { 114 | this.logger.error( error ); 115 | } 116 | return; 117 | } 118 | } 119 | 120 | if ( BotConfig.request.waitingEmoji ) { 121 | try { 122 | await origin.react( BotConfig.request.waitingEmoji ); 123 | } catch ( error ) { 124 | this.logger.error( error ); 125 | } 126 | } 127 | 128 | if ( internalChannel && internalChannel instanceof TextChannel ) { 129 | const embed = new EmbedBuilder() 130 | .setColor( RequestsUtil.getEmbedColor() ) 131 | .setAuthor( { name: origin.author.tag, iconURL: origin.author.avatarURL() ?? undefined } ) 132 | .setDescription( RequestsUtil.getRequestDescription( origin ) ) 133 | .addFields( { 134 | name: 'Go To', 135 | value: `[Message](${ origin.url }) in ${ origin.channel }`, 136 | inline: true, 137 | } ) 138 | .setTimestamp( origin.createdAt ); 139 | 140 | const response = BotConfig.request.prependResponseMessage == PrependResponseMessageType.Always 141 | ? RequestsUtil.getResponseMessage( origin ) 142 | : ' '; 143 | 144 | const copy = await internalChannel.send( { content: response, embeds: [embed] } ); 145 | 146 | if ( BotConfig.request.suggestedEmoji ) { 147 | await ReactionsUtil.reactToMessage( copy, [...BotConfig.request.suggestedEmoji] ); 148 | } 149 | } 150 | }; 151 | } 152 | -------------------------------------------------------------------------------- /src/events/request/RequestReactionRemovalEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { MessageReaction, TextChannel, User } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import DiscordUtil from '../../util/DiscordUtil.js'; 4 | import EventHandler from '../EventHandler.js'; 5 | 6 | export default class RequestReactionRemovalEventHandler implements EventHandler<'messageReactionAdd'> { 7 | public readonly eventName = 'messageReactionAdd'; 8 | 9 | private logger = log4js.getLogger( 'RequestReactionRemovalEventHandler' ); 10 | 11 | // This syntax is used to ensure that `this` refers to the `RequestResolveEventHandler` object 12 | public onEvent = async ( reaction: MessageReaction, user: User ): Promise => { 13 | const message = await DiscordUtil.fetchMessage( reaction.message ); 14 | 15 | this.logger.info( `User ${ user.tag } added '${ reaction.emoji.name }' reaction to request message '${ message.id }'` ); 16 | const guild = message.guild; 17 | if ( guild === null ) return; 18 | 19 | const guildMember = guild.members.resolve( user ); 20 | 21 | if ( guildMember && !guildMember.permissionsIn( message.channel as TextChannel ).has( 'AddReactions' ) ) { 22 | await reaction.users.remove( user ); 23 | } 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/events/request/RequestReopenEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, MessageReaction, TextChannel, User } from 'discord.js'; 2 | import { RequestsUtil } from '../../util/RequestsUtil.js'; 3 | import log4js from 'log4js'; 4 | import BotConfig from '../../BotConfig.js'; 5 | import DiscordUtil from '../../util/DiscordUtil.js'; 6 | import EventHandler from '../EventHandler.js'; 7 | import RequestEventHandler from './RequestEventHandler.js'; 8 | 9 | export default class RequestReopenEventHandler implements EventHandler<'messageReactionAdd'> { 10 | public readonly eventName = 'messageReactionAdd'; 11 | 12 | private logger = log4js.getLogger( 'RequestReopenEventHandler' ); 13 | 14 | private readonly botUserId: string; 15 | private readonly requestEventHandler: RequestEventHandler; 16 | 17 | constructor( botUserId: string, requestEventHandler: RequestEventHandler ) { 18 | this.botUserId = botUserId; 19 | this.requestEventHandler = requestEventHandler; 20 | } 21 | 22 | // This syntax is used to ensure that `this` refers to the `RequestReopenEventHandler` object 23 | public onEvent = async ( { message }: MessageReaction, user: User ): Promise => { 24 | this.logger.info( `User ${ user.tag } is reopening the request message '${ message.id }'` ); 25 | 26 | message = await DiscordUtil.fetchMessage( message ); 27 | 28 | if ( message.author.id !== this.botUserId ) { 29 | return; 30 | } 31 | 32 | const requestMessage = await RequestsUtil.getOriginMessage( message ); 33 | 34 | if ( requestMessage === undefined ) { 35 | this.logger.error( `Could not find origin message for request message '${ message.id }'` ); 36 | return; 37 | } 38 | 39 | const logChannel = await DiscordUtil.getChannel( BotConfig.request.logChannel ); 40 | if ( logChannel && logChannel instanceof TextChannel ) { 41 | const log = new EmbedBuilder() 42 | .setColor( 'Orange' ) 43 | .setAuthor( { name: requestMessage.author.tag, iconURL: requestMessage.author.avatarURL() ?? undefined } ) 44 | .setDescription( requestMessage.content ) 45 | .addFields( 46 | { name: 'Message', value: `[Here](${ requestMessage.url })`, inline: true }, 47 | { name: 'Channel', value: requestMessage.channel.toString(), inline: true }, 48 | { name: 'Created', value: requestMessage.createdAt.toUTCString(), inline: false } 49 | ) 50 | .setFooter( { text: `${ user.tag } reopened this request`, iconURL: user.avatarURL() ?? undefined } ) 51 | .setTimestamp( new Date() ); 52 | 53 | try { 54 | await logChannel.send( { embeds: [log] } ); 55 | } catch ( error ) { 56 | this.logger.error( error ); 57 | } 58 | } 59 | 60 | await this.requestEventHandler.onEvent( requestMessage, true ); 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/events/request/RequestResolveEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, MessageReaction, User } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import BotConfig, { PrependResponseMessageType } from '../../BotConfig.js'; 4 | import ResolveRequestMessageTask from '../../tasks/ResolveRequestMessageTask.js'; 5 | import TaskScheduler from '../../tasks/TaskScheduler.js'; 6 | import { RequestsUtil } from '../../util/RequestsUtil.js'; 7 | import EventHandler from '../EventHandler.js'; 8 | 9 | export default class RequestResolveEventHandler implements EventHandler<'messageReactionAdd'> { 10 | public readonly eventName = 'messageReactionAdd'; 11 | 12 | private logger = log4js.getLogger( 'RequestResolveEventHandler' ); 13 | 14 | private readonly botUserId: string; 15 | 16 | constructor( botUserId: string ) { 17 | this.botUserId = botUserId; 18 | } 19 | 20 | // This syntax is used to ensure that `this` refers to the `RequestResolveEventHandler` object 21 | public onEvent = async ( reaction: MessageReaction, user: User ): Promise => { 22 | if ( reaction.message?.author?.id !== this.botUserId ) return; 23 | 24 | this.logger.info( `User ${ user.tag } added '${ reaction.emoji.name }' reaction to request message '${ reaction.message.id }'` ); 25 | 26 | const embed = new EmbedBuilder( reaction.message.embeds[0].data ).setColor( RequestsUtil.getEmbedColor( user ) ); 27 | await reaction.message.edit( { embeds: [embed] } ); 28 | 29 | if ( BotConfig.request.prependResponseMessage == PrependResponseMessageType.WhenResolved 30 | && BotConfig.request.ignorePrependResponseMessageEmoji !== reaction.emoji.name ) { 31 | const origin = await RequestsUtil.getOriginMessage( reaction.message ); 32 | if ( origin ) { 33 | try { 34 | await reaction.message.edit( { content: RequestsUtil.getResponseMessage( origin ), embeds: [embed] } ); 35 | } catch ( error ) { 36 | this.logger.error( error ); 37 | } 38 | } 39 | } 40 | 41 | if ( BotConfig.request.ignoreResolutionEmoji !== reaction.emoji.name ) { 42 | TaskScheduler.clearMessageTasks( reaction.message ); 43 | TaskScheduler.addOneTimeMessageTask( 44 | reaction.message, 45 | new ResolveRequestMessageTask( reaction.emoji, user ), 46 | BotConfig.request.resolveDelay || 0 47 | ); 48 | } 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/events/request/RequestUnresolveEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, MessageReaction, User } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import BotConfig from '../../BotConfig.js'; 4 | import TaskScheduler from '../../tasks/TaskScheduler.js'; 5 | import DiscordUtil from '../../util/DiscordUtil.js'; 6 | import { RequestsUtil } from '../../util/RequestsUtil.js'; 7 | import EventHandler from '../EventHandler.js'; 8 | 9 | export default class RequestUnresolveEventHandler implements EventHandler<'messageReactionRemove'> { 10 | public readonly eventName = 'messageReactionRemove'; 11 | 12 | private logger = log4js.getLogger( 'RequestUnresolveEventHandler' ); 13 | 14 | private readonly botUserId: string; 15 | 16 | constructor( botUserId: string ) { 17 | this.botUserId = botUserId; 18 | } 19 | 20 | // This syntax is used to ensure that `this` refers to the `RequestUnresolveEventHandler` object 21 | public onEvent = async ( { emoji, message }: MessageReaction, user: User ): Promise => { 22 | message = await DiscordUtil.fetchMessage( message ); 23 | 24 | if ( message.author.id !== this.botUserId ) { 25 | this.logger.info( `User ${ user.tag } removed '${ emoji.name }' reaction from non-bot message '${ message.id }'. Ignored` ); 26 | return; 27 | } 28 | 29 | this.logger.info( `User ${ user.tag } removed '${ emoji.name }' reaction from request message '${ message.id }'` ); 30 | 31 | const embed = new EmbedBuilder( message.embeds[0].data ).setColor( RequestsUtil.getEmbedColor() ); 32 | 33 | await message.edit( { 34 | content: null, 35 | embeds: [embed], 36 | } ); 37 | 38 | if ( message.reactions.cache.size <= BotConfig.request.suggestedEmoji.length ) { 39 | this.logger.info( `Cleared message task for request message '${ message.id }'` ); 40 | TaskScheduler.clearMessageTasks( message ); 41 | } 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/events/request/RequestUpdateEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, Message, Snowflake, TextChannel } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import EventHandler from '../EventHandler.js'; 4 | import { RequestsUtil } from '../../util/RequestsUtil.js'; 5 | import DiscordUtil from '../../util/DiscordUtil.js'; 6 | 7 | export default class RequestUpdateEventHandler implements EventHandler<'messageUpdate'> { 8 | public readonly eventName = 'messageUpdate'; 9 | 10 | private logger = log4js.getLogger( 'RequestUpdateEventHandler' ); 11 | 12 | /** 13 | * A map from request channel IDs to internal channel objects. 14 | */ 15 | private readonly internalChannels: Map; 16 | 17 | constructor( internalChannels: Map ) { 18 | this.internalChannels = internalChannels; 19 | } 20 | 21 | // This syntax is used to ensure that `this` refers to the `RequestUpdateEventHandler` object 22 | public onEvent = async ( oldMessage: Message, newMessage: Message ): Promise => { 23 | this.logger.info( `User ${ oldMessage.author.tag }'s request ${ oldMessage.id } in channel ${ oldMessage.channel.id } was updated` ); 24 | 25 | const internalChannelId = this.internalChannels.get( oldMessage.channel.id ); 26 | if ( internalChannelId === undefined ) return; 27 | 28 | const internalChannel = await DiscordUtil.getChannel( internalChannelId ); 29 | 30 | if ( internalChannel && internalChannel instanceof TextChannel ) { 31 | for ( const [, internalMessage] of internalChannel.messages.cache ) { 32 | const result = await RequestsUtil.getOriginIds( internalMessage ); 33 | if ( !result ) { 34 | continue; 35 | } 36 | if ( result.channelId === oldMessage.channel.id && result.messageId === oldMessage.id ) { 37 | try { 38 | const embed = new EmbedBuilder( internalMessage.embeds[0].data ); 39 | embed.setAuthor( { name: oldMessage.author.tag, iconURL: oldMessage.author.avatarURL() ?? undefined } ); 40 | embed.setDescription( RequestsUtil.getRequestDescription( newMessage ) ); 41 | await internalMessage.edit( { embeds: [embed] } ); 42 | } catch ( error ) { 43 | this.logger.error( error ); 44 | } 45 | } 46 | } 47 | } 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /src/events/request/TestingRequestEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { GuildChannel, Message } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import BotConfig from '../../BotConfig.js'; 4 | import DiscordUtil from '../../util/DiscordUtil.js'; 5 | import { RequestsUtil } from '../../util/RequestsUtil.js'; 6 | import EventHandler from '../EventHandler.js'; 7 | 8 | export default class TestingRequestEventHandler implements EventHandler<'messageCreate'> { 9 | public readonly eventName = 'messageCreate'; 10 | 11 | private logger = log4js.getLogger( 'RequestEventHandler' ); 12 | 13 | // This syntax is used to ensure that `this` refers to the `TestingRequestEventHandler` object 14 | public onEvent = async ( request: Message ): Promise => { 15 | if ( !request.channel.isSendable() ) { 16 | return; 17 | } 18 | if ( request.channel instanceof GuildChannel ) { 19 | this.logger.info( `${ request.author.tag } posted request ${ request.id } in #${ request.channel.name }` ); 20 | } 21 | 22 | const guildMember = request?.guild?.members?.resolve( request.author ); 23 | 24 | if ( guildMember && !guildMember.permissionsIn( BotConfig.request.logChannel ).has( 'ViewChannel' ) ) { 25 | const tickets = RequestsUtil.getTicketIdsFromString( request.content ); 26 | 27 | if ( tickets.length !== 1 ) { 28 | if ( request.deletable ) { 29 | try { 30 | await request.delete(); 31 | } catch ( error ) { 32 | this.logger.error( error ); 33 | } 34 | } 35 | 36 | try { 37 | const warning = await request.channel.send( `${ request.author }, your request doesn't contain exactly one ticket reference.` ); 38 | await DiscordUtil.deleteWithDelay( warning, BotConfig.request.warningLifetime ); 39 | } catch ( error ) { 40 | this.logger.error( error ); 41 | } 42 | 43 | return; 44 | } 45 | 46 | if ( BotConfig.request.invalidRequestJql ) { 47 | if ( !await RequestsUtil.checkTicketValidity( tickets ) ) { 48 | if ( request.deletable ) { 49 | try { 50 | await request.delete(); 51 | } catch ( error ) { 52 | this.logger.error( error ); 53 | } 54 | } 55 | 56 | try { 57 | const warning = await request.channel.send( `${ request.author }, your request contains a ticket that is less than 24 hours old. Please wait until it is at least one day old before making a request.` ); 58 | await DiscordUtil.deleteWithDelay( warning, BotConfig.request.warningLifetime ); 59 | } catch ( error ) { 60 | this.logger.error( error ); 61 | } 62 | return; 63 | } 64 | } 65 | } 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /src/events/roles/RoleRemoveEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { MessageReaction, User } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import BotConfig from '../../BotConfig.js'; 4 | import DiscordUtil from '../../util/DiscordUtil.js'; 5 | import EventHandler from '../EventHandler.js'; 6 | 7 | export default class RoleRemoveEventHandler implements EventHandler<'messageReactionRemove'> { 8 | public readonly eventName = 'messageReactionRemove'; 9 | 10 | private logger = log4js.getLogger( 'RoleRemoveEventHandler' ); 11 | 12 | // This syntax is used to ensure that `this` refers to the `RoleRemoveEventHandler` object 13 | public onEvent = async ( reaction: MessageReaction, user: User ): Promise => { 14 | const group = BotConfig.roleGroups.find( searchedGroup => searchedGroup.message === reaction.message.id ); 15 | const role = group?.roles.find( searchedRole => searchedRole.emoji === reaction.emoji.id || searchedRole.emoji === reaction.emoji.name ); 16 | 17 | if ( !role ) return; 18 | 19 | const guild = reaction.message.guild; 20 | if ( guild === null ) return; 21 | 22 | const member = await DiscordUtil.getMember( guild, user.id ); 23 | if ( member ) { 24 | try { 25 | await member.roles.remove( role.id ); 26 | } catch ( error ) { 27 | this.logger.error( error ); 28 | } 29 | } 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/events/roles/RoleSelectEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { MessageReaction, User } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import BotConfig from '../../BotConfig.js'; 4 | import DiscordUtil from '../../util/DiscordUtil.js'; 5 | import EventHandler from '../EventHandler.js'; 6 | 7 | export default class RoleSelectEventHandler implements EventHandler<'messageReactionAdd'> { 8 | public readonly eventName = 'messageReactionAdd'; 9 | 10 | private logger = log4js.getLogger( 'RoleSelectEventHandler' ); 11 | 12 | // This syntax is used to ensure that `this` refers to the `RoleSelectEventHandler` object 13 | public onEvent = async ( reaction: MessageReaction, user: User ): Promise => { 14 | const group = BotConfig.roleGroups.find( searchedGroup => searchedGroup.message === reaction.message.id ); 15 | if ( group === undefined ) return; 16 | 17 | const role = group.roles.find( searchedRole => searchedRole.emoji === reaction.emoji.id || searchedRole.emoji === reaction.emoji.name ); 18 | 19 | if ( !role ) { 20 | try { 21 | await reaction.users.remove( user ); 22 | } catch ( error ) { 23 | this.logger.error( error ); 24 | } 25 | return; 26 | } 27 | 28 | const guild = reaction.message.guild; 29 | if ( guild === null ) return; 30 | 31 | const member = await DiscordUtil.getMember( guild, user.id ); 32 | 33 | if ( group.radio ) { 34 | // Remove other reactions. 35 | for ( const otherReaction of reaction.message.reactions.cache.values() ) { 36 | if ( otherReaction.emoji.id !== role.emoji ) { 37 | try { 38 | await otherReaction.users.remove( user ); 39 | } catch ( error ) { 40 | this.logger.error( error ); 41 | } 42 | } 43 | } 44 | // Remove other roles. 45 | if ( member ) { 46 | for ( const { id } of group.roles ) { 47 | try { 48 | await member.roles.remove( id ); 49 | } catch ( error ) { 50 | this.logger.error( error ); 51 | } 52 | } 53 | } 54 | } 55 | 56 | if ( member ) { 57 | try { 58 | await member.roles.add( role.id ); 59 | } catch ( error ) { 60 | this.logger.error( error ); 61 | } 62 | } 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /src/mentions/Mention.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | 4 | export abstract class Mention { 5 | public static logger = log4js.getLogger( 'Mention' ); 6 | 7 | abstract getEmbed(): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /src/mentions/MentionRegistry.ts: -------------------------------------------------------------------------------- 1 | import { Mention } from './Mention.js'; 2 | import { SingleMention } from './SingleMention.js'; 3 | import { MultipleMention } from './MultipleMention.js'; 4 | import { TextBasedChannel } from 'discord.js'; 5 | 6 | export class MentionRegistry { 7 | public static getMention( tickets: string[], channel: TextBasedChannel ): Mention { 8 | if ( tickets.length == 1 ) { 9 | return new SingleMention( tickets[0], channel ); 10 | } else { 11 | return new MultipleMention( tickets ); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/mentions/MultipleMention.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from 'discord.js'; 2 | import MojiraBot from '../MojiraBot.js'; 3 | import { Mention } from './Mention.js'; 4 | 5 | export class MultipleMention extends Mention { 6 | private tickets: string[]; 7 | 8 | constructor( tickets: string[] ) { 9 | super(); 10 | 11 | this.tickets = tickets; 12 | } 13 | 14 | public async getEmbed(): Promise { 15 | const embed = new EmbedBuilder(); 16 | embed.setTitle( 'Mentioned tickets' ) 17 | .setColor( 'Red' ); 18 | 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | let searchResults: any; 21 | 22 | try { 23 | searchResults = await MojiraBot.jira.issueSearch.searchForIssuesUsingJql( { 24 | jql: `id IN (${ this.tickets.join( ',' ) }) ORDER BY key ASC`, 25 | maxResults: 10, 26 | fields: [ 'key', 'summary' ], 27 | } ); 28 | } catch ( err ) { 29 | let ticketList = this.tickets.join( ', ' ); 30 | const lastSeparatorPos = ticketList.lastIndexOf( ', ' ); 31 | ticketList = `${ ticketList.substring( 0, lastSeparatorPos ) } and ${ ticketList.substring( lastSeparatorPos + 2, ticketList.length ) }`; 32 | 33 | let errorMessage = `An error occurred while retrieving tickets ${ ticketList }: ${ err.message }`; 34 | 35 | if ( err.response?.data?.errorMessages ) { 36 | for ( const msg of err.response.data.errorMessages ) { 37 | errorMessage += `\n${ msg }`; 38 | } 39 | } 40 | 41 | throw new Error( errorMessage ); 42 | } 43 | 44 | if ( !searchResults.issues ) { 45 | throw new Error( 'No issues were returned by the JIRA API.' ); 46 | } 47 | 48 | for ( const issue of searchResults.issues ) { 49 | embed.addFields( { name: issue.key, value: `[${ issue.fields.summary }](https://bugs.mojang.com/browse/${ issue.key })` } ); 50 | } 51 | 52 | if ( this.tickets.length !== searchResults.issues.length ) { 53 | embed.addFields( { 54 | name: 'More results', 55 | value: `[View all ${ this.tickets.length } tickets](https://bugs.mojang.com/issues/?jql=` + `id IN %28${ this.tickets.join( ',' ) }%29 ORDER BY key ASC`.replace( /\s+/ig, '%20' ) + ')', 56 | } ); 57 | } 58 | 59 | return embed; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/mentions/SingleMention.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, escapeMarkdown, TextBasedChannel } from 'discord.js'; 2 | import MojiraBot from '../MojiraBot.js'; 3 | import { MarkdownUtil } from '../util/MarkdownUtil.js'; 4 | import { Mention } from './Mention.js'; 5 | import { ChannelConfigUtil } from '../util/ChannelConfigUtil.js'; 6 | 7 | export class SingleMention extends Mention { 8 | private ticket: string; 9 | private channel: TextBasedChannel; 10 | 11 | constructor( ticket: string, channel: TextBasedChannel ) { 12 | super(); 13 | 14 | this.ticket = ticket; 15 | this.channel = channel; 16 | } 17 | 18 | public async getEmbed(): Promise { 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | let ticketResult: any; 21 | 22 | try { 23 | ticketResult = await MojiraBot.jira.issues.getIssue( { 24 | issueIdOrKey: this.ticket, 25 | } ); 26 | } catch ( err ) { 27 | let errorMessage = `An error occurred while retrieving ticket ${ this.ticket }: ${ err.message }`; 28 | 29 | if ( err.response ) { 30 | const exception = err.response; 31 | 32 | if ( exception.status === 404 ) { 33 | errorMessage = `${ this.ticket } doesn't seem to exist.`; 34 | } else if ( exception.status === 403 ) { 35 | errorMessage = `${ this.ticket } is private.`; 36 | } else if ( exception.status === 401 ) { 37 | errorMessage = `${ this.ticket } is private or has been deleted.`; 38 | } else if ( exception?.data?.errorMessages ) { 39 | for ( const msg of exception.data.errorMessages ) { 40 | errorMessage += `\n${ msg }`; 41 | } 42 | } 43 | } 44 | 45 | throw new Error( errorMessage ); 46 | } 47 | 48 | if ( !ticketResult.fields ) { 49 | throw new Error( 'No fields were returned by the JIRA API.' ); 50 | } 51 | 52 | let status = ticketResult.fields.status.name; 53 | let largeStatus = false; 54 | if ( ticketResult.fields.resolution ) { 55 | const resolutionDate = MarkdownUtil.timestamp( new Date( ticketResult.fields.resolutiondate ), 'R' ); 56 | status = `Resolved as **${ ticketResult.fields.resolution.name }** ${ resolutionDate }`; 57 | 58 | if ( ticketResult.fields.resolution.id === '3' ) { 59 | const parents = ticketResult.fields.issuelinks 60 | .filter( relation => relation.type.id === '10102' && relation.outwardIssue ) 61 | .map( relation => `\n→ **[${ relation.outwardIssue.key }](https://bugs.mojang.com/browse/${ relation.outwardIssue.key })** *(${ relation.outwardIssue.fields.summary })*` ); 62 | 63 | status += parents.join( ',' ); 64 | largeStatus = parents.length > 0; 65 | } 66 | } 67 | 68 | let description = ticketResult.fields.description || ''; 69 | 70 | // unify line breaks 71 | description = description.replace( /^\s*[\r\n]/gm, '\n' ); 72 | 73 | // convert to Discord markdown 74 | description = MarkdownUtil.jira2md( description ); 75 | 76 | // remove first heading 77 | description = description.replace( /^#.*$/m, '' ); 78 | 79 | // remove empty lines 80 | description = description.replace( /(^|\n)\s*(\n|$)/g, '\n' ); 81 | 82 | // remove all sections except for the first 83 | description = description.replace( /\n#[\s\S]*$/i, '' ); 84 | 85 | // only show first two lines 86 | description = description.split( '\n' ).slice( 0, 2 ).join( '\n' ); 87 | 88 | const embed = new EmbedBuilder(); 89 | 90 | embed.setTitle( this.ensureLength( `[${ ticketResult.key }] ${ escapeMarkdown( ticketResult.fields.summary ) }` ) ) 91 | .setDescription( description.substring( 0, 2048 ) || null ) 92 | .setURL( `https://bugs.mojang.com/browse/${ ticketResult.key }` ) 93 | .setColor( 'Red' ); 94 | 95 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 96 | function findThumbnail( attachments: any[] ): string | undefined { 97 | const allowedMimes = [ 98 | 'image/png', 'image/jpeg', 99 | ]; 100 | 101 | attachments.sort( ( a, b ) => { 102 | return new Date( a.created ).valueOf() - new Date( b.created ).valueOf(); 103 | } ); 104 | 105 | for ( const attachment of attachments ) { 106 | if ( allowedMimes.includes( attachment.mimeType ) ) return attachment.content; 107 | } 108 | 109 | return undefined; 110 | } 111 | 112 | if ( !ChannelConfigUtil.limitedInfo( this.channel ) ) { 113 | embed.setAuthor( { name: ticketResult.fields.reporter.displayName, iconURL: ticketResult.fields.reporter.avatarUrls['48x48'], url: 'https://bugs.mojang.com/secure/ViewProfile.jspa?name=' + encodeURIComponent( ticketResult.fields.reporter.name ) } ) 114 | .addFields( { name: 'Status', value: status, inline: !largeStatus } ); 115 | 116 | // Assigned to, Reported by, Created on, Category, Resolution, Resolved on, Since version, (Latest) affected version, Fixed version(s) 117 | 118 | const thumbnail = findThumbnail( ticketResult.fields.attachment ); 119 | if ( thumbnail !== undefined ) embed.setThumbnail( thumbnail ); 120 | 121 | if ( ticketResult.fields.fixVersions && ticketResult.fields.fixVersions.length ) { 122 | const fixVersions = ticketResult.fields.fixVersions.map( v => v.name ); 123 | embed.addFields( { 124 | name: 'Fix Version' + ( fixVersions.length > 1 ? 's' : '' ), 125 | value: escapeMarkdown( fixVersions.join( ', ' ) ), 126 | inline: true, 127 | } ); 128 | } 129 | 130 | if ( ticketResult.fields.assignee ) { 131 | embed.addFields( { 132 | name: 'Assignee', 133 | value: `[${ escapeMarkdown( ticketResult.fields.assignee.displayName ) }](https://bugs.mojang.com/secure/ViewProfile.jspa?name=${ encodeURIComponent( ticketResult.fields.assignee.name ) })`, 134 | inline: true, 135 | } ); 136 | } 137 | 138 | if ( ticketResult.fields.votes.votes ) { 139 | embed.addFields( { 140 | name: 'Votes', 141 | value: ticketResult.fields.votes.votes.toString(), 142 | inline: true, 143 | } ); 144 | } 145 | 146 | if ( ticketResult.fields.comment.total ) { 147 | embed.addFields( { 148 | name: 'Comments', 149 | value: ticketResult.fields.comment.total.toString(), 150 | inline: true, 151 | } ); 152 | } 153 | 154 | const duplicates = ticketResult.fields.issuelinks.filter( relation => relation.type.id === '10102' && relation.inwardIssue ); 155 | if ( duplicates.length ) { 156 | embed.addFields( { 157 | name: 'Duplicates', 158 | value: duplicates.length.toString(), 159 | inline: true, 160 | } ); 161 | } 162 | 163 | if ( ticketResult.fields.creator.key !== ticketResult.fields.reporter.key ) { 164 | embed.addFields( { 165 | name: 'Creator', 166 | value: `[${ escapeMarkdown( ticketResult.fields.creator.displayName ) }](https://bugs.mojang.com/secure/ViewProfile.jspa?name=${ encodeURIComponent( ticketResult.fields.creator.name ) })`, 167 | inline: true, 168 | } ); 169 | } 170 | 171 | embed.addFields( { 172 | name: 'Created', 173 | value: MarkdownUtil.timestamp( new Date( ticketResult.fields.created ), 'R' ), 174 | inline: true, 175 | } ); 176 | } 177 | 178 | return embed; 179 | } 180 | 181 | private ensureLength( input: string ): string { 182 | if ( input.length > 251 ) { 183 | return input.substring( 0, 251 ) + '...'; 184 | } 185 | return input; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/permissions/AdminPermission.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember } from 'discord.js'; 2 | import Permission from './Permission.js'; 3 | 4 | /** 5 | * Permission level 'Admin' 6 | * This allows the command to be run by any guild member who has the "Ban members" permission serverwide. 7 | */ 8 | export default class AdminPermission extends Permission { 9 | public checkPermission( member?: GuildMember ): boolean { 10 | return member?.permissions.has( 'BanMembers' ) ?? false; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/permissions/AnyPermission.ts: -------------------------------------------------------------------------------- 1 | import Permission from './Permission.js'; 2 | 3 | export default class AnyPermission extends Permission { 4 | public checkPermission(): boolean { 5 | return true; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/permissions/ModeratorPermission.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember } from 'discord.js'; 2 | import Permission from './Permission.js'; 3 | 4 | /** 5 | * Permission level 'Moderator' 6 | * This allows the command to be run by any guild member who has the "Manage messages" permission serverwide. 7 | */ 8 | export default class ModeratorPermission extends Permission { 9 | public checkPermission( member?: GuildMember ): boolean { 10 | return member?.permissions.has( 'ManageMessages' ) ?? false; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/permissions/OwnerPermission.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember } from 'discord.js'; 2 | import Permission from './Permission.js'; 3 | import BotConfig from '../BotConfig.js'; 4 | 5 | export default class OwnerPermission extends Permission { 6 | public checkPermission( member?: GuildMember ): boolean { 7 | return member ? BotConfig.owners.includes( member.id ) : false; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/permissions/Permission.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember } from 'discord.js'; 2 | 3 | export default abstract class Permission { 4 | abstract checkPermission( member?: GuildMember ): boolean; 5 | } 6 | -------------------------------------------------------------------------------- /src/permissions/PermissionRegistry.ts: -------------------------------------------------------------------------------- 1 | import AdminPermission from './AdminPermission.js'; 2 | import AnyPermission from './AnyPermission.js'; 3 | import ModeratorPermission from './ModeratorPermission.js'; 4 | import OwnerPermission from './OwnerPermission.js'; 5 | 6 | export default class PermissionRegistry { 7 | public static ADMIN_PERMISSION = new AdminPermission(); 8 | public static ANY_PERMISSION = new AnyPermission(); 9 | public static MODERATOR_PERMISSION = new ModeratorPermission(); 10 | public static OWNER_PERMISSION = new OwnerPermission(); 11 | } 12 | -------------------------------------------------------------------------------- /src/tasks/AddProgressMessageTask.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, Message } from 'discord.js'; 2 | import MessageTask from './MessageTask.js'; 3 | import log4js from 'log4js'; 4 | 5 | export default class AddProgressMessageTask extends MessageTask { 6 | private static logger = log4js.getLogger( 'AddProgressMessageTask' ); 7 | 8 | private readonly request: Message; 9 | 10 | constructor( request: Message ) { 11 | super(); 12 | this.request = request; 13 | } 14 | 15 | public async run( origin: Message ): Promise { 16 | // If the message is undefined or has been deleted, don't do anything 17 | if ( origin === undefined ) return; 18 | 19 | const comment = origin.content; 20 | const date = origin.createdAt; 21 | const user = origin.author; 22 | 23 | if ( origin.deletable ) { 24 | try { 25 | await origin.delete(); 26 | } catch ( error ) { 27 | AddProgressMessageTask.logger.error( error ); 28 | } 29 | } 30 | 31 | if ( comment ) { 32 | try { 33 | const embed = new EmbedBuilder( this.request.embeds[0].data ); 34 | embed.addFields( { 35 | name: date.toDateString(), 36 | value: `${ user } - ${ comment.replace( `${ this.request.id } `, '' ).replace( `${ this.request.id }\n`, '' ) }`, 37 | } ); 38 | await this.request.edit( { embeds: [embed] } ); 39 | } catch ( error ) { 40 | AddProgressMessageTask.logger.error( error ); 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/tasks/CachedFilterFeedTask.ts: -------------------------------------------------------------------------------- 1 | import { MentionRegistry } from '../mentions/MentionRegistry.js'; 2 | import { FilterFeedConfig } from '../BotConfig.js'; 3 | import { Message, SendableChannels } from 'discord.js'; 4 | import log4js from 'log4js'; 5 | import Task from './Task.js'; 6 | import { NewsUtil } from '../util/NewsUtil.js'; 7 | import MojiraBot from '../MojiraBot.js'; 8 | 9 | export default class CachedFilterFeedTask extends Task { 10 | private static logger = log4js.getLogger( 'CachedFilterFeedTask' ); 11 | private static lastRunRegex = /\{\{lastRun\}\}/g; 12 | 13 | private channel: SendableChannels; 14 | private jql: string; 15 | private jqlRemoved?: string; 16 | private filterFeedEmoji: string; 17 | private title: string; 18 | private titleSingle: string; 19 | private publish: boolean; 20 | 21 | private knownTickets = new Set(); 22 | 23 | private lastRun: number; 24 | 25 | constructor( feedConfig: FilterFeedConfig, channel: SendableChannels ) { 26 | super(); 27 | 28 | this.channel = channel; 29 | this.jql = feedConfig.jql; 30 | this.jqlRemoved = feedConfig.jqlRemoved; 31 | this.filterFeedEmoji = feedConfig.filterFeedEmoji; 32 | this.title = feedConfig.title; 33 | this.titleSingle = feedConfig.titleSingle || feedConfig.title.replace( /\{\{num\}\}/g, '1' ); 34 | this.publish = feedConfig.publish ?? false; 35 | } 36 | 37 | protected async init(): Promise { 38 | this.lastRun = new Date().valueOf(); 39 | 40 | const searchResults = await MojiraBot.jira.issueSearch.searchForIssuesUsingJql( { 41 | jql: this.jql.replace( CachedFilterFeedTask.lastRunRegex, this.lastRun.toString() ), 42 | fields: ['key'], 43 | } ); 44 | 45 | if ( searchResults.issues ) { 46 | for ( const result of searchResults.issues ) { 47 | this.knownTickets.add( result.key ); 48 | } 49 | } 50 | } 51 | 52 | protected async run(): Promise { 53 | let upcomingTickets: string[]; 54 | 55 | try { 56 | const searchResults = await MojiraBot.jira.issueSearch.searchForIssuesUsingJql( { 57 | jql: this.jql.replace( CachedFilterFeedTask.lastRunRegex, this.lastRun.toString() ), 58 | fields: ['key'], 59 | } ); 60 | 61 | if ( !searchResults.issues ) { 62 | CachedFilterFeedTask.logger.error( `[${ this.id }] Error: no issues returned by JIRA` ); 63 | return; 64 | } 65 | 66 | upcomingTickets = searchResults.issues.map( ( { key } ) => key ); 67 | } catch ( err ) { 68 | CachedFilterFeedTask.logger.error( `[${ this.id }] Error when searching for issues`, err ); 69 | return; 70 | } 71 | 72 | if ( this.jqlRemoved !== undefined ) { 73 | try { 74 | const ticketKeys = Array.from( this.knownTickets ); 75 | const previousTicketResults = await MojiraBot.jira.issueSearch.searchForIssuesUsingJql( { 76 | jql: `${ this.jqlRemoved.replace( CachedFilterFeedTask.lastRunRegex, this.lastRun.toString() ) } AND key in (${ ticketKeys.join( ',' ) })`, 77 | fields: ['key'], 78 | } ); 79 | 80 | let removableTickets: string[] = []; 81 | 82 | if ( previousTicketResults?.issues ) { 83 | removableTickets = previousTicketResults.issues.map( ( { key } ) => key ); 84 | } else { 85 | CachedFilterFeedTask.logger.debug( 'No issues returned by JIRA' ); 86 | } 87 | 88 | for ( const ticket of removableTickets ) { 89 | this.knownTickets.delete( ticket ); 90 | CachedFilterFeedTask.logger.debug( `Removed ${ ticket } from known tickets for cached filter feed task ${ this.id }` ); 91 | } 92 | } catch ( err ) { 93 | CachedFilterFeedTask.logger.error( err ); 94 | return; 95 | } 96 | } 97 | 98 | const unknownTickets = upcomingTickets.filter( key => !this.knownTickets.has( key ) ); 99 | 100 | if ( unknownTickets.length > 0 ) { 101 | try { 102 | const embed = await MentionRegistry.getMention( unknownTickets, this.channel ).getEmbed(); 103 | 104 | let message = ''; 105 | 106 | let filterFeedMessage: Message; 107 | 108 | if ( unknownTickets.length > 1 ) { 109 | embed.setTitle( 110 | this.title.replace( /\{\{num\}\}/g, unknownTickets.length.toString() ) 111 | ); 112 | filterFeedMessage = await this.channel.send( { embeds: [embed] } ); 113 | } else { 114 | message = this.titleSingle; 115 | filterFeedMessage = await this.channel.send( { content: message, embeds: [embed] } ); 116 | } 117 | 118 | if ( this.publish ) { 119 | NewsUtil.publishMessage( filterFeedMessage ).catch( err => { 120 | CachedFilterFeedTask.logger.error( `[${ this.id }] Error when publishing message`, err ); 121 | } ); 122 | } 123 | 124 | if ( this.filterFeedEmoji !== undefined ) { 125 | await filterFeedMessage.react( this.filterFeedEmoji ); 126 | } 127 | } catch ( error ) { 128 | CachedFilterFeedTask.logger.error( `[${ this.id }] Could not send Discord message`, error ); 129 | return; 130 | } 131 | } 132 | 133 | this.lastRun = new Date().valueOf(); 134 | 135 | for ( const ticket of unknownTickets ) { 136 | this.knownTickets.add( ticket ); 137 | CachedFilterFeedTask.logger.debug( `[${ this.id }] Added ${ ticket } to known tickets for cached filter feed task ${ this.id }` ); 138 | } 139 | } 140 | 141 | public asString(): string { 142 | return `CachedFilterFeedTask[#${ this.id }]`; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/tasks/FilterFeedTask.ts: -------------------------------------------------------------------------------- 1 | import { MentionRegistry } from '../mentions/MentionRegistry.js'; 2 | import { FilterFeedConfig } from '../BotConfig.js'; 3 | import { Message, SendableChannels } from 'discord.js'; 4 | import log4js from 'log4js'; 5 | import Task from './Task.js'; 6 | import { NewsUtil } from '../util/NewsUtil.js'; 7 | import MojiraBot from '../MojiraBot.js'; 8 | import { LoggerUtil } from '../util/LoggerUtil.js'; 9 | 10 | export default class FilterFeedTask extends Task { 11 | private static logger = log4js.getLogger( 'FilterFeedTask' ); 12 | private static lastRunRegex = /\{\{lastRun\}\}/g; 13 | 14 | private channel: SendableChannels; 15 | private jql: string; 16 | private filterFeedEmoji: string; 17 | private title: string; 18 | private titleSingle: string; 19 | private publish: boolean; 20 | 21 | private lastRun: number; 22 | 23 | constructor( feedConfig: FilterFeedConfig, channel: SendableChannels ) { 24 | super(); 25 | 26 | this.channel = channel; 27 | this.jql = feedConfig.jql; 28 | this.filterFeedEmoji = feedConfig.filterFeedEmoji; 29 | this.title = feedConfig.title; 30 | this.titleSingle = feedConfig.titleSingle || feedConfig.title.replace( /\{\{num\}\}/g, '1' ); 31 | this.publish = feedConfig.publish ?? false; 32 | } 33 | 34 | protected async init(): Promise { 35 | this.lastRun = new Date().valueOf(); 36 | } 37 | 38 | protected async run(): Promise { 39 | let unknownTickets: string[]; 40 | 41 | try { 42 | const searchResults = await MojiraBot.jira.issueSearch.searchForIssuesUsingJql( { 43 | jql: this.jql.replace( FilterFeedTask.lastRunRegex, this.lastRun.toString() ), 44 | fields: ['key'], 45 | } ); 46 | 47 | if ( !searchResults.issues ) { 48 | FilterFeedTask.logger.error( `[${ this.id }] Error: no issues returned by JIRA` ); 49 | return; 50 | } 51 | 52 | unknownTickets = searchResults.issues.map( ( { key } ) => key ); 53 | } catch ( err ) { 54 | FilterFeedTask.logger.error( `[${ this.id }] Error when searching for issues. ${ LoggerUtil.shortenJiraError( err ) }` ); 55 | return; 56 | } 57 | 58 | if ( unknownTickets.length > 0 ) { 59 | try { 60 | const embed = await MentionRegistry.getMention( unknownTickets, this.channel ).getEmbed(); 61 | 62 | let filterFeedMessage: Message; 63 | 64 | if ( unknownTickets.length > 1 ) { 65 | embed.setTitle( 66 | this.title.replace( /\{\{num\}\}/g, unknownTickets.length.toString() ) 67 | ); 68 | filterFeedMessage = await this.channel.send( { embeds: [embed] } ); 69 | } else { 70 | filterFeedMessage = await this.channel.send( { content: this.titleSingle, embeds: [embed] } ); 71 | } 72 | 73 | if ( this.publish ) { 74 | NewsUtil.publishMessage( filterFeedMessage ).catch( err => { 75 | FilterFeedTask.logger.error( `[${ this.id }] Error when publishing message`, err ); 76 | } ); 77 | } 78 | 79 | if ( this.filterFeedEmoji !== undefined ) { 80 | await filterFeedMessage.react( this.filterFeedEmoji ); 81 | } 82 | } catch ( error ) { 83 | FilterFeedTask.logger.error( `[${ this.id }] Could not send Discord message`, error ); 84 | return; 85 | } 86 | } 87 | 88 | this.lastRun = new Date().valueOf(); 89 | } 90 | 91 | public asString(): string { 92 | return `FilterFeedTask[#${ this.id }]`; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/tasks/MessageTask.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'discord.js'; 2 | 3 | export default abstract class MessageTask { 4 | public abstract run( message: Message ): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/tasks/ResolveRequestMessageTask.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, EmojiResolvable, Message, TextChannel, User } from 'discord.js'; 2 | import BotConfig from '../BotConfig.js'; 3 | import DiscordUtil from '../util/DiscordUtil.js'; 4 | import { RequestsUtil } from '../util/RequestsUtil.js'; 5 | import MessageTask from './MessageTask.js'; 6 | import log4js from 'log4js'; 7 | 8 | export default class ResolveRequestMessageTask extends MessageTask { 9 | private static logger = log4js.getLogger( 'ResolveRequestMessageTask' ); 10 | 11 | private readonly emoji: EmojiResolvable; 12 | private readonly user: User; 13 | 14 | constructor( emoji: EmojiResolvable, user: User ) { 15 | super(); 16 | this.emoji = emoji; 17 | this.user = user; 18 | } 19 | 20 | public async run( copy: Message ): Promise { 21 | // If the message has been deleted, don't do anything 22 | if ( copy === undefined ) return; 23 | 24 | const origin = await RequestsUtil.getOriginMessage( copy ); 25 | 26 | if ( copy.deletable ) { 27 | try { 28 | await copy.delete(); 29 | } catch ( error ) { 30 | ResolveRequestMessageTask.logger.error( error ); 31 | } 32 | } 33 | 34 | if ( origin ) { 35 | try { 36 | await origin.reactions.removeAll(); 37 | } catch ( error ) { 38 | ResolveRequestMessageTask.logger.error( error ); 39 | } 40 | 41 | try { 42 | await origin.react( this.emoji ); 43 | } catch ( error ) { 44 | ResolveRequestMessageTask.logger.error( error ); 45 | } 46 | 47 | if ( BotConfig.request.logChannel ) { 48 | const logChannel = await DiscordUtil.getChannel( BotConfig.request.logChannel ); 49 | if ( logChannel && logChannel instanceof TextChannel ) { 50 | const log = new EmbedBuilder() 51 | .setColor( 'Green' ) 52 | .setAuthor( { name: origin.author.tag, iconURL: origin.author.avatarURL() ?? undefined } ) 53 | .setDescription( origin.content ) 54 | .addFields( 55 | { name: 'Message', value: `[Here](${ origin.url })`, inline: true }, 56 | { name: 'Channel', value: origin.channel.toString(), inline: true }, 57 | { name: 'Created', value: origin.createdAt.toUTCString(), inline: false }, 58 | ) 59 | .setFooter( { text: `${ this.user.tag } resolved as ${ this.emoji }`, iconURL: this.user.avatarURL() ?? undefined } ) 60 | .setTimestamp( new Date() ); 61 | 62 | try { 63 | if ( BotConfig.request.prependResponseMessageInLog ) { 64 | const response = RequestsUtil.getResponseMessage( origin ); 65 | await logChannel.send( { content: response, embeds: [log] } ); 66 | } else { 67 | await logChannel.send( { embeds: [log] } ); 68 | } 69 | } catch ( error ) { 70 | ResolveRequestMessageTask.logger.error( error ); 71 | } 72 | } 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/tasks/Task.ts: -------------------------------------------------------------------------------- 1 | import MojiraBot from '../MojiraBot.js'; 2 | import { LoggerUtil } from '../util/LoggerUtil.js'; 3 | 4 | export default abstract class Task { 5 | private static maxId = 0; 6 | protected id = Task.maxId++; 7 | 8 | private initialized = false; 9 | 10 | protected async init(): Promise { 11 | return; 12 | } 13 | 14 | protected abstract run(): Promise; 15 | 16 | public async execute(): Promise { 17 | if ( !this.initialized ) { 18 | try { 19 | MojiraBot.logger.debug( `Initializing ${ this.asString() }` ); 20 | await this.init(); 21 | this.initialized = true; 22 | MojiraBot.logger.info( `Initialized ${ this.asString() }` ); 23 | } catch ( error ) { 24 | MojiraBot.logger.error( `Could not initialize ${ this.asString() }. ${ LoggerUtil.shortenJiraError( error ) }` ); 25 | } 26 | } 27 | 28 | if ( this.initialized ) { 29 | MojiraBot.logger.debug( `Running ${ this.asString() }` ); 30 | await this.run(); 31 | } 32 | } 33 | 34 | public abstract asString(): string; 35 | } 36 | -------------------------------------------------------------------------------- /src/tasks/TaskScheduler.ts: -------------------------------------------------------------------------------- 1 | import Task from './Task.js'; 2 | import { Message, PartialMessage } from 'discord.js'; 3 | import MessageTask from './MessageTask.js'; 4 | 5 | export default class TaskScheduler { 6 | private static readonly intervals: NodeJS.Timeout[] = []; 7 | private static readonly timeouts: NodeJS.Timeout[] = []; 8 | private static readonly messageIntervals: Map = new Map(); 9 | private static readonly messageTimeouts: Map = new Map(); 10 | 11 | public static addTask( task: Task, interval: number ): void { 12 | const id = setInterval( task.execute.bind( task ), interval ); 13 | // Run the task directly after it's been added 14 | task.execute.bind( task )(); 15 | this.intervals.push( id ); 16 | } 17 | 18 | public static addOneTimeTask( task: Task, delay: number ): void { 19 | const id = setTimeout( task.execute.bind( task ), delay ); 20 | this.timeouts.push( id ); 21 | } 22 | 23 | public static addMessageTask( message: Message | PartialMessage, task: MessageTask, interval: number ): void { 24 | const id = setInterval( task.run.bind( task ), interval, message ); 25 | const ids = this.messageIntervals.get( message.id ) || []; 26 | ids.push( id ); 27 | this.messageIntervals.set( message.id, ids ); 28 | } 29 | 30 | public static addOneTimeMessageTask( message: Message | PartialMessage, task: MessageTask, delay: number ): void { 31 | const id = setTimeout( task.run.bind( task ), delay, message ); 32 | const ids = this.messageTimeouts.get( message.id ) || []; 33 | ids.push( id ); 34 | this.messageTimeouts.set( message.id, ids ); 35 | } 36 | 37 | public static clearMessageTasks( message: Message | PartialMessage ): void { 38 | const intervalIds = this.messageIntervals.get( message.id ); 39 | if ( intervalIds ) { 40 | for ( const id of intervalIds ) { 41 | clearInterval( id ); 42 | } 43 | } 44 | this.messageIntervals.delete( message.id ); 45 | 46 | const timeoutIds = this.messageTimeouts.get( message.id ); 47 | if ( timeoutIds ) { 48 | for ( const id of timeoutIds ) { 49 | clearTimeout( id ); 50 | } 51 | } 52 | this.messageTimeouts.delete( message.id ); 53 | } 54 | 55 | public static clearAll(): void { 56 | for ( const id of this.intervals ) { 57 | clearInterval( id ); 58 | } 59 | for ( const id of this.timeouts ) { 60 | clearTimeout( id ); 61 | } 62 | for ( const ids of this.messageIntervals.values() ) { 63 | for ( const id of ids ) { 64 | clearInterval( id ); 65 | } 66 | } 67 | for ( const ids of this.messageTimeouts.values() ) { 68 | for ( const id of ids ) { 69 | clearTimeout( id ); 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/tasks/VersionFeedTask.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, SendableChannels } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import { VersionConfig, VersionFeedConfig } from '../BotConfig.js'; 4 | import { NewsUtil } from '../util/NewsUtil.js'; 5 | import MojiraBot from '../MojiraBot.js'; 6 | import Task from './Task.js'; 7 | import { LoggerUtil } from '../util/LoggerUtil.js'; 8 | import { Version } from 'jira.js/out/version2/models'; 9 | 10 | interface JiraVersion { 11 | id: string; 12 | name: string; 13 | archived: boolean; 14 | released: boolean; 15 | releaseDate?: string; 16 | projectId: number | string; 17 | } 18 | 19 | function versionConv( version: Version ): JiraVersion | undefined { 20 | if ( 21 | version.id === undefined 22 | || version.name === undefined 23 | || version.archived === undefined 24 | || version.released === undefined 25 | || version.projectId === undefined 26 | ) return undefined; 27 | 28 | return { 29 | id: version.id, 30 | name: version.name, 31 | archived: version.archived, 32 | released: version.released, 33 | releaseDate: version.releaseDate, 34 | projectId: version.projectId, 35 | }; 36 | } 37 | 38 | export type VersionChangeType = 'created' | 'released' | 'unreleased' | 'archived' | 'unarchived' | 'renamed'; 39 | 40 | interface JiraVersionChange { 41 | type: VersionChangeType; 42 | versionId: string; 43 | message: string; 44 | embed?: EmbedBuilder; 45 | } 46 | 47 | interface JiraVersionMap { 48 | [id: string]: JiraVersion; 49 | } 50 | 51 | export default class VersionFeedTask extends Task { 52 | private static logger = log4js.getLogger( 'VersionFeedTask' ); 53 | 54 | private channel: SendableChannels; 55 | private projects: VersionConfig[]; 56 | private versionFeedEmoji: string; 57 | private scope: number; 58 | private actions: VersionChangeType[]; 59 | private publish: boolean; 60 | 61 | private cachedVersions: JiraVersionMap = {}; 62 | 63 | constructor( feedConfig: VersionFeedConfig, channel: SendableChannels ) { 64 | super(); 65 | 66 | this.channel = channel; 67 | this.projects = feedConfig.projects; 68 | this.versionFeedEmoji = feedConfig.versionFeedEmoji; 69 | this.scope = feedConfig.scope; 70 | this.actions = feedConfig.actions; 71 | this.publish = feedConfig.publish ?? false; 72 | } 73 | 74 | protected async init(): Promise { 75 | try { 76 | for ( const project of this.projects ) { 77 | const results = await MojiraBot.jira.projectVersions.getProjectVersions( { 78 | projectIdOrKey: project.name, 79 | expand: 'id,name,archived,released', 80 | } ); 81 | 82 | for ( const value of results ) { 83 | const version = versionConv( value ); 84 | if ( version !== undefined ) this.cachedVersions[version.id] = version; 85 | } 86 | } 87 | } catch ( error ) { 88 | // If any request fails, our cache cannot be used. Return error. 89 | this.cachedVersions = {}; 90 | throw error; 91 | } 92 | } 93 | 94 | protected async run(): Promise { 95 | const changes = await this.getAllVersionChanges(); 96 | // VersionFeedTask.logger.debug( `[${ this.id }] Gotten ${ changes.length } relevant version changes: ${ VersionFeedTask.stringifyChanges( changes ) }` ); 97 | 98 | for ( const change of changes ) { 99 | try { 100 | const embeds = change.embed === undefined ? [] : [change.embed]; 101 | const versionFeedMessage = await this.channel.send( { content: change.message, embeds } ); 102 | 103 | if ( this.publish ) { 104 | await NewsUtil.publishMessage( versionFeedMessage ); 105 | } 106 | 107 | if ( this.versionFeedEmoji !== undefined ) { 108 | await versionFeedMessage.react( this.versionFeedEmoji ); 109 | } 110 | } catch ( error ) { 111 | VersionFeedTask.logger.error( `[${ this.id }] Could not send Discord message`, error ); 112 | } 113 | } 114 | } 115 | 116 | private async getAllVersionChanges(): Promise { 117 | const changes: JiraVersionChange[] = []; 118 | 119 | for ( const project of this.projects ) { 120 | changes.push( ...await this.getVersionChangesForProject( project.name ) ); 121 | } 122 | 123 | return changes.filter( change => this.actions.includes( change.type ) ); 124 | } 125 | 126 | private async getVersionChangesForProject( project: string ): Promise { 127 | const results = await MojiraBot.jira.projectVersions.getProjectVersionsPaginated( { 128 | projectIdOrKey: project, 129 | maxResults: this.scope, 130 | orderBy: '-sequence', 131 | } ); 132 | 133 | const changes: JiraVersionChange[] = []; 134 | 135 | // VersionFeedTask.logger.debug( `[${ this.id }] Received ${ results.values?.length } versions for project ${ project }` ); 136 | if ( !results.values ) return changes; 137 | 138 | for ( const value of results.values ) { 139 | try { 140 | const version = versionConv( value ); 141 | if ( version === undefined ) continue; 142 | 143 | const versionChanges = await this.getVersionChanges( this.cachedVersions[version.id], version ); 144 | 145 | if ( versionChanges.length ) { 146 | this.cachedVersions[version.id] = version; 147 | changes.push( ...versionChanges ); 148 | } 149 | } catch ( error ) { 150 | VersionFeedTask.logger.error( error ); 151 | } 152 | } 153 | 154 | // VersionFeedTask.logger.debug( `[${ this.id }] Found ${ changes.length } version changes for project ${ project }: ${ VersionFeedTask.stringifyChanges( changes ) }` ); 155 | 156 | return changes; 157 | } 158 | 159 | private async getVersionChanges( previous: JiraVersion, current: JiraVersion ): Promise { 160 | const changes: JiraVersionChange[] = []; 161 | 162 | if ( previous === undefined ) { 163 | changes.push( { 164 | type: 'created', 165 | versionId: current.id, 166 | message: `Version **${ current.name }** has been created.`, 167 | } ); 168 | } else { 169 | if ( previous.name !== current.name ) { 170 | changes.push( { 171 | type: 'renamed', 172 | versionId: current.id, 173 | message: `Version **${ previous.name }** has been renamed to **${ current.name }**.`, 174 | } ); 175 | } 176 | 177 | if ( previous.archived !== current.archived ) { 178 | if ( current.archived ) { 179 | changes.push( { 180 | type: 'archived', 181 | versionId: current.id, 182 | message: `Version **${ current.name }** has been archived.`, 183 | } ); 184 | } else { 185 | changes.push( { 186 | type: 'unarchived', 187 | versionId: current.id, 188 | message: `Version **${ current.name }** has been unarchived.`, 189 | } ); 190 | } 191 | } 192 | 193 | if ( previous.released !== current.released ) { 194 | if ( current.released ) { 195 | changes.push( { 196 | type: 'released', 197 | versionId: current.id, 198 | message: `Version **${ current.name }** has been released.`, 199 | } ); 200 | } else { 201 | changes.push( { 202 | type: 'unreleased', 203 | versionId: current.id, 204 | message: `Version **${ current.name }** has been unreleased.`, 205 | } ); 206 | } 207 | } 208 | } 209 | 210 | const versionEmbed = await this.getVersionEmbed( current ); 211 | for ( const change of changes ) { 212 | change.embed = versionEmbed; 213 | } 214 | 215 | return changes; 216 | } 217 | 218 | private async getVersionEmbed( version: JiraVersion ): Promise { 219 | const embed = new EmbedBuilder() 220 | .setTitle( version.name ) 221 | .setColor( 'Purple' ); 222 | 223 | let versionIssueCounts: { 224 | issuesAffectedCount: number; 225 | issuesFixedCount: number; 226 | }; 227 | 228 | try { 229 | versionIssueCounts = await MojiraBot.jira.projectVersions.getVersionRelatedIssues( { 230 | id: version.id, 231 | } ); 232 | } catch ( error ) { 233 | VersionFeedTask.logger.error( `[${ this.id }] Error getting versionRelatedIssues: ${ LoggerUtil.shortenJiraError( error ) }` ); 234 | return undefined; 235 | } 236 | 237 | const affectedIssues = versionIssueCounts.issuesAffectedCount; 238 | const fixedIssues = versionIssueCounts.issuesFixedCount; 239 | 240 | if ( affectedIssues > 0 ) { 241 | const affectedSearchQuery = `affectedVersion = ${ version.id } ORDER BY created ASC`; 242 | embed.addFields( { 243 | name: 'Affected', 244 | value: `[${ affectedIssues } issue${ affectedIssues > 1 ? 's' : '' }](https://bugs.mojang.com/issues/?jql=${ affectedSearchQuery.replace( /\s+/ig, '%20' ) })`, 245 | inline: true, 246 | } ); 247 | } 248 | 249 | if ( fixedIssues > 0 ) { 250 | const fixedSearchQuery = `fixVersion = ${ version.id } ORDER BY key ASC`; 251 | embed.addFields( { 252 | name: 'Fixed', 253 | value: `[${ fixedIssues } issue${ fixedIssues > 1 ? 's' : '' }](https://bugs.mojang.com/issues/?jql=${ fixedSearchQuery.replace( /\s+/ig, '%20' ) })`, 254 | inline: true, 255 | } ); 256 | } 257 | 258 | if ( version.releaseDate !== undefined ) { 259 | embed.addFields( { 260 | name: 'Released', 261 | value: version.releaseDate, 262 | inline: true, 263 | } ); 264 | } 265 | 266 | const projectKey = this.projects.find( project => project.id == version.projectId )?.name; 267 | if ( projectKey ) { 268 | embed.addFields( { 269 | name: 'Project', 270 | value: projectKey, 271 | inline: true, 272 | } ); 273 | } 274 | 275 | if ( !embed.data.fields?.length ) { 276 | return undefined; 277 | } 278 | 279 | return embed; 280 | } 281 | 282 | private static stringifyChanges( changes: JiraVersionChange[] ): string { 283 | return `[${ changes.map( change => `${ change.type }:${ change.versionId }` ).join( ',' ) }]`; 284 | } 285 | 286 | public asString(): string { 287 | return `VersionFeedTask[#${ this.id }]`; 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/types/discord.d.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, Collection, SlashCommandBuilder } from 'discord.js'; 2 | 3 | export interface SlashCommandJsonData { 4 | data: SlashCommandBuilder; 5 | execute: ( interaction: ChatInputCommandInteraction ) => Promise; 6 | } 7 | 8 | declare module 'discord.js' { 9 | export interface Client { 10 | commands: Collection 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/util/ChannelConfigUtil.ts: -------------------------------------------------------------------------------- 1 | import { NewsChannel, TextChannel, TextBasedChannel } from 'discord.js'; 2 | 3 | export class ChannelConfigUtil { 4 | // Indicates in the channel's description that mentions are disabled in that channel. 5 | // Tag: ~no-mention 6 | public static mentionsDisabled( channel: TextBasedChannel ): boolean { 7 | if ( channel instanceof TextChannel || channel instanceof NewsChannel ) { 8 | return channel.topic != null && channel.topic.includes( '~no-mention' ); 9 | } 10 | return false; 11 | } 12 | 13 | // Indicates in the channel's description that commands are disabled in that channel. 14 | // Tag: ~no-command 15 | public static commandsDisabled( channel: TextBasedChannel ): boolean { 16 | if ( channel instanceof TextChannel || channel instanceof NewsChannel ) { 17 | return channel.topic != null && channel.topic.includes( '~no-command' ); 18 | } 19 | return false; 20 | } 21 | 22 | // Indicates in the channel's description that mention embeds will have limited information. 23 | // Tag: ~limited-info 24 | public static limitedInfo( channel: TextBasedChannel ): boolean { 25 | if ( channel instanceof TextChannel || channel instanceof NewsChannel ) { 26 | return channel.topic != null && channel.topic.includes( '~limited-info' ); 27 | } 28 | return false; 29 | } 30 | 31 | // Indicates in the channel's description that searches done by /search will not be ephemeral. 32 | // Tag: ~public-search 33 | public static publicSearch( channel: TextBasedChannel ): boolean { 34 | if ( channel instanceof TextChannel || channel instanceof NewsChannel ) { 35 | return channel.topic != null && channel.topic.includes( '~public-search' ); 36 | } 37 | return false; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/util/DiscordUtil.ts: -------------------------------------------------------------------------------- 1 | import log4js from 'log4js'; 2 | import MojiraBot from '../MojiraBot.js'; 3 | import { TextChannel, Message, Guild, GuildMember, MessageReaction, User, Snowflake, PartialMessage, TextBasedChannel, MessageReplyOptions } from 'discord.js'; 4 | 5 | export default class DiscordUtil { 6 | private static logger = log4js.getLogger( 'DiscordUtil' ); 7 | 8 | public static async getChannel( channelId: Snowflake ): Promise { 9 | const channel = await MojiraBot.client.channels.fetch( channelId ); 10 | return channel?.isTextBased() ? channel : undefined; 11 | } 12 | 13 | public static async getMessage( channel: TextChannel, messageId: Snowflake ): Promise { 14 | return await channel.messages.fetch( messageId ); 15 | } 16 | 17 | public static async getMember( guild: Guild, userId: Snowflake ): Promise { 18 | return await guild.members.fetch( userId ); 19 | } 20 | 21 | public static async fetchMessage( message: Message | PartialMessage ): Promise { 22 | if ( message !== undefined && message.partial ) { 23 | message = await message.fetch(); 24 | } 25 | return message as Message; 26 | } 27 | 28 | public static async fetchReaction( reaction: MessageReaction ): Promise { 29 | if ( reaction.partial ) { 30 | reaction = await reaction.fetch(); 31 | } 32 | return reaction; 33 | } 34 | 35 | public static async fetchUser( user: User ): Promise { 36 | if ( user.partial ) { 37 | user = await user.fetch(); 38 | } 39 | return user; 40 | } 41 | 42 | public static deleteWithDelay( message: Message, timeout: number ): Promise { 43 | return new Promise( ( resolve, reject ) => { 44 | setTimeout( async () => { 45 | try { 46 | await message.delete(); 47 | resolve(); 48 | } catch ( e ) { 49 | reject( e ); 50 | } 51 | }, timeout ); 52 | } ); 53 | } 54 | 55 | public static async sendMentionMessage( origin: Message, content: MessageReplyOptions ): Promise { 56 | try { 57 | if ( origin.reference?.messageId ) { 58 | const replyTo = await origin.fetchReference(); 59 | if ( replyTo === undefined ) return; 60 | 61 | if ( origin.mentions.users.first()?.id == replyTo.author.id ) { 62 | await replyTo.reply( { ...content, allowedMentions: { repliedUser: true } } ); 63 | } else { 64 | await replyTo.reply( { ...content, allowedMentions: { repliedUser: false } } ); 65 | } 66 | } else { 67 | const channel = await origin.channel.fetch(); 68 | if ( channel.isSendable() ) { 69 | await channel.send( content ); 70 | } 71 | } 72 | } catch ( e ) { 73 | this.logger.error( e ); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/util/LoggerUtil.ts: -------------------------------------------------------------------------------- 1 | export class LoggerUtil { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | public static shortenJiraError( error: any ): string { 4 | return `Information: 5 | Status code: ${ error?.response?.status } 6 | Status text: ${ error?.response?.statusText } 7 | Error message(s): ${ error?.response?.data?.errorMessages }`.replace( /\t/g, '' ); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/util/MarkdownUtil.ts: -------------------------------------------------------------------------------- 1 | export class MarkdownUtil { 2 | public static timestamp( timestamp: number | Date, style?: string ): string { 3 | if ( timestamp instanceof Date ) { 4 | timestamp = Math.floor( timestamp.getTime() / 1_000 ); 5 | } 6 | 7 | if ( style ) { 8 | return ``; 9 | } else { 10 | return ``; 11 | } 12 | } 13 | 14 | /** 15 | * Converts JIRA markdown to Discord markdown + headings 16 | * Partially adapted from https://github.com/kylefarris/J2M 17 | * 18 | * @param text The text that should be converted 19 | */ 20 | public static jira2md( text: string ): string { 21 | return text 22 | // Unordered lists 23 | .replace( 24 | /^[ \t]*(#+)\s+/gm, 25 | ( _match, nums ) => Array( nums.length ).join( ' ' ) + '1. ' 26 | ) 27 | // Headers 1-6 28 | .replace( 29 | /^h([0-6])\.(.*)$/gm, 30 | ( _match, level, content ) => Array( parseInt( level ) + 1 ).join( '#' ) + content 31 | ) 32 | // Bold 33 | .replace( /\*(\S.*)\*/g, '**$1**' ) 34 | // Monospaced text 35 | .replace( /\{\{([^}]+)\}\}/g, '`$1`' ) 36 | // Inserts / underline 37 | .replace( /\+([^+]*)\+/g, '__$1__' ) 38 | // Remove superscript 39 | .replace( /\^([^^]*)\^/g, '$1' ) 40 | // Remove subscript 41 | .replace( /~([^~]*)~/g, '$1' ) 42 | // Strikethrough 43 | .replace( /(\s+)-(\S+.*?\S)-(\s+)/g, '$1~~$2~~$3' ) 44 | // Code Block 45 | .replace( /\{code([^}]+)?\}[^]*\n?\{code\}/gm, '' ) 46 | // Pre-formatted text 47 | .replace( /{noformat}/g, '```' ) 48 | // Un-named Links 49 | .replace( /\[([^|]+?)\]/g, '$1' ) 50 | // Remove images 51 | .replace( /!(.+)!/g, '' ) 52 | // Named links 53 | .replace( /\[(.+?)\|(.+?)\]/g, '[$1]($2)' ) 54 | // Single paragraph block quote 55 | .replace( /^bq\.\s+/gm, '> ' ) 56 | // Block quote 57 | .replace( /\{quote\}\s*([\s\S]+)\{quote\}/, ( _match, quote ) => quote.replace( /^(.+)$/gm, '> $1\n' ) ) 58 | // Remove color 59 | .replace( /\{color:[^}]+\}([^]*)\{color\}/gm, '$1' ) 60 | // Remove panel 61 | .replace( /\{panel:title=([^}]*)\}\n?([^]*?)\n?\{panel\}/gm, '' ) 62 | // Remove table header 63 | .replace( /^[ \t]*((\|\|[^|]+)+\|\|)[ \t]*$/gm, '' ) 64 | // Remove table rows 65 | .replace( /^[ \t]*((\|[^|]+)+\|)[ \t]*$/gm, '' ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/util/NewsUtil.ts: -------------------------------------------------------------------------------- 1 | import { Message, NewsChannel } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | 4 | export class NewsUtil { 5 | private static logger = log4js.getLogger( 'NewsUtil' ); 6 | 7 | public static async publishMessage( message: Message ): Promise { 8 | if ( !( message.channel instanceof NewsChannel ) ) return; 9 | 10 | try { 11 | await message.crosspost(); 12 | this.logger.info( `Crossposted message ${ message.id } in channel ${ message.channel.name } (${ message.channel.id })` ); 13 | } catch ( error ) { 14 | this.logger.error( error ); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/util/ReactionsUtil.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | 4 | export class ReactionsUtil { 5 | private static logger = log4js.getLogger( 'ReactionsUtil' ); 6 | 7 | public static async reactToMessage( message: Message, reactions: string[] ): Promise { 8 | if ( !reactions.length || message === undefined ) return; 9 | 10 | const reaction = reactions.shift(); 11 | if ( reaction === undefined ) return; 12 | 13 | try { 14 | await message.react( reaction ); 15 | } catch ( err ) { 16 | this.logger.warn( `Error while reacting to message ${ message.id }`, err ); 17 | } 18 | 19 | await this.reactToMessage( message, reactions ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/util/RequestsUtil.ts: -------------------------------------------------------------------------------- 1 | import { APIEmbedField, Message, PartialMessage, Snowflake, TextChannel, User } from 'discord.js'; 2 | import log4js from 'log4js'; 3 | import BotConfig from '../BotConfig.js'; 4 | import DiscordUtil from './DiscordUtil.js'; 5 | import MentionCommand from '../commands/MentionCommand.js'; 6 | import MojiraBot from '../MojiraBot.js'; 7 | 8 | interface OriginIds { 9 | channelId: Snowflake; 10 | messageId: Snowflake; 11 | } 12 | 13 | export class RequestsUtil { 14 | private static logger = log4js.getLogger( 'RequestsUtil' ); 15 | 16 | private static getOriginIdsFromField( field: APIEmbedField ): OriginIds | undefined { 17 | try { 18 | const url = field.value; 19 | 20 | const matches = url.match( /\((.*)\)/ ); 21 | if ( matches === null ) return undefined; 22 | 23 | const messageUrl = matches[1]; 24 | const parts = messageUrl.split( '/' ); 25 | 26 | const channelId = parts[parts.length - 2] as Snowflake; 27 | const messageId = parts[parts.length - 1] as Snowflake; 28 | 29 | if ( channelId && messageId ) { 30 | return { channelId, messageId }; 31 | } else { 32 | return undefined; 33 | } 34 | } catch { 35 | // The field doesn't contain a valid message URL. 36 | return undefined; 37 | } 38 | } 39 | 40 | public static async getOriginIds( message: Message | PartialMessage ): Promise { 41 | try { 42 | const embeds = message.embeds; 43 | if ( embeds.length == 0 ) return undefined; 44 | 45 | // Assume first embed is the actual message. 46 | const fields = embeds[0].fields; 47 | // Assume either the first field or the last field contains the link to the original message. 48 | return this.getOriginIdsFromField( fields[0] ) ?? this.getOriginIdsFromField( fields[fields.length - 1] ); 49 | } catch ( error ) { 50 | this.logger.error( error ); 51 | return undefined; 52 | } 53 | } 54 | 55 | public static async getOriginMessage( internalMessage: Message | PartialMessage ): Promise { 56 | const ids = await this.getOriginIds( internalMessage ); 57 | 58 | if ( !ids ) { 59 | return undefined; 60 | } 61 | 62 | try { 63 | const originChannel = await DiscordUtil.getChannel( ids.channelId ); 64 | if ( originChannel instanceof TextChannel ) { 65 | return await DiscordUtil.getMessage( originChannel, ids.messageId ); 66 | } 67 | } catch { 68 | // The channel and/or the message don't exist. 69 | return undefined; 70 | } 71 | } 72 | 73 | public static getResponseMessage( message: Message ): string { 74 | return ( BotConfig.request.responseMessage || '' ) 75 | .replace( '{{author}}', `@${ message.author.tag }` ) 76 | .replace( '{{url}}', message.url ) 77 | .replace( '{{message}}', message.content.replace( /(^|\n)/g, '$1> ' ) ); 78 | } 79 | 80 | // https://stackoverflow.com/a/3426956 81 | private static hashCode( str: string ): number { 82 | let hash = 0; 83 | for ( let i = 0; i < str.length; i++ ) { 84 | hash = str.charCodeAt( i ) + ( ( hash << 5 ) - hash ); 85 | } 86 | return hash; 87 | } 88 | 89 | // https://stackoverflow.com/a/3426956 90 | public static getEmbedColor( resolver?: User ): 'Blue' | number { 91 | if ( !resolver ) { 92 | return 'Blue'; 93 | } 94 | return this.hashCode( resolver.tag ) & 0x00FFFFFF; 95 | } 96 | 97 | 98 | /** 99 | * This extracts a ticket ID from either a link or a standalone ticket ID. 100 | * E.g. this matches the "MC-1234" in https://bugs.mojang.browse/MC-1234 or in "This is some crazy bug:MC-1234". 101 | * @returns A NEW regex object every time. You have to store it as a variable if you use `exec` on it, otherwise you will encounter infinite loops. 102 | */ 103 | public static getTicketRequestRegex(): RegExp { 104 | return new RegExp( `?`, 'g' ); 105 | } 106 | 107 | public static async checkTicketValidity( tickets: string[] ): Promise { 108 | try { 109 | this.logger.debug( `Checking for ticket validity of tickets ${ tickets.join( ',' ) }` ); 110 | const searchResults = await MojiraBot.jira.issueSearch.searchForIssuesUsingJql( { 111 | jql: `(${ BotConfig.request.invalidRequestJql }) AND key in (${ tickets.join( ',' ) })`, 112 | fields: ['key'], 113 | } ); 114 | if ( searchResults.issues === undefined ) return false; 115 | 116 | const invalidTickets = searchResults.issues.map( ( { key } ) => key ); 117 | this.logger.debug( `Invalid tickets: [${ invalidTickets.join( ',' ) }]` ); 118 | return invalidTickets.length === 0; 119 | } catch ( err ) { 120 | this.logger.error( `Error while checking validity of tickets ${ tickets.join( ',' ) }\n`, err.message ); 121 | return true; 122 | } 123 | } 124 | 125 | /** 126 | * Gets all ticket IDs from a string, including ticket IDs from URLs. 127 | * @param content The string that should be searched for ticket IDs 128 | */ 129 | public static getTicketIdsFromString( content: string ): string[] { 130 | const regex = this.getTicketRequestRegex(); 131 | 132 | const tickets: string[] = []; 133 | for ( const match of content.matchAll( regex ) ) { 134 | if ( match.groups === undefined ) continue; 135 | tickets.push( match.groups['ticketid'] ); 136 | } 137 | 138 | return tickets; 139 | } 140 | 141 | public static getRequestDescription( origin: Message ): string { 142 | const desc = this.replaceTicketReferencesWithRichLinks( origin.content ); 143 | if ( desc.length > 2048 ) return `⚠ [Request too long to be posted, click here to see the request](${ origin.url })`; 144 | return desc; 145 | } 146 | 147 | public static replaceTicketReferencesWithRichLinks( content: string ): string { 148 | const regex = new RegExp( `${ this.getTicketRequestRegex().source }(?\\?[^\\s#>]+)?(?#[^\\s>]+)?>?`, 'g' ); 149 | 150 | // Escape all of the following characters with a backslash: [, ], \ 151 | return content.replace( /([[\]\\])/gm, '\\$1' ) 152 | .replace( regex, '[$$](https://mojira.atlassian.net/browse/$$$)' ); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/util/RoleSelectionUtil.ts: -------------------------------------------------------------------------------- 1 | import log4js from 'log4js'; 2 | import { EmbedBuilder, EmbedType, Message, TextChannel } from 'discord.js'; 3 | import { RoleGroupConfig } from '../BotConfig.js'; 4 | import MojiraBot from '../MojiraBot.js'; 5 | import { ReactionsUtil } from './ReactionsUtil.js'; 6 | import DiscordUtil from './DiscordUtil.js'; 7 | 8 | export class RoleSelectionUtil { 9 | private static logger = log4js.getLogger( 'RoleSelectionUtil' ); 10 | 11 | public static async updateRoleSelectionMessage( groupConfig: RoleGroupConfig ): Promise { 12 | const embed = new EmbedBuilder(); 13 | embed.setTitle( groupConfig.prompt ) 14 | .setColor( groupConfig.color ); 15 | 16 | if ( groupConfig.desc ) { 17 | embed.setDescription( groupConfig.desc ); 18 | } 19 | 20 | for ( const role of groupConfig.roles ) { 21 | const emoji = MojiraBot.client.emojis.resolve( role.emoji ) ?? role.emoji; 22 | 23 | embed.addFields( { name: `${ emoji.toString() }\u2002${ role.title }`, value: role.desc ?? '\u200b', inline: false } ); 24 | } 25 | 26 | const channel = await DiscordUtil.getChannel( groupConfig.channel ); 27 | 28 | if ( !( channel instanceof TextChannel ) ) { 29 | throw new Error( `Channel ${ groupConfig.channel } is not a text channel` ); 30 | } 31 | 32 | let message: Message | undefined; 33 | 34 | if ( groupConfig.message === undefined ) { 35 | // No message has been configured in the config, so create a new one that should then be set in the config 36 | message = await channel.send( { embeds: [embed] } ); 37 | 38 | // TODO: Ideally we would like to save the message ID automagically. 39 | this.logger.warn( `Please set the 'message' for role selection group '${ groupConfig.prompt }' to '${ message.id }' in the config.` ); 40 | groupConfig.message = message.id; 41 | } else { 42 | message = await DiscordUtil.getMessage( channel, groupConfig.message ); 43 | } 44 | 45 | if ( message === undefined ) { 46 | // The role message could not be found, so a new one is created 47 | message = await channel.send( { embeds: [embed] } ); 48 | 49 | // TODO: Ideally we would like to save the message ID automagically. 50 | this.logger.warn( 51 | 'Role message could not be found, and therefore a new one was created. ' + 52 | `Please set the 'message' for role selection group '${ groupConfig.prompt }' to '${ message.id }' in the config.` 53 | ); 54 | } 55 | 56 | // Check if role message needs to be updated 57 | if ( message.embeds.length == 1 ) { 58 | const existingEmbed = message.embeds[0]; 59 | 60 | // The provided equals operator cares about the rich type, which we can't create with EmbedBuilder, so we have to cheat. 61 | if ( existingEmbed.equals( { ...embed.data, type: EmbedType.Rich } ) ) { 62 | // Role message does not need to be updated, nothing else left to do 63 | return; 64 | } 65 | } 66 | 67 | this.logger.info( `Updating role selection message ${ message.id }` ); 68 | 69 | // Update role message 70 | await message.edit( { embeds: [embed] } ); 71 | await ReactionsUtil.reactToMessage( message, groupConfig.roles.map( role => role.emoji ) ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | # Make it so the default deployment is "main" 2 | DEPLOYMENT=${1:-main} 3 | # Start a screen called "mojiradiscordbot-" with the node process 4 | /usr/bin/screen -S mojiradiscordbot-$DEPLOYMENT -L -d -m bash -c "NODE_ENV=$DEPLOYMENT node bin" ; exit 5 | -------------------------------------------------------------------------------- /stop.sh: -------------------------------------------------------------------------------- 1 | # Send ^C to screen session window 2 | screen -S mojiradiscordbot-$1 -X stuff $'\003' 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "es2022", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "es2020", 8 | "dom" 9 | ], 10 | "outDir": "./bin", 11 | "esModuleInterop": true, 12 | "strictNullChecks": true, 13 | }, 14 | "exclude": [ 15 | "node_modules" 16 | ] 17 | } 18 | --------------------------------------------------------------------------------