47 |
64 |
48 |
51 |
52 |
63 | Suspend the installation so that the purge request gets processed
49 | 50 |',
17 | };
18 |
19 | this.setConf({
20 | permLevel: 2,
21 | msgTrigger: true,
22 | });
23 | }
24 |
25 | run(msg, args) {
26 | let command = args.join(' ');
27 | let bot = this.bot;
28 | if (!command || command.length === 0) return;
29 |
30 | this._evalCommand(bot, msg, command, Log)
31 | .then((evaled) => {
32 | if (evaled && typeof evaled === 'string') {
33 | evaled = evaled
34 | .replace(this.tokenRegEx, '-- snip --')
35 | .replace(this.pathRegEx, '.');
36 | }
37 |
38 | let message = [
39 | '`EVAL`',
40 | '```js',
41 | evaled !== undefined ? this._clean(evaled) : 'undefined',
42 | '```',
43 | ].join('\n');
44 |
45 | return msg.channel.send(message);
46 | })
47 | .catch((error) => {
48 | if (error.stack) error.stack = error.stack.replace(this.pathRegEx, '.');
49 | let message = [
50 | '`EVAL`',
51 | '```js',
52 | this._clean(error) || error,
53 | '```',
54 | ].join('\n');
55 | return msg.channel.send(message);
56 | });
57 | }
58 | _evalCommand(bot, msg, command, log) {
59 | return new Promise((resolve, reject) => {
60 | if (!log) log = Log;
61 | let code = command;
62 | try {
63 | var evaled = eval(code);
64 | if (evaled) {
65 | if (typeof evaled === 'object') {
66 | if (evaled._path) delete evaled._path;
67 | try {
68 | evaled = util.inspect(evaled, { depth: 0 });
69 | } catch (err) {
70 | evaled = JSON.stringify(evaled, null, 2);
71 | }
72 | }
73 | }
74 | resolve(evaled);
75 | } catch (error) {
76 | reject(error);
77 | }
78 | });
79 | }
80 |
81 | _clean(text) {
82 | if (typeof text === 'string') {
83 | return text
84 | .replace(/`/g, `\`${String.fromCharCode(8203)}`)
85 | .replace(/@/g, `@${String.fromCharCode(8203)}`)
86 | .replace('``', `\`${String.fromCharCode(8203)}\`}`);
87 | } else {
88 | return text;
89 | }
90 | }
91 | }
92 |
93 | module.exports = EvalCommand;
94 |
--------------------------------------------------------------------------------
/lib/Discord/Commands/GitHubIssue.js:
--------------------------------------------------------------------------------
1 | const Command = require('../Command');
2 | const Channel = require('../../Models/Channel');
3 | const Guild = require('../../Models/Guild');
4 | const GitHub = require('../../GitHub');
5 | const markdown = require('../../Util/markdown');
6 |
7 | class GitHubIssue extends Command {
8 | constructor(bot) {
9 | super(bot);
10 |
11 | this.props.help = {
12 | name: 'issue',
13 | description: 'Search issues or get info about specific issue',
14 | usage: 'issue [query] [p(page)]',
15 | examples: ['issue 5', 'issue search error', 'issue search event p2'],
16 | };
17 |
18 | this.setConf({
19 | guildOnly: true,
20 | aliases: ['issues'],
21 | });
22 | }
23 |
24 | getSlashCommand() {
25 | return super
26 | .getSlashCommand()
27 | .addSubcommand((subcommand) =>
28 | subcommand
29 | .setName('info')
30 | .setDescription(
31 | 'Retrieve info for an issue in the GitHub repository.'
32 | )
33 | .addIntegerOption((option) =>
34 | option
35 | .setName('number')
36 | .setDescription(
37 | "The number of the GitHub issue for the channel's configured repository."
38 | )
39 | .setRequired(true)
40 | .setMinValue(1)
41 | )
42 | )
43 | .addSubcommand((subcommand) =>
44 | subcommand
45 | .setName('search')
46 | .setDescription('Search issues in GitHub repository.')
47 | .addStringOption((option) =>
48 | option
49 | .setName('query')
50 | .setDescription('Search query')
51 | .setRequired(true)
52 | )
53 | .addIntegerOption((option) =>
54 | option
55 | .setName('page')
56 | .setDescription('Specify page of issues')
57 | .setMinValue(1)
58 | )
59 | );
60 | }
61 |
62 | async run(interaction) {
63 | const repo =
64 | (await Channel.find(interaction.channel.id))?.get('repo') ||
65 | (await Guild.find(interaction.guild.id))?.get('repo');
66 |
67 | const subcommand = interaction.options.getSubcommand();
68 |
69 | if (!repo)
70 | return this.commandError(
71 | interaction,
72 | GitHub.Constants.Errors.NO_REPO_CONFIGURED
73 | );
74 |
75 | if (subcommand === 'info') return this.issue(interaction, repo);
76 | if (subcommand === 'search') return this.search(interaction, repo);
77 |
78 | return this.errorUsage(interaction);
79 | }
80 |
81 | async issue(interaction, repository) {
82 | const issueNumber = interaction.options.getInteger('number', true);
83 |
84 | await interaction.deferReply();
85 |
86 | try {
87 | const issue = await GitHub.getRepoIssue(repository, issueNumber);
88 |
89 | const body = issue.body;
90 | const [, imageUrl] = /!\[(?:.*?)\]\((.*?)\)/.exec(body) || [];
91 |
92 | const embed = new this.embed()
93 | .setTitle(`Issue \`#${issue.number}\` - ${issue.title}`)
94 | .setURL(issue.html_url)
95 | .setDescription(markdown.convert(body, 500))
96 | .setColor('#84F139')
97 | .addFields([
98 | {
99 | name: 'Status',
100 | value: issue.state === 'open' ? 'Open' : 'Closed',
101 | inline: true,
102 | },
103 | {
104 | name: 'Labels',
105 | value: issue.labels?.length
106 | ? issue.labels
107 | .map(
108 | (e) =>
109 | `[\`${e.name}\`](${e.url.replace(
110 | 'api.github.com/repos',
111 | 'github.com'
112 | )})`
113 | )
114 | .join(', ')
115 | : 'None',
116 | inline: true,
117 | },
118 | {
119 | name: 'Milestone',
120 | value: issue.milestone
121 | ? `[${issue.milestone.title}](${issue.milestone.html_url})`
122 | : 'None',
123 | inline: true,
124 | },
125 | {
126 | name: 'Assignee',
127 | value: issue.assignee
128 | ? `[${issue.assignee.login}](${issue.assignee.html_url})`
129 | : 'None',
130 | inline: true,
131 | },
132 | {
133 | name: 'Comments',
134 | value: String(issue.comments) || '?',
135 | inline: true,
136 | },
137 | ])
138 | .setFooter({
139 | text: repository,
140 | iconURL: this.bot.user.avatarURL(),
141 | });
142 |
143 | if (imageUrl)
144 | embed.setImage(
145 | imageUrl.startsWith('/')
146 | ? `https://github.com/${repository}/${imageUrl}`
147 | : imageUrl
148 | );
149 |
150 | if (issue.user)
151 | embed.setAuthor({
152 | name: issue.user.login,
153 | iconURL: issue.user.avatar_url,
154 | url: issue.user.html_url,
155 | });
156 |
157 | return interaction.editReply({ embeds: [embed] });
158 | } catch (err) {
159 | const errorTitle = `Issue \`#${issueNumber}\``;
160 |
161 | return this.commandError(
162 | interaction,
163 | err.message !== 'Not Found'
164 | ? err
165 | : "Issue doesn't exist or repo is private",
166 | errorTitle,
167 | repository
168 | );
169 | }
170 | }
171 |
172 | async search(interaction, repository) {
173 | const query = interaction.options.getString('query', true);
174 | const page = interaction.options.getInteger('page') || 1;
175 | const per_page = 10;
176 |
177 | await interaction.deferReply();
178 |
179 | return GitHub.search('issuesAndPullRequests', {
180 | page,
181 | per_page,
182 | q: `${query}+repo:${repository}+type:issue`,
183 | })
184 | .then((results) => {
185 | const totalPages = Math.ceil(results.total_count / per_page);
186 |
187 | const embed = new this.embed({
188 | title: `Issues - search \`${query}\``,
189 | description: '\u200B',
190 | })
191 | .setColor('#84F139')
192 | .setFooter({
193 | text: `${repository} ; page ${page} / ${totalPages}`,
194 | });
195 |
196 | if (results.items?.length) {
197 | embed.setDescription(
198 | results.items
199 | .map(
200 | (issue) =>
201 | `– [**\`#${issue.number}\`**](${issue.html_url}) ${issue.title}`
202 | )
203 | .join('\n')
204 | );
205 | } else {
206 | embed.setDescription('No issues found');
207 | }
208 |
209 | return interaction.editReply({ embeds: [embed] });
210 | })
211 | .catch((err) => {
212 | if (GitHub.isGitHubError(err)) {
213 | const error = GitHub.getGitHubError(err);
214 | return this.commandError(
215 | interaction,
216 | error.errors[0]?.message || '',
217 | `${GitHub.Constants.HOST} | ${error.message}`,
218 | repository
219 | );
220 | } else {
221 | return this.commandError(interaction, err);
222 | }
223 | });
224 | }
225 | }
226 |
227 | module.exports = GitHubIssue;
228 |
--------------------------------------------------------------------------------
/lib/Discord/Commands/GitHubPullRequest.js:
--------------------------------------------------------------------------------
1 | const Command = require('../Command');
2 | const Channel = require('../../Models/Channel');
3 | const Guild = require('../../Models/Guild');
4 | const GitHub = require('../../GitHub');
5 | const markdown = require('../../Util/markdown');
6 |
7 | class GitHubPullRequest extends Command {
8 | constructor(bot) {
9 | super(bot);
10 |
11 | this.props.help = {
12 | name: 'pr',
13 | description: 'Search PRs or get info about specific PR',
14 | usage: 'pr [query] [p(page)]',
15 | examples: ['pr 5', 'pr search error', 'pr search event p2'],
16 | };
17 |
18 | this.setConf({
19 | guildOnly: true,
20 | aliases: ['prs', 'pull', 'pulls'],
21 | });
22 | }
23 |
24 | getSlashCommand() {
25 | return super
26 | .getSlashCommand()
27 | .addSubcommand((subcommand) =>
28 | subcommand
29 | .setName('info')
30 | .setDescription('Retrieve info for an PR in the GitHub repository.')
31 | .addIntegerOption((option) =>
32 | option
33 | .setName('number')
34 | .setDescription(
35 | "The number of the GitHub PR for the channel's configured repository."
36 | )
37 | .setRequired(true)
38 | .setMinValue(1)
39 | )
40 | )
41 | .addSubcommand((subcommand) =>
42 | subcommand
43 | .setName('search')
44 | .setDescription('Search PRs in GitHub repository.')
45 | .addStringOption((option) =>
46 | option
47 | .setName('query')
48 | .setDescription('Search query')
49 | .setRequired(true)
50 | )
51 | .addIntegerOption((option) =>
52 | option
53 | .setName('page')
54 | .setDescription('Specify page of PRs')
55 | .setMinValue(1)
56 | )
57 | );
58 | }
59 |
60 | async run(interaction) {
61 | const repo =
62 | (await Channel.find(interaction.channel.id))?.get('repo') ||
63 | (await Guild.find(interaction.guild.id))?.get('repo');
64 |
65 | const subcommand = interaction.options.getSubcommand();
66 |
67 | if (!repo)
68 | return this.commandError(
69 | interaction,
70 | GitHub.Constants.Errors.NO_REPO_CONFIGURED
71 | );
72 |
73 | if (subcommand === 'info') return this.pr(interaction, repo);
74 | if (subcommand === 'search') return this.search(interaction, repo);
75 |
76 | return this.errorUsage(interaction);
77 | }
78 |
79 | async pr(interaction, repository) {
80 | const prNumber = interaction.options.getInteger('number', true);
81 |
82 | await interaction.deferReply();
83 |
84 | try {
85 | const pr = await GitHub.getRepoPR(repository, prNumber);
86 |
87 | const body = pr.body;
88 | const [, imageUrl] = /!\[(?:.*?)\]\((.*?)\)/.exec(body) || [];
89 |
90 | const embed = new this.embed()
91 | .setTitle(`PR \`#${pr.number}\` - ${pr.title}`)
92 | .setURL(pr.html_url)
93 | .setDescription(markdown.convert(body, 500))
94 | .setColor('#84F139')
95 | .addFields([
96 | {
97 | name: 'Status',
98 | value: pr.state === 'open' ? 'Open' : 'Closed',
99 | inline: true,
100 | },
101 | {
102 | name: 'Merged',
103 | value: pr.merged ? 'Yes' : 'No',
104 | inline: true,
105 | },
106 | {
107 | name: 'Labels',
108 | value: pr.labels.length
109 | ? pr.labels
110 | .map(
111 | (e) =>
112 | `[\`${e.name}\`](${e.url.replace(
113 | 'api.github.com/repos',
114 | 'github.com'
115 | )})`
116 | )
117 | .join(', ')
118 | : 'None',
119 | inline: true,
120 | },
121 | {
122 | name: 'Milestone',
123 | value: pr.milestone
124 | ? `[${pr.milestone.title}](${pr.milestone.html_url})`
125 | : 'None',
126 | inline: true,
127 | },
128 | {
129 | name: 'Assignee',
130 | value: pr.assignee
131 | ? `[${pr.assignee.login}](${pr.assignee.html_url})`
132 | : 'None',
133 | inline: true,
134 | },
135 | {
136 | name: 'Comments',
137 | value: String(pr.comments),
138 | inline: true,
139 | },
140 | {
141 | name: 'Commits',
142 | value: String(pr.commits),
143 | inline: true,
144 | },
145 | {
146 | name: 'Changes',
147 | value: `+${pr.additions} | -${pr.deletions} (${pr.changed_files} changed files)`,
148 | inline: true,
149 | },
150 | ])
151 | .setFooter({
152 | text: repository,
153 | iconURL: this.bot.user.avatarURL(),
154 | });
155 |
156 | if (imageUrl)
157 | embed.setImage(
158 | imageUrl.startsWith('/')
159 | ? `https://github.com/${repository}/${imageUrl}`
160 | : imageUrl
161 | );
162 |
163 | if (pr.user)
164 | embed.setAuthor({
165 | name: pr.user.login,
166 | iconURL: pr.user.avatar_url,
167 | url: pr.user.html_url,
168 | });
169 |
170 | return interaction.editReply({ embeds: [embed] });
171 | } catch (err) {
172 | const errorTitle = `PR \`#${prNumber}\``;
173 |
174 | return this.commandError(
175 | interaction,
176 | err.status !== 404 ? err : "PR doesn't exist or repo is private",
177 | errorTitle,
178 | repository
179 | );
180 | }
181 | }
182 |
183 | async search(interaction, repository) {
184 | const query = interaction.options.getString('query', true);
185 | const page = interaction.options.getInteger('page') || 1;
186 | const per_page = 10;
187 |
188 | await interaction.deferReply();
189 |
190 | return GitHub.search('issuesAndPullRequests', {
191 | page,
192 | per_page,
193 | q: `${query}+repo:${repository}+type:pr`,
194 | })
195 | .then((results) => {
196 | const totalPages = Math.ceil(results.total_count / per_page);
197 |
198 | const embed = new this.embed({
199 | title: `PRs - search \`${query}\``,
200 | description: '\u200B',
201 | })
202 | .setColor('#84F139')
203 | .setFooter({
204 | text: `${repository} ; page ${page} / ${totalPages}`,
205 | });
206 |
207 | if (results.items.length) {
208 | embed.setDescription(
209 | results.items
210 | .map(
211 | (pr) => `– [**\`#${pr.number}\`**](${pr.html_url}) ${pr.title}`
212 | )
213 | .join('\n')
214 | );
215 | } else {
216 | embed.setDescription('No PRs found');
217 | }
218 |
219 | return interaction.editReply({ embeds: [embed] });
220 | })
221 | .catch((err) => {
222 | if (GitHub.isGitHubError(err)) {
223 | const error = GitHub.getGitHubError(err);
224 | return this.commandError(
225 | interaction,
226 | error.errors[0]?.message || '',
227 | `${GitHub.Constants.HOST} | ${error.message}`,
228 | repository
229 | );
230 | } else {
231 | return this.commandError(interaction, err);
232 | }
233 | });
234 | }
235 | }
236 |
237 | module.exports = GitHubPullRequest;
238 |
--------------------------------------------------------------------------------
/lib/Discord/Commands/GitHubSetup.js:
--------------------------------------------------------------------------------
1 | const { CommandInteraction } = require('discord.js');
2 | const uuid = require('uuid');
3 | const Command = require('../Command');
4 | const redis = require('../../Util/redis');
5 | const cache = require('../../Util/cache');
6 |
7 | class GitHubSetupCommand extends Command {
8 | constructor(bot) {
9 | super(bot);
10 |
11 | this.props.help = {
12 | name: 'setup',
13 | summary: "Register GitHub repo's events on the channel.",
14 | };
15 |
16 | this.setConf({
17 | permLevel: 1,
18 | guildOnly: true,
19 | });
20 | }
21 |
22 | /**
23 | * @param {CommandInteraction} interaction
24 | */
25 | async run(interaction) {
26 | const id = uuid.v4();
27 |
28 | await redis.setHash(
29 | 'setup',
30 | id,
31 | {
32 | channel_id: interaction.channel.id,
33 | channel_name: interaction.channel.name,
34 | guild_name: interaction.guild.name,
35 | },
36 | 60 * 30
37 | );
38 |
39 | cache.channels.expire(interaction.channel.id);
40 |
41 | const ttl = Math.floor(Date.now() / 1000) + (await redis.ttl('setup', id));
42 |
43 | await interaction.reply({
44 | embeds: [
45 | {
46 | color: 0x84f139,
47 | description: [
48 | `[Click here](${process.env.WEB_HOST}/setup/${id}) to setup the repository. The link will expire .`,
49 | '',
50 | `The GitHub app is the preferred method of integration. However, you may visit ${process.env.WEB_HOST}/hook/channels/${interaction.channel.id} for instructions on configuring webhooks directly.`,
51 | ].join('\n'),
52 | },
53 | ],
54 | ephemeral: true,
55 | });
56 | }
57 | }
58 |
59 | module.exports = GitHubSetupCommand;
60 |
--------------------------------------------------------------------------------
/lib/Discord/Commands/Help.js:
--------------------------------------------------------------------------------
1 | const Command = require('../Command');
2 |
3 | class HelpCommand extends Command {
4 | constructor(bot) {
5 | super(bot);
6 |
7 | this.props.help = {
8 | name: 'help',
9 | summary:
10 | 'Obsolete command. Yappy now uses slash commands! Use `/` to see available commands.',
11 | };
12 |
13 | this.setConf({
14 | aliases: ['h', 'init', 'remove'],
15 | msgTrigger: true,
16 | });
17 | }
18 |
19 | async run(msg) {
20 | msg.reply(
21 | [
22 | 'Yappy now uses slash commands! Use `/` to see available commands.',
23 | 'To setup repositories, use `/setup`. To configure the filtering options, use `/conf filter`.',
24 | ].join('\n')
25 | );
26 | }
27 | }
28 |
29 | module.exports = HelpCommand;
30 |
--------------------------------------------------------------------------------
/lib/Discord/Commands/Invite.js:
--------------------------------------------------------------------------------
1 | const Command = require('../Command');
2 |
3 | class InviteCommand extends Command {
4 | constructor(bot) {
5 | super(bot);
6 |
7 | this.props.help = {
8 | name: 'invite',
9 | description: 'Get invite link for the bot',
10 | };
11 | }
12 |
13 | run(interaction) {
14 | const botInviteLink =
15 | 'https://discordapp.com/oauth2/authorize?permissions=67193856&scope=bot&client_id=219218963647823872';
16 | const serverInviteLink = 'http://discord.gg/HHqndMG';
17 |
18 | return interaction.reply({
19 | embeds: [
20 | {
21 | title: 'Yappy, the GitHub Monitor',
22 | description: [
23 | '__Invite Link__:',
24 | `**<${botInviteLink}>**`,
25 | '',
26 | '__Official Server__:',
27 | `**<${serverInviteLink}>**`,
28 | ].join('\n'),
29 | color: 0x84f139,
30 | thumbnail: {
31 | url: this.bot.user.avatarURL(),
32 | },
33 | },
34 | ],
35 | ephemeral: true,
36 | });
37 | }
38 | }
39 |
40 | module.exports = InviteCommand;
41 |
--------------------------------------------------------------------------------
/lib/Discord/Commands/Organization.js:
--------------------------------------------------------------------------------
1 | const Command = require('../Command');
2 | const GitHub = require('../../GitHub');
3 | const moment = require('moment');
4 |
5 | class Organization extends Command {
6 | constructor(bot) {
7 | super(bot);
8 |
9 | this.props.help = {
10 | name: 'org',
11 | description: 'Get information about an organization',
12 | usage: 'org ',
13 | examples: ['org YappyBots', 'org GitHub'],
14 | };
15 |
16 | this.help.aliases = ['organization'];
17 | }
18 |
19 | getSlashCommand() {
20 | return super
21 | .getSlashCommand()
22 | .addStringOption((option) =>
23 | option.setName('query').setDescription('The query').setRequired(true)
24 | );
25 | }
26 |
27 | async run(interaction) {
28 | const query = interaction.options.getString('query', true);
29 |
30 | await interaction.deferReply();
31 |
32 | const org = await GitHub.getOrg(query);
33 |
34 | if (!org.login)
35 | return this.commandError(
36 | interaction,
37 | `Unable to get organization info for \`${query}\``
38 | );
39 |
40 | const members = await GitHub.getOrgMembers(org.login);
41 |
42 | const embed = new this.embed()
43 | .setTitle(org.name)
44 | .setURL(org.html_url)
45 | .setColor(0x84f139)
46 | .setDescription(`${org.description}\n`)
47 | .setThumbnail(org.avatar_url)
48 | .setTimestamp()
49 | .addFields([
50 | {
51 | name: 'Website',
52 | value: org.blog || 'None',
53 | inline: true,
54 | },
55 | {
56 | name: 'Location',
57 | value: org.location || 'Unknown',
58 | inline: true,
59 | },
60 | {
61 | name: 'Created At',
62 | value: moment(org.created_at).format('MMMM Do, YYYY. h:mm A'),
63 | inline: true,
64 | },
65 | {
66 | name: 'Members',
67 | value: members.length
68 | ? members
69 | .map((m) => `- [${m.login}](${m.html_url})`)
70 | .slice(0, 15)
71 | .join('\n')
72 | : 'No public members found',
73 | inline: true,
74 | },
75 | {
76 | name: 'Repos',
77 | value: String(org.public_repos),
78 | inline: true,
79 | },
80 | ]);
81 |
82 | return interaction.editReply({ embeds: [embed] });
83 | }
84 | }
85 |
86 | module.exports = Organization;
87 |
--------------------------------------------------------------------------------
/lib/Discord/Commands/Ping.js:
--------------------------------------------------------------------------------
1 | const now = require('performance-now');
2 | const Command = require('../Command');
3 |
4 | class PingCommand extends Command {
5 | constructor(bot) {
6 | super(bot);
7 | this.props.help = {
8 | name: 'ping',
9 | description: 'ping, pong',
10 | usage: 'ping',
11 | };
12 | }
13 | run(interaction) {
14 | const startTime = now();
15 | return interaction.reply(`⏱ Pinging...`).then(() => {
16 | const endTime = now();
17 |
18 | let difference = (endTime - startTime).toFixed(0);
19 | if (difference > 1000) difference = (difference / 1000).toFixed(0);
20 | let differenceText = endTime - startTime > 999 ? 's' : 'ms';
21 |
22 | return interaction.editReply(
23 | `⏱ Ping, Pong! Took ${difference} ${differenceText}`
24 | );
25 | });
26 | }
27 | }
28 |
29 | module.exports = PingCommand;
30 |
--------------------------------------------------------------------------------
/lib/Discord/Commands/Reload.js:
--------------------------------------------------------------------------------
1 | const Command = require('../Command');
2 |
3 | class ReloadCommand extends Command {
4 | constructor(bot) {
5 | super(bot);
6 | this.setHelp({
7 | name: 'reload',
8 | description: 'reloads a command, duh',
9 | usage: 'reload ',
10 | examples: ['reload stats', 'reload test'],
11 | });
12 | this.setConf({
13 | permLevel: 2,
14 | });
15 | }
16 |
17 | getSlashCommand() {
18 | return super.getSlashCommand().addStringOption((option) =>
19 | option
20 | .setName('command')
21 | .setDescription('The name of the command to reload')
22 | .addChoices(
23 | ...['all', ...this.bot.commands.keys()].map((v) => ({
24 | name: v,
25 | value: v,
26 | }))
27 | )
28 | .setRequired(true)
29 | );
30 | }
31 |
32 | async run(interaction) {
33 | const arg = interaction.options.getString('command');
34 | const bot = this.bot;
35 | const command = bot.commands.get(arg);
36 |
37 | await interaction.deferReply({ ephemeral: true });
38 |
39 | if (arg === 'all') {
40 | return this.reloadAllCommands(interaction).catch((err) =>
41 | this.sendError(`all`, err, interaction)
42 | );
43 | } else if (!arg) {
44 | return this.errorUsage(interaction);
45 | } else if (!command) {
46 | return interaction.editReply(`❌ Command \`${arg}\` doesn't exist`);
47 | }
48 |
49 | const fileName = command ? command.help.file : arg;
50 | const cmdName = command ? command.help.name : arg;
51 |
52 | return bot
53 | .reloadCommand(fileName)
54 | .then(() =>
55 | interaction.editReply(`✅ Successfully Reloaded Command \`${cmdName}\``)
56 | )
57 | .catch((e) => this.sendError(cmdName, e, interaction));
58 | }
59 |
60 | sendError(t, e, interaction) {
61 | let content = [
62 | `❌ Unable To Reload \`${t}\``,
63 | '```js',
64 | e.stack ? e.stack.replace(this._path, `.`) : e,
65 | '```',
66 | ];
67 |
68 | return interaction.editReply(content);
69 | }
70 |
71 | async reloadAllCommands(interaction) {
72 | for (const [, command] of this.bot.commands) {
73 | const cmdName = command.help.file || command.help.name;
74 |
75 | try {
76 | await this.bot.reloadCommand(cmdName);
77 | } catch (err) {
78 | this.sendError(cmdName, err, interaction);
79 | }
80 | }
81 |
82 | return interaction.editReply(`✅ Successfully Reloaded All Commands`);
83 | }
84 | }
85 |
86 | module.exports = ReloadCommand;
87 |
--------------------------------------------------------------------------------
/lib/Discord/Commands/Search.js:
--------------------------------------------------------------------------------
1 | const Command = require('../Command');
2 | const GitHub = require('../../GitHub');
3 | const moment = require('moment');
4 |
5 | class GitHubSearch extends Command {
6 | constructor(bot) {
7 | super(bot);
8 |
9 | this.props.help = {
10 | name: 'search',
11 | summary: 'Search repos and users.',
12 | description:
13 | 'Search repos and users.\nType can be any of the following: `repos`, `repositories`, `users`, and a few more.',
14 | usage: 'search ',
15 | examples: ['search repos yappygithub', 'search users datitisev'],
16 | };
17 | }
18 |
19 | getSlashCommand() {
20 | return super
21 | .getSlashCommand()
22 | .addSubcommand((subcommand) =>
23 | subcommand
24 | .setName('repos')
25 | .setDescription('Search repositories on GitHub.')
26 | .addStringOption((option) =>
27 | option
28 | .setName('query')
29 | .setDescription('Search query')
30 | .setRequired(true)
31 | )
32 | )
33 | .addSubcommand((subcommand) =>
34 | subcommand
35 | .setName('users')
36 | .setDescription('Search users on GitHub.')
37 | .addStringOption((option) =>
38 | option
39 | .setName('query')
40 | .setDescription('Search query')
41 | .setRequired(true)
42 | )
43 | );
44 | }
45 |
46 | async run(interaction, args) {
47 | await interaction.deferReply();
48 |
49 | const type = interaction.options.getSubcommand();
50 | const query = interaction.options.getString('query', true);
51 |
52 | const data = await GitHub.search(type, query);
53 |
54 | const { total_count: total, incomplete_results, items } = data;
55 |
56 | if ((!total || total === 0) && !incomplete_results) {
57 | this.commandError(
58 | interaction,
59 | 'No results found',
60 | `Search \`${query}\` of \`${type}\``
61 | );
62 | } else if (total === 0 && incomplete_results) {
63 | this.commandError(
64 | interaction,
65 | "GitHub didn't find all results, and no results were found",
66 | `Search \`${query}\` of \`${type}\``
67 | );
68 | } else {
69 | if (items[0].type === 'User')
70 | return this.users({ interaction, type, query, data });
71 | if (items[0].default_branch)
72 | return this.repos({ interaction, type, query, data });
73 | return this.commandError(
74 | interaction,
75 | 'Unknown items were returned from the search',
76 | `Search \`${query}\` of \`${type}\``
77 | );
78 | }
79 | }
80 |
81 | users({ interaction, data }) {
82 | const item = data.items[0];
83 |
84 | return GitHub.getUserByUsername(item.login).then((user) => {
85 | const embed = new this.embed({
86 | title: user.login,
87 | url: user.html_url,
88 | color: 0x84f139,
89 | description: `${user.bio || '\u200B'}\n`,
90 | thumbnail: {
91 | url: user.avatar_url,
92 | },
93 | timestamp: Date.now(),
94 | fields: [
95 | {
96 | name: 'Name',
97 | value: user.name || user.login,
98 | inline: true,
99 | },
100 | {
101 | name: 'Company',
102 | value: user.company || 'None',
103 | inline: true,
104 | },
105 | {
106 | name: 'Repos',
107 | value: String(user.public_repos) || 'Unknown',
108 | inline: true,
109 | },
110 | {
111 | name: 'Since',
112 | value: moment(user.created_at).format('MMMM Do, YYYY. h:mm A'),
113 | inline: true,
114 | },
115 | ],
116 | });
117 |
118 | return interaction.editReply({ embeds: [embed] });
119 | });
120 | }
121 |
122 | repos({ interaction, data }) {
123 | const repo = data.items[0];
124 |
125 | const embed = new this.embed({
126 | title: repo.full_name,
127 | url: repo.html_url,
128 | color: 0x84f139,
129 | description: `${repo.description || '\u200B'}\n`,
130 | thumbnail: {
131 | url: repo.owner.avatar_url,
132 | },
133 | timestamp: Date.now(),
134 | fields: [
135 | {
136 | name: repo.owner.type,
137 | value: repo.owner
138 | ? `[${repo.owner.name || repo.owner.login}](${repo.owner.html_url})`
139 | : 'Unknown',
140 | inline: true,
141 | },
142 | {
143 | name: 'Stars',
144 | value: String(repo.stargazers_count),
145 | inline: true,
146 | },
147 | {
148 | name: 'Forks',
149 | value: String(repo.forks),
150 | inline: true,
151 | },
152 | {
153 | name: 'Open Issues',
154 | value: repo.has_issues ? String(repo.open_issues) : 'Disabled',
155 | inline: true,
156 | },
157 | {
158 | name: 'Language',
159 | value: repo.language || 'Unknown',
160 | inline: true,
161 | },
162 | ],
163 | });
164 |
165 | return interaction.editReply({ embeds: [embed] });
166 | }
167 | }
168 |
169 | module.exports = GitHubSearch;
170 |
--------------------------------------------------------------------------------
/lib/Discord/Commands/Stats.js:
--------------------------------------------------------------------------------
1 | const moment = require('moment');
2 | const DiscordJS = require('discord.js');
3 | const Command = require('../Command');
4 | const pack = require('../../../package.json');
5 |
6 | require('moment-duration-format');
7 |
8 | const unit = ['', 'K', 'M', 'G', 'T', 'P'];
9 | const GetUptime = (bot) =>
10 | moment
11 | .duration(bot.uptime)
12 | .format('d[ days], h[ hours], m[ minutes, and ]s[ seconds]');
13 | const bytesToSize = (input, precision) => {
14 | let index = Math.floor(Math.log(input) / Math.log(1024));
15 | if (unit >= unit.length) return `${input} B`;
16 | let msg = `${(input / Math.pow(1024, index)).toFixed(precision)} ${
17 | unit[index]
18 | }B`;
19 | return msg;
20 | };
21 |
22 | class StatsCommand extends Command {
23 | constructor(bot) {
24 | super(bot);
25 | this.props.help = {
26 | name: 'stats',
27 | description: 'Shows some stats of the bot',
28 | usage: 'stats',
29 | };
30 | }
31 | run(interaction) {
32 | const bot = this.bot;
33 | const memoryUsage = bytesToSize(process.memoryUsage().heapUsed, 3);
34 | const booted = bot.booted;
35 | const channels = bot.channels.cache;
36 | const textChannels = channels.filter(
37 | (e) => e.type === DiscordJS.ChannelType.GuildText
38 | ).size;
39 | const voiceChannels = channels.filter(
40 | (e) => e.type === DiscordJS.ChannelType.GuildVoice
41 | ).size;
42 |
43 | const embed = {
44 | color: 0xfd9827,
45 | author: {
46 | name: bot.user.username,
47 | icon_url: bot.user.avatarURL(),
48 | },
49 | description: '**Yappy Stats**',
50 | fields: [
51 | {
52 | name: '❯ Uptime',
53 | value: GetUptime(bot),
54 | inline: true,
55 | },
56 | {
57 | name: '❯ Booted',
58 | value: `${booted.date} ${booted.time}`,
59 | inline: true,
60 | },
61 | {
62 | name: '❯ Memory Usage',
63 | value: memoryUsage,
64 | inline: true,
65 | },
66 | {
67 | name: '\u200B',
68 | value: '\u200B',
69 | inline: false,
70 | },
71 | {
72 | name: '❯ Guilds',
73 | value: String(bot.guilds.cache.size),
74 | inline: true,
75 | },
76 | {
77 | name: '❯ Channels',
78 | value: `${channels.size} (${textChannels} text, ${voiceChannels} voice)`,
79 | inline: true,
80 | },
81 | {
82 | name: '❯ Users',
83 | value: String(bot.users.cache.size),
84 | inline: true,
85 | },
86 | {
87 | name: '\u200B',
88 | value: '\u200B',
89 | inline: false,
90 | },
91 | {
92 | name: '❯ Author',
93 | value: pack.author.replace(/<\S+[@]\S+[.]\S+>/g, ''),
94 | inline: true,
95 | },
96 | {
97 | name: '❯ Version',
98 | value: pack.version,
99 | inline: true,
100 | },
101 | {
102 | name: '❯ DiscordJS',
103 | value: `v${DiscordJS.version}`,
104 | inline: true,
105 | },
106 | ],
107 | };
108 |
109 | return interaction.reply({ embeds: [embed] });
110 | }
111 | }
112 |
113 | module.exports = StatsCommand;
114 |
--------------------------------------------------------------------------------
/lib/Discord/Commands/Update.js:
--------------------------------------------------------------------------------
1 | const { exec } = require('child_process');
2 | const path = require('path');
3 | const fs = require('fs');
4 | const jsondiffpatch = require('jsondiffpatch');
5 | const beforePackageJSON = require('../../../package.json');
6 | const Command = require('../Command');
7 |
8 | class UpdateCommand extends Command {
9 | constructor(bot) {
10 | super(bot);
11 |
12 | this.props.help = {
13 | name: 'update',
14 | description: 'update the bot',
15 | usage: 'update [commit/branch]',
16 | };
17 |
18 | this.setConf({
19 | permLevel: 2,
20 | msgTrigger: true,
21 | });
22 | }
23 |
24 | async run(msg, args) {
25 | const ref = args[0];
26 |
27 | let embedData = {
28 | title: 'Updating',
29 | color: 0xfb9738,
30 | description: '\u200B',
31 | fields: [],
32 | footer: {
33 | text: this.bot.user.username,
34 | icon_url: this.bot.user.avatarURL(),
35 | },
36 | };
37 |
38 | const reply = await msg.channel.send({ embeds: [embedData] });
39 |
40 | return this.exec('git pull')
41 | .then((stdout) => {
42 | if (stdout.includes('Already up-to-date')) {
43 | return this.addFieldToEmbed(reply, embedData, {
44 | name: 'Git Pull',
45 | value: 'Already up-to-date',
46 | }).then((m) => {
47 | embedData = m.embeds[0];
48 | return Promise.reject('No update');
49 | });
50 | }
51 | return this.addFieldToEmbed(reply, embedData, {
52 | name: 'Git Pull',
53 | value: `\`\`\`sh\n${stdout.slice(0, 1000)}\n\`\`\``,
54 | });
55 | })
56 | .then(() => {
57 | if (ref) return this.exec(`git checkout ${ref}`);
58 | return;
59 | })
60 | .then(this.getDepsToInstall)
61 | .then((info) => {
62 | if (!info) return Promise.resolve();
63 | return this.addFieldToEmbed(reply, embedData, {
64 | name: 'Dependencies',
65 | value: [
66 | ...(info.install.length
67 | ? [
68 | '**Install:**',
69 | [...info.install]
70 | .map((e) => `- \`${e[0]}@${e[1]}\`\n`)
71 | .join(''),
72 | ]
73 | : []),
74 | ...(info.update.length
75 | ? [
76 | '**Update:**',
77 | info.update
78 | .map((e) => `- \`${e[0]}@${e[1]} -> ${e[2]}\`\n`)
79 | .join(''),
80 | ]
81 | : []),
82 | ...(info.remove.length
83 | ? [
84 | '**Remove:**',
85 | [...info.remove]
86 | .map((e) => `- \`${e[0]}@${e[1]}\`\n`)
87 | .join(''),
88 | ]
89 | : []),
90 | ].join('\n'),
91 | }).then(() => info);
92 | })
93 | .then((info) => {
94 | if (!info) return Promise.resolve();
95 | return this.installDeps(info).then((stdouts) =>
96 | this.addFieldToEmbed(reply, embedData, {
97 | name: 'NPM',
98 | value:
99 | stdouts.length > 0
100 | ? stdouts
101 | .map(
102 | (stdout) => `\`\`\`sh\n${stdout.slice(0, 1000)}\n\`\`\``
103 | )
104 | .join('\n')
105 | : 'No packages were updated',
106 | })
107 | );
108 | })
109 | .then(() =>
110 | msg.channel.send({
111 | embeds: [
112 | {
113 | color: 0x2ecc71,
114 | title: 'Updating',
115 | description: 'Restarting...',
116 | },
117 | ],
118 | })
119 | )
120 | .then(() => {
121 | Log.info('RESTARTING - Executed `update` command');
122 | process.exit(0);
123 | })
124 | .catch((err) => {
125 | if (err === 'No update') return;
126 | return this.commandError(reply, err);
127 | });
128 | }
129 |
130 | getDepsToInstall() {
131 | return new Promise((resolve, reject) => {
132 | fs.readFile(
133 | path.resolve(__dirname, '../../../package.json'),
134 | (err, content) => {
135 | if (err) return reject(err);
136 | const afterPackageJSON = JSON.parse(content);
137 | delete afterPackageJSON.dependencies.debug;
138 | const diff = jsondiffpatch.diff(
139 | beforePackageJSON.dependencies,
140 | afterPackageJSON.dependencies
141 | );
142 | if (!diff) return resolve();
143 | let data = {
144 | install: Object.keys(diff)
145 | .filter((e) => diff[e].length === 1)
146 | .map((e) => [e, diff[e][0]]),
147 | update: Object.keys(diff)
148 | .filter((e) => diff[e].length === 2)
149 | .map((e) => [e, diff[e][0], diff[e][1]]),
150 | remove: Object.keys(diff)
151 | .filter((e) => diff[e].length === 3)
152 | .map((e) => [e, diff[e][0]]),
153 | };
154 | resolve(data);
155 | }
156 | );
157 | });
158 | }
159 |
160 | async installDeps(data) {
161 | let stdouts = [
162 | data.install.length &&
163 | (await this.exec(
164 | `npm i --no-progress ${data.install
165 | .map((e) => `${e[0]}@${e[1]}`)
166 | .join(' ')}`
167 | )),
168 | data.update.length &&
169 | (await this.exec(
170 | `npm upgrade --no-progress ${data.update
171 | .map((e) => `${e[0]}@${e[1]}`)
172 | .join(' ')}`
173 | )),
174 | data.remove.length &&
175 | (await this.exec(
176 | `npm rm --no-progress ${data.remove.map((e) => e[0]).join(' ')}`
177 | )),
178 | ];
179 | return stdouts.filter((e) => !!e);
180 | }
181 |
182 | addFieldToEmbed(reply, data, field) {
183 | data.fields.push(field);
184 | return reply.edit({ embeds: [data] });
185 | }
186 |
187 | exec(cmd, opts = {}) {
188 | return new Promise((resolve, reject) => {
189 | exec(cmd, opts, (err, stdout, stderr) => {
190 | if (err) return reject(stderr);
191 | resolve(stdout);
192 | });
193 | });
194 | }
195 | }
196 |
197 | module.exports = UpdateCommand;
198 |
--------------------------------------------------------------------------------
/lib/Discord/Module.js:
--------------------------------------------------------------------------------
1 | const { EmbedBuilder } = require('@discordjs/builders');
2 | const Discord = require('discord.js');
3 |
4 | /**
5 | * Discord bot middleware, or module
6 | */
7 | class Module {
8 | /**
9 | * @param {Client} bot - discord bot
10 | */
11 | constructor(bot) {
12 | this.bot = bot;
13 | this._path = Log._path;
14 | this.embed = Discord.EmbedBuilder;
15 | }
16 |
17 | /**
18 | * Middleware's priority
19 | * @readonly
20 | * @type {number}
21 | */
22 | get priority() {
23 | return 0;
24 | }
25 |
26 | /**
27 | * Init module
28 | */
29 | init() {}
30 |
31 | /**
32 | * Bot's message middleware function
33 | * @param {Message} msg - the message
34 | * @param {string[]} args - message split by spaces
35 | * @param {function} next - next middleware pls <3
36 | */
37 | run() {
38 | throw new Error(
39 | `No middleware method was set up in module ${this.constructor.name}`
40 | );
41 | }
42 |
43 | /**
44 | * Function to shorten sending error messages
45 | * @param {Message} msg - message sent by user (for channel)
46 | * @param {string} str - error message to send user
47 | * @return {Promise}
48 | */
49 | moduleError(msg, str) {
50 | return msg.channel.send(`❌ ${str}`);
51 | }
52 |
53 | /**
54 | * Convert normal text to an embed object
55 | * @param {string} [title = 'Auto Generated Response'] - embed title
56 | * @param {string|string[]} text - embed description, joined with newline if array
57 | * @param {color} [color = '#84F139'] - embed color
58 | * @return {EmbedBuilder}
59 | */
60 | textToEmbed(title = 'Auto Generated Response', text, color = '#84F139') {
61 | if (Array.isArray(text)) text = text.join('\n');
62 | return new this.embed()
63 | .setColor(color)
64 | .setTitle(title)
65 | .setDescription(text)
66 | .setFooter({
67 | iconURL: this.bot.user.avatarURL(),
68 | text: this.bot.user.username,
69 | });
70 | }
71 | }
72 |
73 | module.exports = Module;
74 |
--------------------------------------------------------------------------------
/lib/Discord/Modules/RunCommand.js:
--------------------------------------------------------------------------------
1 | const Module = require('../Module');
2 | const Logger = require('@YappyBots/addons').discord.logger;
3 | let logger;
4 |
5 | class RunCommandModule extends Module {
6 | constructor(bot) {
7 | super(bot);
8 | logger = new Logger(bot, 'command');
9 | }
10 |
11 | get priority() {
12 | return 10;
13 | }
14 |
15 | run(interaction, next) {
16 | const command = interaction.commandName;
17 |
18 | const bot = this.bot;
19 | const perms = bot.permissions(interaction);
20 | const cmd =
21 | bot.commands.get(command) || bot.commands.get(bot.aliases.get(command));
22 |
23 | if (!cmd) {
24 | return next();
25 | }
26 |
27 | const hasPermission = perms >= cmd.conf.permLevel;
28 |
29 | // logger.message(msg); // TODO
30 |
31 | Log.addBreadcrumb({
32 | category: 'discord.command',
33 | type: 'user',
34 | message: `${interaction} @ <#${interaction.channel?.id}>`,
35 | });
36 |
37 | // Only use for owner-only commands. They should only be accessible in a single server.
38 | if (!hasPermission && cmd.conf.permLevel >= 2)
39 | return cmd.commandError(
40 | interaction,
41 | `Insufficient permissions! Must be **${cmd._permLevelToWord(
42 | cmd.conf.permLevel
43 | )}** or higher`
44 | );
45 |
46 | if (cmd.conf.guildOnly && !interaction.inGuild()) {
47 | return cmd.commandError(interaction, `This is a guild only command.`);
48 | }
49 |
50 | try {
51 | let commandRun = cmd.run(interaction);
52 | if (commandRun && commandRun.catch) {
53 | commandRun.catch((e) => {
54 | logger.error(interaction, e);
55 | return cmd.commandError(interaction, e);
56 | });
57 | }
58 | } catch (e) {
59 | logger.error(interaction, e);
60 | cmd.commandError(interaction, e);
61 | }
62 | }
63 | }
64 |
65 | module.exports = RunCommandModule;
66 |
--------------------------------------------------------------------------------
/lib/Discord/Modules/UnhandledError.js:
--------------------------------------------------------------------------------
1 | const Module = require('../Module');
2 |
3 | class UnhandledErrorModule extends Module {
4 | run(interaction, next, middleware, error) {
5 | const func =
6 | interaction.deferred || interaction.replied ? 'editReply' : 'reply';
7 |
8 | if (!error) {
9 | return interaction[func]('An unknown error occurred', {
10 | ephemeral: true,
11 | });
12 | }
13 |
14 | let embed = this.textToEmbed(
15 | `Yappy, the GitHub Monitor - Unhandled Error: \`${
16 | middleware ? middleware.constructor.name : interaction.commandName
17 | }\``,
18 | '',
19 | '#CE0814'
20 | );
21 | if (typeof error === 'string') embed.setDescription(error);
22 |
23 | return interaction[func]({ embeds: [embed] }, { ephemeral: true });
24 | }
25 | }
26 |
27 | module.exports = UnhandledErrorModule;
28 |
--------------------------------------------------------------------------------
/lib/Discord/index.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const {
3 | GatewayIntentBits,
4 | Options,
5 | Partials,
6 | Colors,
7 | Events,
8 | } = require('discord.js');
9 |
10 | const Client = require('./Client');
11 | const Log = require('../Util/Log');
12 |
13 | const { addCommands } = require('@YappyBots/addons').discord.commands;
14 | const bot = new Client({
15 | name: 'Yappy, the GitHub Monitor',
16 | allowedMentions: { repliedUser: true },
17 | intents: [
18 | GatewayIntentBits.Guilds,
19 | GatewayIntentBits.GuildMessages,
20 | GatewayIntentBits.DirectMessages,
21 | ],
22 | partials: [Partials.Channel],
23 | owner: process.env.DISCORD_OWNER_ID,
24 | makeCache: Options.cacheWithLimits({
25 | ...Options.DefaultMakeCacheSettings,
26 | ReactionManager: 0,
27 | MessageManager: 50,
28 | GuildMemberManager: {
29 | maxSize: 100,
30 | keepOverLimit: (member) => member.id === bot.user.id,
31 | },
32 | }),
33 | });
34 | const TOKEN = process.env.DISCORD_TOKEN;
35 | const logger = new (require('@YappyBots/addons').discord.logger)(bot, 'main');
36 |
37 | const initialization = require('../Models/initialization');
38 |
39 | bot.booted = {
40 | date: new Date().toLocaleDateString(),
41 | time: new Date().toLocaleTimeString(),
42 | };
43 |
44 | bot.statuses = [
45 | 'Online',
46 | 'Connecting',
47 | 'Reconnecting',
48 | 'Idle',
49 | 'Nearly',
50 | 'Offline',
51 | ];
52 | bot.statusColors = ['lightgreen', 'orange', 'orange', 'orange', 'green', 'red'];
53 |
54 | bot.on(Events.ClientReady, () => {
55 | Log.info('Bot | Logged In');
56 | logger.log('Logged in', null, Colors.Green);
57 | initialization(bot);
58 | });
59 |
60 | bot.on('disconnect', (e) => {
61 | Log.warn(`Bot | Disconnected (${e.code}).`);
62 | logger.log('Disconnected', e.code, Colors.Orange);
63 | });
64 |
65 | bot.on(Events.Error, (e) => {
66 | Log.error(e);
67 | logger.log(e.message || 'An error occurred', e.stack || e, Colors.Red);
68 | });
69 |
70 | bot.on(Events.Warn, (e) => {
71 | Log.warn(e);
72 | logger.log(e.message || 'Warning', e.stack || e, Colors.Orange);
73 | });
74 |
75 | bot.on(Events.MessageCreate, async (message) => {
76 | if (!bot.application?.owner) await bot.application?.fetch();
77 |
78 | await bot.runCommandMessage(message);
79 | });
80 |
81 | bot.on(Events.InteractionCreate, async (interaction) => {
82 | if (!interaction.isChatInputCommand()) return;
83 |
84 | try {
85 | await bot.runCommand(interaction);
86 | } catch (e) {
87 | bot.emit('error', e);
88 | await interaction.reply({
89 | content: 'There was an error while executing this command!',
90 | ephemeral: true,
91 | });
92 | }
93 | });
94 |
95 | bot.on('runCommand', Log.message);
96 |
97 | bot.loadCommands(path.resolve(__dirname, 'Commands'));
98 | bot.loadModules(path.resolve(__dirname, 'Modules'));
99 | addCommands(bot);
100 |
101 | // === LOGIN ===
102 | Log.info(`Bot | Logging in...`);
103 |
104 | bot.login(TOKEN).catch((err) => {
105 | Log.error('Bot | Unable to log in');
106 | Log.error(err);
107 | setTimeout(
108 | () => process.exit(Number.isInteger(err?.code) ? err.code : 1),
109 | 3000
110 | );
111 | });
112 |
113 | module.exports = bot;
114 |
--------------------------------------------------------------------------------
/lib/GitHub/Constants.js:
--------------------------------------------------------------------------------
1 | exports.HOST = 'GitHub.com';
2 |
3 | /**
4 | * GitHub Errors
5 | * @type {Object}
6 | */
7 | exports.Errors = {
8 | NO_TOKEN: 'No token was provided via process.env.GITHUB_TOKEN',
9 | REQUIRE_QUERY: 'A query is required',
10 | NO_REPO_CONFIGURED: `Repository for this channel hasn't been configured. Please tell the server owner that they need to do "/conf option channel item:repo value:".`,
11 | };
12 |
--------------------------------------------------------------------------------
/lib/GitHub/EventHandler.js:
--------------------------------------------------------------------------------
1 | const { Collection, resolveColor } = require('discord.js');
2 | const fs = require('fs');
3 | const path = require('path');
4 | const get = require('lodash/get');
5 |
6 | const bot = require('../Discord');
7 | const Log = require('../Util/Log');
8 | const markdown = require('../Util/markdown');
9 |
10 | class Events {
11 | constructor() {
12 | this.events = {};
13 | this.eventDir = path.resolve(__dirname, './Events');
14 | this.eventsList = new Collection();
15 |
16 | this.bot = bot;
17 | this.setup();
18 | }
19 |
20 | async setup() {
21 | fs.readdir(this.eventDir, (err, files) => {
22 | if (err) throw err;
23 |
24 | files.forEach((file) => {
25 | let eventName = file.replace(`.js`, ``);
26 | try {
27 | let event = require(`./Events/${eventName}`);
28 | this.eventsList.set(eventName, new event(this.bot));
29 | Log.debug(`GitHub | Loading Event ${eventName.replace(`-`, `/`)} 👌`);
30 | } catch (e) {
31 | Log.info(`GitHub | Loading Event ${eventName} ❌`);
32 | Log.error(e);
33 | }
34 | });
35 |
36 | return;
37 | });
38 | }
39 |
40 | use(repo, data, eventName) {
41 | const action = data.action || data.status || data.state;
42 | let event = action ? `${eventName}-${action}` : eventName;
43 | repo ||= data.installation?.account?.login;
44 |
45 | try {
46 | const known =
47 | this.eventsList.get(event) || this.eventsList.get(eventName);
48 | event = known || this.eventsList.get('Unknown');
49 |
50 | if (!event || event.placeholder || (event.ignore && event.ignore(data)))
51 | return;
52 | const text = event.text(data, eventName, action);
53 | return {
54 | embed: this.parseEmbed(event.embed(data, eventName, action), data),
55 | text: event.shorten(
56 | `**${repo}:** ${Array.isArray(text) ? text.join('\n') : text}`,
57 | 1950
58 | ),
59 | unknown: !known,
60 | };
61 | } catch (e) {
62 | Log.error(e);
63 | return {
64 | type: 'error',
65 | sentry: e.sentry,
66 | };
67 | }
68 | }
69 |
70 | parseEmbed(embed, data) {
71 | if (embed.color) embed.color = resolveColor(embed.color);
72 |
73 | embed.author = {
74 | name: data.sender.login,
75 | icon_url: data.sender.avatar_url || null,
76 | url: data.sender.html_url,
77 | };
78 | embed.footer = {
79 | text:
80 | data.repository?.full_name ||
81 | data.installation?.account?.login ||
82 | data?.organization?.login,
83 | };
84 | embed.url =
85 | embed?.url || embed.url === null
86 | ? embed.url
87 | : get(data, 'repository.html_url') ||
88 | (data.organization &&
89 | `https://github.com/${get(data, 'organization.login')}`);
90 | embed.timestamp = new Date();
91 |
92 | if (embed.description) {
93 | embed.description = this._beautify(embed.description);
94 | if (embed.description.length > 2048) {
95 | embed.description = `${embed.description.slice(0, 2046).trim()}…`;
96 | }
97 | }
98 |
99 | return embed;
100 | }
101 |
102 | _beautify(content) {
103 | return markdown
104 | .convert(content)
105 | .trim()
106 | .replace(/(\r?\n){2,}/g, '\n\n')
107 | .replace(/^\* \* \*$/gm, '⎯'.repeat(6))
108 | .trim();
109 | }
110 | }
111 |
112 | module.exports = new Events();
113 |
--------------------------------------------------------------------------------
/lib/GitHub/EventIgnoreResponse.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('./EventResponse');
2 |
3 | class EventIgnoreResponse extends EventResponse {
4 | placeholder = true;
5 | }
6 |
7 | module.exports = EventIgnoreResponse;
8 |
--------------------------------------------------------------------------------
/lib/GitHub/EventResponse.js:
--------------------------------------------------------------------------------
1 | const escape = require('markdown-escape');
2 | const { decode } = require('html-entities');
3 |
4 | class EventResponse {
5 | constructor(bot, info) {
6 | this.bot = bot;
7 | this._info = info;
8 | }
9 | get info() {
10 | return this._info;
11 | }
12 |
13 | capitalize(str) {
14 | return str[0].toUpperCase() + str.slice(1);
15 | }
16 |
17 | shorten(str, length) {
18 | return str
19 | ? str.trim().slice(0, length).trim() + (str.length > length ? '...' : '')
20 | : '';
21 | }
22 |
23 | escape(str) {
24 | return decode(escape(str));
25 | }
26 | }
27 |
28 | module.exports = EventResponse;
29 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/Unknown.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | const message = (event) =>
4 | `Add this event to the blacklist with \`/conf filter type:events action:Add Item item:${event}\` or disable these messages with \`/conf option channel item:ignoreUnknown value:false\`.`;
5 |
6 | class Unkown extends EventResponse {
7 | constructor(...args) {
8 | super(...args, {
9 | description: `This response is shown whenever an event fired isn't found.`,
10 | });
11 | }
12 | embed(data, e) {
13 | const action = data.action ? `/${data.action}` : '';
14 | const event = `${e}${action}`;
15 |
16 | const isRepository = !!data.repository;
17 |
18 | return {
19 | color: 'Red',
20 | title: `${
21 | isRepository ? 'Repository' : 'Installation'
22 | } sent unknown event: \`${event}\``,
23 | description: [
24 | 'This most likely means the developers have not gotten to styling this event.',
25 | message(event),
26 | ].join('\n'),
27 | };
28 | }
29 | text(data, e) {
30 | const action = data.action ? `/${data.action}` : '';
31 | const event = `${e}${action}`;
32 |
33 | const type = data.repository ? 'repository' : 'installation';
34 | const name =
35 | data.repository?.full_name ||
36 | data.installation?.account?.login ||
37 | 'unknown';
38 |
39 | return [
40 | `An unknown event (\`${event}\`) has been emitted from ${type} **${name}**.`,
41 | message(event),
42 | ];
43 | }
44 | }
45 |
46 | module.exports = Unkown;
47 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/check_run.js:
--------------------------------------------------------------------------------
1 | const EventIgnoreResponse = require('../EventIgnoreResponse');
2 |
3 | class CheckRun extends EventIgnoreResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'Check runs are in preview on GitHub - this is a placeholder',
8 | });
9 | }
10 | }
11 |
12 | module.exports = CheckRun;
13 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/check_suite.js:
--------------------------------------------------------------------------------
1 | const EventIgnoreResponse = require('../EventIgnoreResponse');
2 |
3 | class CheckSuite extends EventIgnoreResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'Check suites are in preview on GitHub - this is a placeholder',
8 | });
9 | }
10 | }
11 |
12 | module.exports = CheckSuite;
13 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/commit_comment-created.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class CommitCommentCreated extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired whenever someone comments on a commit',
8 | });
9 | }
10 |
11 | embed(data) {
12 | return {
13 | title: `Commented on commit \`${data.comment.commit_id.slice(0, 7)}\``,
14 | url: data.comment.html_url,
15 | description: data.comment.body.slice(0, 2048),
16 | };
17 | }
18 |
19 | text(data) {
20 | const { comment } = data;
21 | const actor = data.sender;
22 |
23 | return [
24 | `💬 **${actor.login}** commented on commit **${comment.commit_id.slice(
25 | 0,
26 | 7
27 | )}**`,
28 | `<${comment.html_url}>`,
29 | ].join('\n');
30 | }
31 | }
32 |
33 | module.exports = CommitCommentCreated;
34 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/create.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class Create extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `This response is fired whenever a branch is created.`,
7 | });
8 | }
9 |
10 | embed(data) {
11 | const { ref_type, ref, master_branch } = data;
12 |
13 | return {
14 | title: `Created ${ref_type} \`${ref}\` from \`${master_branch}\``,
15 | color: `FF9900`,
16 | url: `${data.repository.html_url}/tree/${ref}`,
17 | };
18 | }
19 |
20 | text(data) {
21 | const { ref_type, ref, master_branch, sender } = data;
22 |
23 | return [
24 | `🌲 **${sender.login}** created ${ref_type} \`${ref}\` (from \`${master_branch}\`)`,
25 | ];
26 | }
27 | }
28 |
29 | module.exports = Create;
30 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/delete.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class Delete extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `This response is fired whenever a branch is deleted.`,
7 | });
8 | }
9 | embed(data) {
10 | const { ref_type, ref } = data;
11 |
12 | return {
13 | title: `Deleted ${ref_type} \`${ref}\``,
14 | color: `FF9900`,
15 | };
16 | }
17 | text(data) {
18 | const { ref_type, ref, sender } = data;
19 |
20 | return [`🌲 **${sender.login}** deleted ${ref_type} \`${ref}\``];
21 | }
22 | }
23 |
24 | module.exports = Delete;
25 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/dependabot_alert.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class DependabotCreated extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: 'This event is fired when a vulnerability alert is created',
7 | });
8 | }
9 |
10 | embed({ action, alert }) {
11 | const { dependency, security_vulnerability, security_advisory } = alert;
12 | const { name, ecosystem } = dependency.package;
13 | const { cve_id, ghsa_id, severity, summary } = security_advisory;
14 |
15 | return {
16 | color: '#8e44ad',
17 | title: `${this.formatAction(
18 | action
19 | )} a ${severity} dependabot alert for \`${name}\` (${ecosystem})`,
20 | url: alert.html_url,
21 | description:
22 | action === 'created'
23 | ? [
24 | `${summary} affecting \`${security_vulnerability.vulnerable_version_range}\`.`,
25 | '',
26 | `Classified as [${cve_id}](https://nvd.nist.gov/vuln/detail/${cve_id}), [${ghsa_id}](https://github.com/advisories/${ghsa_id})`,
27 | ].join('\n')
28 | : '',
29 | };
30 | }
31 |
32 | text({ action, alert }) {
33 | const { dependency, security_vulnerability, security_advisory } = alert;
34 | const { name, ecosystem } = dependency.package;
35 | const { severity, references } = security_advisory;
36 |
37 | return [
38 | `🛡 ${this.formatAction(
39 | action
40 | )} a ${severity} dependabot alert for \`${name}\` (${ecosystem}) affecting **${
41 | security_vulnerability.vulnerable_version_range
42 | }**. <${references.find((v) => v.url)?.url}>`,
43 | ];
44 | }
45 |
46 | formatAction(str) {
47 | return this.capitalize(str.replace('_', ' '));
48 | }
49 | }
50 |
51 | module.exports = DependabotCreated;
52 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/fork.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class Fork extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `This response is fired whenever a person forks the repo.`,
7 | });
8 | }
9 | embed(data) {
10 | return {
11 | title: `Forked to ${data.forkee.full_name}`,
12 | url: data.forkee.html_url,
13 | };
14 | }
15 | text(data) {
16 | return [
17 | `🍝 **${data.sender.login}** forked to ${data.forkee.full_name}`,
18 | ` <${data.forkee.html_url}>`,
19 | ];
20 | }
21 | }
22 |
23 | module.exports = Fork;
24 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/gollum.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class Gollum extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: 'This response is fired whenever the wiki is updated.',
7 | });
8 | }
9 |
10 | embed(data) {
11 | const page = data.pages[0];
12 | const action = page.action[0].toUpperCase() + page.action.slice(1, 99);
13 |
14 | return {
15 | title: `${action} wiki page \`${page.title}\``,
16 | url: page.html_url,
17 | color: `#29bb9c`,
18 | };
19 | }
20 |
21 | text(data) {
22 | const actor = data.sender;
23 | const pages = data.pages;
24 | const actions = pages
25 | .map((e) => {
26 | const action = e.action[0].toUpperCase() + e.action.slice(1, 99);
27 | return `${action} **${this.escape(e.title)}** (<${e.html_url}>)`;
28 | })
29 | .join('\n ');
30 |
31 | return [
32 | `📰 **${actor.login}** modified the wiki`,
33 | ` ${actions}`,
34 | ].join('\n');
35 | }
36 | }
37 |
38 | module.exports = Gollum;
39 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/installation-created.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class InstallationCreated extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired when a GitHub installation is created.',
8 | });
9 | }
10 |
11 | embed(data) {
12 | const length = data.repositories.length;
13 | const isAll = data?.installation?.repository_selection === 'all';
14 |
15 | return {
16 | color: 'Red',
17 | title: `GitHub installation created for ${isAll ? 'all' : length} ${
18 | length === 1 && !isAll ? 'repository' : 'repositories'
19 | }`,
20 | url: null,
21 | };
22 | }
23 |
24 | text(data) {
25 | const length = data.repositories.length;
26 | const isAll = data?.installation?.repository_selection === 'all';
27 |
28 | return [
29 | `🏓 GitHub installation created for ${isAll ? 'all' : length} ${
30 | length === 1 && !isAll ? 'repository' : 'repositories'
31 | }!`,
32 | ].join('\n');
33 | }
34 | }
35 |
36 | module.exports = InstallationCreated;
37 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/installation-deleted.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class InstallationDeleted extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired when a GitHub installation is deleted.',
8 | });
9 | }
10 |
11 | embed(data) {
12 | return {
13 | color: 'Red',
14 | title: `GitHub installation deleted`,
15 | url: null,
16 | };
17 | }
18 |
19 | text(data) {
20 | return [`🏓 GitHub installation deleted!`].join('\n');
21 | }
22 | }
23 |
24 | module.exports = InstallationDeleted;
25 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/installation-suspend.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class InstallationSuspend extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired when a GitHub installation is suspended.',
8 | });
9 | }
10 |
11 | embed(data) {
12 | return {
13 | color: 'Red',
14 | title: `GitHub installation suspended`,
15 | url: null,
16 | };
17 | }
18 |
19 | text(data) {
20 | return [`🏓 GitHub installation suspended!`].join('\n');
21 | }
22 | }
23 |
24 | module.exports = InstallationSuspend;
25 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/installation-unsuspend.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class InstallationUnsuspend extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired when a GitHub installation is unsuspended.',
8 | });
9 | }
10 |
11 | embed(data) {
12 | return {
13 | color: 'Red',
14 | title: `GitHub installation unsuspended`,
15 | url: null,
16 | };
17 | }
18 |
19 | text(data) {
20 | return [`🏓 GitHub installation unsuspended!`].join('\n');
21 | }
22 | }
23 |
24 | module.exports = InstallationUnsuspend;
25 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/installation_repositories-added.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class InstallationRepositoriesAdded extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired when repositories are added to the GitHub App installation.',
8 | });
9 | }
10 |
11 | embed(data) {
12 | const { repositories_added, repository_selection } = data;
13 | const length = repositories_added.length;
14 | const isAll = repository_selection === 'all';
15 |
16 | return {
17 | color: 'Red',
18 | title: `Receiving events from ${isAll ? 'all' : length} ${
19 | length === 1 && !isAll ? 'repository' : 'repositories'
20 | }`,
21 | description:
22 | repository_selection === 'all'
23 | ? ''
24 | : repositories_added
25 | .map((repo) => this.escape(repo.full_name))
26 | .join(', '),
27 | url: null,
28 | };
29 | }
30 |
31 | text(data) {
32 | const { repositories_added, repository_selection } = data;
33 | const text =
34 | repository_selection === 'all'
35 | ? 'all repositories'
36 | : repositories_added.map((repo) => `\`${repo.full_name}\``).join(', ');
37 |
38 | return [`🏓 Receiving events from ${text}!`].join('\n');
39 | }
40 | }
41 |
42 | module.exports = InstallationRepositoriesAdded;
43 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/installation_repositories-removed.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class InstallationRepositoriesRemoved extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired when repositories are removed from the GitHub App installation.',
8 | });
9 | }
10 |
11 | embed(data) {
12 | const { repositories_removed, repository_selection } = data;
13 | const length = repositories_removed.length;
14 | const isAll = repository_selection === 'all';
15 |
16 | return {
17 | color: 'Red',
18 | title: `No longer receiving events from ${isAll ? 'all' : length} ${
19 | length === 1 && !isAll ? 'repository' : 'repositories'
20 | }`,
21 | description:
22 | repository_selection === 'all'
23 | ? ''
24 | : repositories_removed
25 | .map((repo) => this.escape(repo.full_name))
26 | .join(', '),
27 | url: null,
28 | };
29 | }
30 |
31 | text(data) {
32 | const { repositories_removed, repository_selection } = data;
33 | const text =
34 | repository_selection === 'all'
35 | ? 'all repositories'
36 | : repositories_removed
37 | .map((repo) => `\`${repo.full_name}\``)
38 | .join(', ');
39 |
40 | return [`🏓 No longer receiving events from ${text}!`].join('\n');
41 | }
42 | }
43 |
44 | module.exports = InstallationRepositoriesRemoved;
45 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issue_comment-created.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueCommentCreated extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired whenever an issue comment is created',
8 | });
9 | }
10 |
11 | embed(data) {
12 | const isIssue = !data.issue.pull_request;
13 | const body = data.comment.body;
14 |
15 | return {
16 | color: isIssue ? '#E48D64' : '#C0E4C0',
17 | title: `Created a comment on ${isIssue ? 'issue' : 'pull request'} #${
18 | data.issue.number
19 | }: **${this.escape(data.issue.title)}**`,
20 | url: data.comment.html_url,
21 | description: this.shorten(body, 1000),
22 | };
23 | }
24 |
25 | text(data) {
26 | const { issue, comment } = data;
27 | const actor = data.sender;
28 | const isComment = !issue.pull_request;
29 |
30 | return [
31 | `💬 **${actor.login}** commented on ${
32 | isComment ? 'issue' : 'pull request'
33 | } **#${issue.number}** (${this.escape(issue.title)})`,
34 | `<${comment.html_url}>`,
35 | ].join('\n');
36 | }
37 | }
38 |
39 | module.exports = IssueCommentCreated;
40 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issue_comment-deleted.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueCommentDeleted extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired whenever an issue comment is deleted',
8 | });
9 | }
10 |
11 | embed(data) {
12 | const isIssue = !data.issue.pull_request;
13 | const body = data.comment.body;
14 |
15 | return {
16 | color: isIssue ? '#E48D64' : '#C0E4C0',
17 | title: `Deleted a comment on ${isIssue ? 'issue' : 'pull request'} #${
18 | data.issue.number
19 | }: **${this.escape(data.issue.title)}**`,
20 | url: data.issue.html_url,
21 | description: this.shorten(body, 200),
22 | };
23 | }
24 |
25 | text(data) {
26 | const { issue, sender: actor } = data;
27 | const isComment = !issue.pull_request;
28 |
29 | return [
30 | `💬 **${actor.login}** deleted a comment on ${
31 | isComment ? 'issue' : 'pull request'
32 | } **#${issue.number}** (${this.escape(issue.title)})`,
33 | `<${issue.html_url}>`,
34 | ].join('\n');
35 | }
36 | }
37 |
38 | module.exports = IssueCommentDeleted;
39 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issue_comment-edited.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueCommentEdited extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: 'This response is fired whenever an issue comment is edited',
7 | });
8 | }
9 |
10 | embed(data) {
11 | const isIssue = !data.issue.pull_request;
12 | const body = data.comment.body;
13 |
14 | return {
15 | color: isIssue ? '#E48D64' : '#C0E4C0',
16 | title: `Edited a comment on ${isIssue ? 'issue' : 'pull request'} #${
17 | data.issue.number
18 | }: **${this.escape(data.issue.title)}**`,
19 | url: data.issue.html_url,
20 | description: this.shorten(body, 200),
21 | };
22 | }
23 |
24 | text(data) {
25 | const { issue, comment } = data;
26 | const actor = data.sender;
27 | const isComment = !issue.pull_request;
28 |
29 | return [
30 | `💬 **${actor.login}** edited a comment on ${
31 | isComment ? 'issue' : 'pull request'
32 | } **#${issue.number}** (${this.escape(issue.title)})`,
33 | `<${comment.html_url}>`,
34 | ].join('\n');
35 | }
36 | }
37 |
38 | module.exports = IssueCommentEdited;
39 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issues-assigned.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueAssigned extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `Shoot! I got assigned to this issue, gotta close it.`,
7 | });
8 | }
9 | embed(data) {
10 | let issue = data.issue;
11 | let assigned = data.assignee;
12 | return {
13 | color: 'E9642D',
14 | title: `Assigned ${assigned.login} to #${issue.number} (**${this.escape(
15 | issue.title
16 | )}**)`,
17 | url: issue.html_url,
18 | };
19 | }
20 | text(data) {
21 | let actor = data.sender;
22 | let issue = data.issue;
23 | let assigned = data.assignee;
24 | return [
25 | `🛠 **${actor.login}** assigned ${
26 | actor.login === assigned.login ? 'themselves' : `**${assigned.login}**`
27 | } to **#${issue.number}** (${this.escape(issue.title)})`,
28 | `<${issue.html_url}>`,
29 | ];
30 | }
31 | }
32 |
33 | module.exports = IssueAssigned;
34 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issues-closed.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueOpened extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `Weeee! An issue got closed!`,
7 | });
8 | }
9 | embed(data) {
10 | const issue = data.issue;
11 | const description = issue.body;
12 |
13 | return {
14 | description: this.shorten(description, 500),
15 | color: 'E9642D',
16 | title: `Closed issue #${issue.number} (**${this.escape(issue.title)}**)`,
17 | url: issue.html_url,
18 | };
19 | }
20 | text(data) {
21 | let actor = data.sender;
22 | let issue = data.issue;
23 | return [
24 | `🛠 **${actor.login}** closed issue **#${issue.number}**: **${this.escape(
25 | issue.title
26 | )}**`,
27 | `<${issue.html_url}>`,
28 | ];
29 | }
30 | }
31 |
32 | module.exports = IssueOpened;
33 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issues-demilestoned.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueDemilestoned extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `An issue got removed from a milestone. RIP Goals!`,
7 | });
8 | }
9 | embed(data) {
10 | let issue = data.issue;
11 | return {
12 | color: 'E9642D',
13 | title: `Removed issue #${issue.number} (**${this.escape(
14 | issue.title
15 | )}**) from a milestone`,
16 | url: issue.html_url,
17 | };
18 | }
19 | text(data) {
20 | let actor = data.sender;
21 | let issue = data.issue;
22 | return [
23 | `🛠 **${actor.login}** removed the milestone from issue **#${
24 | issue.number
25 | }** (**${this.escape(issue.title)}**)`,
26 | `<${issue.html_url}>`,
27 | ];
28 | }
29 | }
30 |
31 | module.exports = IssueDemilestoned;
32 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issues-edited.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueEdited extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `An issue got edited. Good or Bad?.`,
7 | });
8 | }
9 | embed(data) {
10 | const issue = data.issue;
11 | const change = Object.keys(data.changes)[0];
12 | const changed = {
13 | from: data.changes[change].from,
14 | to: data.changes[change].to || issue[change],
15 | };
16 |
17 | return {
18 | color: 'E9642D',
19 | title: `Updated ${change} of issue #${issue.number} (**${this.escape(
20 | issue.title
21 | )}**)`,
22 | url: issue.html_url,
23 | };
24 | }
25 | text(data) {
26 | const actor = data.sender;
27 | const issue = data.issue;
28 | const change = Object.keys(data.changes)[0];
29 |
30 | return [
31 | `🛠 **${actor.login}** updated ${change} of **#${
32 | issue.number
33 | }** (${this.escape(issue.title)})`,
34 | `<${issue.html_url}>`,
35 | ];
36 | }
37 | }
38 |
39 | module.exports = IssueEdited;
40 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issues-labeled.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueLabeled extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `An issue got labeled. Organization!`,
7 | });
8 | }
9 | embed(data) {
10 | let issue = data.issue;
11 | let label = data.label;
12 | return {
13 | color: 'E9642D',
14 | title: `Added label **${this.escape(label.name)}** to #${
15 | issue.number
16 | } (**${this.escape(issue.title)}**)`,
17 | url: issue.html_url,
18 | };
19 | }
20 | text(data) {
21 | let actor = data.sender;
22 | let issue = data.issue;
23 | let label = data.label;
24 | return [
25 | `🛠 **${actor.login}** added label **${this.escape(
26 | label.name
27 | )}** to issue **#${issue.number}** (**${this.escape(issue.title)}**)`,
28 | `<${issue.html_url}>`,
29 | ];
30 | }
31 | }
32 |
33 | module.exports = IssueLabeled;
34 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issues-milestoned.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueMilestoned extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `An issue got added to a milestone. Goals!`,
7 | });
8 | }
9 | embed(data) {
10 | let issue = data.issue;
11 | let milestone = issue.milestone;
12 | return {
13 | color: 'E9642D',
14 | title: `Added issue #${issue.number} (**${this.escape(
15 | issue.title
16 | )}**) to milestone **${this.escape(milestone.title)}**`,
17 | url: issue.html_url,
18 | };
19 | }
20 | text(data) {
21 | let actor = data.sender;
22 | let issue = data.issue;
23 | let milestone = issue.milestone;
24 | return [
25 | `🛠 **${actor.login}** added issue **#${issue.number}** (**${this.escape(
26 | issue.title
27 | )}**) to milestone **${this.escape(milestone.title)}**`,
28 | `<${issue.html_url}>`,
29 | ];
30 | }
31 | }
32 |
33 | module.exports = IssueMilestoned;
34 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issues-opened.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueOpened extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `Uh, oh. An issue got opened!`,
7 | });
8 | }
9 | embed(data) {
10 | const issue = data.issue;
11 | const description = issue.body;
12 |
13 | return {
14 | color: 'E9642D',
15 | title: `Opened issue #${issue.number} (**${this.escape(issue.title)}**)`,
16 | url: issue.html_url,
17 | description: this.shorten(description, 1000),
18 | };
19 | }
20 | text(data) {
21 | let actor = data.sender;
22 | let issue = data.issue;
23 | return [
24 | `🛠 **${actor.login}** opened issue **#${issue.number}**: **${this.escape(
25 | issue.title
26 | )}**`,
27 | `<${issue.html_url}>`,
28 | ];
29 | }
30 | }
31 |
32 | module.exports = IssueOpened;
33 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issues-reopened.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueReopened extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `Uh, oh. An issue got reopened!`,
7 | });
8 | }
9 | embed(data) {
10 | const { body, number, title, html_url } = data.issue;
11 |
12 | return {
13 | color: 'E9642D',
14 | title: `Reopened issue #${number} (**${this.escape(title)}**)`,
15 | url: html_url,
16 | description: this.shorten(body, 200),
17 | };
18 | }
19 | text(data) {
20 | const { sender: actor, issue } = data;
21 |
22 | return [
23 | `🛠 **${actor.login}** reopened issue **#${
24 | issue.number
25 | }**: **${this.escape(issue.title)}**`,
26 | `<${issue.html_url}>`,
27 | ];
28 | }
29 | }
30 |
31 | module.exports = IssueReopened;
32 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issues-unassigned.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueUnassigned extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `Weeee! I got unassigned from an issue! Responsibility lifted.`,
7 | });
8 | }
9 | embed(data) {
10 | let issue = data.issue;
11 | let assigned = data.assignee;
12 | return {
13 | color: 'E9642D',
14 | title: `Unassigned ${assigned.login} from #${
15 | issue.number
16 | } (**${this.escape(issue.title)}**)`,
17 | url: issue.html_url,
18 | };
19 | }
20 | text(data) {
21 | let actor = data.sender;
22 | let issue = data.issue;
23 | let assigned = data.assignee;
24 | return [
25 | `🛠 **${actor.login}** unassigned ${
26 | actor.login === assigned.login ? 'themselves' : `**${assigned.login}**`
27 | } from **#${issue.number}** (${this.escape(issue.title)})`,
28 | `<${issue.html_url}>`,
29 | ];
30 | }
31 | }
32 |
33 | module.exports = IssueUnassigned;
34 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/issues-unlabeled.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class IssueUnabeled extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `An issue got unlabeled. Organization!`,
7 | });
8 | }
9 | embed(data) {
10 | let issue = data.issue;
11 | let label = data.label;
12 | return {
13 | color: 'E9642D',
14 | title: `Removed label **${this.escape(label.name)}** from #${
15 | issue.number
16 | } (**${this.escape(issue.title)}**)`,
17 | url: issue.html_url,
18 | };
19 | }
20 | text(data) {
21 | let actor = data.sender;
22 | let issue = data.issue;
23 | let label = data.label;
24 | return [
25 | `🛠 **${actor.login}** removed label **${this.escape(
26 | label.name
27 | )}** from **#${issue.number}** (**${this.escape(issue.title)}**)`,
28 | `<${issue.html_url}>`,
29 | ];
30 | }
31 | }
32 |
33 | module.exports = IssueUnabeled;
34 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/member-added.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class MemberAdded extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `This response is fired whenever a user is added to a repository`,
7 | });
8 | }
9 | embed(data) {
10 | const { member } = data;
11 |
12 | return {
13 | color: 'E9642D',
14 | title: `Added ${member.login} as a collaborator`,
15 | };
16 | }
17 | text(data) {
18 | const { member, sender } = data;
19 |
20 | return [
21 | `👨🔧 **${sender.login}** added ${sender.login} as a collaborator`,
22 | `<${member.html_url}>`,
23 | ];
24 | }
25 | }
26 |
27 | module.exports = MemberAdded;
28 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/member-deleted.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class MemberDeleted extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `This response is fired whenever a user is removed from a repository`,
7 | });
8 | }
9 | embed(data) {
10 | const { member } = data;
11 |
12 | return {
13 | color: 'E9642D',
14 | title: `Removed ${member.login} as a collaborator`,
15 | };
16 | }
17 | text(data) {
18 | const { member, sender } = data;
19 |
20 | return [
21 | `👨🔧 **${sender.login}** removed ${sender.login} as a collaborator`,
22 | `<${member.html_url}>`,
23 | ];
24 | }
25 | }
26 |
27 | module.exports = MemberDeleted;
28 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/meta.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | // This seems to only be sent on GH app creation now, so it may be useless.
4 | // Installations send `installation.created` and `installation.deleted` events.
5 | class Meta extends EventResponse {
6 | constructor(...args) {
7 | super(...args, {
8 | description: `Something happened relating to the webhook.`,
9 | });
10 | }
11 | embed(data) {
12 | const { action, hook } = data;
13 |
14 | return {
15 | color: 'Red',
16 | title: `${hook.type} webhook was ${action}.`,
17 | };
18 | }
19 | text(data) {
20 | const { action, hook } = data;
21 |
22 | return `🏓 ${hook.type} webhook was ${action}.`;
23 | }
24 | }
25 |
26 | module.exports = Meta;
27 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/page_build.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PageBuild extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired whenever GitHub Pages does something.',
8 | });
9 | }
10 | embed(data) {
11 | const { build } = data;
12 |
13 | return {
14 | color: '#e74c3c',
15 | title: `GitHub Pages failed to build your site`,
16 | description: build.error.message,
17 | };
18 | }
19 | text(data) {
20 | const { repository, build } = data;
21 |
22 | return [
23 | `📃 GitHub Pages failed to build your site.`,
24 | build.error.message,
25 | `<${repository.html_url}>`,
26 | ];
27 | }
28 |
29 | ignore(data) {
30 | return data.build.status !== 'errored';
31 | }
32 | }
33 |
34 | module.exports = PageBuild;
35 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/ping.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | // This seems to only be sent on GH app creation now, so it may be useless.
4 | // Installations send `installation.created` and `installation.deleted` events.
5 | class Ping extends EventResponse {
6 | constructor(...args) {
7 | super(...args, {
8 | description: `Ping, pong! Webhooks are ready!`,
9 | });
10 | }
11 | embed(data) {
12 | const { repository, organization, hook, zen } = data;
13 | const name = repository
14 | ? repository.full_name
15 | : organization
16 | ? organization.login
17 | : null;
18 | return {
19 | color: 'Red',
20 | title: `Ping, Pong! \`${name}\` Synced Successfully!`,
21 | description: `${zen}\nListening to the following events: ${hook.events
22 | .map((e) => `\`${e}\``)
23 | .join(`, `)}`,
24 | };
25 | }
26 | text(data) {
27 | return `🏓 Ping, pong! Listening to the following events: ${data.hook.events
28 | .map((e) => `\`${e}\``)
29 | .join(`, `)}`;
30 | }
31 | }
32 |
33 | module.exports = Ping;
34 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/public.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class Public extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired whenever a repo is open sourced \\o/',
8 | });
9 | }
10 |
11 | embed() {
12 | return {
13 | title: `Now open sourced! 🎉`,
14 | url: null,
15 | };
16 | }
17 |
18 | text(data) {
19 | return [`🎉 **${data.sender.login}** open sourced the repo!`].join('\n');
20 | }
21 | }
22 |
23 | module.exports = Public;
24 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request-assigned.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestAssigned extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired whenever a person is assigned to a pull request.',
8 | });
9 | }
10 | embed(data) {
11 | const { pull_request: pr, assignee } = data;
12 |
13 | return {
14 | color: '#149617',
15 | title: `Assigned ${assignee.login} to #${pr.number} (**${this.escape(
16 | pr.title
17 | )}**)`,
18 | url: pr.html_url,
19 | };
20 | }
21 | text(data) {
22 | const { sender: actor, pull_request: pr, assignee } = data;
23 | return [
24 | `⛽ **${actor.login}** assigned ${
25 | actor.login === assignee.login ? 'themselves' : `**${assignee.login}**`
26 | } to **#${pr.number}** (${this.escape(pr.title)})`,
27 | `<${pr.html_url}>`,
28 | ];
29 | }
30 | }
31 |
32 | module.exports = PullRequestAssigned;
33 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request-closed.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullrequestClosed extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `Weeee! An PR got closed!`,
7 | });
8 | }
9 | embed(data) {
10 | const pr = data.pull_request;
11 |
12 | return {
13 | color: '#149617',
14 | title: `${pr.merged ? 'Merged' : 'Closed'} pull request #${
15 | pr.number
16 | } (**${this.escape(pr.title)}**)`,
17 | url: pr.html_url,
18 | description: this.shorten(pr.body, 1000),
19 | };
20 | }
21 | text(data) {
22 | const actor = data.sender;
23 | const pr = data.pull_request;
24 |
25 | return [
26 | `⛽ **${actor.login}** ${
27 | pr.merged ? 'merged' : 'closed'
28 | } pull request **#${pr.number}** (_${this.escape(pr.title)}_)`,
29 | `<${pr.html_url}>`,
30 | ];
31 | }
32 | }
33 |
34 | module.exports = PullrequestClosed;
35 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request-edited.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestEdited extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `An issue got edited. Good or Bad?.`,
7 | });
8 | }
9 | embed(data) {
10 | const pr = data.pull_request;
11 | const change = Object.keys(data.changes)[0];
12 | const changed = {
13 | from: data.changes[change].from,
14 | to: data.changes[change].to || pr[change],
15 | };
16 |
17 | return {
18 | color: '#149617',
19 | title: `Updated ${change} of pull request #${pr.number} (**${this.escape(
20 | pr.title
21 | )}**)`,
22 | url: pr.html_url,
23 | };
24 | }
25 | text(data) {
26 | const actor = data.sender;
27 | const pr = data.pull_request;
28 | const change = Object.keys(data.changes)[0];
29 | return [
30 | `⛽ **${actor.login}** updated ${change} of pull request **#${
31 | pr.number
32 | }** (${this.escape(pr.title)})`,
33 | `<${pr.html_url}>`,
34 | ];
35 | }
36 | }
37 |
38 | module.exports = PullRequestEdited;
39 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request-labeled.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestLabeled extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `A pull request got labeled. Organization!`,
7 | });
8 | }
9 | embed(data) {
10 | const pr = data.pull_request;
11 | const label = data.label;
12 |
13 | return {
14 | color: '#149617',
15 | title: `Added label **${this.escape(label.name)}** to #${
16 | pr.number
17 | } (**${this.escape(pr.title)}**)`,
18 | url: pr.html_url,
19 | };
20 | }
21 | text(data) {
22 | const actor = data.sender;
23 | const pr = data.pull_request;
24 | const label = data.label;
25 |
26 | return [
27 | `⛽ **${actor.login}** added label **${this.escape(
28 | label.name
29 | )}** to issue **#${pr.number}** (${this.escape(pr.title)})`,
30 | `<${pr.html_url}>`,
31 | ];
32 | }
33 | }
34 |
35 | module.exports = PullRequestLabeled;
36 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request-opened.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestOpened extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `Uh, oh. A PR got opened!`,
7 | });
8 | }
9 | embed(data) {
10 | const pr = data.pull_request;
11 |
12 | return {
13 | color: '#149617',
14 | title: `Opened pull request #${pr.number} (**${this.escape(pr.title)}**)`,
15 | url: pr.html_url,
16 | description: this.shorten(pr.body, 1000),
17 | fields: [
18 | {
19 | name: 'Commits',
20 | value: String(pr.commits),
21 | inline: true,
22 | },
23 | {
24 | name: 'Changes',
25 | value: `\`+${pr.additions}\` \`-${pr.deletions}\` (${
26 | pr.changed_files
27 | } ${pr.changed_files > 1 ? 'files' : 'file'})`,
28 | inline: true,
29 | },
30 | ],
31 | };
32 | }
33 | text(data) {
34 | const actor = data.sender;
35 | const pr = data.pull_request;
36 | return [
37 | `⛽ **${actor.login}** opened pull request **#${
38 | pr.number
39 | }**: _${this.escape(pr.title)}_`,
40 | `<${pr.html_url}>`,
41 | ];
42 | }
43 | }
44 |
45 | module.exports = PullRequestOpened;
46 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request-reopened.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestReopened extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired whenever a pull request is reopened.',
8 | });
9 | }
10 | embed(data) {
11 | const pr = data.pull_request;
12 |
13 | return {
14 | color: '#149617',
15 | title: `Reopened pull request #${pr.number} (**${this.escape(
16 | pr.title
17 | )}**)`,
18 | url: pr.html_url,
19 | description: this.shorten(pr.body, 200),
20 | };
21 | }
22 | text(data) {
23 | const { sender: actor, pull_request: pr } = data;
24 |
25 | return [
26 | `⛽ **${actor.login}** reopened pull request **#${
27 | pr.number
28 | }**: _${this.escape(pr.title)}_`,
29 | `<${pr.html_url}>`,
30 | ];
31 | }
32 | }
33 |
34 | module.exports = PullRequestReopened;
35 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request-review_request_removed.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestReviewRequestRemoved extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired whenever a review request is removed from a pull request.',
8 | });
9 | }
10 | embed(data) {
11 | const { pull_request: pr, requested_reviewer, requested_team } = data;
12 | const reviewers = this.formatReviewer(requested_reviewer, requested_team);
13 |
14 | return {
15 | color: '#149617',
16 | title: `Removed a review request from ${reviewers} from #${pr.number}`,
17 | url: pr.html_url,
18 | };
19 | }
20 | text(data) {
21 | const {
22 | sender: actor,
23 | pull_request: pr,
24 | requested_reviewer,
25 | requested_team,
26 | } = data;
27 | const reviewers = this.formatReviewer(requested_reviewer, requested_team);
28 |
29 | return [
30 | `⛽ **${actor.login}** removed a review request from ${reviewers} from **#${pr.number}**`,
31 | `<${pr.html_url}>`,
32 | ];
33 | }
34 |
35 | formatReviewer(user, team) {
36 | if (user) return user.login;
37 | if (team) return team.slug;
38 |
39 | return '*Unknown*';
40 | }
41 | }
42 |
43 | module.exports = PullRequestReviewRequestRemoved;
44 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request-review_requested.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestReviewRequested extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired whenever a review is requested for a pull request.',
8 | });
9 | }
10 | embed(data) {
11 | const { pull_request: pr, requested_reviewer, requested_team } = data;
12 | const reviewers = this.formatReviewer(requested_reviewer, requested_team);
13 |
14 | return {
15 | color: '#149617',
16 | title: `Requested a review from ${reviewers} for #${pr.number}`,
17 | url: pr.html_url,
18 | };
19 | }
20 | text(data) {
21 | const {
22 | sender: actor,
23 | pull_request: pr,
24 | requested_reviewer,
25 | requested_team,
26 | } = data;
27 | const reviewers = this.formatReviewer(requested_reviewer, requested_team);
28 |
29 | return [
30 | `⛽ **${actor.login}** requested a review from ${reviewers} for **#${pr.number}**`,
31 | `<${pr.html_url}>`,
32 | ];
33 | }
34 |
35 | formatReviewer(user, team) {
36 | if (user) return user.login;
37 | if (team) return team.slug;
38 |
39 | return '*Unknown*';
40 | }
41 | }
42 |
43 | module.exports = PullRequestReviewRequested;
44 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request-unassigned.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestUnassigned extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `This response is fired whenever a person is unassigned from a pull request.`,
7 | });
8 | }
9 | embed(data) {
10 | const { pull_request: pr, assignee } = data;
11 |
12 | return {
13 | color: '#149617',
14 | title: `Unassigned ${assignee.login} from #${pr.number} (**${this.escape(
15 | pr.title
16 | )}**)`,
17 | url: pr.html_url,
18 | };
19 | }
20 | text(data) {
21 | const { sender: actor, pull_request: pr, assignee } = data;
22 |
23 | return [
24 | `⛽ **${actor.login}** unassigned ${
25 | actor.login === assignee.login ? 'themselves' : `**${assignee.login}**`
26 | } from **#${pr.number}** (${this.escape(pr.title)})`,
27 | `<${pr.html_url}>`,
28 | ];
29 | }
30 | }
31 |
32 | module.exports = PullRequestUnassigned;
33 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request-unlabeled.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestUnlabeled extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `A pull request got unlabeled. Organization!`,
7 | });
8 | }
9 | embed(data) {
10 | const pr = data.pull_request;
11 | const label = data.label;
12 |
13 | return {
14 | color: '#149617',
15 | title: `Removed label **${this.escape(label.name)}** from #${
16 | pr.number
17 | } (**${this.escape(pr.title)}**)`,
18 | url: pr.html_url,
19 | };
20 | }
21 | text(data) {
22 | const actor = data.sender;
23 | const pr = data.pull_request;
24 | const label = data.label;
25 |
26 | return [
27 | `⛽ **${actor.login}** removed label **${this.escape(
28 | label.name
29 | )}** from issue **#${pr.number}** (${this.escape(pr.title)})`,
30 | `<${pr.html_url}>`,
31 | ];
32 | }
33 | }
34 |
35 | module.exports = PullRequestUnlabeled;
36 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request_review-edited.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestReviewEdited extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `A pull request review got edited. This may fire when creating a review, in which case the payload is ignored.`,
7 | });
8 | }
9 | embed(data) {
10 | const pr = data.pull_request;
11 | const review = data.review;
12 | const changes = Object.keys(data.changes);
13 |
14 | return {
15 | color: '#C0E4C0',
16 | title: `Updated ${changes.join(', ')} of a pull request review in #${
17 | pr.number
18 | } (**${this.escape(pr.title)}**)`,
19 | url: review.html_url,
20 | };
21 | }
22 |
23 | text(data) {
24 | const actor = data.sender;
25 | const pr = data.pull_request;
26 | const review = data.review;
27 | const changes = Object.keys(data.changes);
28 |
29 | return [
30 | `⛽ **${actor.login}** updated ${changes.join(
31 | ', '
32 | )} of a pull request review in **#${pr.number}** (${this.escape(
33 | pr.title
34 | )})`,
35 | `<${review.html_url}>`,
36 | ];
37 | }
38 |
39 | ignore(data) {
40 | return !Object.keys(data.changes).length;
41 | }
42 | }
43 |
44 | module.exports = PullRequestReviewEdited;
45 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request_review-submitted.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestReviewSubmitted extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired when a review is submitted to a pull request',
8 | });
9 |
10 | this.colors = {
11 | approved: '#149617',
12 | commented: '#C0E4C0',
13 | };
14 | }
15 |
16 | embed(data) {
17 | const { review, pull_request: pr } = data;
18 |
19 | return {
20 | color: this.colors[review.state?.toLowerCase()] ?? this.colors.commented,
21 | title: `${this.getReviewState(review, true)} #${
22 | pr.number
23 | } (**${this.escape(pr.title)}**)`,
24 | description: this.shorten(review.body, 1000),
25 | url: review.html_url,
26 | };
27 | }
28 |
29 | text(data) {
30 | const { sender, review, pull_request: pr } = data;
31 |
32 | return [
33 | `☑ **${sender.login}** ${this.getReviewState(review)} **#${
34 | pr.number
35 | }** (${this.escape(pr.title)})`,
36 | `<${review.html_url}>`,
37 | ].join('\n');
38 | }
39 |
40 | getReviewState(review, capitalize = false) {
41 | const response = review.state.split('_').reverse().join(' ').toLowerCase();
42 |
43 | return capitalize ? this.capitalize(response) : response;
44 | }
45 | }
46 |
47 | module.exports = PullRequestReviewSubmitted;
48 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request_review_comment-created.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestReviewCommentCreated extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired when a review comment is created in a pull request',
8 | });
9 | }
10 |
11 | embed(data) {
12 | const { comment, pull_request: pr } = data;
13 |
14 | return {
15 | color: '#C0E4C0',
16 | title: `Commented on file \`${comment.path}\` for a review in #${
17 | pr.number
18 | } (**${this.escape(pr.title)}**)`,
19 | description: this.shorten(comment.body, 1000),
20 | url: comment.html_url,
21 | };
22 | }
23 |
24 | text(data) {
25 | const { sender, comment, pull_request: pr } = data;
26 |
27 | return [
28 | `**${sender.login}** commented on file \`${
29 | comment.path
30 | }\` for a review in **#${pr.number}** (${this.escape(pr.title)})`,
31 | `<${comment.html_url}>`,
32 | ].join('\n');
33 | }
34 | }
35 |
36 | module.exports = PullRequestReviewCommentCreated;
37 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/pull_request_review_thread-resolved.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class PullRequestReviewThreadResolved extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This response is fired when a review thread is resolved in a pull request',
8 | });
9 | }
10 |
11 | embed(data) {
12 | const { thread, pull_request: pr } = data;
13 | const url = thread.comments[0]?.html_url;
14 |
15 | return {
16 | color: '#C0E4C0',
17 | title: `Resolved a review thread in #${pr.number} (**${this.escape(
18 | pr.title
19 | )}**)`,
20 | url,
21 | };
22 | }
23 |
24 | text(data) {
25 | const { sender, thread, pull_request: pr } = data;
26 | const url = thread.comments[0]?.html_url;
27 |
28 | return [
29 | `**${sender.login}** resolved a review thread in **#${
30 | pr.number
31 | }** (${this.escape(pr.title)})`,
32 | `<${url}>`,
33 | ].join('\n');
34 | }
35 | }
36 |
37 | module.exports = PullRequestReviewThreadResolved;
38 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/push.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 | const { removeUrlEmbedding } = require('../../Util');
3 |
4 | class Push extends EventResponse {
5 | constructor(...args) {
6 | super(...args, {
7 | description: `This event is fired when someone pushes commits to a branch.`,
8 | });
9 | }
10 | embed(data) {
11 | const branch = data.ref?.split('/').slice(2).join('/') || 'unknown';
12 | const commits = data.commits || [];
13 | let pretext = commits.map((commit) => {
14 | let commitMessage = this.escape(commit.message.split('\n')[0]);
15 | let author =
16 | commit.author.username ||
17 | commit.committer.name ||
18 | commit.committer.username ||
19 | data.sender.login;
20 | let sha = commit.id.slice(0, 7);
21 | return `[\`${sha}\`](${commit.url}) ${this.shorten(
22 | commitMessage,
23 | 500
24 | )} [${author}]`;
25 | });
26 |
27 | for (let i = 0; i < pretext.length; i++) {
28 | if (pretext.slice(0, i + 1).join('\n').length > 2048) {
29 | pretext = pretext.slice(0, i || 1);
30 | break;
31 | }
32 | }
33 |
34 | let description = pretext.join('\n');
35 | return {
36 | color: '7289DA',
37 | title: `Pushed ${commits.length} ${
38 | commits.length !== 1 ? 'commits' : 'commit'
39 | } to \`${branch}\``,
40 | url: data.compare,
41 | description,
42 | };
43 | }
44 | text(data) {
45 | const actor = data.sender || {};
46 | const branch = data.ref ? data.ref.split('/')[2] : 'unknown';
47 | const commits = data.commits || [];
48 | const commitCount = data.commits ? data.commits.length : 'unknown';
49 | if (!commitCount) return '';
50 | let msg = `⚡ **${actor.login}** pushed ${commitCount} ${
51 | commitCount !== 1 ? 'commits' : 'commit'
52 | } to \`${branch}\``;
53 | let commitArr = commits.map((commit) => {
54 | let commitMessage = removeUrlEmbedding(
55 | commit.message.replace(/\n/g, '\n ')
56 | );
57 | return ` \`${commit.id.slice(0, 7)}\` ${commitMessage} [${
58 | commit.author.username ||
59 | commit.committer.name ||
60 | commit.committer.username ||
61 | data.sender.login
62 | }]`;
63 | });
64 | commitArr.length = data.commits.length > 5 ? 5 : commitArr.length;
65 | msg += `\n${commitArr.join('\n')}`;
66 | msg += `\n<${data.compare}>`;
67 |
68 | return msg;
69 | }
70 |
71 | ignore(data) {
72 | return !data.commits || !data.commits.length;
73 | }
74 | }
75 |
76 | module.exports = Push;
77 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/release-created.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | // Do not show description in the embed if it is a draft.
4 | class ReleaseCreated extends EventResponse {
5 | constructor(...args) {
6 | super(...args, {
7 | description:
8 | 'This response is fired whenever a release is created or drafted (not published!)',
9 | });
10 | }
11 |
12 | embed(data) {
13 | const {
14 | name,
15 | body,
16 | draft,
17 | prerelease,
18 | tag_name: tag,
19 | target_commitish: branch,
20 | html_url,
21 | } = data.release;
22 |
23 | return {
24 | color: `#f0c330`,
25 | title: `${draft ? 'Drafted' : 'Created'} ${
26 | prerelease ? 'pre-release' : 'release'
27 | } \`${tag || '-'}\` (${this.escape(name)}) from branch \`${branch}\``,
28 | description: draft ? '' : this.shorten(body, 1000),
29 | url: html_url,
30 | };
31 | }
32 |
33 | text(data) {
34 | const { sender, release } = data;
35 | const {
36 | login,
37 | prerelease,
38 | draft,
39 | name,
40 | tag_name: tag,
41 | target_commitish: branch,
42 | html_url,
43 | } = release;
44 |
45 | return [
46 | `📡 **${login}** ${draft ? 'drafted' : 'created'} ${
47 | prerelease ? 'pre-release' : 'release'
48 | } **${this.escape(name)}** (${tag || '-'}) on branch \`${branch}\``,
49 | `<${html_url}>`,
50 | ].join('\n');
51 | }
52 |
53 | // Do not send event if it has already been published. Published releases send ~3 events: created, published, and (pre)released.
54 | ignore(data) {
55 | return !!data?.release?.published_at;
56 | }
57 | }
58 |
59 | module.exports = ReleaseCreated;
60 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/release-published.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class Release extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: 'This response is fired whenever a release is published',
7 | });
8 | }
9 |
10 | embed(data) {
11 | const { release } = data;
12 | const {
13 | name,
14 | body,
15 | prerelease,
16 | tag_name: tag,
17 | target_commitish: branch,
18 | html_url,
19 | } = release;
20 |
21 | return {
22 | color: `#f0c330`,
23 | title: `Published ${
24 | prerelease ? 'pre-release' : 'release'
25 | } \`${tag}\` (${this.escape(name)}) from branch \`${branch}\``,
26 | description: this.shorten(body, 1000),
27 | url: html_url,
28 | };
29 | }
30 |
31 | text(data) {
32 | const { sender, release } = data;
33 | const {
34 | prerelease,
35 | name,
36 | tag_name: tag,
37 | target_commitish: branch,
38 | html_url,
39 | } = release;
40 |
41 | return [
42 | `📡 **${sender.login}** published ${
43 | prerelease ? 'pre-release' : 'release'
44 | } **${this.escape(name)}** (${tag}) on branch \`${branch}\``,
45 | `<${html_url}>`,
46 | ].join('\n');
47 | }
48 | }
49 |
50 | module.exports = Release;
51 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/repository-deleted.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class RepositoryDeleted extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `This response is fired whenever a repo is deleted (org only)`,
7 | });
8 | }
9 | embed() {
10 | return {
11 | title: 'Deleted repo',
12 | color: 'C23616',
13 | url: null,
14 | };
15 | }
16 | text(data) {
17 | return [
18 | `🗑 **${data.sender.login}** deleted repository \`${data.repository.full_name}\``,
19 | ];
20 | }
21 | }
22 |
23 | module.exports = RepositoryDeleted;
24 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/repository.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class Repository extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | "This response is fired whenever a repository's status is updated",
8 | });
9 | }
10 |
11 | embed(data) {
12 | return {
13 | color: `#972e26`,
14 | title: `${this.capitalize(data.action)} repo`,
15 | };
16 | }
17 |
18 | text(data) {
19 | return `💿 **${data.sender.login}** ${this.capitalize(
20 | data.action
21 | )} the repo`;
22 | }
23 | }
24 |
25 | module.exports = Repository;
26 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/star-created.js:
--------------------------------------------------------------------------------
1 | const EventResponse = require('../EventResponse');
2 |
3 | class StarCreated extends EventResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `This response is fired whenever a person stars a repo.`,
7 | });
8 | }
9 | embed() {
10 | return {
11 | title: 'Starred repo',
12 | };
13 | }
14 | text(data) {
15 | return `⭐ Starred by ${data.sender.login}`;
16 | }
17 | }
18 |
19 | module.exports = StarCreated;
20 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/star-deleted.js:
--------------------------------------------------------------------------------
1 | const EventIgnoreResponse = require('../EventIgnoreResponse');
2 |
3 | class StarDeleted extends EventIgnoreResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `Placeholder to ignore star/deleted events.`,
7 | });
8 | }
9 | }
10 |
11 | module.exports = StarDeleted;
12 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/status-failure.js:
--------------------------------------------------------------------------------
1 | const status = require('./status');
2 |
3 | class StatusFailure extends status {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This event gets fired when a status check fails on a commit',
8 | });
9 | }
10 |
11 | embed(data) {
12 | const embed = super.embed(data);
13 |
14 | embed.color = '#e74c3c';
15 |
16 | return embed;
17 | }
18 |
19 | ignore(data) {
20 | return data.description.includes('GitHub Pages');
21 | }
22 | }
23 |
24 | module.exports = StatusFailure;
25 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/status-success.js:
--------------------------------------------------------------------------------
1 | const status = require('./status');
2 |
3 | class StatusSuccess extends status {
4 | constructor(...args) {
5 | super(...args, {
6 | description:
7 | 'This event gets fired when a status check succeeds on a commit',
8 | });
9 | }
10 |
11 | embed(data) {
12 | const embed = super.embed(data);
13 |
14 | embed.color = '#c0e4c0';
15 |
16 | return embed;
17 | }
18 |
19 | ignore(data) {
20 | return data.description.includes('GitHub Pages');
21 | }
22 | }
23 |
24 | module.exports = StatusSuccess;
25 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/status.js:
--------------------------------------------------------------------------------
1 | const EventIgnoreResponse = require('../EventIgnoreResponse');
2 |
3 | class Status extends EventIgnoreResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: "Don't report pending or errored status checks.",
7 | });
8 | }
9 |
10 | // used by extending events
11 | embed(data) {
12 | const { context, description, sha, branches, target_url } = data;
13 | const branch = branches[0] ? `\`${branches[0].name}\`@` : '';
14 |
15 | return {
16 | color: null,
17 | title: `${this.escape(branch)}\`${sha.slice(0, 7)}\` — ${
18 | description || context
19 | }`,
20 | url: target_url,
21 | };
22 | }
23 |
24 | text(data) {
25 | const { sha, description, target_url: url } = data;
26 |
27 | return [
28 | `📝 Commit \`${sha.slice(0, 7)}\`'s test - **${description}** (_${
29 | data.context
30 | }_)`,
31 | url ? `<${url}>` : '',
32 | ].join('\n');
33 | }
34 | }
35 |
36 | module.exports = Status;
37 |
--------------------------------------------------------------------------------
/lib/GitHub/Events/watch.js:
--------------------------------------------------------------------------------
1 | const EventIgnoreResponse = require('../EventIgnoreResponse');
2 |
3 | class Watch extends EventIgnoreResponse {
4 | constructor(...args) {
5 | super(...args, {
6 | description: `Placeholder to ignore watch events - they are fired on stars too.`,
7 | });
8 | }
9 | }
10 |
11 | module.exports = Watch;
12 |
--------------------------------------------------------------------------------
/lib/GitHub/GitHubRepoParser.js:
--------------------------------------------------------------------------------
1 | let url = require('url');
2 |
3 | class GitHubRepoParser {
4 | constructor() {
5 | this.Parse = this.Parse.bind(this);
6 | }
7 |
8 | Parse(str) {
9 | if (typeof str !== 'string' || !str.length) return null;
10 | if (str.indexOf('git@gist') > -1 || str.indexOf('//gist') > -1) return null;
11 |
12 | let obj = url.parse(str);
13 | if (
14 | typeof obj.path !== 'string' ||
15 | !obj.path.length ||
16 | typeof obj.pathname !== 'string' ||
17 | !obj.pathname.length
18 | )
19 | return null;
20 |
21 | obj.path = this._trimSlash(obj.path);
22 | obj.pathname = this._trimSlash(obj.pathname);
23 |
24 | if (obj.path.indexOf('repos') === 0) obj.path = obj.path.slice(6);
25 |
26 | let seg = obj.path.split('/').filter(Boolean);
27 | let hasBlob = seg[2] === 'blob';
28 |
29 | if (hasBlob && !this._isChecksum(seg[3])) obj.branch = seg[3];
30 |
31 | let tree = str.indexOf('tree');
32 | if (tree > -1) obj.branch = str.slice(tree + 5);
33 |
34 | obj.owner = this._owner(seg[0]);
35 | obj.name = this._name(seg[1]);
36 |
37 | if (seg.length > 1 && obj.owner && obj.name) {
38 | obj.repo = `${obj.owner}/${obj.name}`;
39 | } else {
40 | let href = obj.href.split(':');
41 |
42 | if (href.length === 2 && obj.href.indexOf('//') === -1) {
43 | obj.repo = obj.repo || href[href.length - 1];
44 | let repoSegments = obj.repo.split('/');
45 | obj.owner = repoSegments[0];
46 | obj.name = repoSegments[1];
47 | } else {
48 | let match = obj.href.match(/\/([^\/]*)$/); // eslint-disable-line no-useless-escape
49 | obj.owner = match ? match[1] : null;
50 | obj.repo = null;
51 | }
52 |
53 | if (obj.repo && (!obj.owner || !obj.name)) {
54 | let segs = obj.repo.split('/');
55 | if (segs.length === 2) {
56 | obj.owner = segs[0];
57 | obj.name = segs[1];
58 | }
59 | }
60 | }
61 |
62 | obj.branch = obj.branch || seg[2] || this._getBranch(obj.path, obj);
63 |
64 | let res = {};
65 | res.host = obj.host || 'github.com';
66 | res.owner = obj.owner || null;
67 | res.name = obj.name || null;
68 | res.repo = obj.repo;
69 | res.repository = res.repo;
70 | res.branch = obj.branch;
71 | return res;
72 | }
73 |
74 | _isChecksum(str) {
75 | return /^[a-f0-9]{40}$/i.test(str);
76 | }
77 |
78 | _getBranch(str, obj) {
79 | var branch;
80 | var segs = str.split('#');
81 | if (segs.length !== 1) {
82 | branch = segs[segs.length - 1];
83 | }
84 | if (!branch && obj.hash && obj.hash.charAt(0) === '#') {
85 | branch = obj.hash.slice(1);
86 | }
87 | return branch || 'master';
88 | }
89 |
90 | _trimSlash(path) {
91 | return path.charAt(0) === '/' ? path.slice(1) : path;
92 | }
93 |
94 | _name(str) {
95 | return str ? str.replace(/^\W+|\.git$/g, '') : null;
96 | }
97 |
98 | _owner(str) {
99 | if (!str) return null;
100 | var idx = str.indexOf(':');
101 | if (idx > -1) {
102 | return str.slice(idx + 1);
103 | }
104 | return str;
105 | }
106 | }
107 |
108 | module.exports = new GitHubRepoParser();
109 |
--------------------------------------------------------------------------------
/lib/GitHub/index.js:
--------------------------------------------------------------------------------
1 | const { Octokit } = require('@octokit/rest');
2 | const {
3 | createOAuthAppAuth,
4 | createOAuthUserAuth,
5 | } = require('@octokit/auth-oauth-app');
6 | const pick = require('lodash/pick');
7 | const Constants = require('./Constants');
8 | const Log = require('../Util/Log');
9 | const redis = require('../Util/redis');
10 | const GitHubRepoParse = require('./GitHubRepoParser').Parse;
11 |
12 | /**
13 | * Methods to retrieve information from GitHub using package `github`
14 | * Made these so it's easier to remember. Plus autocomplete ;)
15 | */
16 | class GitHub {
17 | constructor() {
18 | this.Constants = Constants;
19 |
20 | Log.info(`GitHub | Logging in...`);
21 |
22 | const {
23 | GITHUB_CLIENT_ID: clientId,
24 | GITHUB_CLIENT_SECRET: clientSecret,
25 | GITHUB_TOKEN: token,
26 | } = process.env;
27 |
28 | if (clientId && clientSecret) {
29 | Log.info(`GitHub | OAuth app credentials provided`);
30 |
31 | this.appGh = new Octokit({
32 | authStrategy: createOAuthAppAuth,
33 | auth: {
34 | clientId,
35 | clientSecret,
36 | },
37 | });
38 | } else {
39 | Log.warn(
40 | `GitHub | No OAuth app credentials provided! /setup won't work.`
41 | );
42 | }
43 |
44 | if (token) {
45 | this.tokenAvailable = true;
46 | this.token = token;
47 |
48 | this.gh = new Octokit({
49 | auth: this.token,
50 | request: {
51 | timeout: 5000,
52 | },
53 | });
54 |
55 | Log.info(`GitHub | General token provided.`);
56 | } else {
57 | Log.warn(`GitHub | No token provided! Skipped login.`);
58 | }
59 | }
60 |
61 | userOAuthToken(code) {
62 | if (!this.appGh) throw new Error('No OAuth app credentials provided!');
63 |
64 | return this.appGh.auth({
65 | type: 'oauth-user',
66 | code,
67 | });
68 | }
69 |
70 | fromOAuthToken(token) {
71 | return new Octokit({
72 | authStrategy: createOAuthUserAuth,
73 | auth: {
74 | type: 'token',
75 | tokenType: 'oauth',
76 | clientType: 'oauth-app',
77 | clientId: process.env.GITHUB_CLIENT_ID,
78 | clientSecret: process.env.GITHUB_CLIENT_SECRET,
79 | token: token,
80 | scopes: [],
81 | },
82 | headers: {
83 | 'X-GitHub-Api-Version': '2022-11-28',
84 | },
85 | });
86 | }
87 |
88 | async userOAuthRepositories(token, setupId, bypassCache = false) {
89 | const octokit = this.fromOAuthToken(token);
90 |
91 | const cached =
92 | !bypassCache && (await redis.getHashKey('setup', setupId, 'repos'));
93 |
94 | if (cached) return JSON.parse(cached);
95 |
96 | // Actually retrieve from GitHub API
97 | const installations = await octokit.paginate('GET /user/installations');
98 | const repos = await Promise.all(
99 | installations.map((installation) =>
100 | octokit
101 | .paginate(`GET /user/installations/${installation.id}/repositories`)
102 | .then((repositories) => [
103 | pick(installation, [
104 | 'id',
105 | 'suspended_at',
106 | 'account.login',
107 | 'account.id',
108 | 'account.avatar_url',
109 | 'account.type',
110 | 'repository_selection',
111 | 'html_url',
112 | ]),
113 | repositories
114 | .filter((repo) => repo.permissions.admin)
115 | .map((repo) =>
116 | pick(repo, ['id', 'name', 'full_name', 'private', 'html_url'])
117 | ),
118 | ])
119 | .catch((err) => {
120 | Log.error(err);
121 | throw new Error(
122 | `Failed to get repositories for installation of ${installation.account.login} (${installation.id})`
123 | );
124 | })
125 | )
126 | );
127 |
128 | if (!cached) {
129 | await redis.setHash('setup', `${setupId}`, {
130 | repos: JSON.stringify(repos),
131 | });
132 | }
133 |
134 | return repos;
135 | }
136 |
137 | async getMeta() {
138 | if (!this._tokenAvailable()) return {};
139 |
140 | const res = await this.gh.meta.get();
141 |
142 | return res.data;
143 | }
144 |
145 | /**
146 | * Get GitHub repository information
147 | * @param {String} repository - Repo's full name or url
148 | * @return {Promise}
149 | */
150 | getRepo(repository) {
151 | if (!this._tokenAvailable()) return Promise.resolve({});
152 |
153 | const repo = GitHubRepoParse(repository);
154 |
155 | return this.gh.repos
156 | .get({
157 | owner: repo.owner,
158 | repo: repo.name,
159 | })
160 | .then((res) => res.data);
161 | }
162 |
163 | /**
164 | * Get GitHub issue from repository
165 | * @param {String} repository - repo's full name or url
166 | * @param {Number} issue - issue number
167 | * @return {Promise}
168 | */
169 | getRepoIssue(repository, issue) {
170 | if (!this._tokenAvailable()) return Promise.resolve({});
171 |
172 | const repo =
173 | typeof repository === 'object' ? repository : GitHubRepoParse(repository);
174 |
175 | return this.gh.issues
176 | .get({
177 | owner: repo.owner,
178 | repo: repo.name,
179 | issue_number: issue,
180 | })
181 | .then((res) => res.data);
182 | }
183 |
184 | /**
185 | * Get GitHub PR from repository
186 | * @param {String} repository - repo's full name or url
187 | * @param {Number} issue - PR number
188 | * @return {Promise}
189 | */
190 | getRepoPR(repository, issue) {
191 | if (!this._tokenAvailable()) return Promise.resolve({});
192 |
193 | const repo =
194 | typeof repository === 'object' ? repository : GitHubRepoParse(repository);
195 |
196 | return this.gh.pulls
197 | .get({
198 | owner: repo.owner,
199 | repo: repo.name,
200 | pull_number: issue,
201 | })
202 | .then((res) => res.data);
203 | }
204 |
205 | /**
206 | * Get user by username
207 | * @param {String} username - user username
208 | * @return {Promise}
209 | */
210 | getUserByUsername(username) {
211 | if (!this._tokenAvailable()) return Promise.resolve({});
212 |
213 | return this.gh.users
214 | .getByUsername({
215 | username,
216 | })
217 | .then((res) => res.data);
218 | }
219 |
220 | /**
221 | * Get organization
222 | * @param {String} org - org name
223 | * @return {Promise}
224 | */
225 | getOrg(org) {
226 | if (!this._tokenAvailable()) return Promise.resolve({});
227 |
228 | return this.gh.orgs
229 | .get({
230 | org,
231 | })
232 | .then((res) => res.data);
233 | }
234 |
235 | /**
236 | * Get public repos in organization
237 | * @param {String} org - org name
238 | * @return {Promise}
239 | */
240 | getOrgRepos(org) {
241 | if (!this._tokenAvailable()) return Promise.reject();
242 |
243 | return this.gh.repos
244 | .listForOrg({
245 | org,
246 | })
247 | .then((res) => res.data);
248 | }
249 |
250 | /**
251 | * Get organization members
252 | * @param {String} org - org name
253 | * @return {Promise}
254 | */
255 | getOrgMembers(org) {
256 | if (!this._tokenAvailable()) return Promise.resolve([]);
257 |
258 | return this.gh.orgs
259 | .listMembers({
260 | org,
261 | page: 1,
262 | })
263 | .then((res) => res.data);
264 | }
265 |
266 | /**
267 | * Search GitHub
268 | * @param {String} type - what to search the query for, i.e. repositories
269 | * @param {Object|String} data
270 | * @return {Promise}
271 | */
272 | search(type, data) {
273 | if (!this._tokenAvailable()) return Promise.resolve({});
274 |
275 | return this.gh.search[type](
276 | typeof data === 'string'
277 | ? {
278 | q: data,
279 | }
280 | : data
281 | ).then((res) => res.data);
282 | }
283 |
284 | /**
285 | * Get response error object
286 | * @param {Error} err GitHub error
287 | * @return {Object}
288 | */
289 | getGitHubError(err) {
290 | return JSON.parse(
291 | err.message && err.message.startsWith('{') ? err.message : err
292 | );
293 | }
294 |
295 | /**
296 | * Detect if response error is github error
297 | * @param {Error} err
298 | * @return {Boolean}
299 | */
300 | isGitHubError(err) {
301 | return (
302 | err &&
303 | err.headers &&
304 | err.headers.server &&
305 | err.headers.server === this.Constants.HOST
306 | );
307 | }
308 |
309 | _tokenAvailable() {
310 | if (!this.tokenAvailable) {
311 | Log.warn(`GitHub | Returning sample github data`);
312 | return false;
313 | }
314 | return true;
315 | }
316 | }
317 |
318 | module.exports = new GitHub();
319 |
--------------------------------------------------------------------------------
/lib/Models/Channel.js:
--------------------------------------------------------------------------------
1 | const crypto = require('crypto');
2 | const bookshelf = require('.');
3 |
4 | require('./Guild');
5 |
6 | const Channel = bookshelf.Model.extend(
7 | {
8 | tableName: 'channels',
9 |
10 | casts: {
11 | useEmbed: 'boolean',
12 | ignoreUnknown: 'boolean',
13 |
14 | eventsList: 'array',
15 | usersList: 'array',
16 | branchesList: 'array',
17 | reposList: 'array',
18 | },
19 |
20 | guild() {
21 | return this.belongsTo('Guild');
22 | },
23 |
24 | connections() {
25 | return this.hasMany('ChannelConnection');
26 | },
27 |
28 | /**
29 | * @deprecated LEGACY
30 | */
31 | repos() {
32 | return this.hasMany('LegacyChannelRepo');
33 | },
34 |
35 | /**
36 | * @deprecated LEGACY
37 | */
38 | orgs() {
39 | return this.hasMany('LegacyChannelOrg');
40 | },
41 |
42 | getConnections() {
43 | return this.related('connections').pluck('githubName');
44 | },
45 |
46 | getLegacyConnections() {
47 | return [
48 | ...this.related('orgs').pluck('name'),
49 | ...this.related('repos').pluck('name'),
50 | ];
51 | },
52 |
53 | async getSecret() {
54 | const secret = this.get('secret');
55 |
56 | if (secret) return secret;
57 |
58 | const newSecret = crypto.randomBytes(32).toString('hex');
59 |
60 | await this.save({
61 | secret: newSecret,
62 | });
63 |
64 | return newSecret;
65 | },
66 | },
67 | {
68 | validKeys: ['repo', 'useEmbed', 'ignoreUnknown', 'secret'],
69 |
70 | async create(channel) {
71 | if (!channel?.guild?.id) return Promise.resolve();
72 |
73 | const Guild = bookshelf.model('Guild'); // regular import doesn't work due to circular deps
74 |
75 | // prevent failing foreign key
76 | if (!(await Guild.find(channel.guild.id)))
77 | await Guild.create(channel.guild);
78 |
79 | Log.info(`DB | Channels + <#${channel.id}>`);
80 |
81 | return this.forge({
82 | id: channel.id,
83 |
84 | guildId: channel.guild.id,
85 | }).save(null, {
86 | method: 'insert',
87 | });
88 | },
89 |
90 | /**
91 | * Delete channel
92 | * @param {external:Channel} channel
93 | * @param {boolean} [fail]
94 | */
95 | delete(channel, fail = true) {
96 | Log.info(`DB | Channels - <#${channel.id}>`);
97 |
98 | return this.forge({
99 | id: channel.id,
100 | }).destroy({
101 | require: fail,
102 | });
103 | },
104 |
105 | findByRepo(repo) {
106 | const r = repo?.toLowerCase();
107 |
108 | return this.query((qb) =>
109 | qb
110 | .join('channel_repos', 'channel_repos.channel_id', 'channels.id')
111 | .where('channel_repos.name', 'LIKE', r)
112 | ).fetchAll();
113 | },
114 |
115 | findByOrg(org) {
116 | const r = org?.toLowerCase();
117 |
118 | return this.query((qb) =>
119 | qb
120 | .join('channel_orgs', 'channel_orgs.id', 'channels.id')
121 | .where('channel_orgs.name', 'LIKE', r)
122 | ).fetchAll();
123 | },
124 |
125 | findByChannel(channel) {
126 | return this.forge()
127 | .where('id', channel?.id || channel)
128 | .fetch();
129 | },
130 |
131 | addRepoToChannel(channel, repo) {
132 | return this.findByChannel(channel)
133 | .then((ch) => ch.addRepo(repo))
134 | .catch(Channel.NotFoundError, () => null);
135 | },
136 | }
137 | );
138 |
139 | module.exports = bookshelf.model('Channel', Channel);
140 |
--------------------------------------------------------------------------------
/lib/Models/ChannelConnection.js:
--------------------------------------------------------------------------------
1 | const bookshelf = require('.');
2 |
3 | require('./Channel');
4 |
5 | const ChannelConnection = bookshelf.Model.extend({
6 | tableName: 'channel_connections',
7 |
8 | channel() {
9 | return this.belongsTo('Channel');
10 | },
11 | });
12 |
13 | module.exports = bookshelf.model('ChannelConnection', ChannelConnection);
14 |
--------------------------------------------------------------------------------
/lib/Models/Guild.js:
--------------------------------------------------------------------------------
1 | const bookshelf = require('.');
2 |
3 | require('./Channel');
4 |
5 | const Guild = bookshelf.Model.extend(
6 | {
7 | tableName: 'guilds',
8 |
9 | channels() {
10 | return this.belongsTo('Channel');
11 | },
12 | },
13 | {
14 | validKeys: ['repo'],
15 |
16 | create(guild) {
17 | Log.info(`DB | Guilds + ${guild.id}`);
18 |
19 | return this.forge({
20 | id: guild.id,
21 | }).save(null, {
22 | method: 'insert',
23 | });
24 | },
25 |
26 | /**
27 | * Delete guild
28 | * @param {external:Guild} guild
29 | * @param {boolean} [fail]
30 | */
31 | delete(guild, fail = true) {
32 | Log.info(`DB | Guilds - ${guild.id}`);
33 |
34 | return this.forge({
35 | id: guild.id,
36 | }).destroy({
37 | require: fail,
38 | });
39 | },
40 | }
41 | );
42 |
43 | module.exports = bookshelf.model('Guild', Guild);
44 |
--------------------------------------------------------------------------------
/lib/Models/LegacyChannelOrg.js:
--------------------------------------------------------------------------------
1 | const bookshelf = require('.');
2 |
3 | require('./Channel');
4 |
5 | const LegacyChannelOrg = bookshelf.Model.extend({
6 | tableName: 'channel_orgs',
7 |
8 | channel() {
9 | return this.belongsTo('Channel');
10 | },
11 | });
12 |
13 | module.exports = bookshelf.model('LegacyChannelOrg', LegacyChannelOrg);
14 |
--------------------------------------------------------------------------------
/lib/Models/LegacyChannelRepo.js:
--------------------------------------------------------------------------------
1 | const bookshelf = require('.');
2 |
3 | require('./Channel');
4 |
5 | const LegacyChannelRepo = bookshelf.Model.extend({
6 | tableName: 'channel_repos',
7 |
8 | channel() {
9 | return this.belongsTo('Channel');
10 | },
11 | });
12 |
13 | module.exports = bookshelf.model('LegacyChannelRepo', LegacyChannelRepo);
14 |
--------------------------------------------------------------------------------
/lib/Models/ServerConfig.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose');
2 | const Collection = require('discord.js').Collection;
3 | const Schema = mongoose.Schema;
4 |
5 | /**
6 | * The server config Schema
7 | * @typedef {Object} ServerConfigSchema
8 | * @property {String} guildName Guild Name
9 | * @property {String} guildID Guild ID
10 | */
11 | const serverConfigSchema = Schema({
12 | guildName: String,
13 | guildID: String,
14 | });
15 |
16 | const serverConfig = mongoose.model('ServerConfig', serverConfigSchema);
17 |
18 | /**
19 | * A Channel Config Item
20 | */
21 | class ServerConfigItem {
22 | constructor(client, config) {
23 | /**
24 | * The bot client
25 | * @type Client
26 | * @private
27 | */
28 | this._client = client;
29 | for (let key in config) {
30 | if (config.hasOwnProperty(key)) {
31 | this[key] = config[key];
32 | }
33 | }
34 | }
35 | /**
36 | * Set a specific config property to a value for this config item
37 | * @param {String} prop Property to modify
38 | * @param {String} value The new value for the property
39 | * @see ServerConfig#set
40 | * @return {Promise}
41 | */
42 | set(prop, value) {
43 | return this._client.set(this.guildID, prop, value);
44 | }
45 | /**
46 | * Delete guild config
47 | * @see ServerConfig#delete
48 | * @return {Promise}
49 | */
50 | delete() {
51 | return this._client.delete(this.guildID);
52 | }
53 | }
54 |
55 | /**
56 | * The Channel Config manager
57 | */
58 | class ServerConfig {
59 | constructor() {
60 | /**
61 | * All the config
62 | * @type {Collection}
63 | * @private
64 | */
65 | this._data = new Collection();
66 | this.setup();
67 | this.validKeys = [];
68 | this.setupEvents = false;
69 | this.loaded = false;
70 | }
71 | /**
72 | * Get config from database and add to this._data
73 | */
74 | setup() {
75 | serverConfig.find({}).then((configs) => {
76 | this.loaded = true;
77 | configs.forEach((row) => {
78 | if (!row.guildID) return;
79 | this._data.set(row.guildID, new ServerConfigItem(this, row._doc));
80 | });
81 | });
82 | }
83 | /**
84 | * Initialize configuration and Discord bot events
85 | * @param {external:Client} bot
86 | */
87 | init(bot) {
88 | if (!this.loaded) return setTimeout(() => this.init(bot), 5000);
89 | for (const [, g] of bot.guilds) {
90 | const guild = g;
91 | if (!guild) continue;
92 | if (!this.has(guild.id)) {
93 | Log.info(`ServerConf | Adding "${guild.name}"`);
94 | this.add(guild).catch((e) => bot.emit('error', e));
95 | }
96 | }
97 | if (this.setupEvents) return;
98 | this.setupEvents = true;
99 | bot.on('guildDelete', (guild) => {
100 | if (!guild || !guild.available) return;
101 | Log.info(`ServerConf | Deleting "${guild.name}"`);
102 | this.delete(guild.id).catch((e) => bot.emit('error', e));
103 | });
104 | bot.on('guildCreate', (guild) => {
105 | if (!guild || !guild.available) return;
106 | let g = this.get(guild.id);
107 | if (g) return;
108 | Log.info(`ServerConf | Adding "${guild.name}"`);
109 | this.add(guild).catch((e) => bot.emit('error', e));
110 | });
111 | }
112 |
113 | /**
114 | * Delete guild config
115 | * @param {Guild|String} guildID Guild config to delete
116 | * @return {Promise}
117 | */
118 | delete(guildID) {
119 | if (guildID.id) guildID = guildID.id;
120 | return serverConfig
121 | .findOneAndRemove({
122 | guildID,
123 | })
124 | .then(() => {
125 | let oldData = this._data;
126 | let newData = oldData.filter((e) => e.guildID !== guildID);
127 | this._data = newData;
128 | return Promise.resolve(this);
129 | });
130 | }
131 |
132 | /**
133 | * Add channel to config
134 | * @param {Guild} guildID Guild to add config of
135 | * @return {Promise}
136 | */
137 | add(guild) {
138 | if (!guild || !guild.id) return Promise.reject(`No guild passed!`);
139 | if (this.has(guild.id))
140 | return Promise.reject(`Guild already has an entry in database`);
141 | let conf = {
142 | guildID: guild.id,
143 | guildName: guild.name,
144 | };
145 | return serverConfig.create(conf).then(() => {
146 | this._data.set(conf.guildID, new ServerConfigItem(this, conf));
147 | return Promise.resolve(this);
148 | });
149 | }
150 |
151 | /**
152 | * Replace specific guild config prop with value
153 | * @param {Guild|String} guildID Guild id to change config of
154 | * @param {String} prop Property to set
155 | * @param {String} value Value to set property to
156 | * @return {Promise} updated config item
157 | */
158 | set(guildID, prop, value) {
159 | return new Promise((resolve, reject) => {
160 | if (guildID.id) guildID = guildID.id;
161 | let oldConfig = this._data.find((e) => e.guildID === guildID);
162 | let newConfig = oldConfig;
163 | newConfig[prop] = value;
164 | serverConfig.findOneAndUpdate(
165 | {
166 | guildID,
167 | },
168 | newConfig,
169 | {
170 | new: true,
171 | },
172 | (err) => {
173 | if (err) return reject(err);
174 | this._data.set(
175 | newConfig.channel,
176 | new ServerConfigItem(this, newConfig)
177 | );
178 | resolve(this);
179 | }
180 | );
181 | });
182 | }
183 |
184 | /**
185 | * Get guild conf
186 | * @param {Guild|String} guildID Guild id to change config of
187 | * @return {ServerConfigItem} updated config item
188 | */
189 | get(guildID) {
190 | return this._data.get(guildID);
191 | }
192 |
193 | /**
194 | * Has guild conf
195 | * @param {Guild|String} guildID Guild id to check if has config
196 | * @return {Boolean}
197 | */
198 | has(guildID) {
199 | if (guildID.id) guildID = guildID.id;
200 | return this._data.has(guildID);
201 | }
202 | }
203 |
204 | module.exports = new ServerConfig();
205 |
--------------------------------------------------------------------------------
/lib/Models/index.js:
--------------------------------------------------------------------------------
1 | const knex = require('knex')(require('../../knexfile'));
2 |
3 | const bookshelf = require('bookshelf')(knex);
4 |
5 | bookshelf.plugin('bookshelf-case-converter-plugin');
6 | bookshelf.plugin([__dirname + '/plugin.js']);
7 |
8 | module.exports = bookshelf;
9 |
--------------------------------------------------------------------------------
/lib/Models/initialization.js:
--------------------------------------------------------------------------------
1 | const Guild = require('./Guild');
2 | const Channel = require('./Channel');
3 |
4 | const loaded = { guilds: false, channels: false };
5 |
6 | module.exports = async (bot) => {
7 | if (!loaded.guilds) {
8 | loaded.guilds = true;
9 |
10 | bot.on('guildDelete', async (guild) => {
11 | if (!guild || !guild.available) return;
12 |
13 | await Guild.delete(guild, false);
14 | });
15 | }
16 |
17 | if (!loaded.channels) {
18 | loaded.channels = true;
19 |
20 | bot.on('channelDelete', async (channel) => {
21 | if (!channel || channel.type !== 'text') return;
22 |
23 | await Channel.delete(channel, false);
24 | });
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/lib/Models/plugin.js:
--------------------------------------------------------------------------------
1 | const _ = require('lodash');
2 |
3 | module.exports = (bookshelf) =>
4 | (bookshelf.Model = bookshelf.Model.extend(
5 | {
6 | parse: function (attrs) {
7 | const clone = _.mapKeys(attrs, function (value, key) {
8 | return _.camelCase(key);
9 | });
10 |
11 | if (this.casts)
12 | Object.keys(this.casts).forEach((key) => {
13 | const type = this.casts[key];
14 | const val = clone[key];
15 |
16 | if (type === 'boolean' && val !== undefined) {
17 | clone[key] = !(val === 'false' || val == 0);
18 | }
19 |
20 | if (type === 'array') {
21 | try {
22 | clone[key] = JSON.parse(val) || [];
23 | } catch (err) {
24 | clone[key] = [];
25 | }
26 | }
27 | });
28 |
29 | return clone;
30 | },
31 | format: function (attrs) {
32 | const clone = attrs;
33 |
34 | if (this.casts)
35 | Object.keys(this.casts).forEach((key) => {
36 | const type = this.casts[key];
37 | const val = clone[key];
38 |
39 | if (type === 'boolean' && val !== undefined) {
40 | clone[key] = Number(val === true || val === 'true');
41 | }
42 |
43 | if (type === 'array' && val) {
44 | clone[key] = JSON.stringify(val);
45 | }
46 | });
47 |
48 | return _.mapKeys(attrs, function (value, key) {
49 | return _.snakeCase(key);
50 | });
51 | },
52 | },
53 | {
54 | find(id, withRelated = []) {
55 | const model = this.forge({
56 | id,
57 | });
58 |
59 | Log.addBreadcrumb({
60 | category: 'db.find',
61 | message: `${model.tableName} #${id} ${
62 | withRelated?.length ? `+ ${withRelated.join(', ')}` : ``
63 | }`,
64 | level: 'debug',
65 | });
66 |
67 | return model.fetch({
68 | withRelated,
69 | require: false,
70 | });
71 | },
72 |
73 | async findOrCreate(object, withRelated = []) {
74 | const model = await this.find(object.id, withRelated);
75 |
76 | if (!model) {
77 | return await this.create(object);
78 | }
79 |
80 | return model;
81 | },
82 | }
83 | ));
84 |
--------------------------------------------------------------------------------
/lib/Util/Log.js:
--------------------------------------------------------------------------------
1 | const winston = require('winston');
2 | const path = require('path');
3 | const util = require('util');
4 |
5 | const { MESSAGE } = require('triple-beam');
6 | const jsonStringify = require('fast-safe-stringify');
7 | const cleanStack = require('clean-stack');
8 | const PrettyError = require('pretty-error');
9 | const pe = new PrettyError();
10 |
11 | const Sentry = require('@sentry/node');
12 |
13 | pe.alias(process.cwd(), '.');
14 | pe.skipPackage('discord.js', 'ws');
15 |
16 | pe.appendStyle({
17 | 'pretty-error > trace > item': {
18 | marginBottom: 0,
19 | },
20 | });
21 |
22 | const simple = winston.format((info) => {
23 | const stringifiedRest = jsonStringify(
24 | Object.assign({}, info, {
25 | level: undefined,
26 | message: undefined,
27 | splat: undefined,
28 | timestamp: undefined,
29 | })
30 | );
31 |
32 | const padding = (info.padding && info.padding[info.level]) || '';
33 | if (stringifiedRest !== '{}') {
34 | info[MESSAGE] =
35 | `${info.timestamp} ${info.level}:${padding} ${info.message} ${stringifiedRest}`;
36 | } else {
37 | info[MESSAGE] =
38 | `${info.timestamp} ${info.level}:${padding} ${info.message}`;
39 | }
40 |
41 | return info;
42 | });
43 |
44 | class Log {
45 | constructor() {
46 | this._colors = {
47 | error: 'red',
48 | warn: 'yellow',
49 | info: 'cyan',
50 | debug: 'green',
51 | message: 'white',
52 | verbose: 'grey',
53 | };
54 | this._path = new RegExp(path.resolve(__dirname, '../../'), 'g');
55 | this.logger = winston.createLogger({
56 | level: process.env.LOG_LEVEL,
57 | levels: {
58 | error: 0,
59 | warn: 1,
60 | info: 2,
61 | message: 3,
62 | verbose: 4,
63 | debug: 5,
64 | silly: 6,
65 | },
66 | format: winston.format.combine(
67 | winston.format.colorize(),
68 | winston.format.timestamp({
69 | format: 'MM/D/YY HH:mm:ss',
70 | }),
71 | winston.format.prettyPrint(),
72 | winston.format.align()
73 | ),
74 | transports: [
75 | new winston.transports.Console({
76 | level: process.env.LOG_LEVEL || 'info',
77 | format: simple(),
78 | handleExceptions: true,
79 | }),
80 | ],
81 | exitOnError: false,
82 | });
83 |
84 | winston.addColors(this._colors);
85 |
86 | this.sentry = !!process.env.SENTRY;
87 |
88 | this.error = this.error.bind(this);
89 | this.warn = this.warn.bind(this);
90 | this.info = this.info.bind(this);
91 | this.message = this.message.bind(this);
92 | this.verbose = this.verbose.bind(this);
93 | this.debug = this.debug.bind(this);
94 | this.silly = this.silly.bind(this);
95 |
96 | this._token = process.env.DISCORD_TOKEN;
97 | this._tokenRegEx = new RegExp(this._token, 'g');
98 | }
99 |
100 | error(error, ...args) {
101 | if (!error) return;
102 |
103 | if (this.sentry && !error.sentry) {
104 | let eventId;
105 |
106 | if (error instanceof Error) {
107 | eventId = Sentry.captureException(error);
108 | } else {
109 | eventId = Sentry.captureMessage(
110 | typeof error === 'object' ? util.inspect(error) : error
111 | );
112 | }
113 |
114 | if (typeof error === 'object') error.sentry = eventId;
115 | }
116 |
117 | if (error.name == 'DiscordAPIError') delete error.stack;
118 |
119 | if (error.stack) error.stack = cleanStack(error.stack);
120 |
121 | if (error instanceof Error) error = pe.render(error);
122 |
123 | this.logger.error(error, ...args);
124 | return this;
125 | }
126 |
127 | warn(warn, ...args) {
128 | this.logger.warn(warn, ...args);
129 | if (this.sentry) {
130 | if (typeof warn === 'object') {
131 | Sentry.captureException(warn, { level: 'warning' });
132 | } else {
133 | Sentry.captureMessage(warn, { level: 'warning' });
134 | }
135 | }
136 | return this;
137 | }
138 |
139 | info(...args) {
140 | this.logger.info(...args);
141 | return this;
142 | }
143 |
144 | message(msg) {
145 | let author = msg.author;
146 | let channel = msg.channel.guild
147 | ? `#${msg.channel.name}`
148 | : `${author.username}#${author.discriminator}`;
149 | let server = msg.channel.guild ? msg.channel.guild.name : `Private Message`;
150 | let message = `${server} > ${channel} > @${author.username}#${author.discriminator} : ${msg.content}`;
151 |
152 | this.logger.message(message);
153 | return this;
154 | }
155 |
156 | verbose(...args) {
157 | this.logger.verbose(...args);
158 | return this;
159 | }
160 |
161 | debug(arg, ...args) {
162 | if (typeof arg === 'object') arg = util.inspect(arg, { depth: 0 });
163 | this.logger.debug(arg, ...args);
164 | return this;
165 | }
166 |
167 | silly(...args) {
168 | this.logger.silly(...args);
169 | return this;
170 | }
171 |
172 | addBreadcrumb(data) {
173 | if (!Sentry) return;
174 |
175 | return Sentry.addBreadcrumb(data);
176 | }
177 |
178 | configureExpressInit(app) {
179 | if (this.sentry) {
180 | Sentry.setupExpressErrorHandler(app);
181 |
182 | this.info('Sentry | Express initialized');
183 | }
184 | }
185 | }
186 |
187 | module.exports = new Log();
188 |
--------------------------------------------------------------------------------
/lib/Util/MergeDefault.js:
--------------------------------------------------------------------------------
1 | module.exports = function merge(def, given) {
2 | if (!given) return def;
3 | for (const key in def) {
4 | if (!{}.hasOwnProperty.call(given, key)) {
5 | given[key] = def[key];
6 | } else if (given[key] === Object(given[key])) {
7 | given[key] = merge(def[key], given[key]);
8 | }
9 | }
10 |
11 | return given;
12 | };
13 |
--------------------------------------------------------------------------------
/lib/Util/YappyGitHub.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const git = require('git-rev-sync');
3 |
4 | class YappyGitHub {
5 | constructor() {
6 | this.directories = {
7 | root: path.resolve(__dirname, '../../'),
8 | Discord: path.resolve(__dirname, '../Discord'),
9 | DiscordCommands: path.resolve(__dirname, '../Discord/Commands'),
10 | GitHub: path.resolve(__dirname, '../GitHub'),
11 | Models: path.resolve(__dirname, '../Models'),
12 | Util: __dirname,
13 | };
14 | this.git = {
15 | release: git.long(),
16 | };
17 | }
18 | }
19 |
20 | module.exports = new YappyGitHub();
21 |
--------------------------------------------------------------------------------
/lib/Util/cache.js:
--------------------------------------------------------------------------------
1 | const pick = require('lodash/pick');
2 | const bot = require('../Discord');
3 | const { LRUCache } = require('lru-cache');
4 | const { Guild } = require('discord.js');
5 |
6 | const cache = new LRUCache({ max: 500, ttl: 1000 * 60 * 60 * 12 });
7 |
8 | const expireChannel = (id) => cache.delete(id);
9 |
10 | const fetchChannel = async (id) => {
11 | // Snowflakes are at least 17 digits
12 | if (!id || id.length < 17 || !/^\d+$/.test(id)) return null;
13 |
14 | if (cache.has(id)) {
15 | return cache.get(id);
16 | }
17 |
18 | let channel;
19 |
20 | try {
21 | channel =
22 | bot.channels.cache.get(id) ||
23 | (await bot.channels.fetch(id, { allowUnknownGuild: true }));
24 | } catch (err) {
25 | cache.set(id, null, { ttl: 1000 * 60 * 60 * 1 });
26 |
27 | return null;
28 | }
29 |
30 | const reduced = pick(channel, [
31 | 'guild.id',
32 | 'guild.ownerId',
33 | 'guild.name',
34 | 'guild.icon',
35 | 'id',
36 | 'name',
37 | 'type',
38 | ]);
39 |
40 | cache.set(id, reduced);
41 |
42 | return reduced;
43 | };
44 |
45 | const resolveChannel = async (id) => {
46 | const channel = id.id ? id : await fetchChannel(id);
47 |
48 | if (!channel) return null;
49 |
50 | // Essentially just calls `createChannel` internal util
51 | // and doesn't use the internal djs cache (as intended)
52 | return bot.channels._add(channel, resolveGuild(channel.guild), {
53 | cache: false,
54 | });
55 | };
56 |
57 | const resolveGuild = (guild) => {
58 | if (!guild?.id) return null;
59 |
60 | guild.owner_id ??= guild.ownerId;
61 |
62 | return new Guild(bot, guild);
63 | };
64 |
65 | module.exports = {
66 | channels: {
67 | fetch: fetchChannel,
68 | expire: expireChannel,
69 | resolve: resolveChannel,
70 | },
71 | };
72 |
--------------------------------------------------------------------------------
/lib/Util/filter.js:
--------------------------------------------------------------------------------
1 | const isFound = (data, item) =>
2 | data.includes(item) || data.includes(item.split('/')[0]);
3 |
4 | module.exports = {
5 | whitelist: (data) => (item) => isFound(data || [], item || ''),
6 | blacklist: (data) => (item) => !isFound(data || [], item || ''),
7 | };
8 |
--------------------------------------------------------------------------------
/lib/Util/index.js:
--------------------------------------------------------------------------------
1 | const MergeDefault = require('./MergeDefault');
2 |
3 | /**
4 | * Some utilities :)
5 | */
6 | class Util {
7 | constructor() {
8 | this.urlRegEx =
9 | /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g;
10 | this.removeUrlEmbedding = this.removeUrlEmbedding.bind(this);
11 | }
12 | /**
13 | * Merge an object with a defaults object
14 | * @param {Object} def Default
15 | * @param {Object} given Object given to merge with default
16 | * @return {Object} Merged object
17 | */
18 | MergeDefault(...args) {
19 | return MergeDefault(...args);
20 | }
21 |
22 | /**
23 | * Remove url embedding
24 | * @param {String} text
25 | * @return {String}
26 | */
27 | removeUrlEmbedding(text) {
28 | return text.replace(this.urlRegEx, (url) => `<${url}>`);
29 | }
30 | }
31 |
32 | module.exports = new Util();
33 |
--------------------------------------------------------------------------------
/lib/Util/markdown.js:
--------------------------------------------------------------------------------
1 | const showdown = require('showdown');
2 | const TurndownService = require('turndown');
3 |
4 | const ghConverter = new showdown.Converter();
5 |
6 | ghConverter.setFlavor('github');
7 | ghConverter.setOption('tasklists', false);
8 | ghConverter.setOption('tables', false);
9 |
10 | ghConverter.addExtension(() => ({
11 | type: 'output',
12 | regex: /\[(x|\s*?)]/gm,
13 | replace: (match, p1) => ` ${p1 === 'x' ? '☑' : '☐'}`,
14 | }));
15 |
16 | const turndownService = new TurndownService({
17 | codeBlockStyle: 'fenced',
18 | });
19 |
20 | module.exports.convert = (text, limit) => {
21 | let converted = text;
22 |
23 | try {
24 | text = turndownService.turndown(ghConverter.makeHtml(text));
25 | } catch (e) {
26 | Log.error(e);
27 | }
28 |
29 | if (limit && converted.length > limit) {
30 | return `${converted.slice(0, limit).trim()} …`;
31 | }
32 |
33 | return converted;
34 | };
35 |
--------------------------------------------------------------------------------
/lib/Util/redis.js:
--------------------------------------------------------------------------------
1 | const redis = require('redis');
2 | const Log = require('./Log');
3 | const client = redis.createClient(
4 | process.env.REDIS_HOST,
5 | process.env.REDIS_PORT,
6 | {
7 | socket: {
8 | connectTimeout: 5000,
9 | },
10 | }
11 | );
12 |
13 | exports.get = (db, key) => client.get(`${db}:${key}`);
14 | exports.set = (db, key, value, expiry = -1, opts = {}) =>
15 | client.set(`${db}:${key}`, value, {
16 | EX: expiry,
17 | ...opts,
18 | });
19 |
20 | exports.expire = (db, key, expiry) => client.expire(`${db}:${key}`, expiry);
21 | exports.ttl = (db, key) => client.ttl(`${db}:${key}`);
22 |
23 | exports.setHash = (db, key, data, expiry) =>
24 | client
25 | .hSet(`${db}:${key}`, data)
26 | .then(() => expiry && client.expire(`${db}:${key}`, expiry));
27 | exports.getHash = (db, key) => client.hGetAll(`${db}:${key}`);
28 | exports.getHashKey = (db, key, hash) => client.hGet(`${db}:${key}`, hash);
29 |
30 | client
31 | .connect()
32 | .then(() => Log.info('Redis | Connected'))
33 | .catch((err) => {
34 | Log.error('Redis | Failed to connect');
35 | Log.error(err);
36 | });
37 |
38 | exports = client;
39 |
--------------------------------------------------------------------------------
/lib/Web/errors/index.js:
--------------------------------------------------------------------------------
1 | class NotFoundError extends Error {
2 | constructor(message) {
3 | super(message);
4 |
5 | this.status = 404;
6 | this.name = 'Not Found';
7 | }
8 | }
9 |
10 | class ForbiddenError extends Error {
11 | constructor(message) {
12 | super(message);
13 |
14 | this.status = 403;
15 | this.name = 'Forbidden';
16 | }
17 | }
18 |
19 | class BadRequestError extends Error {
20 | constructor(message) {
21 | super(message);
22 |
23 | this.status = 400;
24 | this.name = 'Bad Request';
25 | }
26 | }
27 |
28 | class TooManyRequestsError extends Error {
29 | constructor(message) {
30 | super(message);
31 |
32 | this.status = 429;
33 | this.name = 'Too Many Requests';
34 | }
35 | }
36 |
37 | class MethodNotAllowedError extends Error {
38 | constructor(message) {
39 | super(message);
40 |
41 | this.status = 405;
42 | this.name = 'Method Not Allowed';
43 | }
44 | }
45 |
46 | module.exports = {
47 | NotFoundError,
48 | ForbiddenError,
49 | BadRequestError,
50 | TooManyRequestsError,
51 | MethodNotAllowedError,
52 | };
53 |
--------------------------------------------------------------------------------
/lib/Web/middleware/cache.js:
--------------------------------------------------------------------------------
1 | module.exports = (req, res, next) => {
2 | if (req.method === 'GET') {
3 | res.set('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour
4 | } else {
5 | // Remove cache for the other HTTP methods to avoid stale data
6 | res.set('Cache-Control', 'no-store');
7 | }
8 |
9 | next();
10 | };
11 |
--------------------------------------------------------------------------------
/lib/Web/middleware/verifyWebhookOrigin.js:
--------------------------------------------------------------------------------
1 | const IPCIDR = require('ip-cidr');
2 | const NodeCache = require('node-cache');
3 | const GitHub = require('../../GitHub');
4 | const { ForbiddenError } = require('../errors');
5 | const asyncHandler = require('../utils/asyncHandler');
6 |
7 | const cache = new NodeCache();
8 |
9 | const HOUR_IN_SECONDS = 60 * 60;
10 | const WEEK_IN_SECONDS = 60 * 60 * 24 * 7;
11 | const BYPASS = !!process.env.GITHUB_WEBHOOK_DISABLE_IP_CHECK;
12 |
13 | if (BYPASS) Log.warn('GitHub | Webhook IP check disabled!');
14 |
15 | // If we have a cached list, use it if it is less than a week old.
16 | // If we don't have a cached list but we did attempt to fetch, do not retry if within an hour.
17 | const getAllowed = async () => {
18 | const cached = cache.get('allowed');
19 |
20 | if (cached) return cached;
21 | if (cache.get('failed')) return;
22 |
23 | let hooks;
24 |
25 | try {
26 | Log.info('GitHub | Fetching allowed IPs');
27 | hooks = (await GitHub.getMeta()).hooks;
28 | } catch (err) {
29 | Log.error('GitHub | Failed to fetch allowed IPs');
30 | Log.error(err);
31 | }
32 |
33 | if (!hooks?.length) return cache.set('failed', true, HOUR_IN_SECONDS);
34 |
35 | const ips = hooks.map((hook) => new IPCIDR(hook));
36 |
37 | cache.set('allowed', ips, WEEK_IN_SECONDS);
38 |
39 | return ips;
40 | };
41 |
42 | module.exports = asyncHandler(async (req, res, next) => {
43 | if (req.method !== 'POST') return next();
44 |
45 | res.type('json');
46 |
47 | if (BYPASS) return next();
48 |
49 | const allowedIPs = await getAllowed();
50 | const clientIP = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
51 |
52 | const allowed = allowedIPs?.some?.((range) => range.contains(clientIP));
53 |
54 | if (!allowed) {
55 | return next(new ForbiddenError());
56 | }
57 |
58 | return next();
59 | });
60 |
--------------------------------------------------------------------------------
/lib/Web/middleware/verifyWebhookSecret.js:
--------------------------------------------------------------------------------
1 | const crypto = require('crypto');
2 |
3 | const sigHeaderName = 'X-Hub-Signature-256';
4 | const sigHashAlg = 'sha256';
5 | const ghWebhookSecret = process.env.GITHUB_WEBHOOK_SECRET;
6 |
7 | if (!ghWebhookSecret) {
8 | Log.warn(
9 | 'GitHub | No app webhook secret set! Webhooks will not be verified.'
10 | );
11 | }
12 |
13 | const checkSecret = (req, secret) => {
14 | const header = req.get(sigHeaderName);
15 |
16 | if (!secret || !header) {
17 | return false;
18 | }
19 |
20 | const sig = Buffer.from(header || '', 'utf8');
21 | const hmac = crypto.createHmac(sigHashAlg, secret);
22 | const digest = Buffer.from(
23 | `${sigHashAlg}=${hmac.update(req.rawBody).digest('hex')}`,
24 | 'utf8'
25 | );
26 |
27 | return sig.length === digest.length && crypto.timingSafeEqual(digest, sig);
28 | };
29 |
30 | const verifyWebhookSecret = (req, res, next) => {
31 | if (!ghWebhookSecret) return next();
32 | if (!req.rawBody) {
33 | return next('Request body empty');
34 | }
35 |
36 | if (!checkSecret(req, ghWebhookSecret)) {
37 | return next(401);
38 | }
39 |
40 | return next();
41 | };
42 |
43 | module.exports = { verifyWebhookSecret, checkSecret };
44 |
--------------------------------------------------------------------------------
/lib/Web/purge.js:
--------------------------------------------------------------------------------
1 | const rateLimit = require('express-rate-limit');
2 | const uuid = require('uuid');
3 | const ChannelConnection = require('../Models/ChannelConnection');
4 | const asyncHandler = require('./utils/asyncHandler');
5 | const redis = require('../Util/redis');
6 | const bodyParser = require('body-parser');
7 |
8 | const limiter = rateLimit({
9 | windowMs: 60 * 1000, // 1 minute
10 | max: 5, // 2 requests,
11 | handler: (req, res, next) => next(new TooManyRequestsError()),
12 | });
13 |
14 | module.exports = (app) => {
15 | app.use('/purge', bodyParser.urlencoded({ extended: true }));
16 | app.use('/purge', bodyParser.json());
17 |
18 | app.get('/purge', (req, res) => {
19 | res.render('purge/form', {
20 | error: req.query.error,
21 | });
22 | });
23 |
24 | app.post(
25 | '/purge/start',
26 | limiter,
27 | asyncHandler(async (req, res) => {
28 | // https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
29 |
30 | const body = await req.body;
31 |
32 | // Turnstile injects a token in "cf-turnstile-response".
33 | const token = body['cf-turnstile-response'];
34 | const ip = req.get('CF-Connecting-IP');
35 |
36 | // Validate the token by calling the "/siteverify" API endpoint.
37 | let formData = new FormData();
38 | formData.append('secret', process.env.TURNSTILE_SECRET_KEY);
39 | formData.append('response', token);
40 | formData.append('remoteip', ip);
41 |
42 | const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
43 | const result = await fetch(url, {
44 | body: formData,
45 | method: 'POST',
46 | });
47 |
48 | const outcome = await result.json();
49 |
50 | if (!outcome.success) {
51 | return res.redirect(
52 | `${process.env.WEB_HOST}/purge?error=${encodeURIComponent(
53 | outcome['error-codes'].join(' / ')
54 | )}`
55 | );
56 | }
57 |
58 | const id = uuid.v4();
59 |
60 | await redis.setHash(
61 | 'setup',
62 | id,
63 | {
64 | channel_id: -1,
65 | channel_name: '',
66 | guild_name: '',
67 | },
68 | 60 * 30
69 | );
70 |
71 | res.redirect(`${process.env.WEB_HOST}/setup/${id}`);
72 |
73 | return;
74 | })
75 | );
76 | };
77 |
--------------------------------------------------------------------------------
/lib/Web/utils/asyncHandler.js:
--------------------------------------------------------------------------------
1 | // Taken from https://github.com/getsentry/sentry-javascript/issues/3284#issuecomment-838690126
2 | const asyncHandler = (fn) => {
3 | const asyncFn =
4 | fn.length <= 3
5 | ? (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)
6 | : (err, req, res, next) =>
7 | Promise.resolve(fn(err, req, res, next)).catch(next);
8 |
9 | Object.defineProperty(asyncFn, 'name', {
10 | value: fn.name,
11 | });
12 |
13 | return asyncFn;
14 | };
15 |
16 | module.exports = asyncHandler;
17 |
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 |
3 | require('./instrument');
4 |
5 | global.Log = require('./Util/Log');
6 |
7 | async function initialize() {
8 | const components = [
9 | { name: 'models', import: () => require('./Models') },
10 | { name: 'github', import: () => require('./GitHub') },
11 | { name: 'discord', import: () => require('./Discord') },
12 | { name: 'web', import: () => require('./Web') },
13 | ];
14 |
15 | Log.info('* Initializing components...');
16 |
17 | try {
18 | for (const component of components) {
19 | const startTime = process.hrtime();
20 |
21 | try {
22 | await Promise.resolve(component.import());
23 | const [seconds, nanoseconds] = process.hrtime(startTime);
24 | const ms = (seconds * 1000 + nanoseconds / 1e6).toFixed(2);
25 | Log.info(`* Loaded ${component.name} (${ms}ms)`);
26 | } catch (err) {
27 | Log.error(`* Failed to load ${component.name}:`, err);
28 | throw err; // Re-throw to stop initialization
29 | }
30 | }
31 |
32 | Log.info('* All components initialized successfully');
33 | } catch (err) {
34 | Log.error('* Initialization failed:', err);
35 | process.exit(1);
36 | }
37 | }
38 |
39 | const logUnhandled = (type) => (err) => {
40 | try {
41 | Log.error(`Unhandled ${type}:`);
42 | Log.error(err);
43 | } catch (err) {
44 | console.error(`Unhandled ${type}:`);
45 | console.error(err);
46 | }
47 | };
48 |
49 | process.on('unhandledRejection', logUnhandled('Rejection'));
50 | process.on('uncaughtException', logUnhandled('Exception'));
51 |
52 | // Start initialization
53 | initialize().catch((err) => {
54 | Log.error('Fatal initialization error:', err);
55 | process.exit(1);
56 | });
57 |
--------------------------------------------------------------------------------
/lib/instrument.js:
--------------------------------------------------------------------------------
1 | const YappyGitHub = require('./Util/YappyGitHub');
2 |
3 | if (process.env.SENTRY) {
4 | console.log(`Sentry | Initializing...`);
5 |
6 | const Sentry = require('@sentry/node');
7 | const tracesSampleRate = Number(process.env.SENTRY_SAMPLE_RATE) || 0;
8 |
9 | Sentry.init({
10 | dsn: process.env.SENTRY,
11 | release: YappyGitHub.git.release,
12 | environment:
13 | process.env.NODE_ENV === 'production' ? 'production' : 'development',
14 | integrations: [
15 | Sentry.httpIntegration({ tracing: true }),
16 | Sentry.expressIntegration(),
17 | Sentry.contextLinesIntegration(),
18 | Sentry.onUncaughtExceptionIntegration(),
19 | Sentry.onUnhandledRejectionIntegration(),
20 | Sentry.nativeNodeFetchIntegration(),
21 | ],
22 | tracesSampleRate,
23 | autoSessionTracking: false,
24 | defaultIntegrations: false,
25 | });
26 |
27 | console.log(`Sentry | Initialized (sample rate = ${tracesSampleRate})`);
28 | }
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@yappybots/github",
3 | "version": "3.0.0",
4 | "description": "A GitHub repo monitor bot for Discord",
5 | "main": "lib/index.js",
6 | "private": true,
7 | "scripts": {
8 | "start": "node lib/index.js",
9 | "lint": "prettier --write lib db",
10 | "db:migrate": "knex migrate:latest",
11 | "db:rollback": "knex migrate:rollback"
12 | },
13 | "engines": {
14 | "node": ">=18.0.0"
15 | },
16 | "repository": {
17 | "url": "https://github.com/YappyBots/YappyGitHub",
18 | "type": "git"
19 | },
20 | "author": "David Sevilla Martin (https://dsevilla.dev)",
21 | "license": "MIT",
22 | "dependencies": {
23 | "@octokit/auth-app": "^7.1.5",
24 | "@octokit/rest": "^21.1.1",
25 | "@sentry/node": "^8.49.0",
26 | "@YappyBots/addons": "github:YappyBots/yappy-addons#1107d5d",
27 | "better-sqlite3": "^9.2.2",
28 | "body-parser": "^1.20.2",
29 | "bookshelf": "^1.2.0",
30 | "bookshelf-case-converter-plugin": "^2.0.0",
31 | "clean-stack": "^3.0.0",
32 | "discord.js": "^14.16.3",
33 | "dotenv": "^16.4.5",
34 | "ejs": "^3.1.9",
35 | "express": "^4.21.1",
36 | "express-async-handler": "^1.2.0",
37 | "express-rate-limit": "^7.4.1",
38 | "fast-safe-stringify": "^2.1.1",
39 | "git-rev-sync": "^3.0.1",
40 | "helmet": "^7.1.0",
41 | "hpp": "^0.2.3",
42 | "html-entities": "^2.5.2",
43 | "ip-cidr": "^3.1.0",
44 | "jsondiffpatch": "^0.4.1",
45 | "knex": "^3.1.0",
46 | "lru-cache": "^10.1.0",
47 | "markdown-escape": "^2.0.0",
48 | "moment": "^2.30.1",
49 | "moment-duration-format": "^2.3.2",
50 | "node-cache": "^5.1.2",
51 | "p-queue": "^6.5.0",
52 | "performance-now": "^2.1.0",
53 | "pretty-error": "^4.0.0",
54 | "redis": "^4.7.0",
55 | "showdown": "^2.1.0",
56 | "swag": "^0.7.0",
57 | "turndown": "^7.2.0",
58 | "uuid": "^9.0.1",
59 | "winston": "^3.17.0"
60 | },
61 | "devDependencies": {
62 | "prettier": "^3.3.3"
63 | },
64 | "overrides": {
65 | "swag": {
66 | "handlebars": "^4.7.7"
67 | },
68 | "bookshelf": {
69 | "knex": "$knex"
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/views/error.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= name %> | Yappy GitHub
8 |
26 |
27 |
28 | <% if (typeof stack !== 'undefined') { %>
29 | <% if (typeof status !== 'undefined') { %>
30 | <%= status %> <%= name || '' %>
31 | <% } %>
32 |
33 | <%= stack %>
34 | <% } else { %>
35 | <%= status %> <%= name || '' %>
36 |
37 | <% if (message) { %>
38 | <%= message %>
39 | <% } %>
40 | <% } %>
41 |
42 | <% if (typeof link !== 'undefined') { %>
43 |
44 | <% } %>
45 |
46 | <% if (typeof sentry !== 'undefined') { %>
47 | <%= sentry %>
48 | <% } %>
49 |
50 |
--------------------------------------------------------------------------------
/views/hook-channel.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | <%- include('./partials/head') %>
7 |
8 |
9 |
10 | Hook | Yappy GitHub
11 |
12 |
13 |
20 |
21 |
22 |
23 | Configuring a Hook
24 |
25 |
26 | This hook will forward events to the following channels (comma-separated in the URL; max 10):
27 |
28 |
29 | <% for (const id of ids) { %>
30 | -
31 |
<%= id %>
⧉
32 |
33 | <% } %>
34 |
35 |
36 | Make sure these are text channels in servers (not DMs) and that the bot has access to them.
37 | Additionally, verify that the bot the appropriate permissions in the channels listed above.
38 |
39 |
40 |
41 |
42 | Instructions
43 |
44 |
45 | Create a webhook on the GitHub repository or organization you wish to receive events for in the channels listed above.
46 | Keep in mind per-channel configurations apply individually. In other words, events ignored in one channel will still be sent to the others unless you configure them otherwise.
47 |
48 |
49 |
50 |
51 | Payload URL
52 |
53 | <% const link = `${process.env.WEB_HOST}/hook/channels/${ids.join(',')}` %>
54 |
55 | <%= link %>
56 |
57 |
58 |
59 | Content Type
60 | application/json
is preferred
61 |
62 |
63 | Secret
64 |
65 |
66 | Do not use sensitive data for webhook secrets.
67 | Any user with server administrator perms (or access to the /conf option channel
command) can view a channel's secret.
68 |
69 |
70 | View your channel's randomly-generated secret by running /conf option channel item:secret
.
71 |
72 | <% if (ids.length > 1) { %>
73 |
74 |
75 | Since you're sending the hook to multiple channels at once, make sure their secrets are all the same.
76 | The event will only be sent to channels whose secret matches the incoming webhook's.
77 |
78 | <% } %>
79 |
80 |
81 |
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/views/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Yappy GitHub
8 |
9 |
40 |
41 |
42 |
43 |
44 |
45 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | Introducing Yappy GitHub
75 |
76 |
77 |
78 | It's time to say hello to GitHub repo events right in your Discord server.
79 | Simply install the GitHub app and set up the connection to your Discord channel with /setup
!
80 |
81 |
82 |
83 |
84 |
85 | Guilds
86 | ≈<%= approxGuildCount %>
87 |
88 |
89 |
90 |
91 | Connections
92 | <%= connections %>
93 |
94 |
95 |
96 |
97 |
98 | Add Yappy GitHub
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | - Copyright © <%= new Date().getFullYear() %> David Sevilla Martin
110 | - Made with Bulma
111 | - Made for Discord
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
--------------------------------------------------------------------------------
/views/partials/head.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/views/partials/setup-button.ejs:
--------------------------------------------------------------------------------
1 | <% const actionText = (typeof action !== 'undefined') ? action : (connected ? 'disconnect' : 'connect') %>
2 |
3 |
--------------------------------------------------------------------------------
/views/purge/dashboard.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Purge | Yappy GitHub
6 |
7 |
8 |
9 | <%- include('../partials/head') %>
10 |
11 |
12 |
13 |
57 |
58 |
59 |
60 | Disconnect
61 |
76 |
138 |
139 |
140 |
141 |
142 |
154 |
155 |
--------------------------------------------------------------------------------
/views/purge/form.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%- include('../partials/head') %>
8 |
9 |
10 |
11 |
12 | Purge | Yappy GitHub
13 |
14 |
15 |
16 |
23 |
24 |
25 |
26 | Purge GitHub Installation
27 |
28 |
29 |
30 | Log in through this page to disconnect installations & repositories configured through the GitHub App from the bot.
31 | Note that anyone with administrator access to the repository can re-configure the channels to receive events from GitHub after purging.
32 |
33 |
34 |
35 | Clicking the button below will allow you to log in with GitHub and select which installations and/or paths to purge.
36 | Nothing will happen until you confirm each purge request on the next page.
37 |
38 |
39 |
40 | The point of this page is to allow repository/org owners to control the channels that receive events from their repositories.
41 | If you are not the owner of the repository/org, please ask the owner to use this page to disconnect the bot from their repositories.
42 | To configure an individual channel, use the bot's /setup
command in the channel.
43 |
44 |
45 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/views/setup.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Setup | Yappy GitHub
6 |
7 |
8 |
9 | <%- include('./partials/head') %>
10 |
11 |
12 |
13 |
53 |
54 |
55 |
56 |
57 |
58 |
127 |
128 |
129 |
130 | Connected to Discord
131 |
132 | <% const link = `${process.env.WEB_HOST}/hook/channels/${setupData.channel_id}` %>
133 |
134 | If you'd rather simply use webhooks, change your webhook URLs to specify the channel(s) where to send events to
135 | <%= link %>.
136 |
137 |
138 | <% if (legacyOrgs.length || legacyRepos.length) { %>
139 |
140 | Legacy
141 | These were initialized using the old setup system. If the repo/org names have changed since, these will no longer work.
142 | We recommend switching away from this system and either using the GitHub App integration or the new webhook URL above.
143 |
144 |
145 | Removing these is irreversible! This will not remove the webhooks from GitHub -- other Discord channels may continue receiving events through the legacy webhook.
146 |
147 |
148 |
149 | <% [legacyOrgs, legacyRepos].forEach(function (legacy) { %>
150 | <% legacy.forEach(function (conn) { %>
151 | <% const ghName = conn.get('name') %>
152 | <% const type = conn.tableName.slice('channel_'.length, -1) %>
153 |
154 | -
155 |
156 | <%- include('./partials/setup-button', { type: `legacy-${type}`, id: encodeURIComponent(conn.get('name')), connected: true }) %>
157 |
158 |
159 |
160 | <%= ghName %>
161 |
162 |
163 | (legacy <%= type %>)
164 |
165 | <% }) %>
166 | <% }) %>
167 |
168 |
169 | <% } %>
170 |
171 |
172 |
173 | GitHub App
174 |
175 | <% let anyMissing = false %>
176 |
177 | <% connections.forEach(function (conn) { %>
178 | -
179 |
180 | <%- include('./partials/setup-button', { type: conn.get('type'), id: conn.get('githubId'), connected: true }) %>
181 |
182 |
183 | <% const result = conn.get('type') === 'install'
184 | ? githubApp.map(v => v[0]).find(v => v?.id == conn.get('githubId'))
185 | : githubApp.map(v => v[1]?.find(v => v.id == conn.get('githubId'))).filter(Boolean)[0] %>
186 | <% const ghName = (conn.get('type') === 'install'
187 | ? result?.account?.login
188 | : result?.full_name || result?.name
189 | ) || conn.get('githubName') %>
190 |
191 |
192 | <%= ghName %>
193 |
194 |
195 | (<%= conn.get('type') %>)
196 |
197 | <% if (!result) { %>
198 | <% anyMissing = true %>
199 |
200 |
201 |
202 | <% } %>
203 |
204 | <% }) %>
205 |
206 | <% if (anyMissing) { %>
207 |
208 | Some of the connections above are no longer valid. The GitHub app may have lost access to them.
209 | You will not receive events from them.
210 |
211 | <% } %>
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
--------------------------------------------------------------------------------