Add github webhook and mapping

This commit is contained in:
Tarrgon
2025-06-22 08:15:03 -04:00
parent 5dc38227a1
commit 8ddfbed396
12 changed files with 212 additions and 7 deletions
+1
View File
@@ -2,6 +2,7 @@ DISCORD_TOKEN=
DISCORD_CLIENT_SECRET= DISCORD_CLIENT_SECRET=
DISCORD_CLIENT_ID= DISCORD_CLIENT_ID=
DISCORD_GUILD_ID= DISCORD_GUILD_ID=
RELEASE_SECRET=
LINK_SECRET=super_secret_for_url_discord LINK_SECRET=super_secret_for_url_discord
E621_BASE_URL=https://e621.net E621_BASE_URL=https://e621.net
E926_BASE_URL=https://e926.net E926_BASE_URL=https://e926.net
+2
View File
@@ -6,6 +6,7 @@
"": { "": {
"dependencies": { "dependencies": {
"@redis/client": "^5.1.0", "@redis/client": "^5.1.0",
"body-parser": "^2.2.0",
"discord-oauth2": "^2.12.1", "discord-oauth2": "^2.12.1",
"discord.js": "^14.19.3", "discord.js": "^14.19.3",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
@@ -1792,6 +1793,7 @@
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^1.0.5",
+1
View File
@@ -14,6 +14,7 @@
}, },
"dependencies": { "dependencies": {
"@redis/client": "^5.1.0", "@redis/client": "^5.1.0",
"body-parser": "^2.2.0",
"discord-oauth2": "^2.12.1", "discord-oauth2": "^2.12.1",
"discord.js": "^14.19.3", "discord.js": "^14.19.3",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
+88
View File
@@ -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'));
}
}
};
+14
View File
@@ -93,6 +93,12 @@ export default {
.setName('remove-link-skip-channel') .setName('remove-link-skip-channel')
.setDescription('Remove a link skip channel.') .setDescription('Remove a link skip channel.')
.setRequired(false) .setRequired(false)
)
.addChannelOption(option =>
option
.setName('github-release-channel')
.setDescription('Set the github release channel.')
.setRequired(false)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); 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.' }); if (response.length == 0) return interaction.editReply({ content: 'No settings provided.' });
interaction.editReply({ content: response }); interaction.editReply({ content: response });
+2 -1
View File
@@ -2,13 +2,14 @@ import dotenv from 'dotenv';
dotenv.config(); 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 = { export const config = {
DISCORD_TOKEN, DISCORD_TOKEN,
DISCORD_CLIENT_ID, DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_CLIENT_SECRET,
DISCORD_GUILD_ID, DISCORD_GUILD_ID,
RELEASE_SECRET,
LINK_SECRET, LINK_SECRET,
E621_BASE_URL, E621_BASE_URL,
E926_BASE_URL, E926_BASE_URL,
+2 -2
View File
@@ -3,7 +3,7 @@ import { Client as DiscordClient, GatewayIntentBits, Guild, MessageFlags, Partia
import { config } from './config'; import { config } from './config';
import { Handler } from './types'; import { Handler } from './types';
import { checkExpiredBans, initIfNecessary, loadHandlersFrom, refreshCommands } from './utils'; import { checkExpiredBans, initIfNecessary, loadHandlersFrom, refreshCommands } from './utils';
import { initializeDiscordJoiner } from './discord-joiner'; import { initializeWebserver } from './webserver';
import { Database } from './shared/Database'; import { Database } from './shared/Database';
import { handleAuditLogCreate, handleBanRemove, handleBulkMessageDelete, handleGuildCreate, handleMemberJoin, handleMessageCreate, handleMessageDelete, handleMessageUpdate, handleThreadCreate, handleVoiceStateUpdate } from './events'; import { handleAuditLogCreate, handleBanRemove, handleBulkMessageDelete, handleGuildCreate, handleMemberJoin, handleMessageCreate, handleMessageDelete, handleMessageUpdate, handleThreadCreate, handleVoiceStateUpdate } from './events';
import { pruneOldTickets, ticketCooldownMap } from './shared/ticket-cooldown'; import { pruneOldTickets, ticketCooldownMap } from './shared/ticket-cooldown';
@@ -135,7 +135,7 @@ client.on('ready', async () => {
await Database.open('./data/discord-main.db'); await Database.open('./data/discord-main.db');
await openRedisClient(config.REDIS_URL!, client); await openRedisClient(config.REDIS_URL!, client);
await initializeDiscordJoiner(); await initializeWebserver(client);
// Prune ticket cooldowns that are expired every day // Prune ticket cooldowns that are expired every day
setInterval(pruneOldTickets, 8.64e+7); setInterval(pruneOldTickets, 8.64e+7);
+42 -2
View File
@@ -3,7 +3,7 @@ import { open, Database as SqliteDatabase } from 'sqlite';
import { config } from '../config'; import { config } from '../config';
import DiscordOAuth2 from 'discord-oauth2'; import DiscordOAuth2 from 'discord-oauth2';
import { serializeMessage, wait } from '../utils'; 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'; import { Message } from '../events';
const DB_SCHEMA = ` const DB_SCHEMA = `
@@ -27,7 +27,8 @@ const DB_SCHEMA = `
private_help_role_id TEXT, private_help_role_id TEXT,
staff_categories TEXT, staff_categories TEXT,
safe_channels TEXT, safe_channels TEXT,
link_skip_channels TEXT link_skip_channels TEXT,
github_release_channel TEXT
); );
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
@@ -68,6 +69,12 @@ const DB_SCHEMA = `
user_id TEXT, user_id TEXT,
expires_at datetime expires_at datetime
); );
CREATE TABLE IF NOT EXISTS github_user_mapping (
id INTEGER PRIMARY KEY,
discord_id TEXT,
github_username TEXT
);
`; `;
export class Database { 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); 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. // 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. // But it does allow me to skip rewriting this a bunch.
static async getGuildArraySetting(setting: GuildArraySetting, guildId: string): Promise<string[]> { static async getGuildArraySetting(setting: GuildArraySetting, guildId: string): Promise<string[]> {
@@ -322,4 +333,33 @@ export class Database {
} }
// -- END BANS -- // -- 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<string | null> {
const mapping = await Database.db.get<Pick<GithubUserMapping, 'discord_id'>>('SELECT discord_id FROM github_user_mapping WHERE github_username = ?', githubUsername);
return mapping?.discord_id ?? null;
}
static async getGithubFromDiscordId(discordId: string): Promise<string | null> {
const mapping = await Database.db.get<Pick<GithubUserMapping, 'github_username'>>('SELECT github_username FROM github_user_mapping WHERE discord_id = ?', discordId);
return mapping?.github_username ?? null;
}
static async getAllGithubUserMappings(): Promise<GithubUserMapping[]> {
return await Database.db.all<GithubUserMapping[]>('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 --
} }
+7
View File
@@ -21,6 +21,7 @@ export type GuildSettings = {
staff_categories?: string staff_categories?: string
safe_channels?: string safe_channels?: string
link_skip_channels?: string link_skip_channels?: string
github_release_channel?: string
} }
export type GuildArraySetting = 'staff_categories' | 'safe_channels' | 'link_skip_channels'; export type GuildArraySetting = 'staff_categories' | 'safe_channels' | 'link_skip_channels';
@@ -49,3 +50,9 @@ export type Ban = {
user_id: string user_id: string
expires_at: string expires_at: string
} }
export type GithubUserMapping = {
id: number
discord_id: string
github_username: string
}
+13
View File
@@ -0,0 +1,13 @@
import { Database } from '../shared/Database';
const mentionRegex = new RegExp('@([\\S]+)', 'gi');
export async function fixPings(body: string): Promise<string> {
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;
});
}
@@ -7,6 +7,9 @@ import session from 'express-session';
import MemoryStore from 'memorystore'; import MemoryStore from 'memorystore';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; 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' { declare module 'express-session' {
interface SessionData { interface SessionData {
@@ -15,6 +18,7 @@ declare module 'express-session' {
oauthState: string; oauthState: string;
} }
} }
const GITHUB_REPO_ID = 852534222;//169334303;
const DEV_BASE_URL = `http://localhost:${config.PORT}`; const DEV_BASE_URL = `http://localhost:${config.PORT}`;
const PROD_BASE_URL = 'https://discord.e621.net'; const PROD_BASE_URL = 'https://discord.e621.net';
@@ -72,7 +76,7 @@ async function handleInitial(req: Request, res: Response): Promise<any> {
const digest = crypto.createHash('sha256').update(authString).digest('hex'); const digest = crypto.createHash('sha256').update(authString).digest('hex');
if (hash != digest) { if (hash !== digest) {
console.error(`Bad auth: ${hash} ${digest}`); console.error(`Bad auth: ${hash} ${digest}`);
return sendForbidden(res, 'Bad auth'); 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)); 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<any> {
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 app = express();
const Store = MemoryStore(session); const Store = MemoryStore(session);
@@ -172,6 +207,9 @@ export function initializeDiscordJoiner() {
app.get('/', handleInitial); app.get('/', handleInitial);
app.get('/callback', handleCallback); app.get('/callback', handleCallback);
app.use(bodyParser.raw({ type: 'application/json' }));
app.post('/release', handleGithubRelease.bind(null, client));
app.listen(config.PORT, (error) => { app.listen(config.PORT, (error) => {
if (error) { if (error) {
throw error; throw error;