├── .env.example ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── README.md ├── db ├── migrate-from-mongo.js └── migrations │ ├── 2019_06_21_000000_create_tables.js │ ├── 2019_06_22_000000_create_channel_repos_table.js │ ├── 2019_06_23_000000_create_channel_orgs_table.js │ ├── 2019_06_23_000001_add_ignored_repos_column.js │ ├── 2020_04_19_000000_add_filtering_columns.js │ ├── 2020_04_19_000001_add_repo_column_to_guilds.js │ ├── 2020_04_23_000000_add_ignore_unknown_column_to_channels.js │ ├── 2023_05_22_000000_update_channel_orgs_table.js │ ├── 2023_05_22_000001_create_channel_connections_table.js │ ├── 2023_05_28_000000_make_ignore_unknown_default.js │ ├── 2023_07_30_000000_add_channel_secret_column.js │ ├── 2024_03_03_000000_remove_discord_names.js │ └── 2024_03_03_000001_remove_server_prefix.js ├── knexfile.js ├── lib ├── Discord │ ├── Client.js │ ├── Command.js │ ├── Commands │ │ ├── Announce.js │ │ ├── Clean.js │ │ ├── Conf.js │ │ ├── Deploy.js │ │ ├── Eval.js │ │ ├── GitHubIssue.js │ │ ├── GitHubPullRequest.js │ │ ├── GitHubSetup.js │ │ ├── Help.js │ │ ├── Invite.js │ │ ├── Organization.js │ │ ├── Ping.js │ │ ├── Reload.js │ │ ├── Search.js │ │ ├── Stats.js │ │ └── Update.js │ ├── Module.js │ ├── Modules │ │ ├── RunCommand.js │ │ └── UnhandledError.js │ └── index.js ├── GitHub │ ├── Constants.js │ ├── EventHandler.js │ ├── EventIgnoreResponse.js │ ├── EventResponse.js │ ├── Events │ │ ├── Unknown.js │ │ ├── check_run.js │ │ ├── check_suite.js │ │ ├── commit_comment-created.js │ │ ├── create.js │ │ ├── delete.js │ │ ├── dependabot_alert.js │ │ ├── fork.js │ │ ├── gollum.js │ │ ├── installation-created.js │ │ ├── installation-deleted.js │ │ ├── installation-suspend.js │ │ ├── installation-unsuspend.js │ │ ├── installation_repositories-added.js │ │ ├── installation_repositories-removed.js │ │ ├── issue_comment-created.js │ │ ├── issue_comment-deleted.js │ │ ├── issue_comment-edited.js │ │ ├── issues-assigned.js │ │ ├── issues-closed.js │ │ ├── issues-demilestoned.js │ │ ├── issues-edited.js │ │ ├── issues-labeled.js │ │ ├── issues-milestoned.js │ │ ├── issues-opened.js │ │ ├── issues-reopened.js │ │ ├── issues-unassigned.js │ │ ├── issues-unlabeled.js │ │ ├── member-added.js │ │ ├── member-deleted.js │ │ ├── meta.js │ │ ├── page_build.js │ │ ├── ping.js │ │ ├── public.js │ │ ├── pull_request-assigned.js │ │ ├── pull_request-closed.js │ │ ├── pull_request-edited.js │ │ ├── pull_request-labeled.js │ │ ├── pull_request-opened.js │ │ ├── pull_request-reopened.js │ │ ├── pull_request-review_request_removed.js │ │ ├── pull_request-review_requested.js │ │ ├── pull_request-unassigned.js │ │ ├── pull_request-unlabeled.js │ │ ├── pull_request_review-edited.js │ │ ├── pull_request_review-submitted.js │ │ ├── pull_request_review_comment-created.js │ │ ├── pull_request_review_thread-resolved.js │ │ ├── push.js │ │ ├── release-created.js │ │ ├── release-published.js │ │ ├── repository-deleted.js │ │ ├── repository.js │ │ ├── star-created.js │ │ ├── star-deleted.js │ │ ├── status-failure.js │ │ ├── status-success.js │ │ ├── status.js │ │ └── watch.js │ ├── GitHubRepoParser.js │ └── index.js ├── Models │ ├── Channel.js │ ├── ChannelConnection.js │ ├── Guild.js │ ├── LegacyChannelOrg.js │ ├── LegacyChannelRepo.js │ ├── ServerConfig.js │ ├── index.js │ ├── initialization.js │ └── plugin.js ├── Util │ ├── Log.js │ ├── MergeDefault.js │ ├── YappyGitHub.js │ ├── cache.js │ ├── filter.js │ ├── index.js │ ├── markdown.js │ └── redis.js ├── Web │ ├── errors │ │ └── index.js │ ├── index.js │ ├── middleware │ │ ├── cache.js │ │ ├── verifyWebhookOrigin.js │ │ └── verifyWebhookSecret.js │ ├── purge.js │ ├── setup.js │ └── utils │ │ └── asyncHandler.js ├── index.js └── instrument.js ├── package-lock.json ├── package.json └── views ├── error.ejs ├── hook-channel.ejs ├── index.ejs ├── partials ├── head.ejs └── setup-button.ejs ├── purge ├── dashboard.ejs └── form.ejs └── setup.ejs /.env.example: -------------------------------------------------------------------------------- 1 | WEB_IP= 2 | WEB_PORT= 3 | WEB_HOST= 4 | 5 | REDIS_HOST= 6 | REDIS_PORT= 7 | 8 | DISCORD_TOKEN= 9 | DISCORD_OWNER_ID= 10 | DISCORD_OWNER_SERVER= 11 | DISCORD_CLIENT_ID= 12 | DISCORD_CLIENT_SECRET= 13 | DISCORD_CHANNEL_LOGGING= 14 | 15 | GITHUB_APP_ID= 16 | GITHUB_APP_SLUG= # the slug of the app's public url 17 | GITHUB_CLIENT_ID= 18 | GITHUB_CLIENT_SECRET= 19 | GITHUB_WEBHOOK_SECRET= 20 | GITHUB_WEBHOOK_DISABLE_IP_CHECK= 21 | 22 | # Used for issue, pr, org, etc... commands 23 | # Recommended to use a personal fine-grained token with only public read access 24 | GITHUB_TOKEN= 25 | 26 | # Used for the public purge functionality 27 | TURNSTILE_SITE_KEY= 28 | TURNSTILE_SECRET_KEY= 29 | 30 | SENTRY= 31 | SENTRY_SAMPLE_RATE= 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .env 4 | .vscode 5 | db/*.sqlite 6 | 7 | yalc.lock 8 | .yalc -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "endOfLine": "lf" 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yappy, the GitHub Monitor 2 | 3 | Patreon donate button 4 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FYappyBots%2FYappyGitHub.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2FYappyBots%2FYappyGitHub?ref=badge_shield) 5 | 6 | Monitor your github repos by adding this bot to your server, set up a channel for it, and don't miss any events! 7 | 8 | ## Information 9 | 10 | Join our Discord server at https://discord.gg/HHqndMG 11 | 12 | ## GitHub 13 | 14 | When setting up the bot, the site will ask you to sign in with GitHub. 15 | This is temporary & allows the bot to confirm the user has perms in the repositories (enough to install an app!). Installing the app in repositories requires permissions so that the bot can receive events (webhooks) pertaining to those areas. For example, if the bot doesn't have permission to read PRs in the repository, GitHub doesn't allow it to receive webhooks for PR-related activity. 16 | 17 | - **Dependabot Alerts**: dependabot vulnerability alerts 18 | - **Code / Contents**: commit comment, create/delete branch, fork, gollum (wiki), push, release 19 | - **Commit Statuses**: commit statuses (doesn't seem to include workflow commit statuses! likely only external apps) 20 | - **Issues**: issue, milestone 21 | - **Metadata**: meta, label, public, repository, star, watch 22 | - **Pages**: GitHub pages failure events 23 | - **Pull Requests**: pull requests 24 | 25 | This data is only "read" when receiving webhooks from GitHub (to convert into Discord embeds for the relevant channels) and when configuring the bot through the `/setup` command. 26 | In the latter case, the only API call made is to obtain "Metadata" to retrieve the list of available repositories for an installation. The access token is stored for <30 minutes while the user uses the setup dashboard, and then forgotten. 27 | 28 | ## Discord 29 | 30 | ### Commands 31 | 32 | You can use the following commands through Discord Slash Commands. 33 | 34 | __**Util**__: 35 | - `help` - a help command... yeah :P 36 | - `invite` - how to invite the bot and set up github events! 37 | - `clean` - cleans the bot's messages found in the last 100 messages 38 | - `ping` - uh... ping? pong! 39 | - `stats` - shows the stats of the bot... what else? 40 | 41 | __**GitHub**__: 42 | - `issue search ` - search issues by any field in the global repo 43 | - `issue info ` - gives info about that specific issue in the global repo 44 | - `pr search ` - search pull requests by any field in the global repo 45 | - `pr info ` - gives info about that specific pr in the global repo 46 | 47 | __**Admin**__: 48 | - `conf option channel` - view & edit the channel config 49 | - `conf option guild` - view & edit the server config 50 | - `conf filter` - set filter whitelist/blacklist for channel 51 | - `setup` - add & remove connections (repos, installations in orgs/accounts) from channel 52 | 53 | ### Developer Documentation 54 | 55 | https://yappybots.github.io/#/docs/yappygithub/ -------------------------------------------------------------------------------- /db/migrate-from-mongo.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const { default: PQueue } = require('p-queue'); 3 | const Guild = require('../lib/Models/Guild'); 4 | const Channel = require('../lib/Models/Channel'); 5 | 6 | require('dotenv').config(); 7 | 8 | const channelConfig = mongoose.model('ChannelConfig', { 9 | guildName: String, 10 | guildID: String, 11 | channelName: String, 12 | channelID: String, 13 | repo: String, 14 | repos: Array, 15 | embed: Boolean, 16 | disabledEvents: Array, 17 | ignoredUsers: Array, 18 | ignoredBranches: Array, 19 | }); 20 | 21 | const serverConfig = mongoose.model('ServerConfig', { 22 | guildName: String, 23 | guildID: String, 24 | prefix: String, 25 | }); 26 | 27 | process.on('unhandledRejection', console.error); 28 | 29 | (async () => { 30 | console.log('DB |> Connecting'); 31 | 32 | await mongoose.connect(process.env.DB_URL, { 33 | useNewUrlParser: true, 34 | }); 35 | 36 | console.log('DB |> Connected'); 37 | 38 | // === GUILDS === 39 | 40 | console.log('DB |> Guilds |> Retrieving'); 41 | 42 | const guilds = await Guild.fetchAll(); 43 | const guildConfigs = await serverConfig.find({}); 44 | const guildsToMigrate = [ 45 | ...new Set( 46 | guildConfigs 47 | .map((e) => String(e.get('guildId'))) 48 | .filter((id) => !guilds.get(id)) 49 | ), 50 | ]; 51 | 52 | console.log(`DB |> Guilds |> Migrating (${guildsToMigrate.length})`); 53 | 54 | const queue = new PQueue({ 55 | concurrency: 5, 56 | }); 57 | 58 | if (guildsToMigrate.length) process.stdout.write('DB |> Guilds |> '); 59 | 60 | await queue.addAll( 61 | guildsToMigrate.map((id) => async () => { 62 | const guild = guildConfigs.filter((e) => e.get('guildId') == id)[0]; 63 | 64 | if (await Guild.find(id)) return process.stdout.write('!'); 65 | 66 | await Guild.forge({ 67 | id, 68 | prefix: guild.get('prefix'), 69 | repo: guild.get('repo'), 70 | }).save(null, { 71 | method: 'insert', 72 | }); 73 | 74 | process.stdout.write('.'); 75 | }) 76 | ); 77 | 78 | if (guildsToMigrate.length) process.stdout.write('\n'); 79 | 80 | // === CHANNELS === 81 | 82 | console.log('DB |> Channels |> Retrieving'); 83 | 84 | const channels = await Channel.fetchAll(); 85 | const channelConfigs = await channelConfig.find({}); 86 | const channelsToMigrate = [ 87 | ...new Set( 88 | channelConfigs 89 | .map((e) => String(e.get('channelId'))) 90 | .filter((id) => !channels.get(id)) 91 | ), 92 | ]; 93 | 94 | console.log(`DB |> Channels |> Migrating (${channelsToMigrate.length})`); 95 | 96 | const qq = new PQueue({ 97 | concurrency: 1, 98 | }); 99 | 100 | if (channelsToMigrate.length) process.stdout.write('DB |> Channels |> '); 101 | 102 | await qq.addAll( 103 | channelsToMigrate.map((id) => async () => { 104 | const ch = channelConfigs.filter((e) => e.get('channelId') == id); 105 | const guildId = ch.map((e) => e.get('guildId')).filter(Boolean)[0]; 106 | 107 | if (await Channel.find(id)) return process.stdout.write('!'); 108 | 109 | const channel = await Channel.forge({ 110 | id, 111 | guild_id: guildId, 112 | ignore_unknown: true, 113 | }).save(null, { 114 | method: 'insert', 115 | }); 116 | 117 | const repos = ch.map((e) => e.get('repo')).flat(); 118 | 119 | await Promise.all( 120 | repos.map((repo) => 121 | channel.related('repos').create({ 122 | name: repo, 123 | }) 124 | ) 125 | ); 126 | 127 | process.stdout.write('.'); 128 | }) 129 | ); 130 | 131 | if (channelsToMigrate.length) process.stdout.write('\n'); 132 | 133 | process.exit(0); 134 | })(); 135 | -------------------------------------------------------------------------------- /db/migrations/2019_06_21_000000_create_tables.js: -------------------------------------------------------------------------------- 1 | exports.up = (knex) => { 2 | return knex.schema 3 | .createTable('guilds', (t) => { 4 | t.string('id').primary(); 5 | 6 | t.string('name').nullable(); 7 | 8 | t.string('prefix').nullable(); 9 | }) 10 | 11 | .createTable('channels', (t) => { 12 | t.string('id').primary(); 13 | 14 | t.string('name').nullable(); 15 | t.string('guild_id').nullable(); 16 | 17 | t.string('repo').nullable(); 18 | 19 | t.boolean('use_embed').defaultTo(true); 20 | 21 | t.json('disabled_events').defaultTo(['merge_request/update']); 22 | 23 | t.json('ignored_users').defaultTo([]); 24 | t.json('ignored_branches').defaultTo([]); 25 | 26 | t.foreign('guild_id').references('guilds.id').onDelete('cascade'); 27 | }); 28 | }; 29 | 30 | exports.down = (knex) => { 31 | return knex.schema.dropTable('channels').dropTable('guilds'); 32 | }; 33 | -------------------------------------------------------------------------------- /db/migrations/2019_06_22_000000_create_channel_repos_table.js: -------------------------------------------------------------------------------- 1 | exports.up = (knex) => 2 | knex.schema.createTable('channel_repos', (t) => { 3 | t.increments('id'); 4 | 5 | t.string('channel_id'); 6 | t.string('name'); 7 | 8 | t.foreign('channel_id').references('channels.id').onDelete('cascade'); 9 | }); 10 | 11 | exports.down = (knex) => knex.schema.dropTable('channel_repos'); 12 | -------------------------------------------------------------------------------- /db/migrations/2019_06_23_000000_create_channel_orgs_table.js: -------------------------------------------------------------------------------- 1 | exports.up = (knex) => 2 | knex.schema.createTable('channel_orgs', (t) => { 3 | t.string('id').primary(); 4 | 5 | t.string('name').index(); 6 | 7 | t.foreign('id').references('channels.id').onDelete('cascade'); 8 | }); 9 | 10 | exports.down = (knex) => knex.schema.dropTable('channel_orgs'); 11 | -------------------------------------------------------------------------------- /db/migrations/2019_06_23_000001_add_ignored_repos_column.js: -------------------------------------------------------------------------------- 1 | exports.up = (knex) => 2 | knex.schema.table('channels', (t) => { 3 | t.json('ignored_repos').defaultTo([]); 4 | }); 5 | 6 | exports.down = (knex) => 7 | knex.schema.table('channels', (t) => { 8 | t.dropColumn('ignored_repos'); 9 | }); 10 | -------------------------------------------------------------------------------- /db/migrations/2020_04_19_000000_add_filtering_columns.js: -------------------------------------------------------------------------------- 1 | exports.up = (knex) => 2 | knex.schema.table('channels', (t) => { 3 | t.enum('events_type', ['whitelist', 'blacklist']).defaultTo('blacklist'); 4 | t.renameColumn('disabled_events', 'events_list'); 5 | 6 | t.enum('users_type', ['whitelist', 'blacklist']).defaultTo('blacklist'); 7 | t.renameColumn('ignored_users', 'users_list'); 8 | 9 | t.enum('branches_type', ['whitelist', 'blacklist']).defaultTo('blacklist'); 10 | t.renameColumn('ignored_branches', 'branches_list'); 11 | 12 | t.enum('repos_type', ['whitelist', 'blacklist']).defaultTo('blacklist'); 13 | t.renameColumn('ignored_repos', 'repos_list'); 14 | }); 15 | 16 | exports.down = (knex) => 17 | knex.schema.table('channels', (t) => { 18 | t.dropColumn('events_type'); 19 | t.renameColumn('events_list', 'disabled_events'); 20 | 21 | t.dropColumn('users_type'); 22 | t.renameColumn('users_list', 'ignored_users'); 23 | 24 | t.dropColumn('branches_type'); 25 | t.renameColumn('branches_list', 'ignored_branches'); 26 | 27 | t.dropColumn('repos_type'); 28 | t.renameColumn('repos_list', 'ignored_repos'); 29 | }); 30 | -------------------------------------------------------------------------------- /db/migrations/2020_04_19_000001_add_repo_column_to_guilds.js: -------------------------------------------------------------------------------- 1 | exports.up = (knex) => 2 | knex.schema.table('guilds', (t) => { 3 | t.string('repo').nullable(); 4 | }); 5 | 6 | exports.down = (knex) => 7 | knex.schema.table('guilds', (t) => { 8 | t.dropColumn('repo'); 9 | }); 10 | -------------------------------------------------------------------------------- /db/migrations/2020_04_23_000000_add_ignore_unknown_column_to_channels.js: -------------------------------------------------------------------------------- 1 | exports.up = (knex) => 2 | knex.schema.table('channels', (t) => { 3 | t.boolean('ignore_unknown').defaultTo(false); 4 | }); 5 | 6 | exports.down = (knex) => 7 | knex.schema.table('channels', (t) => { 8 | t.dropColumn('ignore_unknown'); 9 | }); 10 | -------------------------------------------------------------------------------- /db/migrations/2023_05_22_000000_update_channel_orgs_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async (knex) => { 2 | await knex.schema.table('channel_orgs', (t) => 3 | t.renameColumn('id', 'channel_id') 4 | ); 5 | await knex.schema.table('channel_orgs', (t) => { 6 | t.dropPrimary(); 7 | t.integer('id').unsigned(); 8 | }); 9 | await knex.schema.table('channel_orgs', (t) => 10 | t.increments('id').primary().alter() 11 | ); 12 | }; 13 | 14 | exports.down = async (knex) => { 15 | await knex.schema.table('channel_orgs', (t) => { 16 | t.dropPrimary(); 17 | t.dropColumn('id'); 18 | }); 19 | await knex.schema.table('channel_orgs', (t) => 20 | t.renameColumn('channel_id', 'id') 21 | ); 22 | await knex.schema.table('channel_orgs', (t) => t.primary('id')); 23 | }; 24 | -------------------------------------------------------------------------------- /db/migrations/2023_05_22_000001_create_channel_connections_table.js: -------------------------------------------------------------------------------- 1 | exports.up = (knex) => 2 | knex.schema.createTable('channel_connections', (t) => { 3 | t.increments('id'); 4 | 5 | t.string('channel_id'); 6 | t.enum('type', ['repo', 'install']); 7 | t.string('github_name'); 8 | t.integer('github_id').unsigned(); 9 | 10 | t.foreign('channel_id').references('channels.id').onDelete('cascade'); 11 | t.unique(['channel_id', 'type', 'github_id']); 12 | }); 13 | 14 | exports.down = (knex) => knex.schema.dropTable('channel_connections'); 15 | -------------------------------------------------------------------------------- /db/migrations/2023_05_28_000000_make_ignore_unknown_default.js: -------------------------------------------------------------------------------- 1 | // Too many unknown events now, the default should be to ignore them 2 | exports.up = async (knex) => 3 | knex.transaction(async (trx) => { 4 | await trx.raw( 5 | 'ALTER TABLE channels ADD COLUMN ignore_unknown_ integer DEFAULT true' 6 | ); 7 | await trx.raw('ALTER TABLE channels DROP COLUMN ignore_unknown'); 8 | await trx.raw( 9 | 'ALTER TABLE channels RENAME COLUMN ignore_unknown_ TO ignore_unknown' 10 | ); 11 | }); 12 | 13 | // This doesn't rollback the values because it's not possible to know what they were before 14 | exports.down = async (knex) => { 15 | // This is a no-op 16 | }; 17 | -------------------------------------------------------------------------------- /db/migrations/2023_07_30_000000_add_channel_secret_column.js: -------------------------------------------------------------------------------- 1 | exports.up = (knex) => 2 | knex.schema.table('channels', (t) => { 3 | t.string('secret').nullable(); 4 | }); 5 | 6 | exports.down = (knex) => 7 | knex.schema.table('channels', (t) => { 8 | t.dropColumn('secret'); 9 | }); 10 | -------------------------------------------------------------------------------- /db/migrations/2024_03_03_000000_remove_discord_names.js: -------------------------------------------------------------------------------- 1 | exports.up = async (knex) => 2 | knex.transaction(async (trx) => { 3 | await trx.raw('ALTER TABLE channels DROP COLUMN name'); 4 | await trx.raw('ALTER TABLE guilds DROP COLUMN name'); 5 | }); 6 | 7 | exports.down = async (knex) => { 8 | await knex.schema.table('channels', (table) => table.string('name')); 9 | await knex.schema.table('guilds', (table) => table.string('name')); 10 | }; 11 | -------------------------------------------------------------------------------- /db/migrations/2024_03_03_000001_remove_server_prefix.js: -------------------------------------------------------------------------------- 1 | exports.up = async (knex) => 2 | knex.transaction( 3 | async (trx) => await trx.raw('ALTER TABLE guilds DROP COLUMN prefix') 4 | ); 5 | 6 | exports.down = async (knex) => { 7 | // No-op 8 | }; 9 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | module.exports = { 4 | client: 'better-sqlite3', 5 | connection: { 6 | filename: "./db/db.sqlite" 7 | }, 8 | pool: { 9 | min: 2, 10 | max: 10, 11 | afterCreate: (db, done) => { 12 | // db is a better-sqlite3 Database instance 13 | done(); 14 | } 15 | }, 16 | migrations: { 17 | tableName: 'migrations', 18 | directory: './db/migrations' 19 | }, 20 | useNullAsDefault: true, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/Discord/Command.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@YappyBots/addons').discord.structures.Command; 2 | -------------------------------------------------------------------------------- /lib/Discord/Commands/Announce.js: -------------------------------------------------------------------------------- 1 | const Command = require('../Command'); 2 | 3 | class AnnounceCommand extends Command { 4 | constructor(bot) { 5 | super(bot); 6 | this.props.help = { 7 | name: 'announce', 8 | description: 'announce something to all server owners', 9 | usage: 'announce ', 10 | }; 11 | this.setConf({ 12 | permLevel: 2, 13 | msgTrigger: true, 14 | }); 15 | } 16 | async run(msg, args) { 17 | let announcement = args.join(' '); 18 | let owners = this.bot.guilds.cache 19 | .map((e) => e.owner) 20 | .filter((e, i, o) => o.indexOf(e) === i); 21 | let messagedOwners = []; 22 | let message = await msg.channel.send({ 23 | embeds: [ 24 | { 25 | title: 'Announce', 26 | color: 0xfb5432, 27 | description: 'Announcing message....', 28 | timestamp: new Date(), 29 | }, 30 | ], 31 | }); 32 | for (let owner of owners) { 33 | if (!owner) return; 34 | if (messagedOwners.includes(owner.id)) return; 35 | messagedOwners.push(owner.id); 36 | let embed = new this.embed() 37 | .setAuthor({ 38 | name: msg.author.username, 39 | iconURL: msg.author.avatarURL(), 40 | }) 41 | .setColor(0xfb5432) 42 | .setTitle( 43 | `Announcement to all server owners of servers using Yappy GitHub` 44 | ) 45 | .setDescription([`\u200B`, announcement, `\u200B`].join('\n')) 46 | .setTimestamp(); 47 | await owner.send({ embeds: [embed] }); 48 | } 49 | // await message.delete(); 50 | return message.edit({ 51 | embed: { 52 | title: 'Announce', 53 | color: 0x1f9523, 54 | description: 'Successfully announced!', 55 | timestamp: new Date(), 56 | }, 57 | }); 58 | } 59 | } 60 | 61 | module.exports = AnnounceCommand; 62 | -------------------------------------------------------------------------------- /lib/Discord/Commands/Clean.js: -------------------------------------------------------------------------------- 1 | const { CommandInteraction } = require('discord.js'); 2 | const Command = require('../Command'); 3 | 4 | class CleanCommand extends Command { 5 | constructor(bot) { 6 | super(bot); 7 | 8 | this.props.help = { 9 | name: 'clean', 10 | description: 11 | 'Clean 10 messages (by default) sent by the bot, found in the last 50 messages in the channel.', 12 | usage: 'clean [number=10]', 13 | examples: ['clean', 'clean 14'], 14 | }; 15 | 16 | this.setConf({ 17 | permLevel: 1, 18 | }); 19 | } 20 | 21 | getSlashCommand() { 22 | return super 23 | .getSlashCommand() 24 | .addIntegerOption((option) => 25 | option 26 | .setName('count') 27 | .setDescription('The number of messages to clean.') 28 | .setMinValue(1) 29 | ); 30 | } 31 | 32 | /** 33 | * 34 | * @param {CommandInteraction} interaction 35 | */ 36 | async run(interaction) { 37 | const messageCount = interaction.options.getInteger('count') || 10; 38 | 39 | await interaction.deferReply({ ephemeral: true }); 40 | 41 | const messages = await interaction.channel.messages.fetch({ 42 | limit: 50, 43 | }); 44 | 45 | const msgs = messages.filter((e) => e.author.equals(this.bot.user)); 46 | let i = 0; 47 | for (let [, message] of msgs) { 48 | if (i >= messageCount) break; 49 | message.delete(); 50 | i++; 51 | } 52 | 53 | await interaction.editReply(`Done. Deleted ${i} messages`); 54 | } 55 | } 56 | 57 | module.exports = CleanCommand; 58 | -------------------------------------------------------------------------------- /lib/Discord/Commands/Deploy.js: -------------------------------------------------------------------------------- 1 | const Command = require('../Command'); 2 | 3 | class DeployCommand extends Command { 4 | constructor(bot) { 5 | super(bot); 6 | this.props.help = { 7 | name: 'deploy', 8 | description: 'deploy slash commands', 9 | }; 10 | this.setConf({ 11 | permLevel: 2, 12 | msgTrigger: true, 13 | }); 14 | } 15 | async run(msg, args) { 16 | const env = args[0] || process.env.NODE_ENV; 17 | const isDev = env !== 'production'; 18 | 19 | try { 20 | await this.bot.registerCommands(env); 21 | } catch (e) { 22 | Log.error(e); 23 | 24 | return msg.channel.send('❌ Failed to deploy commands'); 25 | } 26 | 27 | return msg.channel.send({ 28 | embeds: [ 29 | new this.embed().setTitle( 30 | `Deployed ${isDev ? '*SERVER*' : '*GLOBAL*'} Commands` 31 | ), 32 | ], 33 | }); 34 | } 35 | } 36 | 37 | module.exports = DeployCommand; 38 | -------------------------------------------------------------------------------- /lib/Discord/Commands/Eval.js: -------------------------------------------------------------------------------- 1 | const Command = require('../Command'); 2 | const util = require('util'); 3 | const path = require('path'); 4 | const Log = require('../../Util/Log'); 5 | 6 | class EvalCommand extends Command { 7 | constructor(bot) { 8 | super(bot); 9 | 10 | this.tokenRegEx = new RegExp(this.bot.token, 'g'); 11 | this.pathRegEx = new RegExp(path.resolve(__dirname, '../../../'), 'g'); 12 | 13 | this.props.help = { 14 | name: 'eval', 15 | description: 'Eval code, admin only', 16 | usage: 'eval ', 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 |

    <%= link %>

    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 | 52 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 80 | 81 |
    Payload URL 53 | <% const link = `${process.env.WEB_HOST}/hook/channels/${ids.join(',')}` %> 54 | 55 | <%= link %> 56 |
    Content Typeapplication/json is preferred
    Secret 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 |
    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 | Yappy GitHub Screenshot 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 | 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 |
    4 | 13 |
    -------------------------------------------------------------------------------- /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 |
    62 |
    63 | Unlinking an installation will delete all GitHub App connections configured for that installation and all listed repositories below.
    64 |   Any installation's repositories that are not listed may continue to receive events if they were configured individually.
    65 |   To avoid this, allow the GitHub App to access all repositories (or the ones you know have been configured) so the purge process can remove them.
    66 | Unlinking a repository will delete all GitHub App connections configured for that repository.

    67 | Note that the GitHub App installation will still exist and new connections can be configured by those with administrator access to the repository.
    68 | In addition, this does not affect any webhooks you may have created in your GitHub repository & organization settings.

    69 | 70 | This action is irreversible.

    71 | 72 | You will have to re-configure the channels to receive events from GitHub after purging if you wish to continue using the bot.
    73 | The number of channels connected to each installation and repository is listed in each row.
    74 |
    75 |
    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 |
    46 |
    47 | 51 | 52 |
    53 | <% if (turnstileKey = process.env.TURNSTILE_SITE_KEY) { %> 54 |
    55 | <% } %> 56 | 57 | <% if (error) { %> 58 |

    ERROR: <%= error %>

    59 | <% } %> 60 | 61 | 62 |
    63 |
    64 |
    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 | --------------------------------------------------------------------------------