diff --git a/.env.sample b/.env.sample index 8108070..319afdb 100644 --- a/.env.sample +++ b/.env.sample @@ -2,6 +2,7 @@ DISCORD_TOKEN= DISCORD_CLIENT_SECRET= DISCORD_CLIENT_ID= DISCORD_GUILD_ID= +RELEASE_SECRET= LINK_SECRET=super_secret_for_url_discord E621_BASE_URL=https://e621.net E926_BASE_URL=https://e926.net diff --git a/package-lock.json b/package-lock.json index 7cf65f3..76959e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@redis/client": "^5.1.0", + "body-parser": "^2.2.0", "discord-oauth2": "^2.12.1", "discord.js": "^14.19.3", "dotenv": "^16.5.0", @@ -1792,6 +1793,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", diff --git a/package.json b/package.json index 3072cf7..a9a50f2 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@redis/client": "^5.1.0", + "body-parser": "^2.2.0", "discord-oauth2": "^2.12.1", "discord.js": "^14.19.3", "dotenv": "^16.5.0", diff --git a/src/commands/github-mapping.ts b/src/commands/github-mapping.ts new file mode 100644 index 0000000..3e77e0f --- /dev/null +++ b/src/commands/github-mapping.ts @@ -0,0 +1,88 @@ +import { ApplicationIntegrationType, BitFieldResolvable, ChatInputCommandInteraction, Client, GuildBasedChannel, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils'; +import { Database } from '../shared/Database'; + +const mentionRegex = new RegExp(MessageMentions.UsersPattern); + +export default { + name: 'github-mapping', + data: new SlashCommandBuilder() + .setName('github-mapping') + .setDescription('Maps github users to discord ids for releases.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(subcommand => + subcommand + .setName('add') + .setDescription('Add a user mapping.') + .addStringOption(option => + option + .setName('discord-user') + .setDescription('The discord user id, or mention, of the user.') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('github-name') + .setDescription('The github username of the user (case sensitive).') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Remove a user mapping.') + .addStringOption(option => + option + .setName('discord-user') + .setDescription('The discord user id, or mention, of the user.') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('List all github-discord mappings.') + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + + const subcommand = await interaction.options.getSubcommand(true); + + if (subcommand == 'add') { + const discordUserInput = interaction.options.getString('discord-user', true); + + const matches = mentionRegex.exec(discordUserInput); + mentionRegex.lastIndex = 0; + + const idToUse = matches ? matches.groups!.id : discordUserInput; + + const githubName = interaction.options.getString('github-name', true); + + const existingMappingId = await Database.getGithubFromDiscordId(idToUse); + const existingMappingName = await Database.getDiscordIdFromGithub(githubName); + + if (existingMappingId) return interaction.editReply(`Discord user id is already mapped to ${existingMappingId}`); + if (existingMappingName) return interaction.editReply(`Github username is already mapped to <@${existingMappingName}> (${existingMappingName})`); + + Database.putGithubUserMapping(idToUse, githubName); + + return interaction.editReply('Mapping added.'); + } else if (subcommand == 'remove') { + const discordUserInput = interaction.options.getString('discord-user', true); + + const matches = mentionRegex.exec(discordUserInput); + mentionRegex.lastIndex = 0; + + const idToUse = matches ? matches.groups!.id : discordUserInput; + + Database.removeGithubUserMapping(idToUse); + return interaction.editReply('Mapping removed.'); + } else if (subcommand == 'list') { + const allMappings = await Database.getAllGithubUserMappings(); + + return interaction.editReply(allMappings.map(m => `- <@${m.discord_id}> (${m.discord_id}) - ${m.github_username}`).join('\n')); + } + } +}; \ No newline at end of file diff --git a/src/commands/settings.ts b/src/commands/settings.ts index a478f0b..e033a37 100644 --- a/src/commands/settings.ts +++ b/src/commands/settings.ts @@ -93,6 +93,12 @@ export default { .setName('remove-link-skip-channel') .setDescription('Remove a link skip channel.') .setRequired(false) + ) + .addChannelOption(option => + option + .setName('github-release-channel') + .setDescription('Set the github release channel.') + .setRequired(false) ), handler: async function (client: Client, interaction: ChatInputCommandInteraction) { await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); @@ -241,6 +247,14 @@ export default { } } + const githubReleaseChannel = interaction.options.getChannel('github-release-channel'); + + if (githubReleaseChannel) { + await Database.setGuildGithubReleaseChannel(interaction.guildId!, githubReleaseChannel.id); + + response += `Github releases channel set to ${githubReleaseChannel}.\n`; + } + if (response.length == 0) return interaction.editReply({ content: 'No settings provided.' }); interaction.editReply({ content: response }); diff --git a/src/config.ts b/src/config.ts index a1fd775..d132e4a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,13 +2,14 @@ import dotenv from 'dotenv'; dotenv.config(); -const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_GUILD_ID, LINK_SECRET, E621_BASE_URL, E926_BASE_URL, REDIS_URL, PORT } = process.env; +const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_GUILD_ID, RELEASE_SECRET, LINK_SECRET, E621_BASE_URL, E926_BASE_URL, REDIS_URL, PORT } = process.env; export const config = { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_GUILD_ID, + RELEASE_SECRET, LINK_SECRET, E621_BASE_URL, E926_BASE_URL, diff --git a/src/index.ts b/src/index.ts index dce2bdf..3aba22a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { Client as DiscordClient, GatewayIntentBits, Guild, MessageFlags, Partia import { config } from './config'; import { Handler } from './types'; import { checkExpiredBans, initIfNecessary, loadHandlersFrom, refreshCommands } from './utils'; -import { initializeDiscordJoiner } from './discord-joiner'; +import { initializeWebserver } from './webserver'; import { Database } from './shared/Database'; import { handleAuditLogCreate, handleBanRemove, handleBulkMessageDelete, handleGuildCreate, handleMemberJoin, handleMessageCreate, handleMessageDelete, handleMessageUpdate, handleThreadCreate, handleVoiceStateUpdate } from './events'; import { pruneOldTickets, ticketCooldownMap } from './shared/ticket-cooldown'; @@ -135,7 +135,7 @@ client.on('ready', async () => { await Database.open('./data/discord-main.db'); await openRedisClient(config.REDIS_URL!, client); - await initializeDiscordJoiner(); + await initializeWebserver(client); // Prune ticket cooldowns that are expired every day setInterval(pruneOldTickets, 8.64e+7); diff --git a/src/shared/Database.ts b/src/shared/Database.ts index f99fccc..9bf159d 100644 --- a/src/shared/Database.ts +++ b/src/shared/Database.ts @@ -3,7 +3,7 @@ import { open, Database as SqliteDatabase } from 'sqlite'; import { config } from '../config'; import DiscordOAuth2 from 'discord-oauth2'; import { serializeMessage, wait } from '../utils'; -import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Note, Ban, GuildArraySetting } from '../types'; +import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Note, Ban, GuildArraySetting, GithubUserMapping } from '../types'; import { Message } from '../events'; const DB_SCHEMA = ` @@ -27,7 +27,8 @@ const DB_SCHEMA = ` private_help_role_id TEXT, staff_categories TEXT, safe_channels TEXT, - link_skip_channels TEXT + link_skip_channels TEXT, + github_release_channel TEXT ); CREATE TABLE IF NOT EXISTS messages ( @@ -68,6 +69,12 @@ const DB_SCHEMA = ` user_id TEXT, expires_at datetime ); + + CREATE TABLE IF NOT EXISTS github_user_mapping ( + id INTEGER PRIMARY KEY, + discord_id TEXT, + github_username TEXT + ); `; export class Database { @@ -167,6 +174,10 @@ export class Database { await Database.db.run('UPDATE settings SET private_help_role_id = ? WHERE guild_id = ?', id, guildId); } + static async setGuildGithubReleaseChannel(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET github_release_channel = ? WHERE guild_id = ?', id, guildId); + } + // Since "setting" has guaranteed values and is never set by the user, this shouldn't cause any security issues. // But it does allow me to skip rewriting this a bunch. static async getGuildArraySetting(setting: GuildArraySetting, guildId: string): Promise { @@ -322,4 +333,33 @@ export class Database { } // -- END BANS -- + + // -- START GITHUB USER MAPPING -- + + // github_user_mapping + static async putGithubUserMapping(discordId: string, githubUsername: string) { + await Database.db.run('INSERT INTO github_user_mapping(discord_id, github_username) VALUES (?, ?)', discordId, githubUsername); + } + + static async getDiscordIdFromGithub(githubUsername: string): Promise { + const mapping = await Database.db.get>('SELECT discord_id FROM github_user_mapping WHERE github_username = ?', githubUsername); + + return mapping?.discord_id ?? null; + } + + static async getGithubFromDiscordId(discordId: string): Promise { + const mapping = await Database.db.get>('SELECT github_username FROM github_user_mapping WHERE discord_id = ?', discordId); + + return mapping?.github_username ?? null; + } + + static async getAllGithubUserMappings(): Promise { + return await Database.db.all('SELECT * from github_user_mapping'); + } + + static async removeGithubUserMapping(discordId: string) { + await Database.db.run('DELETE from github_user_mapping WHERE discord_id = ?', discordId); + } + + // -- END GITHUB USER MAPPING -- } \ No newline at end of file diff --git a/src/types/database-types.d.ts b/src/types/database-types.d.ts index dc49d61..3029524 100644 --- a/src/types/database-types.d.ts +++ b/src/types/database-types.d.ts @@ -21,6 +21,7 @@ export type GuildSettings = { staff_categories?: string safe_channels?: string link_skip_channels?: string + github_release_channel?: string } export type GuildArraySetting = 'staff_categories' | 'safe_channels' | 'link_skip_channels'; @@ -48,4 +49,10 @@ export type Ban = { id: number user_id: string expires_at: string +} + +export type GithubUserMapping = { + id: number + discord_id: string + github_username: string } \ No newline at end of file diff --git a/src/utils/github-user-utils.ts b/src/utils/github-user-utils.ts new file mode 100644 index 0000000..3b57dbc --- /dev/null +++ b/src/utils/github-user-utils.ts @@ -0,0 +1,13 @@ +import { Database } from '../shared/Database'; + +const mentionRegex = new RegExp('@([\\S]+)', 'gi'); + +export async function fixPings(body: string): Promise { + const mappings = await Database.getAllGithubUserMappings(); + + return body.replaceAll(mentionRegex, (match, username) => { + const mapping = mappings.find(m => m.github_username == username); + + return mapping ? `<@${mapping.discord_id}>` : match; + }); +} \ No newline at end of file diff --git a/src/discord-joiner/index.ts b/src/webserver/index.ts similarity index 73% rename from src/discord-joiner/index.ts rename to src/webserver/index.ts index 31f1a31..b1219f5 100644 --- a/src/discord-joiner/index.ts +++ b/src/webserver/index.ts @@ -7,6 +7,9 @@ import session from 'express-session'; import MemoryStore from 'memorystore'; import fs from 'fs'; import path from 'path'; +import { Client } from 'discord.js'; +import bodyParser from 'body-parser'; +import { fixPings } from '../utils/github-user-utils'; declare module 'express-session' { interface SessionData { @@ -15,6 +18,7 @@ declare module 'express-session' { oauthState: string; } } +const GITHUB_REPO_ID = 852534222;//169334303; const DEV_BASE_URL = `http://localhost:${config.PORT}`; const PROD_BASE_URL = 'https://discord.e621.net'; @@ -72,7 +76,7 @@ async function handleInitial(req: Request, res: Response): Promise { const digest = crypto.createHash('sha256').update(authString).digest('hex'); - if (hash != digest) { + if (hash !== digest) { console.error(`Bad auth: ${hash} ${digest}`); return sendForbidden(res, 'Bad auth'); } @@ -147,7 +151,38 @@ function render(res: Response, code: number, title: string = '', message: string res.status(code).setHeader('Content-Type', 'text/html').send(PAGE_TEMPLATE.replaceAll('{{ title }}', title).replaceAll('{{ message }}', message)); } -export function initializeDiscordJoiner() { +async function handleGithubRelease(client: Client, req: Request, res: Response): Promise { + const signature = (req.headers['x-hub-signature-256'] as string).split('=')[1]; + const computedSignature = crypto.createHmac('sha256', config.RELEASE_SECRET!).update(req.body).digest('hex'); + + if (signature !== computedSignature) { + return res.sendStatus(401); + } + + res.sendStatus(200); + + const data = JSON.parse(req.body); + if (data.action != 'published' || data.repository.id != GITHUB_REPO_ID) return; + + const settings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); + + if (!settings || !settings.github_release_channel) return; + + const channel = await client.channels.fetch(settings.github_release_channel); + + if (!channel || !channel.isSendable()) return; + + const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + + const date = new Date(); + + const message = `## [${months[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}](<${data.release.html_url}>)\n\n${await fixPings(data.release.body)}`; + + const sentMessage = await channel.send(message); + await sentMessage.startThread({ name: data.release.tag_name }); +} + +export function initializeWebserver(client: Client) { const app = express(); const Store = MemoryStore(session); @@ -172,6 +207,9 @@ export function initializeDiscordJoiner() { app.get('/', handleInitial); app.get('/callback', handleCallback); + app.use(bodyParser.raw({ type: 'application/json' })); + app.post('/release', handleGithubRelease.bind(null, client)); + app.listen(config.PORT, (error) => { if (error) { throw error; diff --git a/src/discord-joiner/templates/page.html b/src/webserver/templates/page.html similarity index 100% rename from src/discord-joiner/templates/page.html rename to src/webserver/templates/page.html