Implement webhook handler for GitHub

I've created a middleware that runs when `/github` is appended to the
webhook URL. The middleware parses the GitHub webhook and turns it into
usable embeds.
This commit is contained in:
Zane Helton 2025-06-29 03:18:43 -04:00
parent 81d4313631
commit 77dd481742

View File

@ -14,8 +14,9 @@ import {
WebhookUpdateSchema, WebhookUpdateSchema,
handleFile, handleFile,
ValidateName, ValidateName,
EmbedType,
} from "@spacebar/util"; } from "@spacebar/util";
import { Request, Response, Router } from "express"; import { NextFunction, Request, Response, Router } from "express";
import { HTTPError } from "lambert-server"; import { HTTPError } from "lambert-server";
import multer from "multer"; import multer from "multer";
import { MoreThan } from "typeorm"; import { MoreThan } from "typeorm";
@ -75,43 +76,7 @@ const messageUpload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
}); // max upload 50 mb }); // max upload 50 mb
// https://discord.com/developers/docs/resources/webhook#execute-webhook const executeWebhook = async (req: Request, res: Response) => {
// TODO: GitHub/Slack compatible hooks
router.post(
"/",
messageUpload.any(),
(req, res, next) => {
if (req.body.payload_json) {
req.body = JSON.parse(req.body.payload_json);
}
next();
},
route({
requestBody: "WebhookExecuteSchema",
query: {
wait: {
type: "boolean",
required: false,
description:
"waits for server confirmation of message send before response, and returns the created message body",
},
thread_id: {
type: "string",
required: false,
description:
"Send a message to the specified thread within a webhook's channel.",
},
},
responses: {
204: {},
400: {
body: "APIErrorResponse",
},
404: {},
},
}),
async (req: Request, res: Response) => {
const { wait } = req.query; const { wait } = req.query;
if (!wait) return res.status(204).send(); if (!wait) return res.status(204).send();
@ -208,11 +173,11 @@ router.post(
} }
} }
// TODO: set username and avatar based on body
const embeds = body.embeds || []; const embeds = body.embeds || [];
const message = await handleMessage({ const message = await handleMessage({
...body, ...body,
username: body.username || webhook.name,
avatar_url: body.avatar_url || webhook.avatar,
type: 0, type: 0,
pinned: false, pinned: false,
webhook_id: webhook.id, webhook_id: webhook.id,
@ -223,6 +188,7 @@ router.post(
attachments, attachments,
timestamp: new Date(), timestamp: new Date(),
}); });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore dont care2 //@ts-ignore dont care2
message.edited_timestamp = null; message.edited_timestamp = null;
@ -231,6 +197,7 @@ router.post(
await Promise.all([ await Promise.all([
message.save(), message.save(),
webhook.channel.save(),
emitEvent({ emitEvent({
event: "MESSAGE_CREATE", event: "MESSAGE_CREATE",
channel_id: webhook.channel_id, channel_id: webhook.channel_id,
@ -244,7 +211,455 @@ router.post(
); );
return res.json(message); return res.json(message);
};
const parseGitHubWebhook = (
req: Request,
res: Response,
next: NextFunction,
) => {
const eventType = req.headers["x-github-event"] as string;
if (!eventType) {
throw new HTTPError("Missing X-GitHub-Event header", 400);
}
if (eventType === "ping") {
return res.status(200).json({ message: "pong" });
}
const discordPayload = transformGitHubToDiscord(eventType, req.body);
if (!discordPayload) {
// Unsupported event type
return res.status(204).send();
}
req.body = discordPayload;
console.dir(req.body, { depth: null });
// Set default wait=true for GitHub webhooks so they get a response
req.query.wait = req.query.wait || "true";
next();
};
function transformGitHubToDiscord(
eventType: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any,
): WebhookExecuteSchema | null {
switch (eventType) {
case "star":
return {
username: "GitHub",
// TODO: Provide a static avatar for GitHub
embeds: [
{
title: `⭐ New star on ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: `${payload.sender?.login} starred the repository`,
color: 0xffd700,
thumbnail: {
url: payload.sender?.avatar_url,
}, },
timestamp: new Date().toISOString(),
},
],
};
case "commit_comment":
return {
username: "GitHub",
embeds: [
{
title: `💬 Comment on Commit ${payload.comment?.commit_id} in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: payload.comment?.body || "No comment",
color: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "create":
return {
username: "GitHub",
embeds: [
{
title: ` ${payload.ref_type} created in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: `A new ${payload.ref_type} named \`${payload.ref}\` was created`,
color: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "delete":
return {
username: "GitHub",
embeds: [
{
title: `🗑️ ${payload.ref_type} deleted in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: `The ${payload.ref_type} named \`${payload.ref}\` was deleted`,
color: 0xf04747,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "fork":
return {
username: "GitHub",
embeds: [
{
title: `🍴 Repository forked: ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: `${payload.sender?.login} forked the repository`,
color: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "issue_comment":
return {
username: "GitHub",
embeds: [
{
title: `💬 Comment on Issue #${payload.issue?.number} in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: payload.comment?.body || "No comment",
color: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "issues":
return {
username: "GitHub",
embeds: [
{
title: `📝 Issue ${payload.action} in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: payload.issue?.title,
color:
payload.issue?.state === "open"
? 0x43b581
: 0xf04747,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "member":
return {
username: "GitHub",
embeds: [
{
title: `👤 Member ${payload.action} in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: `${payload.member?.login} was ${payload.action} to the repository`,
color: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "public":
return {
username: "GitHub",
embeds: [
{
title: `🌐 Repository ${payload.repository?.full_name} is now public`,
type: EmbedType.rich,
description: `${payload.repository?.full_name} is now public`,
color: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "pull_request":
return {
username: "GitHub",
embeds: [
{
title: `🔀 Pull Request ${payload.action} in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: payload.pull_request?.title,
color:
payload.pull_request?.state === "open"
? 0x43b581
: 0xf04747,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "pull_request_review":
return {
username: "GitHub",
embeds: [
{
title: `📝 Pull Request Review ${payload.action} in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: payload.review?.body || "No review body",
color:
payload.review?.state === "approved"
? 0x43b581
: payload.review?.state === "changes_requested"
? 0xf04747
: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "pull_request_review_comment":
return {
username: "GitHub",
embeds: [
{
title: `💬 Comment on Pull Request #${payload.pull_request?.number} in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: payload.comment?.body || "No comment",
color: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "push": {
const commits = payload.commits?.slice(0, 5) || [];
if (commits.length === 0) {
return null;
}
return {
username: "GitHub",
embeds: [
{
title: `📤 Push to ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: `${commits.length} commit${commits.length !== 1 ? "s" : ""} to \`${payload.ref?.replace("refs/heads/", "")}\``,
color: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
// TODO: Improve this by adding fields for recent commits
timestamp: new Date().toISOString(),
},
],
};
}
case "release":
return {
username: "GitHub",
embeds: [
{
title: `🚀 Release ${payload.release?.tag_name} ${payload.action} in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: payload.release?.name || "No title",
color: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "watch":
return {
username: "GitHub",
embeds: [
{
title: `👀 ${payload.repository?.full_name} is now watched`,
type: EmbedType.rich,
description: `${payload.sender?.login} started watching the repository`,
color: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "check_run":
return {
username: "GitHub",
embeds: [
{
title: `✅ Check Run ${payload.check_run?.name} in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description:
payload.check_run?.output?.title || "No title",
color:
payload.check_run?.conclusion === "success"
? 0x43b581
: payload.check_run?.conclusion === "failure"
? 0xf04747
: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "check_suite":
return {
username: "GitHub",
embeds: [
{
title: `✅ Check Suite ${payload.check_suite?.status} in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description:
payload.check_suite?.head_branch || "No branch",
color:
payload.check_suite?.conclusion === "success"
? 0x43b581
: payload.check_suite?.conclusion === "failure"
? 0xf04747
: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "discussion":
return {
username: "GitHub",
embeds: [
{
title: `💬 Discussion ${payload.discussion?.title} in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: payload.discussion?.body || "No body",
color: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
case "discussion_comment":
return {
username: "GitHub",
embeds: [
{
title: `💬 Comment on Discussion #${payload.discussion?.number} in ${payload.repository?.full_name}`,
type: EmbedType.rich,
description: payload.comment?.body || "No comment",
color: 0x7289da,
thumbnail: {
url: payload.sender?.avatar_url,
},
timestamp: new Date().toISOString(),
},
],
};
default:
// console.debug("Unsupported GitHub event type:", eventType);
return null;
}
}
router.post(
"/github",
parseGitHubWebhook,
messageUpload.any(),
(req, _res, next) => {
if (req.body.payload_json) {
req.body = JSON.parse(req.body.payload_json);
}
next();
},
route({
requestBody: "WebhookExecuteSchema",
query: {
wait: {
type: "boolean",
required: false,
description:
"waits for server confirmation of message send before response, and returns the created message body",
},
thread_id: {
type: "string",
required: false,
description:
"Send a message to the specified thread within a webhook's channel.",
},
},
responses: {
204: {},
400: {
body: "APIErrorResponse",
},
404: {},
},
}),
executeWebhook,
);
// https://discord.com/developers/docs/resources/webhook#execute-webhook
// TODO: Slack compatible hooks
router.post(
"/",
messageUpload.any(),
(req, res, next) => {
if (req.body.payload_json) {
req.body = JSON.parse(req.body.payload_json);
}
next();
},
route({
requestBody: "WebhookExecuteSchema",
query: {
wait: {
type: "boolean",
required: false,
description:
"waits for server confirmation of message send before response, and returns the created message body",
},
thread_id: {
type: "string",
required: false,
description:
"Send a message to the specified thread within a webhook's channel.",
},
},
responses: {
204: {},
400: {
body: "APIErrorResponse",
},
404: {},
},
}),
executeWebhook,
); );
router.delete( router.delete(