├── .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://github.com/mojira/mojira-discord-bot/issues)
3 | [](https://github.com/mojira/mojira-discord-bot/stargazers)
4 | [](https://github.com/mojira/mojira-discord-bot/blob/master/LICENSE.md)
5 |
6 | # Mojira Discord Bot
7 |
8 |
9 |
10 |
11 |
12 |
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( `(?:https?://(?:report\\.)?bugs\\.mojang\\.com/(?:browse(?:/\\w+/issues)?|projects/\\w+/issues|servicedesk/customer/portal/\\d+)/|\\b)${ MentionCommand.ticketPattern }>?`, '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 |
--------------------------------------------------------------------------------