Merge pull request #1305 from ZaneH/feat/handle-github-webhook

Implement GitHub-compatible webhook
This commit is contained in:
Puyodead1 2025-07-12 12:56:00 -04:00 committed by GitHub
commit 9347a3ec6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 724 additions and 147 deletions

View File

@ -0,0 +1,552 @@
import { getProxyUrl, route } from "@spacebar/api";
import { capitalize, EmbedType, WebhookExecuteSchema } from "@spacebar/util";
import { NextFunction, Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
import { executeWebhook } from "../../../../util/handlers/Webhook";
const router = Router();
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;
// 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":
if (payload.action !== "created") {
return null;
}
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
timestamp: new Date().toISOString(),
},
],
};
case "create":
return {
username: "GitHub",
embeds: [
{
title: ` ${capitalize(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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// TODO: Improve this by adding `fields` to show recent commits
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
timestamp: new Date().toISOString(),
},
],
};
case "watch":
return null;
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
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,
proxy_url: getProxyUrl(
new URL(payload.sender?.avatar_url),
80,
80,
),
width: 80,
height: 80,
},
// @ts-expect-error Validate using string in schema
timestamp: new Date().toISOString(),
},
],
};
default:
// console.debug("Unsupported GitHub event type:", eventType);
return null;
}
}
router.post(
"/",
parseGitHubWebhook,
(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,
);
export default router;

View File

@ -1,24 +1,18 @@
import { handleMessage, postHandleMessage, route } from "@spacebar/api";
import { route } from "@spacebar/api";
import {
Attachment,
Config,
DiscordApiErrors,
FieldErrors,
Message,
MessageCreateEvent,
Webhook,
WebhookExecuteSchema,
emitEvent,
uploadFile,
WebhooksUpdateEvent,
WebhookUpdateSchema,
handleFile,
ValidateName,
Webhook,
WebhooksUpdateEvent,
WebhookUpdateSchema,
} from "@spacebar/util";
import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
import multer from "multer";
import { MoreThan } from "typeorm";
import { executeWebhook } from "../../../../util/handlers/Webhook";
const router = Router();
router.get(
@ -76,11 +70,11 @@ const messageUpload = multer({
}); // max upload 50 mb
// https://discord.com/developers/docs/resources/webhook#execute-webhook
// TODO: GitHub/Slack compatible hooks
// TODO: Slack compatible hooks
router.post(
"/",
messageUpload.any(),
(req, res, next) => {
(req, _res, next) => {
if (req.body.payload_json) {
req.body = JSON.parse(req.body.payload_json);
}
@ -111,140 +105,7 @@ router.post(
404: {},
},
}),
async (req: Request, res: Response) => {
const { wait } = req.query;
if (!wait) return res.status(204).send();
const { webhook_id, token } = req.params;
const body = req.body as WebhookExecuteSchema;
const attachments: Attachment[] = [];
// ensure one of content, embeds, components, or file is present
if (
!body.content &&
!body.embeds &&
!body.components &&
!body.file &&
!body.attachments
) {
throw DiscordApiErrors.CANNOT_SEND_EMPTY_MESSAGE;
}
// block username from containing certain words
// TODO: configurable additions
if (body.username) {
ValidateName(body.username);
}
// block username from being certain words
// TODO: configurable additions
const blockedEquals = ["everyone", "here"];
for (const word of blockedEquals) {
if (body.username?.toLowerCase() === word) {
return res.status(400).json({
username: [`Username cannot be "${word}"`],
});
}
}
const webhook = await Webhook.findOne({
where: {
id: webhook_id,
},
relations: ["channel", "guild", "application"],
});
if (!webhook) {
throw DiscordApiErrors.UNKNOWN_WEBHOOK;
}
if (!webhook.channel.isWritable()) {
throw new HTTPError(
`Cannot send messages to channel of type ${webhook.channel.type}`,
400,
);
}
if (webhook.token !== token) {
throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED;
}
// TODO: creating messages by users checks if the user can bypass rate limits, we cant do that on webhooks, but maybe we could check the application if there is one?
const limits = Config.get().limits;
if (limits.absoluteRate.register.enabled) {
const count = await Message.count({
where: {
channel_id: webhook.channel_id,
timestamp: MoreThan(
new Date(
Date.now() - limits.absoluteRate.sendMessage.window,
),
),
},
});
if (count >= limits.absoluteRate.sendMessage.limit)
throw FieldErrors({
channel_id: {
code: "TOO_MANY_MESSAGES",
message: req.t("common:toomany.MESSAGE"),
},
});
}
const files = (req.files as Express.Multer.File[]) ?? [];
for (const currFile of files) {
try {
const file = await uploadFile(
`/attachments/${webhook.channel.id}`,
currFile,
);
attachments.push(
Attachment.create({ ...file, proxy_url: file.url }),
);
} catch (error) {
return res.status(400).json({ message: error?.toString() });
}
}
// TODO: set username and avatar based on body
const embeds = body.embeds || [];
const message = await handleMessage({
...body,
type: 0,
pinned: false,
webhook_id: webhook.id,
application_id: webhook.application?.id,
embeds,
// TODO: Support thread_id/thread_name once threads are implemented
channel_id: webhook.channel_id,
attachments,
timestamp: new Date(),
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore dont care2
message.edited_timestamp = null;
webhook.channel.last_message_id = message.id;
await Promise.all([
message.save(),
emitEvent({
event: "MESSAGE_CREATE",
channel_id: webhook.channel_id,
data: message,
} as MessageCreateEvent),
]);
// no await as it shouldnt block the message send function and silently catch error
postHandleMessage(message).catch((e) =>
console.error("[Message] post-message handler failed", e),
);
return res.json(message);
},
executeWebhook,
);
router.delete(

View File

@ -0,0 +1,154 @@
import { handleMessage, postHandleMessage } from "@spacebar/api";
import {
Attachment,
Config,
DiscordApiErrors,
emitEvent,
FieldErrors,
Message,
MessageCreateEvent,
uploadFile,
ValidateName,
Webhook,
WebhookExecuteSchema,
} from "@spacebar/util";
import { Request, Response } from "express";
import { HTTPError } from "lambert-server";
import { MoreThan } from "typeorm";
export const executeWebhook = async (req: Request, res: Response) => {
const { wait } = req.query;
if (!wait) return res.status(204).send();
const { webhook_id, token } = req.params;
const body = req.body as WebhookExecuteSchema;
const attachments: Attachment[] = [];
// ensure one of content, embeds, components, or file is present
if (
!body.content &&
!body.embeds &&
!body.components &&
!body.file &&
!body.attachments
) {
throw DiscordApiErrors.CANNOT_SEND_EMPTY_MESSAGE;
}
// block username from containing certain words
// TODO: configurable additions
if (body.username) {
ValidateName(body.username);
}
// block username from being certain words
// TODO: configurable additions
const blockedEquals = ["everyone", "here"];
for (const word of blockedEquals) {
if (body.username?.toLowerCase() === word) {
return res.status(400).json({
username: [`Username cannot be "${word}"`],
});
}
}
const webhook = await Webhook.findOne({
where: {
id: webhook_id,
},
relations: ["channel", "guild", "application"],
});
if (!webhook) {
throw DiscordApiErrors.UNKNOWN_WEBHOOK;
}
if (!webhook.channel.isWritable()) {
throw new HTTPError(
`Cannot send messages to channel of type ${webhook.channel.type}`,
400,
);
}
if (webhook.token !== token) {
throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED;
}
// TODO: creating messages by users checks if the user can bypass rate limits, we cant do that on webhooks, but maybe we could check the application if there is one?
const limits = Config.get().limits;
if (limits.absoluteRate.register.enabled) {
const count = await Message.count({
where: {
channel_id: webhook.channel_id,
timestamp: MoreThan(
new Date(
Date.now() - limits.absoluteRate.sendMessage.window,
),
),
},
});
if (count >= limits.absoluteRate.sendMessage.limit)
throw FieldErrors({
channel_id: {
code: "TOO_MANY_MESSAGES",
message: req.t("common:toomany.MESSAGE"),
},
});
}
const files = (req.files as Express.Multer.File[]) ?? [];
for (const currFile of files) {
try {
const file = await uploadFile(
`/attachments/${webhook.channel.id}`,
currFile,
);
attachments.push(
Attachment.create({ ...file, proxy_url: file.url }),
);
} catch (error) {
return res.status(400).json({ message: error?.toString() });
}
}
const embeds = body.embeds || [];
const message = await handleMessage({
...body,
username: body.username || webhook.name,
avatar_url: body.avatar_url || webhook.avatar,
type: 0,
pinned: false,
webhook_id: webhook.id,
application_id: webhook.application?.id,
embeds,
// TODO: Support thread_id/thread_name once threads are implemented
channel_id: webhook.channel_id,
attachments,
timestamp: new Date(),
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore dont care2
message.edited_timestamp = null;
webhook.channel.last_message_id = message.id;
await Promise.all([
message.save(),
webhook.channel.save(),
emitEvent({
event: "MESSAGE_CREATE",
channel_id: webhook.channel_id,
data: message,
} as MessageCreateEvent),
]);
// no await as it shouldnt block the message send function and silently catch error
postHandleMessage(message).catch((e) =>
console.error("[Message] post-message handler failed", e),
);
return res.json(message);
};

View File

@ -22,3 +22,13 @@ export function trimSpecial(str?: string): string {
if (!str) return "";
return str.replace(SPECIAL_CHAR, "").trim();
}
/**
* Capitalizes the first letter of a string.
* @param str The string to capitalize.
* @returns The capitalized string.
*/
export function capitalize(str: string): string {
if (!str) return "";
return str.charAt(0).toUpperCase() + str.slice(1);
}