1
0
mirror of https://github.com/spacebarchat/server.git synced 2024-11-25 03:33:33 +01:00

Merge branch 'master' of https://github.com/DEVTomatoCake/spacebar-server into feat/local-image-proxy

This commit is contained in:
TomatoCake 2024-08-15 16:00:18 +02:00
commit 16322a8829
56 changed files with 126358 additions and 7441 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1719254875,
"narHash": "sha256-ECni+IkwXjusHsm9Sexdtq8weAq/yUyt1TWIemXt3Ko=",
"lastModified": 1723362943,
"narHash": "sha256-dFZRVSgmJkyM0bkPpaYRtG/kRMRTorUIDj8BxoOt1T4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2893f56de08021cffd9b6b6dfc70fd9ccd51eb60",
"rev": "a58bc8ad779655e790115244571758e8de055e3d",
"type": "github"
},
"original": {

View File

@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-RxGkjCU9qqqDMjhJ5aEq1w7c7lS4nAp0/3F0zASJQms="
"npmDepsHash": "sha256-kdS1SwcBu6Dor92iO1ickLgz0T5UL16nyA49xXGajf4="
}

4631
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -59,14 +59,14 @@
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.56.0",
"express": "^4.18.2",
"express": "^4.19.2",
"husky": "^8.0.3",
"prettier": "^2.8.8",
"pretty-quick": "^3.1.3",
"typescript": "^4.9.5"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.385.0",
"@aws-sdk/client-s3": "^3.629.0",
"@sentry/integrations": "^7.66.0",
"@sentry/node": "^7.66.0",
"ajv": "8.6.2",
@ -97,7 +97,7 @@
"node-2fa": "^2.0.3",
"node-fetch": "^2.6.12",
"node-os-utils": "^1.3.7",
"nodemailer": "^6.9.4",
"nodemailer": "^6.9.14",
"picocolors": "^1.0.0",
"probe-image-size": "^7.2.3",
"proxy-agent": "^6.3.0",
@ -107,7 +107,7 @@
"typeorm": "^0.3.17",
"typescript-json-schema": "^0.50.1",
"wretch": "^2.6.0",
"ws": "^8.13.0"
"ws": "^8.17.1"
},
"_moduleAliases": {
"@spacebar/api": "dist/api",

View File

@ -1,5 +1,5 @@
diff --git a/node_modules/express/lib/response.js b/node_modules/express/lib/response.js
index fede486..e3d868e 100644
index dd7b3c8..a339896 100644
--- a/node_modules/express/lib/response.js
+++ b/node_modules/express/lib/response.js
@@ -27,7 +27,6 @@ var merge = require('utils-merge');
@ -10,21 +10,15 @@ index fede486..e3d868e 100644
var cookie = require('cookie');
var send = require('send');
var extname = path.extname;
@@ -49,13 +48,6 @@ var res = Object.create(http.ServerResponse.prototype)
@@ -54,7 +53,6 @@ module.exports = res
* @private
*/
module.exports = res
-/**
- * Module variables.
- * @private
- */
-
-var charsetRegExp = /;\s*charset\s*=/;
-
var schemaAndHostRegExp = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\/[^\\\/\?]+/;
/**
* Set status `code`.
*
@@ -164,17 +156,6 @@ res.send = function send(body) {
@@ -165,16 +163,6 @@ res.send = function send(body) {
break;
}
@ -38,11 +32,10 @@ index fede486..e3d868e 100644
- this.set('Content-Type', setCharset(type, 'utf-8'));
- }
- }
-
// determine if ETag should be generated
var etagFn = app.get('etag fn')
var generateETag = !this.get('ETag') && typeof etagFn === 'function'
@@ -780,17 +761,6 @@ res.header = function header(field, val) {
@@ -781,17 +769,6 @@ res.header = function header(field, val) {
? val.map(String)
: String(val);

View File

@ -16,8 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { checkToken, Rights } from "@spacebar/util";
import * as Sentry from "@sentry/node";
import { checkToken, Rights } from "@spacebar/util";
import { NextFunction, Request, Response } from "express";
import { HTTPError } from "lambert-server";
@ -32,7 +32,7 @@ export const NO_AUTHORIZATION_ROUTES = [
"/auth/forgot",
"/auth/reset",
// Routes with a seperate auth system
"/webhooks/",
/\/webhooks\/\d+\/\w+\/?/, // no token requires auth
// Public information endpoints
"/ping",
"/gateway",

View File

@ -57,7 +57,7 @@ router.post(
res.send({
token: await generateToken(user.id),
}).status(204);
});
},
);

View File

@ -331,4 +331,74 @@ router.delete(
},
);
router.delete(
"/:emoji/:burst/:user_id",
route({
responses: {
204: {},
400: {
body: "APIErrorResponse",
},
404: {},
403: {},
},
}),
async (req: Request, res: Response) => {
let { user_id } = req.params;
const { message_id, channel_id } = req.params;
const emoji = getEmoji(req.params.emoji);
const channel = await Channel.findOneOrFail({
where: { id: channel_id },
});
const message = await Message.findOneOrFail({
where: { id: message_id, channel_id },
});
if (user_id === "@me") user_id = req.user_id;
else {
const permissions = await getPermission(
req.user_id,
undefined,
channel_id,
);
permissions.hasThrow("MANAGE_MESSAGES");
}
const already_added = message.reactions.find(
(x) =>
(x.emoji.id === emoji.id && emoji.id) ||
x.emoji.name === emoji.name,
);
if (!already_added || !already_added.user_ids.includes(user_id))
throw new HTTPError("Reaction not found", 404);
already_added.count--;
if (already_added.count <= 0) message.reactions.remove(already_added);
else
already_added.user_ids.splice(
already_added.user_ids.indexOf(user_id),
1,
);
await message.save();
await emitEvent({
event: "MESSAGE_REACTION_REMOVE",
channel_id,
data: {
user_id: req.user_id,
channel_id,
message_id,
guild_id: channel.guild_id,
emoji,
},
} as MessageReactionRemoveEvent);
res.sendStatus(204);
},
);
export default router;

View File

@ -130,30 +130,45 @@ router.get(
query.take = Math.floor(limit / 2);
if (query.take != 0) {
const [right, left] = await Promise.all([
Message.find({ ...query, where: { id: LessThan(around) } }),
Message.find({
...query,
where: { id: MoreThanOrEqual(around) },
where: { channel_id, id: LessThan(around) },
}),
Message.find({
...query,
where: { channel_id, id: MoreThanOrEqual(around) },
order: { timestamp: "ASC" },
}),
]);
left.push(...right);
messages = left;
messages = left.sort(
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
);
} else {
query.take = 1;
const message = await Message.findOne({
...query,
where: { id: around },
where: { channel_id, id: around },
});
messages = message ? [message] : [];
}
} else {
if (after) {
if (BigInt(after) > BigInt(Snowflake.generate()))
return res.status(422);
throw new HTTPError(
"after parameter must not be greater than current time",
422,
);
query.where.id = MoreThan(after);
query.order = { timestamp: "ASC" };
} else if (before) {
if (BigInt(before) > BigInt(Snowflake.generate()))
return res.status(422);
throw new HTTPError(
"before parameter must not be greater than current time",
422,
);
query.where.id = LessThan(before);
}

View File

@ -26,8 +26,8 @@ import {
WebhookCreateSchema,
WebhookType,
handleFile,
trimSpecial,
isTextChannel,
trimSpecial,
} from "@spacebar/util";
import crypto from "crypto";
import { Request, Response, Router } from "express";
@ -35,10 +35,12 @@ import { HTTPError } from "lambert-server";
const router: Router = Router();
//TODO: implement webhooks
router.get(
"/",
route({
description:
"Returns a list of channel webhook objects. Requires the MANAGE_WEBHOOKS permission.",
permission: "MANAGE_WEBHOOKS",
responses: {
200: {
body: "APIWebhookArray",
@ -46,7 +48,32 @@ router.get(
},
}),
async (req: Request, res: Response) => {
res.json([]);
const { channel_id } = req.params;
const webhooks = await Webhook.find({
where: { channel_id },
relations: [
"user",
"channel",
"source_channel",
"guild",
"source_guild",
"application",
],
});
const instanceUrl =
Config.get().api.endpointPublic || "http://localhost:3001";
return res.json(
webhooks.map((webhook) => ({
...webhook,
url:
instanceUrl +
"/webhooks/" +
webhook.id +
"/" +
webhook.token,
})),
);
},
);
@ -89,15 +116,15 @@ router.post(
if (avatar) avatar = await handleFile(`/avatars/${channel_id}`, avatar);
const hook = Webhook.create({
const hook = await Webhook.create({
type: WebhookType.Incoming,
name,
avatar,
guild_id: channel.guild_id,
channel_id: channel.id,
user_id: req.user_id,
token: crypto.randomBytes(24).toString("base64"),
});
token: crypto.randomBytes(24).toString("base64url"),
}).save();
const user = await User.getPublicUser(req.user_id);

View File

@ -39,8 +39,8 @@ router.get(
const { primary_only } = req.query;
const out = primary_only
? await Categories.find()
: await Categories.find({ where: { is_primary: true } });
? await Categories.find({ where: { is_primary: true } })
: await Categories.find();
res.send(out);
},

View File

@ -27,6 +27,7 @@ import {
} from "@spacebar/util";
import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
import { Config } from "@spacebar/util";
const router: Router = Router();
@ -52,7 +53,8 @@ router.post(
const userIds: Array<string> = req.body.user_ids;
if (!userIds) throw new HTTPError("The user_ids array is missing", 400);
if (userIds.length > 200)
if (userIds.length > Config.get().limits.guild.maxBulkBanUsers)
throw new HTTPError(
"The user_ids array must be between 1 and 200 in length",
400,

View File

@ -16,12 +16,51 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Router, Response, Request } from "express";
import { route } from "@spacebar/api";
import { Config, Webhook } from "@spacebar/util";
import { Request, Response, Router } from "express";
const router = Router();
//TODO: implement webhooks
router.get("/", route({}), async (req: Request, res: Response) => {
res.json([]);
router.get(
"/",
route({
description:
"Returns a list of guild webhook objects. Requires the MANAGE_WEBHOOKS permission.",
permission: "MANAGE_WEBHOOKS",
responses: {
200: {
body: "APIWebhookArray",
},
},
}),
async (req: Request, res: Response) => {
const { guild_id } = req.params;
const webhooks = await Webhook.find({
where: { guild_id },
relations: [
"user",
"channel",
"source_channel",
"guild",
"source_guild",
"application",
],
});
const instanceUrl =
Config.get().api.endpointPublic || "http://localhost:3001";
return res.json(
webhooks.map((webhook) => ({
...webhook,
url:
instanceUrl +
"/webhooks/" +
webhook.id +
"/" +
webhook.token,
})),
);
},
);
export default router;

View File

@ -60,7 +60,7 @@ router.post(
}),
]);
return res.status(204);
return res.sendStatus(204);
},
);

View File

@ -19,6 +19,8 @@
import { route } from "@spacebar/api";
import {
Badge,
Config,
FieldErrors,
Member,
PrivateUserProjection,
User,
@ -136,6 +138,18 @@ router.patch(
select: [...PrivateUserProjection, "data"],
});
if (body.bio) {
const { maxBio } = Config.get().limits.user;
if (body.bio.length > maxBio) {
throw FieldErrors({
bio: {
code: "BIO_INVALID",
message: `Bio must be less than ${maxBio} in length`,
},
});
}
}
user.assign(body);
await user.save();

View File

@ -120,7 +120,7 @@ router.patch(
if (!body.password)
throw FieldErrors({
password: {
message: req.t("auth:register.INVALID_PASSWORD"),
message: req.t("auth:login.INVALID_PASSWORD"),
code: "INVALID_PASSWORD",
},
});
@ -160,6 +160,15 @@ router.patch(
},
});
}
if (!body.password) {
throw FieldErrors({
password: {
message: req.t("auth:login.INVALID_PASSWORD"),
code: "INVALID_PASSWORD",
},
});
}
}
if (body.discriminator) {
@ -180,6 +189,18 @@ router.patch(
}
}
if (body.bio) {
const { maxBio } = Config.get().limits.user;
if (body.bio.length > maxBio) {
throw FieldErrors({
bio: {
code: "BIO_INVALID",
message: `Bio must be less than ${maxBio} in length`,
},
});
}
}
user.assign(body);
user.validate();
await user.save();

View File

@ -107,7 +107,7 @@ router.put(
user_id: owner.id,
});
return res.status(204);
return res.sendStatus(204);
},
);

View File

@ -0,0 +1,251 @@
import { handleMessage, postHandleMessage, route } from "@spacebar/api";
import {
Attachment,
Config,
DiscordApiErrors,
FieldErrors,
Message,
MessageCreateEvent,
Webhook,
WebhookExecuteSchema,
emitEvent,
uploadFile,
} from "@spacebar/util";
import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
import multer from "multer";
import { MoreThan } from "typeorm";
const router = Router();
router.get(
"/",
route({
description: "Returns a webhook object for the given id and token.",
responses: {
200: {
body: "APIWebhook",
},
404: {},
},
}),
async (req: Request, res: Response) => {
const { webhook_id, token } = req.params;
const webhook = await Webhook.findOne({
where: {
id: webhook_id,
},
relations: [
"user",
"channel",
"source_channel",
"guild",
"source_guild",
"application",
],
});
if (!webhook) {
throw DiscordApiErrors.UNKNOWN_WEBHOOK;
}
if (webhook.token !== token) {
throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED;
}
const instanceUrl =
Config.get().api.endpointPublic || "http://localhost:3001";
return res.json({
...webhook,
url: instanceUrl + "/webhooks/" + webhook.id + "/" + webhook.token,
});
},
);
// TODO: config max upload size
const messageUpload = multer({
limits: {
fileSize: Config.get().limits.message.maxAttachmentSize,
fields: 10,
// files: 1
},
storage: multer.memoryStorage(),
}); // max upload 50 mb
// https://discord.com/developers/docs/resources/webhook#execute-webhook
// 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;
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
const blockedContains = ["discord", "clyde", "spacebar"];
for (const word of blockedContains) {
if (body.username?.toLowerCase().includes(word)) {
return res.status(400).json({
username: [`Username cannot contain "${word}"`],
});
}
}
// 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);
},
);
export default router;

View File

@ -0,0 +1,57 @@
import { route } from "@spacebar/api";
import {
Config,
DiscordApiErrors,
getPermission,
Webhook,
} from "@spacebar/util";
import { Request, Response, Router } from "express";
const router = Router();
router.get(
"/",
route({
description:
"Returns a webhook object for the given id. Requires the MANAGE_WEBHOOKS permission or to be the owner of the webhook.",
responses: {
200: {
body: "APIWebhook",
},
404: {},
},
}),
async (req: Request, res: Response) => {
const { webhook_id } = req.params;
const webhook = await Webhook.findOneOrFail({
where: { id: webhook_id },
relations: [
"user",
"channel",
"source_channel",
"guild",
"source_guild",
"application",
],
});
if (webhook.guild_id) {
const permission = await getPermission(
req.user_id,
webhook.guild_id,
);
if (!permission.has("MANAGE_WEBHOOKS"))
throw DiscordApiErrors.UNKNOWN_WEBHOOK;
} else if (webhook.user_id != req.user_id)
throw DiscordApiErrors.UNKNOWN_WEBHOOK;
const instanceUrl =
Config.get().api.endpointPublic || "http://localhost:3001";
return res.json({
...webhook,
url: instanceUrl + "/webhooks/" + webhook.id + "/" + webhook.token,
});
},
);
export default router;

View File

@ -43,9 +43,12 @@ import {
//CHANNEL_MENTION,
USER_MENTION,
Webhook,
handleFile,
Permissions,
} from "@spacebar/util";
import { HTTPError } from "lambert-server";
import { In } from "typeorm";
import fetch from "node-fetch";
const allow_empty = false;
// TODO: check webhook, application, system author, stickers
// TODO: embed gifs/videos/images
@ -93,13 +96,63 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> {
where: { id: opts.application_id },
});
}
let permission: undefined | Permissions;
if (opts.webhook_id) {
message.webhook = await Webhook.findOneOrFail({
where: { id: opts.webhook_id },
});
message.author =
(await User.findOne({
where: { id: opts.webhook_id },
})) || undefined;
if (!message.author) {
message.author = User.create({
id: opts.webhook_id,
username: message.webhook.name,
discriminator: "0000",
avatar: message.webhook.avatar,
public_flags: 0,
premium: false,
premium_type: 0,
bot: true,
created_at: new Date(),
verified: true,
rights: "0",
data: {
valid_tokens_since: new Date(),
},
});
await message.author.save();
}
const permission = await getPermission(
if (opts.username) {
message.username = opts.username;
message.author.username = message.username;
}
if (opts.avatar_url) {
const avatarData = await fetch(opts.avatar_url);
const base64 = await avatarData
.buffer()
.then((x) => x.toString("base64"));
const dataUri =
"data:" +
avatarData.headers.get("content-type") +
";base64," +
base64;
message.avatar = await handleFile(
`/avatars/${opts.webhook_id}`,
dataUri as string,
);
message.author.avatar = message.avatar;
}
} else {
permission = await getPermission(
opts.author_id,
channel.guild_id,
opts.channel_id,
@ -117,7 +170,6 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> {
const guild = await Guild.findOneOrFail({
where: { id: channel.guild_id },
});
if (!opts.message_reference.guild_id)
opts.message_reference.guild_id = channel.guild_id;
if (!opts.message_reference.channel_id)
@ -140,6 +192,7 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> {
otherwise backfilling won't work **/
message.type = MessageType.REPLY;
}
}
// TODO: stickers/activity
if (
@ -183,14 +236,18 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> {
const role = await Role.findOneOrFail({
where: { id: mention, guild_id: channel.guild_id },
});
if (role.mentionable || permission.has("MANAGE_ROLES")) {
if (
role.mentionable ||
opts.webhook_id ||
permission?.has("MANAGE_ROLES")
) {
mention_role_ids.push(mention);
}
},
),
);
if (permission.has("MENTION_EVERYONE")) {
if (opts.webhook_id || permission?.has("MENTION_EVERYONE")) {
mention_everyone =
!!content.match(EVERYONE_MENTION) ||
!!content.match(HERE_MENTION);
@ -316,4 +373,6 @@ interface MessageOptions extends MessageCreateSchema {
attachments?: Attachment[];
edited_timestamp?: Date;
timestamp?: Date;
username?: string;
avatar_url?: string;
}

View File

@ -50,7 +50,7 @@ export async function Close(this: WebSocket, code: number, reason: Buffer) {
} as SessionsReplace);
const session = sessions.first() || {
activities: [],
client_info: {},
client_status: {},
status: "offline",
};
@ -68,7 +68,7 @@ export async function Close(this: WebSocket, code: number, reason: Buffer) {
data: {
user: userOrId,
activities: session.activities,
client_status: session?.client_info,
client_status: session?.client_status,
status: session.status,
},
} as PresenceUpdateEvent);

View File

@ -122,8 +122,8 @@ export async function onIdentify(this: WebSocket, data: Payload) {
session_id: this.session_id,
status: identify.presence?.status || "online",
client_info: {
client: identify.properties?.$device,
os: identify.properties?.os,
client: identify.properties?.device || identify.properties?.$device,
os: identify.properties?.os || identify.properties?.$os,
version: 0,
},
activities: identify.presence?.activities, // TODO: validation
@ -372,7 +372,7 @@ export async function onIdentify(this: WebSocket, data: Payload) {
data: {
user: user.toPublicUser(),
activities: session.activities,
client_status: session.client_info,
client_status: session.client_status,
status: session.status,
},
} as PresenceUpdateEvent),

View File

@ -248,7 +248,7 @@ export async function onLazyRequest(this: WebSocket, { d }: Payload) {
d: {
user: user,
activities: session?.activities || [],
client_status: session?.client_info,
client_status: session?.client_status,
status: session?.status || "offline",
} as Presence,
});

View File

@ -35,14 +35,19 @@ export async function onPresenceUpdate(this: WebSocket, { d }: Payload) {
{ status: presence.status, activities: presence.activities },
);
const session = await Session.findOneOrFail({
select: ["client_status"],
where: { session_id: this.session_id },
});
await emitEvent({
event: "PRESENCE_UPDATE",
user_id: this.user_id,
data: {
user: await User.getPublicUser(this.user_id),
activities: presence.activities,
client_status: {}, // TODO:
status: presence.status,
activities: presence.activities,
client_status: session.client_status,
},
} as PresenceUpdateEvent);
}

View File

@ -16,8 +16,109 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { WebSocket } from "@spacebar/gateway";
import {
getPermission,
GuildMembersChunkEvent,
Member,
Presence,
RequestGuildMembersSchema,
Session,
} from "@spacebar/util";
import { WebSocket, Payload, OPCODES, Send } from "@spacebar/gateway";
import { check } from "./instanceOf";
import { FindManyOptions, In, Like } from "typeorm";
export function onRequestGuildMembers(this: WebSocket) {
// return this.close(CLOSECODES.Unknown_error);
export async function onRequestGuildMembers(this: WebSocket, { d }: Payload) {
// TODO: check data
check.call(this, RequestGuildMembersSchema, d);
const { guild_id, query, presences, nonce } =
d as RequestGuildMembersSchema;
let { limit, user_ids } = d as RequestGuildMembersSchema;
if ("query" in d && (!limit || Number.isNaN(limit)))
throw new Error('"query" requires "limit" to be set');
if ("query" in d && user_ids)
throw new Error('"query" and "user_ids" are mutually exclusive');
if (user_ids && !Array.isArray(user_ids)) user_ids = [user_ids];
user_ids = user_ids as string[] | undefined;
// TODO: Configurable limit?
if ((query || (user_ids && user_ids.length > 0)) && (!limit || limit > 100))
limit = 100;
const permissions = await getPermission(this.user_id, guild_id);
permissions.hasThrow("VIEW_CHANNEL");
const whereQuery: FindManyOptions["where"] = {};
if (query) {
whereQuery.user = {
username: Like(query + "%"),
};
} else if (user_ids && user_ids.length > 0) {
whereQuery.id = In(user_ids);
}
const memberFind: FindManyOptions = {
where: {
...whereQuery,
guild_id,
},
relations: ["user", "roles"],
};
if (limit) memberFind.take = Math.abs(Number(limit || 100));
const members = await Member.find(memberFind);
const baseData = {
guild_id,
nonce,
};
const chunkCount = Math.ceil(members.length / 1000);
let notFound: string[] = [];
if (user_ids && user_ids.length > 0)
notFound = user_ids.filter(
(id) => !members.some((member) => member.id == id),
);
const chunks: GuildMembersChunkEvent["data"][] = [];
while (members.length > 0) {
const chunk: Member[] = members.splice(0, 1000);
const presenceList: Presence[] = [];
if (presences) {
for await (const member of chunk) {
const session = await Session.findOne({
where: { user_id: member.id },
});
if (session)
presenceList.push({
user: member.user.toPublicUser(),
status: session.status,
activities: session.activities,
client_status: session.client_status,
});
}
}
chunks.push({
...baseData,
members: chunk.map((member) => member.toPublicMember()),
presences: presences ? presenceList : undefined,
chunk_index: chunks.length,
chunk_count: chunkCount,
});
}
if (notFound.length > 0) chunks[0].not_found = notFound;
chunks.forEach((chunk) => {
Send(this, {
op: OPCODES.Dispatch,
s: this.sequence++,
t: "GUILD_MEMBERS_CHUNK",
d: chunk,
});
});
}

View File

@ -21,5 +21,6 @@ export class GuildLimits {
maxEmojis: number = 2000;
maxMembers: number = 25000000;
maxChannels: number = 65535;
maxBulkBanUsers: number = 200;
maxChannelsInCategory: number = 65535;
}

View File

@ -20,4 +20,5 @@ export class UserLimits {
maxGuilds: number = 1048576;
maxUsername: number = 32;
maxFriends: number = 5000;
maxBio: number = 190;
}

View File

@ -46,6 +46,10 @@ export class Categories extends BaseClassWithoutId {
@Column({ type: "simple-json" })
localizations: string;
// Whether to show the category prominently (e.g. in a sidebar) instead of only secondary (e.g. in search results)
@Column({ nullable: true })
is_primary: boolean;
@Column({ nullable: true })
icon?: string;
}

View File

@ -216,17 +216,23 @@ export class Message extends BaseClass {
};
@Column({ type: "simple-json", nullable: true })
components?: MessageComponent[];
components?: ActionRowComponent[];
@Column({ type: "simple-json", nullable: true })
poll?: Poll;
@Column({ nullable: true })
username?: string;
@Column({ nullable: true })
avatar?: string;
toJSON(): Message {
return {
...this,
author_id: undefined,
member_id: undefined,
webhook_id: undefined,
webhook_id: this.webhook_id ?? undefined,
application_id: undefined,
nonce: this.nonce ?? undefined,
@ -237,7 +243,12 @@ export class Message extends BaseClass {
reactions: this.reactions ?? undefined,
sticker_items: this.sticker_items ?? undefined,
message_reference: this.message_reference ?? undefined,
author: this.author?.toPublicUser() ?? undefined,
author: {
...(this.author?.toPublicUser() ?? undefined),
// Webhooks
username: this.username ?? this.author?.username,
avatar: this.avatar ?? this.author?.avatar,
},
activity: this.activity ?? undefined,
application: this.application ?? undefined,
components: this.components ?? undefined,
@ -248,21 +259,100 @@ export class Message extends BaseClass {
}
export interface MessageComponent {
type: number;
style?: number;
type: MessageComponentType;
}
export interface ActionRowComponent extends MessageComponent {
type: MessageComponentType.ActionRow;
components: (
| ButtonComponent
| StringSelectMenuComponent
| SelectMenuComponent
| TextInputComponent
)[];
}
export interface ButtonComponent extends MessageComponent {
type: MessageComponentType.Button;
style: ButtonStyle;
label?: string;
emoji?: PartialEmoji;
custom_id?: string;
sku_id?: string;
url?: string;
disabled?: boolean;
components: MessageComponent[];
}
export enum ButtonStyle {
Primary = 1,
Secondary = 2,
Success = 3,
Danger = 4,
Link = 5,
Premium = 6,
}
export interface SelectMenuComponent extends MessageComponent {
type:
| MessageComponentType.StringSelect
| MessageComponentType.UserSelect
| MessageComponentType.RoleSelect
| MessageComponentType.MentionableSelect
| MessageComponentType.ChannelSelect;
custom_id: string;
channel_types?: number[];
placeholder?: string;
default_values?: SelectMenuDefaultOption[]; // only for non-string selects
min_values?: number;
max_values?: number;
disabled?: boolean;
}
export interface SelectMenuOption {
label: string;
value: string;
description?: string;
emoji?: PartialEmoji;
default?: boolean;
}
export interface SelectMenuDefaultOption {
id: string;
type: "user" | "role" | "channel";
}
export interface StringSelectMenuComponent extends SelectMenuComponent {
type: MessageComponentType.StringSelect;
options: SelectMenuOption[];
}
export interface TextInputComponent extends MessageComponent {
type: MessageComponentType.TextInput;
custom_id: string;
style: TextInputStyle;
label: string;
min_length?: number;
max_length?: number;
required?: boolean;
value?: string;
placeholder?: string;
}
export enum TextInputStyle {
Short = 1,
Paragraph = 2,
}
export enum MessageComponentType {
Script = 0, // self command script
ActionRow = 1,
Button = 2,
StringSelect = 3,
TextInput = 4,
UserSelect = 5,
RoleSelect = 6,
MentionableSelect = 7,
ChannelSelect = 8,
}
export interface Embed {

View File

@ -19,7 +19,7 @@
import { User } from "./User";
import { BaseClass } from "./BaseClass";
import { Column, Entity, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { Status } from "../interfaces/Status";
import { ClientStatus, Status } from "../interfaces/Status";
import { Activity } from "../interfaces/Activity";
//TODO we need to remove all sessions on server start because if the server crashes without closing websockets it won't delete them
@ -43,7 +43,6 @@ export class Session extends BaseClass {
@Column({ type: "simple-json", nullable: true })
activities: Activity[];
// TODO client_status
@Column({ type: "simple-json", select: false })
client_info: {
client: string;
@ -51,6 +50,9 @@ export class Session extends BaseClass {
version: number;
};
@Column({ type: "simple-json" })
client_status: ClientStatus;
@Column({ nullable: false, type: "varchar" })
status: Status; //TODO enum
}

View File

@ -130,7 +130,7 @@ export class User extends BaseClass {
bot: boolean = false; // if user is bot
@Column()
bio: string = ""; // short description of the user (max 190 chars -> should be configurable)
bio: string = ""; // short description of the user
@Column()
system: boolean = false; // shouldn't be used, the api sends this field type true, if the generated message comes from a system generated author

View File

@ -35,23 +35,23 @@ export class Webhook extends BaseClass {
type: WebhookType;
@Column({ nullable: true })
name?: string;
name: string;
@Column({ nullable: true })
avatar?: string;
avatar: string;
@Column({ nullable: true })
token?: string;
@Column({ nullable: true })
@RelationId((webhook: Webhook) => webhook.guild)
guild_id: string;
guild_id?: string;
@JoinColumn({ name: "guild_id" })
@ManyToOne(() => Guild, {
onDelete: "CASCADE",
})
guild: Guild;
guild?: Guild;
@Column({ nullable: true })
@RelationId((webhook: Webhook) => webhook.channel)
@ -85,11 +85,23 @@ export class Webhook extends BaseClass {
@Column({ nullable: true })
@RelationId((webhook: Webhook) => webhook.guild)
source_guild_id: string;
source_guild_id?: string;
@JoinColumn({ name: "source_guild_id" })
@ManyToOne(() => Guild, {
onDelete: "CASCADE",
})
source_guild: Guild;
source_guild?: Guild;
@Column({ nullable: true })
@RelationId((webhook: Webhook) => webhook.channel)
source_channel_id: string;
@JoinColumn({ name: "source_channel_id" })
@ManyToOne(() => Channel, {
onDelete: "CASCADE",
})
source_channel: Channel;
url: string;
}

View File

@ -280,8 +280,8 @@ export interface GuildMembersChunkEvent extends Event {
members: PublicMember[];
chunk_index: number;
chunk_count: number;
not_found: string[];
presences: Presence[];
not_found?: string[];
presences?: Presence[];
nonce?: string;
};
}

View File

@ -21,5 +21,6 @@ export type Status = "idle" | "dnd" | "online" | "offline" | "invisible";
export interface ClientStatus {
desktop?: string; // e.g. Windows/Linux/Mac
mobile?: string; // e.g. iOS/Android
web?: string; // e.g. browser, bot account
web?: string; // e.g. browser, bot account, unknown
embedded?: string; // e.g. embedded
}

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class WebhookMessageProperties1721298824927
implements MigrationInterface
{
name = "WebhookMessageProperties1721298824927";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `messages` ADD `username` text NULL",
);
await queryRunner.query(
"ALTER TABLE `messages` ADD `avatar` text NULL",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `messages` DROP COLUMN `username`",
);
await queryRunner.query("ALTER TABLE `messages` DROP COLUMN `avatar`");
}
}

View File

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class client_status1723347738541 implements MigrationInterface {
name = "client_status1723347738541";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `sessions` ADD `client_status` text NULL AFTER `client_info`",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `sessions` DROP COLUMN `client_status`",
);
}
}

View File

@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class DiscoveryCategoryIcon1723577874393 implements MigrationInterface {
name = "DiscoveryCategoryIcon1723577874393";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `categories` ADD `icon` text NULL",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query("ALTER TABLE `categories` DROP COLUMN `icon`");
}
}

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class WebhookSourceChannel1723644478176 implements MigrationInterface {
name = "WebhookSourceChannel1723644478176";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `webhooks` ADD COLUMN `source_channel_id` VARCHAR(255) NULL DEFAULT NULL AFTER `source_guild_id`",
);
await queryRunner.query(
"ALTER TABLE `webhooks` ADD CONSTRAINT `FK_d64f38834fa676f6caa4786ddd6` FOREIGN KEY (`source_channel_id`) REFERENCES `channels` (`id`) ON UPDATE NO ACTION ON DELETE CASCADE",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `webhooks` DROP FOREIGN KEY `FK_d64f38834fa676f6caa4786ddd6`",
);
await queryRunner.query(
"ALTER TABLE `webhooks` DROP COLUMN `source_channel_id`",
);
}
}

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class WebhookMessageProperties1721298824927
implements MigrationInterface
{
name = "WebhookMessageProperties1721298824927";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `messages` ADD `username` text NULL",
);
await queryRunner.query(
"ALTER TABLE `messages` ADD `avatar` text NULL",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `messages` DROP COLUMN `username`",
);
await queryRunner.query("ALTER TABLE `messages` DROP COLUMN `avatar`");
}
}

View File

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class client_status1723347738541 implements MigrationInterface {
name = "client_status1723347738541";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `sessions` ADD `client_status` text NULL AFTER `client_info`",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `sessions` DROP COLUMN `client_status`",
);
}
}

View File

@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class DiscoveryCategoryIcon1723577874393 implements MigrationInterface {
name = "DiscoveryCategoryIcon1723577874393";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `categories` ADD `icon` text NULL",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query("ALTER TABLE `categories` DROP COLUMN `icon`");
}
}

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class WebhookSourceChannel1723644478176 implements MigrationInterface {
name = "WebhookSourceChannel1723644478176";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `webhooks` ADD COLUMN `source_channel_id` VARCHAR(255) NULL DEFAULT NULL AFTER `source_guild_id`",
);
await queryRunner.query(
"ALTER TABLE `webhooks` ADD CONSTRAINT `FK_d64f38834fa676f6caa4786ddd6` FOREIGN KEY (`source_channel_id`) REFERENCES `channels` (`id`) ON UPDATE NO ACTION ON DELETE CASCADE",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `webhooks` DROP FOREIGN KEY `FK_d64f38834fa676f6caa4786ddd6`",
);
await queryRunner.query(
"ALTER TABLE `webhooks` DROP COLUMN `source_channel_id`",
);
}
}

View File

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class WebhookMessageProperties1721298824927
implements MigrationInterface
{
name = "WebhookMessageProperties1721298824927";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query("ALTER TABLE messages ADD username text NULL");
await queryRunner.query("ALTER TABLE messages ADD avatar text NULL");
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query("ALTER TABLE messages DROP COLUMN username");
await queryRunner.query("ALTER TABLE messages DROP COLUMN avatar");
}
}

View File

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class client_status1723347738541 implements MigrationInterface {
name = "client_status1723347738541";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE sessions ADD client_status text NULL",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE sessions DROP COLUMN client_status",
);
}
}

View File

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class DiscoveryCategoryIcon1723577874393 implements MigrationInterface {
name = "DiscoveryCategoryIcon1723577874393";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query("ALTER TABLE categories ADD icon text NULL");
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query("ALTER TABLE categories DROP COLUMN icon");
}
}

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class WebhookSourceChannel1723644478176 implements MigrationInterface {
name = "WebhookSourceChannel1723644478176";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE webhooks ADD COLUMN source_channel_id VARCHAR(255) NULL DEFAULT NULL",
);
await queryRunner.query(
"ALTER TABLE webhooks ADD CONSTRAINT FK_d64f38834fa676f6caa4786ddd6 FOREIGN KEY (source_channel_id) REFERENCES channels (id) ON UPDATE NO ACTION ON DELETE CASCADE",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE webhooks DROP CONSTRAINT FK_d64f38834fa676f6caa4786ddd6",
);
await queryRunner.query(
"ALTER TABLE webhooks DROP COLUMN source_channel_id",
);
}
}

View File

@ -16,9 +16,14 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Embed, MessageComponent, PollAnswer, PollMedia } from "@spacebar/util";
import {
ActionRowComponent,
Embed,
PollAnswer,
PollMedia,
} from "@spacebar/util";
type Attachment = {
export type MessageCreateAttachment = {
id: string;
filename: string;
};
@ -52,9 +57,9 @@ export interface MessageCreateSchema {
TODO: we should create an interface for attachments
TODO: OpenWAAO<-->attachment-style metadata conversion
**/
attachments?: Attachment[];
attachments?: MessageCreateAttachment[];
sticker_ids?: string[];
components?: MessageComponent[];
components?: ActionRowComponent[];
// TODO: Fix TypeScript errors in src\api\util\handlers\Message.ts once this is enabled
poll?: PollCreationSchema;
enforce_nonce?: boolean; // For Discord compatibility, it's the default behavior here

View File

@ -0,0 +1,35 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export interface RequestGuildMembersSchema {
guild_id: string;
query?: string;
limit?: number;
presences?: boolean;
user_ids?: string | string[];
nonce?: string;
}
export const RequestGuildMembersSchema = {
guild_id: String,
$query: String,
$limit: Number,
$presences: Boolean,
$user_ids: [] as string | string[],
$nonce: String,
};

View File

@ -23,9 +23,6 @@ export interface UserModifySchema {
*/
username?: string;
avatar?: string | null;
/**
* @maxLength 1024
*/
bio?: string;
accent_color?: number;
banner?: string | null;

View File

@ -16,7 +16,6 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// TODO: webhooks
export interface WebhookCreateSchema {
/**
* @maxLength 80

View File

@ -0,0 +1,46 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Embed } from "../entities";
import { MessageCreateAttachment } from "./MessageCreateSchema";
export interface WebhookExecuteSchema {
content?: string;
username?: string;
avatar_url?: string;
tts?: boolean;
embeds?: Embed[];
allowed_mentions?: {
parse?: string[];
roles?: string[];
users?: string[];
replied_user?: boolean;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
components?: any[];
file?: { filename: string };
payload_json?: string;
/**
TODO: we should create an interface for attachments
TODO: OpenWAAO<-->attachment-style metadata conversion
**/
attachments?: MessageCreateAttachment[];
flags?: number;
thread_name?: string;
applied_tags?: string[];
}

View File

@ -58,6 +58,7 @@ export * from "./PurgeSchema";
export * from "./RegisterSchema";
export * from "./RelationshipPostSchema";
export * from "./RelationshipPutSchema";
export * from "./RequestGuildMembersSchema";
export * from "./RoleModifySchema";
export * from "./RolePositionUpdateSchema";
export * from "./SelectProtocolSchema";
@ -79,5 +80,6 @@ export * from "./VoiceStateUpdateSchema";
export * from "./VoiceVideoSchema";
export * from "./WebAuthnSchema";
export * from "./WebhookCreateSchema";
export * from "./WebhookExecuteSchema";
export * from "./WidgetModifySchema";
export * from "./responses";

View File

@ -17,9 +17,9 @@
*/
import {
ActionRowComponent,
Attachment,
Embed,
MessageComponent,
MessageType,
Poll,
PublicUser,
@ -42,7 +42,7 @@ export interface GuildMessagesSearchMessage {
timestamp: string;
edited_timestamp: string | null;
flags: number;
components: MessageComponent[];
components: ActionRowComponent[];
poll: Poll;
hit: true;
}

View File

@ -578,7 +578,7 @@ export const DiscordApiErrors = {
UNKNOWN_TOKEN: new ApiError("Unknown token", 10012),
UNKNOWN_USER: new ApiError("Unknown user", 10013),
UNKNOWN_EMOJI: new ApiError("Unknown emoji", 10014),
UNKNOWN_WEBHOOK: new ApiError("Unknown webhook", 10015),
UNKNOWN_WEBHOOK: new ApiError("Unknown webhook", 10015, 404),
UNKNOWN_WEBHOOK_SERVICE: new ApiError("Unknown webhook service", 10016),
UNKNOWN_CONNECTION: new ApiError("Unknown connection", 10017, 400),
UNKNOWN_SESSION: new ApiError("Unknown session", 10020),