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:
parent
81d4313631
commit
77dd481742
@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user