Skip to content

Commit c827280

Browse files
committedMar 22, 2025·
init work
1 parent ec1d727 commit c827280

File tree

6 files changed

+5313
-1
lines changed

6 files changed

+5313
-1
lines changed
 

‎assets/schemas.json

+4,967
Large diffs are not rendered by default.

‎package-lock.json

+14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+2
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@
7171
},
7272
"dependencies": {
7373
"@aws-sdk/client-s3": "^3.682.0",
74+
"@octokit/webhooks-schemas": "^7.6.1",
75+
"@octokit/webhooks-types": "^7.6.1",
7476
"@sentry/node": "^8.35.0",
7577
"ajv": "^8.17.1",
7678
"ajv-formats": "^3.0.1",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import { handleMessage, postHandleMessage, route } from "@spacebar/api";
2+
import {
3+
Attachment,
4+
Config,
5+
DiscordApiErrors,
6+
Embed,
7+
FieldErrors,
8+
Message,
9+
MessageCreateEvent,
10+
Webhook,
11+
WebhookExecuteSchema,
12+
emitEvent,
13+
uploadFile,
14+
} from "@spacebar/util";
15+
import { Request, Response, Router } from "express";
16+
import { HTTPError } from "lambert-server";
17+
import multer from "multer";
18+
import { MoreThan } from "typeorm";
19+
import { WebhookEvent } from "@octokit/webhooks-types";
20+
21+
const router = Router();
22+
23+
// TODO: config max upload size
24+
const messageUpload = multer({
25+
limits: {
26+
fileSize: Config.get().limits.message.maxAttachmentSize,
27+
fields: 10,
28+
// files: 1
29+
},
30+
storage: multer.memoryStorage(),
31+
}); // max upload 50 mb
32+
33+
// https://discord.com/developers/docs/resources/webhook#execute-webhook
34+
// TODO: GitHub/Slack compatible hooks
35+
router.post(
36+
"/",
37+
messageUpload.any(),
38+
(req, res, next) => {
39+
if (req.body.payload_json) {
40+
req.body = JSON.parse(req.body.payload_json);
41+
}
42+
43+
next();
44+
},
45+
route({
46+
//requestBody: "GithubCompatibleWebhookSchema",
47+
query: {
48+
wait: {
49+
type: "boolean",
50+
required: false,
51+
description:
52+
"waits for server confirmation of message send before response, and returns the created message body",
53+
},
54+
thread_id: {
55+
type: "string",
56+
required: false,
57+
description:
58+
"Send a message to the specified thread within a webhook's channel.",
59+
},
60+
},
61+
responses: {
62+
204: {},
63+
400: {
64+
body: "APIErrorResponse",
65+
},
66+
404: {},
67+
},
68+
}),
69+
async (req: Request, res: Response) => {
70+
const { wait } = req.query;
71+
//if (!wait) res.status(204).send();
72+
73+
const { webhook_id, token } = req.params;
74+
75+
const attachments: Attachment[] = [];
76+
77+
const webhook = await Webhook.findOne({
78+
where: {
79+
id: webhook_id,
80+
},
81+
relations: ["channel", "guild", "application"],
82+
});
83+
84+
if (!webhook) {
85+
throw DiscordApiErrors.UNKNOWN_WEBHOOK;
86+
}
87+
88+
if (webhook.token !== token) {
89+
throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED;
90+
}
91+
92+
if (!webhook.channel.isWritable()) {
93+
throw new HTTPError(
94+
`Cannot send messages to channel of type ${webhook.channel.type}`,
95+
400,
96+
);
97+
}
98+
99+
// 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?
100+
const limits = Config.get().limits;
101+
if (limits.absoluteRate.register.enabled) {
102+
const count = await Message.count({
103+
where: {
104+
channel_id: webhook.channel_id,
105+
timestamp: MoreThan(
106+
new Date(
107+
Date.now() - limits.absoluteRate.sendMessage.window,
108+
),
109+
),
110+
},
111+
});
112+
113+
if (count >= limits.absoluteRate.sendMessage.limit)
114+
throw FieldErrors({
115+
channel_id: {
116+
code: "TOO_MANY_MESSAGES",
117+
message: req.t("common:toomany.MESSAGE"),
118+
},
119+
});
120+
}
121+
122+
const body = req.body as object;
123+
let message: Message;
124+
function getUserInfo(obj: unknown) {
125+
if (
126+
typeof obj === "object" &&
127+
obj !== null &&
128+
"user" in obj &&
129+
typeof obj.user === "object" &&
130+
obj.user !== null
131+
) {
132+
const user = obj.user;
133+
134+
return {
135+
login: getStringOrFail(user, "login"),
136+
avatar: getStringOrFail(user, "avatar_url"),
137+
url: getStringOrFail(user, "html_url"),
138+
};
139+
}
140+
throw new Error("catch me");
141+
}
142+
function getStringOrFail(obj: unknown, val: string) {
143+
//@ts-expect-error error is supposed to happen
144+
const valy = obj[val];
145+
if (typeof valy !== "string") {
146+
throw new Error("catch me");
147+
}
148+
return valy;
149+
}
150+
function getNumberOrFail(obj: unknown, val: string) {
151+
//@ts-expect-error error is supposed to happen
152+
const valy = obj[val];
153+
if (typeof valy !== "number") {
154+
throw new Error("catch me");
155+
}
156+
return valy;
157+
}
158+
try {
159+
if ("event" in body) {
160+
console.log("event");
161+
if (
162+
"payload" in body &&
163+
typeof body.payload === "object" &&
164+
body.payload !== null
165+
) {
166+
console.log("pay");
167+
const payload = body.payload as { repository: unknown };
168+
let embed: Embed;
169+
console.log(body.event);
170+
const repo = {
171+
full_name: getStringOrFail(
172+
payload["repository"],
173+
"full_name",
174+
),
175+
};
176+
switch (body.event) {
177+
case "issue_comment":
178+
case "issues":
179+
console.log("issues");
180+
if (
181+
"issue" in payload &&
182+
typeof payload.issue === "object" &&
183+
payload.issue !== null
184+
) {
185+
const user = getUserInfo(payload.issue);
186+
if (!user) return res.status(204).send();
187+
//console.log(body, user);
188+
embed = {};
189+
let action: string;
190+
switch (
191+
(action = getStringOrFail(
192+
payload,
193+
"action",
194+
))
195+
) {
196+
case "closed":
197+
case "opened":
198+
case "reopened":
199+
case "deleted":
200+
case "locked":
201+
case "pinned":
202+
case "unlocked":
203+
case "unpinned":
204+
case "milestoned":
205+
case "demilestoned":
206+
console.log(
207+
"body",
208+
"user",
209+
payload.issue,
210+
);
211+
embed.author = {
212+
name: user.login,
213+
icon_url: user.avatar,
214+
url: user.url,
215+
};
216+
embed.title = `[${repo.full_name}] Issue ${action}: #${getNumberOrFail(payload.issue, "number")} ${getStringOrFail(payload.issue, "title")}`;
217+
218+
embed.url = getStringOrFail(
219+
payload.issue,
220+
"html_url",
221+
);
222+
break;
223+
case "created": {
224+
if (
225+
!(
226+
"comment" in payload &&
227+
typeof payload.comment ==
228+
"object" &&
229+
payload.comment !== null
230+
)
231+
) {
232+
throw new Error();
233+
}
234+
const user = getUserInfo(
235+
payload.comment,
236+
);
237+
embed.author = {
238+
name: user.login,
239+
icon_url: user.avatar,
240+
url: user.url,
241+
};
242+
embed.title = `[${repo.full_name}] Comment on issue: #${getNumberOrFail(payload.issue, "number")} ${getStringOrFail(payload.issue, "title")}`;
243+
embed.description = getStringOrFail(
244+
payload.comment,
245+
"body",
246+
);
247+
embed.url = getStringOrFail(
248+
payload.issue,
249+
"repository_url",
250+
);
251+
break;
252+
}
253+
default:
254+
console.error(payload.action);
255+
}
256+
console.log(embed);
257+
} else {
258+
return res.status(204).send();
259+
}
260+
message = await handleMessage({
261+
type: 0,
262+
pinned: false,
263+
webhook_id: webhook.id,
264+
application_id: webhook.application?.id,
265+
embeds: [embed],
266+
// TODO: Support thread_id/thread_name once threads are implemented
267+
channel_id: webhook.channel_id,
268+
attachments,
269+
timestamp: new Date(),
270+
});
271+
break;
272+
default:
273+
return res.status(204).send();
274+
}
275+
} else {
276+
return res.status(204).send();
277+
}
278+
} else {
279+
return res.status(204).send();
280+
}
281+
} catch {
282+
return res.status(204).send();
283+
}
284+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
285+
//@ts-ignore dont care2
286+
message.edited_timestamp = null;
287+
288+
webhook.channel.last_message_id = message.id;
289+
290+
await Promise.all([
291+
message.save(),
292+
emitEvent({
293+
event: "MESSAGE_CREATE",
294+
channel_id: webhook.channel_id,
295+
data: message,
296+
} as MessageCreateEvent),
297+
]);
298+
299+
// no await as it shouldnt block the message send function and silently catch error
300+
postHandleMessage(message).catch((e) =>
301+
console.error("[Message] post-message handler failed", e),
302+
);
303+
304+
return res.json(message);
305+
},
306+
);
307+
308+
export default router;

‎src/api/routes/webhooks/#webhook_id/#token/index.ts

-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ const messageUpload = multer({
7070
},
7171
storage: multer.memoryStorage(),
7272
}); // max upload 50 mb
73-
7473
// https://discord.com/developers/docs/resources/webhook#execute-webhook
7574
// TODO: GitHub/Slack compatible hooks
7675
router.post(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
3+
Copyright (C) 2025 Spacebar and Spacebar Contributors
4+
5+
This program is free software: you can redistribute it and/or modify
6+
it under the terms of the GNU Affero General Public License as published
7+
by the Free Software Foundation, either version 3 of the License, or
8+
(at your option) any later version.
9+
10+
This program is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
GNU Affero General Public License for more details.
14+
15+
You should have received a copy of the GNU Affero General Public License
16+
along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
//import schema from "@octokit/webhooks-schemas";
19+
20+
export interface GithubCompatibleWebhookSchema {
21+
temp: string;
22+
}

0 commit comments

Comments
 (0)
Please sign in to comment.