diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/index.ts b/src/api/routes/channels/#channel_id/messages/#message_id/index.ts
index 934efc316..a72d2080a 100644
--- a/src/api/routes/channels/#channel_id/messages/#message_id/index.ts
+++ b/src/api/routes/channels/#channel_id/messages/#message_id/index.ts
@@ -17,309 +17,350 @@
*/
import {
- Attachment,
- Channel,
- Message,
- MessageCreateEvent,
- MessageDeleteEvent,
- MessageUpdateEvent,
- Snowflake,
- SpacebarApiErrors,
- emitEvent,
- getPermission,
- getRights,
- uploadFile,
- NewUrlUserSignatureData,
+ Attachment,
+ Channel,
+ Message,
+ MessageCreateEvent,
+ MessageDeleteEvent,
+ MessageUpdateEvent,
+ Snowflake,
+ SpacebarApiErrors,
+ emitEvent,
+ getPermission,
+ getRights,
+ uploadFile,
+ NewUrlUserSignatureData,
} from "@spacebar/util";
+import { In } from "typeorm";
import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
import multer from "multer";
import { handleMessage, postHandleMessage, route } from "../../../../../util";
import { MessageCreateAttachment, MessageCreateCloudAttachment, MessageCreateSchema, MessageEditSchema } from "@spacebar/schemas";
+import { resolveMessageInChannel, computeProjectionsForMessage } from "../../../../../util/helpers/MessageProjection";
const router = Router({ mergeParams: true });
// TODO: message content/embed string length limit
const messageUpload = multer({
- limits: {
- fileSize: 1024 * 1024 * 100,
- fields: 10,
- files: 1,
- },
- storage: multer.memoryStorage(),
+ limits: {
+ fileSize: 1024 * 1024 * 100,
+ fields: 10,
+ files: 1,
+ },
+ storage: multer.memoryStorage(),
}); // max upload 50 mb
router.patch(
- "/",
- route({
- requestBody: "MessageEditSchema",
- permission: "SEND_MESSAGES",
- right: "SEND_MESSAGES",
- responses: {
- 200: {
- body: "Message",
- },
- 400: {
- body: "APIErrorResponse",
- },
- 403: {},
- 404: {},
- },
- }),
- async (req: Request, res: Response) => {
- const { message_id, channel_id } = req.params;
- let body = req.body as MessageEditSchema;
-
- const message = await Message.findOneOrFail({
- where: { id: message_id, channel_id },
- relations: { attachments: true },
- });
-
- const permissions = await getPermission(req.user_id, undefined, channel_id);
-
- const rights = await getRights(req.user_id);
-
- if (req.user_id !== message.author_id) {
- if (!rights.has("MANAGE_MESSAGES")) {
- permissions.hasThrow("MANAGE_MESSAGES");
- body = { flags: body.flags };
- // guild admins can only suppress embeds of other messages, no such restriction imposed to instance-wide admins
- }
- } else rights.hasThrow("SELF_EDIT_MESSAGES");
-
- // no longer necessary, somehow resolved by updating the type of `attachments`...?
- // //@ts-expect-error Something is wrong with message_reference here, TS complains since "channel_id" is optional in MessageCreateSchema
- const new_message = await handleMessage({
- ...message,
- // TODO: should message_reference be overridable?
- message_reference: message.message_reference,
- ...body,
- author_id: message.author_id,
- channel_id,
- id: message_id,
- edited_timestamp: new Date(),
- });
-
- await Promise.all([
- new_message.save(),
- await emitEvent({
- event: "MESSAGE_UPDATE",
- channel_id,
- data: {
- ...new_message.toJSON(),
- nonce: undefined,
- member: new_message.member?.toPublicMember(),
- },
- } as MessageUpdateEvent),
- ]);
-
- postHandleMessage(new_message);
-
- // TODO: a DTO?
- return res.json({
- ...new_message.toJSON(),
- id: new_message.id,
- type: new_message.type,
- channel_id: new_message.channel_id,
- member: new_message.member?.toPublicMember(),
- author: new_message.author?.toPublicUser(),
- attachments: new_message.attachments,
- embeds: new_message.embeds,
- mentions: new_message.embeds,
- mention_roles: new_message.mention_roles,
- mention_everyone: new_message.mention_everyone,
- pinned: new_message.pinned,
- timestamp: new_message.timestamp,
- edited_timestamp: new_message.edited_timestamp,
-
- // these are not in the Discord.com response
- mention_channels: new_message.mention_channels,
- });
- },
+ "/",
+ route({
+ requestBody: "MessageEditSchema",
+ permission: "SEND_MESSAGES",
+ right: "SEND_MESSAGES",
+ responses: {
+ 200: {
+ body: "Message",
+ },
+ 400: {
+ body: "APIErrorResponse",
+ },
+ 403: {},
+ 404: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { message_id, channel_id } = req.params;
+ let body = req.body as MessageEditSchema;
+
+ const message = await resolveMessageInChannel(message_id, channel_id, req.user_id);
+
+ const permissions = await getPermission(req.user_id, undefined, channel_id);
+
+ const rights = await getRights(req.user_id);
+
+ if (req.user_id !== message.author_id) {
+ if (!rights.has("MANAGE_MESSAGES")) {
+ permissions.hasThrow("MANAGE_MESSAGES");
+ body = { flags: body.flags };
+ // guild admins can only suppress embeds of other messages, no such restriction imposed to instance-wide admins
+ }
+ } else rights.hasThrow("SELF_EDIT_MESSAGES");
+
+ // no longer necessary, somehow resolved by updating the type of `attachments`...?
+ // //@ts-expect-error Something is wrong with message_reference here, TS complains since "channel_id" is optional in MessageCreateSchema
+ const new_message = await handleMessage({
+ ...message,
+ // TODO: should message_reference be overridable?
+ message_reference: message.message_reference,
+ ...body,
+ author_id: message.author_id,
+ channel_id,
+ id: message_id,
+ edited_timestamp: new Date(),
+ });
+
+ await new_message.save();
+
+ const projections = await computeProjectionsForMessage(new_message);
+
+ await Promise.all(
+ projections.map((projection) =>
+ emitEvent({
+ event: "MESSAGE_UPDATE",
+ channel_id: projection.channelId,
+ data: {
+ ...new_message.toProjectedJSON(projection.channelId),
+ nonce: undefined,
+ member: new_message.member?.toPublicMember(),
+ },
+ } as MessageUpdateEvent),
+ ),
+ );
+
+ postHandleMessage(new_message);
+
+ return res.json({
+ ...new_message.toJSON(),
+ id: new_message.id,
+ type: new_message.type,
+ channel_id: new_message.channel_id,
+ member: new_message.member?.toPublicMember(),
+ author: new_message.author?.toPublicUser(),
+ attachments: new_message.attachments,
+ embeds: new_message.embeds,
+ mentions: new_message.embeds,
+ mention_roles: new_message.mention_roles,
+ mention_everyone: new_message.mention_everyone,
+ pinned: new_message.pinned,
+ timestamp: new_message.timestamp,
+ edited_timestamp: new_message.edited_timestamp,
+
+ // these are not in the Discord.com response
+ mention_channels: new_message.mention_channels,
+ });
+ },
);
// Backfill message with specific timestamp
router.put(
- "/",
- messageUpload.single("file"),
- (req, res, next) => {
- if (req.body.payload_json) {
- req.body = JSON.parse(req.body.payload_json);
- }
-
- next();
- },
- route({
- requestBody: "MessageCreateSchema",
- permission: "SEND_MESSAGES",
- right: "SEND_BACKDATED_EVENTS",
- responses: {
- 200: {
- body: "Message",
- },
- 400: {
- body: "APIErrorResponse",
- },
- 403: {},
- 404: {},
- },
- }),
- async (req: Request, res: Response) => {
- const { channel_id, message_id } = req.params;
- const body = req.body as MessageCreateSchema;
- const attachments: (MessageCreateAttachment | MessageCreateCloudAttachment)[] = body.attachments ?? [];
-
- const rights = await getRights(req.user_id);
- rights.hasThrow("SEND_MESSAGES");
-
- // regex to check if message contains anything other than numerals ( also no decimals )
- if (!message_id.match(/^\+?\d+$/)) {
- throw new HTTPError("Message IDs must be positive integers", 400);
- }
-
- const snowflake = Snowflake.deconstruct(message_id);
- if (Date.now() < snowflake.timestamp) {
- // message is in the future
- throw SpacebarApiErrors.CANNOT_BACKFILL_TO_THE_FUTURE;
- }
-
- const exists = await Message.findOne({
- where: { id: message_id, channel_id: channel_id },
- });
- if (exists) {
- throw SpacebarApiErrors.CANNOT_REPLACE_BY_BACKFILL;
- }
-
- if (req.file) {
- try {
- const file = await uploadFile(`/attachments/${req.params.channel_id}`, req.file);
- attachments.push(Attachment.create({ ...file, proxy_url: file.url }));
- } catch (error) {
- return res.status(400).json(error);
- }
- }
- const channel = await Channel.findOneOrFail({
- where: { id: channel_id },
- relations: { recipients: { user: true } },
- });
-
- const embeds = body.embeds || [];
- if (body.embed) embeds.push(body.embed);
- const message = await handleMessage({
- ...body,
- type: 0,
- pinned: false,
- author_id: req.user_id,
- id: message_id,
- embeds,
- channel_id,
- attachments,
- edited_timestamp: undefined,
- timestamp: new Date(snowflake.timestamp),
- });
-
- //Fix for the client bug
- delete message.member;
-
- await Promise.all([
- message.save(),
- emitEvent({
- event: "MESSAGE_CREATE",
- channel_id: channel_id,
- data: message,
- } as MessageCreateEvent),
- channel.save(),
- ]);
-
- // 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.withSignedAttachments(
- new NewUrlUserSignatureData({
- ip: req.ip,
- userAgent: req.headers["user-agent"] as string,
- }),
- ),
- );
- },
+ "/",
+ messageUpload.single("file"),
+ async (req, res, next) => {
+ if (req.body.payload_json) {
+ req.body = JSON.parse(req.body.payload_json);
+ }
+
+ next();
+ },
+ route({
+ requestBody: "MessageCreateSchema",
+ permission: "SEND_MESSAGES",
+ right: "SEND_BACKDATED_EVENTS",
+ responses: {
+ 200: {
+ body: "Message",
+ },
+ 400: {
+ body: "APIErrorResponse",
+ },
+ 403: {},
+ 404: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { channel_id, message_id } = req.params;
+ const body = req.body as MessageCreateSchema;
+ const attachments: (MessageCreateAttachment | MessageCreateCloudAttachment)[] = body.attachments ?? [];
+
+ const rights = await getRights(req.user_id);
+ rights.hasThrow("SEND_MESSAGES");
+
+ // regex to check if message contains anything other than numerals ( also no decimals )
+ if (!message_id.match(/^\+?\d+$/)) {
+ throw new HTTPError("Message IDs must be positive integers", 400);
+ }
+
+ const snowflake = Snowflake.deconstruct(message_id);
+ if (Date.now() < snowflake.timestamp) {
+ // message is in the future
+ throw SpacebarApiErrors.CANNOT_BACKFILL_TO_THE_FUTURE;
+ }
+
+ const exists = await Message.findOne({
+ where: { id: message_id, channel_id: channel_id },
+ });
+ if (exists) {
+ throw SpacebarApiErrors.CANNOT_REPLACE_BY_BACKFILL;
+ }
+
+ if (req.file) {
+ try {
+ const file = await uploadFile(`/attachments/${req.params.channel_id}`, req.file);
+ attachments.push(Attachment.create({ ...file, proxy_url: file.url }));
+ } catch (error) {
+ return res.status(400).json(error);
+ }
+ }
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ relations: ["recipients", "recipients.user"],
+ });
+
+ const embeds = body.embeds || [];
+ if (body.embed) embeds.push(body.embed);
+ const message = await handleMessage({
+ ...body,
+ type: 0,
+ pinned: false,
+ author_id: req.user_id,
+ id: message_id,
+ embeds,
+ channel_id,
+ attachments,
+ edited_timestamp: undefined,
+ timestamp: new Date(snowflake.timestamp),
+ });
+
+ //Fix for the client bug
+ delete message.member;
+
+ await Promise.all([
+ message.save(),
+ emitEvent({
+ event: "MESSAGE_CREATE",
+ channel_id: channel_id,
+ data: message,
+ } as MessageCreateEvent),
+ channel.save(),
+ ]);
+
+ // 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.withSignedAttachments(
+ new NewUrlUserSignatureData({
+ ip: req.ip,
+ userAgent: req.headers["user-agent"] as string,
+ }),
+ ),
+ );
+ },
);
router.get(
- "/",
- route({
- permission: "VIEW_CHANNEL",
- responses: {
- 200: {
- body: "Message",
- },
- 400: {
- body: "APIErrorResponse",
- },
- 403: {},
- 404: {},
- },
- }),
- async (req: Request, res: Response) => {
- const { message_id, channel_id } = req.params;
-
- const message = await Message.findOneOrFail({
- where: { id: message_id, channel_id },
- relations: { attachments: true },
- });
-
- const permissions = await getPermission(req.user_id, undefined, channel_id);
-
- if (message.author_id !== req.user_id) permissions.hasThrow("READ_MESSAGE_HISTORY");
-
- return res.json(message);
- },
+ "/",
+ route({
+ permission: "VIEW_CHANNEL",
+ responses: {
+ 200: {
+ body: "Message",
+ },
+ 400: {
+ body: "APIErrorResponse",
+ },
+ 403: {},
+ 404: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { message_id, channel_id } = req.params;
+
+ const message = await resolveMessageInChannel(message_id, channel_id, req.user_id);
+
+ const permissions = await getPermission(req.user_id, undefined, channel_id);
+
+ if (message.author_id !== req.user_id) permissions.hasThrow("READ_MESSAGE_HISTORY");
+
+ if (!message.reply_ids) {
+ const replies = await Message.find({
+ where: {
+ channel_id: message.channel_id,
+ message_reference: {
+ message_id: message.id,
+ },
+ },
+ select: ["id"],
+ });
+
+ if (replies.length > 0) {
+ message.reply_ids = replies.map((r) => r.id);
+ await Message.update({ id: message.id }, { reply_ids: message.reply_ids });
+ } else {
+ message.reply_ids = [];
+ await Message.update({ id: message.id }, { reply_ids: [] });
+ }
+ }
+
+ return res.json(message.toJSON());
+ },
);
router.delete(
- "/",
- route({
- responses: {
- 204: {},
- 400: {
- body: "APIErrorResponse",
- },
- 404: {},
- },
- }),
- async (req: Request, res: Response) => {
- const { message_id, channel_id } = req.params;
-
- const channel = await Channel.findOneOrFail({
- where: { id: channel_id },
- });
- const message = await Message.findOneOrFail({
- where: { id: message_id },
- });
-
- const rights = await getRights(req.user_id);
-
- if (message.author_id !== req.user_id) {
- if (!rights.has("MANAGE_MESSAGES")) {
- const permission = await getPermission(req.user_id, channel.guild_id, channel_id);
- permission.hasThrow("MANAGE_MESSAGES");
- }
- } else rights.hasThrow("SELF_DELETE_MESSAGES");
-
- await Message.delete({ id: message_id });
-
- await emitEvent({
- event: "MESSAGE_DELETE",
- channel_id,
- data: {
- id: message_id,
- channel_id,
- guild_id: channel.guild_id,
- },
- } as MessageDeleteEvent);
-
- res.sendStatus(204);
- },
+ "/",
+ route({
+ responses: {
+ 204: {},
+ 400: {
+ body: "APIErrorResponse",
+ },
+ 404: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { message_id, channel_id } = req.params;
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ const message = await resolveMessageInChannel(message_id, channel_id, req.user_id);
+
+ const rights = await getRights(req.user_id);
+
+ if (message.author_id !== req.user_id) {
+ if (!rights.has("MANAGE_MESSAGES")) {
+ const permission = await getPermission(req.user_id, channel.guild_id, channel_id);
+ permission.hasThrow("MANAGE_MESSAGES");
+ }
+ } else rights.hasThrow("SELF_DELETE_MESSAGES");
+
+ if (message.message_reference?.message_id) {
+ const parentMessage = await Message.findOne({
+ where: { id: message.message_reference.message_id },
+ });
+ if (parentMessage?.reply_ids) {
+ parentMessage.reply_ids = parentMessage.reply_ids.filter((id) => id !== message_id);
+ await Message.update({ id: parentMessage.id }, { reply_ids: parentMessage.reply_ids });
+ }
+ }
+
+ if (message.reply_ids?.length) {
+ const permissions = await getPermission(req.user_id, channel.guild_id, channel_id);
+ if (permissions.has("MANAGE_MESSAGES")) {
+ await Message.delete({ id: In(message.reply_ids) });
+ }
+ }
+
+ await Message.delete({ id: message_id });
+
+ const projections = await computeProjectionsForMessage(message);
+
+ await Promise.all(
+ projections.map((projection) =>
+ emitEvent({
+ event: "MESSAGE_DELETE",
+ channel_id: projection.channelId,
+ data: {
+ id: message_id,
+ channel_id: projection.channelId,
+ guild_id: channel.guild_id,
+ },
+ } as MessageDeleteEvent),
+ ),
+ );
+
+ res.sendStatus(204);
+ },
);
export default router;
diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts b/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts
index b21dbd349..a63a848b8 100644
--- a/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts
+++ b/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts
@@ -29,12 +29,17 @@ import {
MessageReactionRemoveEmojiEvent,
MessageReactionRemoveEvent,
User,
- arrayRemove,
} from "@spacebar/util";
import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
import { In } from "typeorm";
import { PartialEmoji, PublicMemberProjection, PublicUserProjection } from "@spacebar/schemas";
+import {
+ resolveMessageInChannel,
+ computeProjectionsForMessage,
+ getIntimacyBroadcastRuleForChannel,
+ filterReactionsForIntimacyBroadcast,
+} from "../../../../../util/helpers/MessageProjection";
const router = Router({ mergeParams: true });
// TODO: check if emoji is really an unicode emoji or a properly encoded external emoji
@@ -74,17 +79,26 @@ router.delete(
where: { id: channel_id },
});
- await Message.update({ id: message_id, channel_id }, { reactions: [] });
+ const message = await resolveMessageInChannel(message_id, channel_id, req.user_id);
- await emitEvent({
- event: "MESSAGE_REACTION_REMOVE_ALL",
- channel_id,
- data: {
- channel_id,
- message_id,
- guild_id: channel.guild_id,
- },
- } as MessageReactionRemoveAllEvent);
+ message.reactions = [];
+ await message.save();
+
+ const projections = await computeProjectionsForMessage(message);
+
+ await Promise.all(
+ projections.map((projection) =>
+ emitEvent({
+ event: "MESSAGE_REACTION_REMOVE_ALL",
+ channel_id: projection.channelId,
+ data: {
+ channel_id: projection.channelId,
+ message_id,
+ guild_id: channel.guild_id,
+ },
+ } as MessageReactionRemoveAllEvent),
+ ),
+ );
res.sendStatus(204);
},
@@ -107,27 +121,30 @@ router.delete(
const { message_id, channel_id } = req.params;
const emoji = getEmoji(req.params.emoji);
- const message = await Message.findOneOrFail({
- where: { id: message_id, channel_id },
- });
+ const message = await resolveMessageInChannel(message_id, channel_id, req.user_id);
const already_added = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name);
if (!already_added) throw new HTTPError("Reaction not found", 404);
- arrayRemove(message.reactions, already_added);
-
- await Promise.all([
- message.save(),
- emitEvent({
- event: "MESSAGE_REACTION_REMOVE_EMOJI",
- channel_id,
- data: {
- channel_id,
- message_id,
- guild_id: message.guild_id,
- emoji,
- },
- } as MessageReactionRemoveEmojiEvent),
- ]);
+ message.reactions.remove(already_added);
+
+ await message.save();
+
+ const projections = await computeProjectionsForMessage(message);
+
+ await Promise.all(
+ projections.map((projection) =>
+ emitEvent({
+ event: "MESSAGE_REACTION_REMOVE_EMOJI",
+ channel_id: projection.channelId,
+ data: {
+ channel_id: projection.channelId,
+ message_id,
+ guild_id: message.guild_id,
+ emoji,
+ },
+ } as MessageReactionRemoveEmojiEvent),
+ ),
+ );
res.sendStatus(204);
},
@@ -152,10 +169,17 @@ router.get(
const { message_id, channel_id } = req.params;
const emoji = getEmoji(req.params.emoji);
- const message = await Message.findOneOrFail({
- where: { id: message_id, channel_id },
- });
- const reaction = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name);
+ const message = await resolveMessageInChannel(message_id, channel_id, req.user_id);
+
+ const intimacyRule = await getIntimacyBroadcastRuleForChannel(message.source_channel_id || message.channel_id!);
+ const isIntimacyBroadcast = intimacyRule !== null;
+
+ let reactions = message.reactions;
+ if (isIntimacyBroadcast) {
+ reactions = filterReactionsForIntimacyBroadcast(reactions, req.user_id, message.author_id);
+ }
+
+ const reaction = reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name);
if (!reaction) throw new HTTPError("Reaction not found", 404);
const users = (
@@ -193,9 +217,7 @@ router.put(
const channel = await Channel.findOneOrFail({
where: { id: channel_id },
});
- const message = await Message.findOneOrFail({
- where: { id: message_id, channel_id },
- });
+ const message = await resolveMessageInChannel(message_id, channel_id, req.user_id);
const already_added = message.reactions.find((x) => (x.emoji.id === emoji.id && emoji.id) || x.emoji.name === emoji.name);
if (!already_added) req.permission?.hasThrow("ADD_REACTIONS");
@@ -231,18 +253,24 @@ router.put(
})
).toPublicMember();
- await emitEvent({
- event: "MESSAGE_REACTION_ADD",
- channel_id,
- data: {
- user_id: req.user_id,
- channel_id,
- message_id,
- guild_id: channel.guild_id,
- emoji,
- member,
- },
- } as MessageReactionAddEvent);
+ const projections = await computeProjectionsForMessage(message);
+
+ await Promise.all(
+ projections.map((projection) =>
+ emitEvent({
+ event: "MESSAGE_REACTION_ADD",
+ channel_id: projection.channelId,
+ data: {
+ user_id: req.user_id,
+ channel_id: projection.channelId,
+ message_id,
+ guild_id: channel.guild_id,
+ emoji,
+ member,
+ },
+ } as MessageReactionAddEvent),
+ ),
+ );
res.sendStatus(204);
},
@@ -269,9 +297,7 @@ router.delete(
const channel = await Channel.findOneOrFail({
where: { id: channel_id },
});
- const message = await Message.findOneOrFail({
- where: { id: message_id, channel_id },
- });
+ const message = await resolveMessageInChannel(message_id, channel_id, req.user_id);
if (user_id === "@me") user_id = req.user_id;
else {
@@ -284,22 +310,28 @@ router.delete(
already_added.count--;
- if (already_added.count <= 0) arrayRemove(message.reactions, already_added);
+ 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);
+ const projections = await computeProjectionsForMessage(message);
+
+ await Promise.all(
+ projections.map((projection) =>
+ emitEvent({
+ event: "MESSAGE_REACTION_REMOVE",
+ channel_id: projection.channelId,
+ data: {
+ user_id: req.user_id,
+ channel_id: projection.channelId,
+ message_id,
+ guild_id: channel.guild_id,
+ emoji,
+ },
+ } as MessageReactionRemoveEvent),
+ ),
+ );
res.sendStatus(204);
},
@@ -326,9 +358,7 @@ router.delete(
const channel = await Channel.findOneOrFail({
where: { id: channel_id },
});
- const message = await Message.findOneOrFail({
- where: { id: message_id, channel_id },
- });
+ const message = await resolveMessageInChannel(message_id, channel_id, req.user_id);
if (user_id === "@me") user_id = req.user_id;
else {
@@ -341,22 +371,28 @@ router.delete(
already_added.count--;
- if (already_added.count <= 0) arrayRemove(message.reactions, already_added);
+ 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);
+ const projections = await computeProjectionsForMessage(message);
+
+ await Promise.all(
+ projections.map((projection) =>
+ emitEvent({
+ event: "MESSAGE_REACTION_REMOVE",
+ channel_id: projection.channelId,
+ data: {
+ user_id: req.user_id,
+ channel_id: projection.channelId,
+ message_id,
+ guild_id: channel.guild_id,
+ emoji,
+ },
+ } as MessageReactionRemoveEvent),
+ ),
+ );
res.sendStatus(204);
},
diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/replies.ts b/src/api/routes/channels/#channel_id/messages/#message_id/replies.ts
new file mode 100644
index 000000000..84162e940
--- /dev/null
+++ b/src/api/routes/channels/#channel_id/messages/#message_id/replies.ts
@@ -0,0 +1,165 @@
+/*
+ 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 .
+*/
+
+import { Message, Channel, getPermission, getRights, emitEvent, MessageDeleteBulkEvent } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+import { In } from "typeorm";
+import { route } from "../../../../../util";
+import { resolveMessageInChannel } from "../../../../../util/helpers/MessageProjection";
+
+const router = Router();
+
+router.get(
+ "/",
+ route({
+ permission: "VIEW_CHANNEL",
+ responses: {
+ 200: { body: "APIMessageArray" },
+ 403: {},
+ 404: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { message_id, channel_id } = req.params;
+
+ const permissions = await getPermission(req.user_id, undefined, channel_id);
+ permissions.hasThrow("READ_MESSAGE_HISTORY");
+
+ const parentMessage = await resolveMessageInChannel(message_id, channel_id, req.user_id);
+
+ if (!parentMessage.reply_ids) {
+ const replies = await Message.find({
+ where: {
+ channel_id: channel_id,
+ message_reference: {
+ message_id: message_id,
+ },
+ },
+ select: ["id"],
+ });
+
+ if (replies.length > 0) {
+ parentMessage.reply_ids = replies.map((r) => r.id);
+ await Message.update({ id: message_id }, { reply_ids: parentMessage.reply_ids });
+ } else {
+ parentMessage.reply_ids = [];
+ await Message.update({ id: message_id }, { reply_ids: [] });
+ }
+ }
+
+ const replyMessages = await Message.find({
+ where: {
+ id: In(parentMessage.reply_ids || []),
+ channel_id: channel_id,
+ },
+ relations: ["author", "webhook", "application", "mentions", "mention_roles", "mention_channels", "sticker_items", "attachments"],
+ order: { timestamp: "ASC" },
+ });
+
+ return res.json(
+ replyMessages.map((m) => {
+ const json = m.toJSON();
+ json.reply_ids = m.reply_ids ?? undefined;
+ return json;
+ }),
+ );
+ },
+);
+
+router.delete(
+ "/",
+ route({
+ responses: {
+ 204: {},
+ 403: {},
+ 404: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { message_id, channel_id } = req.params;
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+
+ const permissions = await getPermission(req.user_id, channel.guild_id, channel_id);
+
+ const parentMessage = await resolveMessageInChannel(message_id, channel_id, req.user_id);
+
+ if (!parentMessage.reply_ids) {
+ const replies = await Message.find({
+ where: {
+ channel_id: channel_id,
+ message_reference: {
+ message_id: message_id,
+ },
+ },
+ select: ["id"],
+ });
+
+ if (replies.length > 0) {
+ parentMessage.reply_ids = replies.map((r) => r.id);
+ await Message.update({ id: message_id }, { reply_ids: parentMessage.reply_ids });
+ } else {
+ parentMessage.reply_ids = [];
+ await Message.update({ id: message_id }, { reply_ids: [] });
+ }
+ }
+
+ if (parentMessage.reply_ids?.length) {
+ const replyMessages = await Message.find({
+ where: { id: In(parentMessage.reply_ids) },
+ select: ["id", "author_id"],
+ });
+
+ const userOwnedReplies = replyMessages.filter((msg) => msg.author_id === req.user_id);
+ const otherOwnedReplies = replyMessages.filter((msg) => msg.author_id !== req.user_id);
+
+ const rights = await getRights(req.user_id);
+
+ if (userOwnedReplies.length > 0) {
+ rights.hasThrow("SELF_DELETE_MESSAGES");
+ }
+
+ if (otherOwnedReplies.length > 0) {
+ if (!rights.has("MANAGE_MESSAGES")) {
+ permissions.hasThrow("MANAGE_MESSAGES");
+ }
+ }
+
+ await Message.delete({ id: In(parentMessage.reply_ids) });
+
+ await emitEvent({
+ event: "MESSAGE_DELETE_BULK",
+ channel_id,
+ data: {
+ ids: parentMessage.reply_ids,
+ channel_id,
+ guild_id: channel.guild_id,
+ },
+ } as MessageDeleteBulkEvent);
+
+ parentMessage.reply_ids = [];
+ await Message.update({ id: message_id }, { reply_ids: [] });
+ }
+
+ res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts b/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts
new file mode 100644
index 000000000..ba7a29698
--- /dev/null
+++ b/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts
@@ -0,0 +1,104 @@
+import { Router, Request, Response } from "express";
+import { route } from "@spacebar/api";
+import { resolveLimit, getGuildLimits, Channel, Message, ChannelTypes, getPermission, Snowflake, DiscordApiErrors, ThreadMember } from "@spacebar/util";
+import { resolveMessageInChannel } from "../../../../../util/helpers/MessageProjection";
+
+const router = Router({ mergeParams: true });
+
+router.post(
+ "/",
+ route({
+ permission: "USE_PUBLIC_THREADS",
+ responses: { 201: { body: "Channel" } },
+ }),
+ async (req: Request, res: Response) => {
+ const { channel_id, message_id } = req.params as {
+ channel_id: string;
+ message_id: string;
+ };
+
+ const message = await resolveMessageInChannel(message_id, channel_id, req.user_id);
+
+ const existingThread = await Channel.findOne({
+ where: { parent_id: channel_id, last_message_id: message_id },
+ });
+
+ if (existingThread) {
+ throw DiscordApiErrors.THREAD_ALREADY_CREATED_FOR_THIS_MESSAGE;
+ }
+
+ const { name, auto_archive_duration } = req.body as {
+ name: string;
+ auto_archive_duration?: number;
+ };
+
+ const thread = await Channel.create({
+ id: Snowflake.generate(),
+ type: ChannelTypes.GUILD_PUBLIC_THREAD,
+ name: name || `Thread from ${message.author?.username || "Unknown"}`,
+ parent_id: channel_id,
+ guild_id: message.guild_id,
+ owner_id: req.user_id,
+ last_message_id: message_id,
+ default_auto_archive_duration: auto_archive_duration || 1440,
+ created_at: new Date(),
+ }).save();
+
+ await ThreadMember.create({
+ thread_id: thread.id,
+ user_id: req.user_id!,
+ created_at: new Date(),
+ flags: 0,
+ }).save();
+
+ res.status(201).json(thread);
+ },
+);
+
+router.get(
+ "/",
+ route({
+ permission: "VIEW_CHANNEL",
+ responses: { 200: { body: "Object" } },
+ query: {
+ before: { type: "string", required: false },
+ after: { type: "string", required: false },
+ limit: { type: "number", required: false },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { channel_id, message_id } = req.params as {
+ channel_id: string;
+ message_id: string;
+ };
+ const qLimit = (req.query as { limit?: number }).limit;
+ const guildId = (req as Request & { guild_id?: string }).guild_id;
+ const limits = getGuildLimits(guildId).threads;
+ const limit = resolveLimit(qLimit, limits.maxThreadPageSize, limits.defaultThreadPageSize, limits.maxThreadPageSize);
+
+ const threads = await Channel.find({
+ where: { parent_id: channel_id, last_message_id: message_id },
+ select: ["id", "name", "type", "created_at", "default_auto_archive_duration", "last_message_id"],
+ take: limit,
+ });
+
+ const threadsWithRemaining = threads.map((thread) => {
+ const lastActivityAt = thread.last_message_id ? new Date() : thread.created_at;
+ const inactivityMs = Date.now() - lastActivityAt.getTime();
+ const durationMs = (thread.default_auto_archive_duration || 1440) * 60 * 1000;
+ const remainingAutoArchive = Math.max(0, durationMs - inactivityMs);
+
+ return {
+ ...thread,
+ remainingAutoArchive: Math.floor(remainingAutoArchive / 1000),
+ };
+ });
+
+ res.status(200).json({
+ threads: threadsWithRemaining,
+ has_more: threads.length >= limit,
+ });
+ },
+);
+
+export default router;
diff --git a/src/api/routes/channels/#channel_id/messages/index.ts b/src/api/routes/channels/#channel_id/messages/index.ts
index dafdde7e3..f9221e17c 100644
--- a/src/api/routes/channels/#channel_id/messages/index.ts
+++ b/src/api/routes/channels/#channel_id/messages/index.ts
@@ -18,276 +18,242 @@
import { handleMessage, postHandleMessage, route } from "@spacebar/api";
import {
- Attachment,
- Channel,
- Config,
- DmChannelDTO,
- DiscordApiErrors,
- FieldErrors,
- Member,
- Message,
- MessageCreateEvent,
- MessageCreateSchema,
- Reaction,
- ReadState,
- Rights,
- Snowflake,
- User,
- emitEvent,
- getPermission,
- isTextChannel,
- getUrlSignature,
- uploadFile,
- NewUrlSignatureData,
- NewUrlUserSignatureData,
+ ApiError,
+ Attachment,
+ AutomodRule,
+ AutomodTriggerTypes,
+ Channel,
+ Config,
+ DiscordApiErrors,
+ DmChannelDTO,
+ emitEvent,
+ FieldErrors,
+ getPermission,
+ getUrlSignature,
+ Member,
+ Message,
+ MessageCreateEvent,
+ NewUrlSignatureData,
+ NewUrlUserSignatureData,
+ ReadState,
+ Relationship,
+ Rights,
+ Snowflake,
+ uploadFile,
+ User,
} from "@spacebar/util";
import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
import multer from "multer";
-import {
- FindManyOptions,
- FindOperator,
- LessThan,
- MoreThan,
- MoreThanOrEqual,
-} from "typeorm";
+import { FindManyOptions, FindOperator, LessThan, MoreThan, MoreThanOrEqual } from "typeorm";
import { URL } from "url";
-
-const router: Router = Router();
+import {
+ AutomodCustomWordsRule,
+ AutomodRuleActionType,
+ AutomodRuleEventType,
+ isTextChannel,
+ MessageCreateAttachment,
+ MessageCreateCloudAttachment,
+ MessageCreateSchema,
+ Reaction,
+ RelationshipType,
+} from "@spacebar/schemas";
+import { getProjectedMessagesForChannel, getIntimacyBroadcastRuleForChannel, filterReactionsForIntimacyBroadcast } from "../../../../util/helpers/MessageProjection";
+
+const router: Router = Router({ mergeParams: true });
async function populateForwardLinks(messages: Message[]): Promise {
- for (const message of messages) {
- if (!message.reply_ids) {
- const replies = await Message.find({
- where: {
- channel_id: message.channel_id,
- message_reference: {
- message_id: message.id,
- },
- },
- select: ["id"],
- });
-
- if (replies.length > 0) {
- message.reply_ids = replies.map((r) => r.id);
- await Message.update(
- { id: message.id },
- { reply_ids: message.reply_ids },
- );
- } else {
- message.reply_ids = [];
- await Message.update({ id: message.id }, { reply_ids: [] });
- }
- }
- }
+ for (const message of messages) {
+ if (!message.reply_ids) {
+ const replies = await Message.find({
+ where: {
+ channel_id: message.channel_id,
+ message_reference: {
+ message_id: message.id,
+ },
+ },
+ select: ["id"],
+ });
+
+ if (replies.length > 0) {
+ message.reply_ids = replies.map((r) => r.id);
+ await Message.update({ id: message.id }, { reply_ids: message.reply_ids });
+ } else {
+ message.reply_ids = [];
+ await Message.update({ id: message.id }, { reply_ids: [] });
+ }
+ }
+ }
}
// https://discord.com/developers/docs/resources/channel#create-message
// get messages
router.get(
- "/",
- route({
- query: {
- around: {
- type: "string",
- },
- before: {
- type: "string",
- },
- after: {
- type: "string",
- },
- limit: {
- type: "number",
- description:
- "max number of messages to return (1-100). defaults to 50",
- },
- },
- responses: {
- 200: {
- body: "APIMessageArray",
- },
- 400: {
- body: "APIErrorResponse",
- },
- 403: {},
- 404: {},
- },
- }),
- async (req: Request, res: Response) => {
- const channel_id = req.params.channel_id;
- const channel = await Channel.findOneOrFail({
- where: { id: channel_id },
- });
- if (!channel) throw new HTTPError("Channel not found", 404);
-
- isTextChannel(channel.type);
- const around = req.query.around ? `${req.query.around}` : undefined;
- const before = req.query.before ? `${req.query.before}` : undefined;
- const after = req.query.after ? `${req.query.after}` : undefined;
- const limit = Number(req.query.limit) || 50;
- if (limit < 1 || limit > 100)
- throw new HTTPError("limit must be between 1 and 100", 422);
-
- const permissions = await getPermission(
- req.user_id,
- channel.guild_id,
- channel_id,
- );
- permissions.hasThrow("VIEW_CHANNEL");
- if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json([]);
-
- const query: FindManyOptions & {
- where: { id?: FindOperator | FindOperator[] };
- } = {
- order: { timestamp: "DESC" },
- take: limit,
- where: { channel_id },
- relations: [
- "author",
- "webhook",
- "application",
- "mentions",
- "mention_roles",
- "mention_channels",
- "sticker_items",
- "attachments",
- ],
- };
-
- let messages: Message[];
-
- if (around) {
- query.take = Math.floor(limit / 2);
- if (query.take != 0) {
- const [right, left] = await Promise.all([
- Message.find({
- ...query,
- where: { channel_id, id: LessThan(around) },
- }),
- Message.find({
- ...query,
- where: { channel_id, id: MoreThanOrEqual(around) },
- order: { timestamp: "ASC" },
- }),
- ]);
- left.push(...right);
- messages = left.sort(
- (a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
- );
- } else {
- query.take = 1;
- const message = await Message.findOne({
- ...query,
- where: { channel_id, id: around },
- });
- messages = message ? [message] : [];
- }
- } else {
- if (after) {
- if (BigInt(after) > BigInt(Snowflake.generate()))
- 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()))
- throw new HTTPError(
- "before parameter must not be greater than current time",
- 422,
- );
-
- query.where.id = LessThan(before);
- }
-
- messages = await Message.find(query);
- }
-
- const endpoint = Config.get().cdn.endpointPublic;
-
- await populateForwardLinks(messages);
-
- const ret = messages.map((x: Message) => {
- x = x.toJSON();
-
- (x.reactions || []).forEach((y: Partial) => {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- //@ts-ignore
- if ((y.user_ids || []).includes(req.user_id)) y.me = true;
- delete y.user_ids;
- });
- if (!x.author)
- x.author = User.create({
- id: "4",
- discriminator: "0000",
- username: "Spacebar Ghost",
- public_flags: 0,
- });
- x.attachments?.forEach((y: Attachment) => {
- // dynamically set attachment proxy_url in case the endpoint changed
- const uri = y.proxy_url.startsWith("http")
- ? y.proxy_url
- : `https://example.org${y.proxy_url}`;
-
- const url = new URL(uri);
- if (endpoint) {
- const newBase = new URL(endpoint);
- url.protocol = newBase.protocol;
- url.hostname = newBase.hostname;
- url.port = newBase.port;
- }
-
- y.proxy_url = url.toString();
-
- y.proxy_url = getUrlSignature(
- new NewUrlSignatureData({
- url: y.proxy_url,
- userAgent: req.headers["user-agent"],
- ip: req.ip,
- }),
- )
- .applyToUrl(y.proxy_url)
- .toString();
-
- y.url = getUrlSignature(
- new NewUrlSignatureData({
- url: y.url,
- userAgent: req.headers["user-agent"],
- ip: req.ip,
- }),
- )
- .applyToUrl(y.url)
- .toString();
- });
-
- /**
+ "/",
+ route({
+ query: {
+ around: {
+ type: "string",
+ required: false,
+ },
+ before: {
+ type: "string",
+ required: false,
+ },
+ after: {
+ type: "string",
+ required: false,
+ },
+ limit: {
+ type: "number",
+ required: false,
+ description: "max number of messages to return (1-100). defaults to 50",
+ },
+ },
+ responses: {
+ 200: {
+ body: "APIMessageArray",
+ },
+ 400: {
+ body: "APIErrorResponse",
+ },
+ 403: {},
+ 404: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const channel_id = req.params.channel_id;
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ if (!channel) throw new HTTPError("Channel not found", 404);
+
+ isTextChannel(channel.type);
+ const around = req.query.around ? `${req.query.around}` : undefined;
+ const before = req.query.before ? `${req.query.before}` : undefined;
+ const after = req.query.after ? `${req.query.after}` : undefined;
+ const limit = Number(req.query.limit) || 50;
+ if (limit < 1 || limit > 100) throw new HTTPError("limit must be between 1 and 100", 422);
+
+ const permissions = await getPermission(req.user_id, channel.guild_id, channel_id);
+ permissions.hasThrow("VIEW_CHANNEL");
+ if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json([]);
+
+ const messages = await getProjectedMessagesForChannel(channel_id, req.user_id, {
+ around,
+ before,
+ after,
+ limit,
+ });
+
+ const endpoint = Config.get().cdn.endpointPublic;
+
+ await populateForwardLinks(messages);
+
+ const intimacyRule = await getIntimacyBroadcastRuleForChannel(channel_id);
+ const isIntimacyBroadcast = intimacyRule !== null;
+
+ const ret = messages.map((x: Message) => {
+ const projected = x.toProjectedJSON(channel_id);
+
+ let reactions = projected.reactions || [];
+ if (isIntimacyBroadcast) {
+ reactions = filterReactionsForIntimacyBroadcast(reactions, req.user_id, x.author_id);
+ }
+
+ reactions.forEach((y: Partial) => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ //@ts-ignore
+ if ((y.user_ids || []).includes(req.user_id)) y.me = true;
+ delete y.user_ids;
+ });
+ projected.reactions = reactions;
+ if (!projected.author)
+ projected.author = User.create({
+ id: "4",
+ discriminator: "0000",
+ username: "Spacebar Ghost",
+ public_flags: 0,
+ });
+ projected.attachments?.forEach((y: Attachment) => {
+ // dynamically set attachment proxy_url in case the endpoint changed
+ const uri = y.proxy_url.startsWith("http") ? y.proxy_url : `https://example.org${y.proxy_url}`;
+
+ const url = new URL(uri);
+ if (endpoint) {
+ const newBase = new URL(endpoint);
+ url.protocol = newBase.protocol;
+ url.hostname = newBase.hostname;
+ url.port = newBase.port;
+ }
+
+ y.proxy_url = url.toString();
+
+ y.proxy_url = getUrlSignature(
+ new NewUrlSignatureData({
+ url: y.proxy_url,
+ userAgent: req.headers["user-agent"],
+ ip: req.ip,
+ }),
+ )
+ .applyToUrl(y.proxy_url)
+ .toString();
+
+ y.url = getUrlSignature(
+ new NewUrlSignatureData({
+ url: y.url,
+ userAgent: req.headers["user-agent"],
+ ip: req.ip,
+ }),
+ )
+ .applyToUrl(y.url)
+ .toString();
+ });
+
+ /**
Some clients ( discord.js ) only check if a property exists within the response,
which causes errors when, say, the `application` property is `null`.
**/
- // for (var curr in x) {
- // if (x[curr] === null)
- // delete x[curr];
- // }
-
- return x;
- });
-
- return res.json(ret);
- },
+ // for (var curr in projected) {
+ // if (projected[curr] === null)
+ // delete projected[curr];
+ // }
+
+ return projected;
+ });
+
+ await ret
+ .filter((x: MessageCreateSchema) => x.interaction_metadata && !x.interaction_metadata.user)
+ .forEachAsync(async (x: MessageCreateSchema) => {
+ x.interaction_metadata!.user = x.interaction!.user = await User.findOneOrFail({ where: { id: (x as Message).interaction_metadata!.user_id } });
+ });
+
+ // polyfill message references for old messages
+ await ret
+ .filter((msg) => msg.message_reference && !msg.referenced_message?.id)
+ .forEachAsync(async (msg) => {
+ const whereOptions: { id: string; guild_id?: string; channel_id?: string } = {
+ id: msg.message_reference!.message_id,
+ };
+ if (msg.message_reference!.guild_id) whereOptions.guild_id = msg.message_reference!.guild_id;
+ if (msg.message_reference!.channel_id) whereOptions.channel_id = msg.message_reference!.channel_id;
+
+ msg.referenced_message = await Message.findOne({ where: whereOptions, relations: ["author", "mentions", "mention_roles", "mention_channels"] });
+ });
+
+ return res.json(ret);
+ },
);
// TODO: config max upload size
const messageUpload = multer({
- limits: {
- fileSize: Config.get().limits.message.maxAttachmentSize,
- fields: 10,
- // files: 1
- },
- storage: multer.memoryStorage(),
+ limits: {
+ fileSize: Config.get().limits.message.maxAttachmentSize,
+ fields: 10,
+ // files: 1
+ },
+ storage: multer.memoryStorage(),
}); // max upload 50 mb
/**
TODO: dynamically change limit of MessageCreateSchema with config
@@ -299,215 +265,263 @@ const messageUpload = multer({
**/
// Send message
router.post(
- "/",
- messageUpload.any(),
- (req, res, next) => {
- if (req.body.payload_json) {
- req.body = JSON.parse(req.body.payload_json);
- }
-
- next();
- },
- route({
- requestBody: "MessageCreateSchema",
- permission: "SEND_MESSAGES",
- right: "SEND_MESSAGES",
- responses: {
- 200: {
- body: "Message",
- },
- 400: {
- body: "APIErrorResponse",
- },
- 403: {},
- 404: {},
- },
- }),
- async (req: Request, res: Response) => {
- const { channel_id } = req.params;
- const body = req.body as MessageCreateSchema;
- const attachments: Attachment[] = [];
-
- const channel = await Channel.findOneOrFail({
- where: { id: channel_id },
- relations: ["recipients", "recipients.user"],
- });
- if (!channel.isWritable()) {
- throw new HTTPError(
- `Cannot send messages to channel of type ${channel.type}`,
- 400,
- );
- }
-
- if (body.nonce) {
- const existing = await Message.findOne({
- where: {
- nonce: body.nonce,
- channel_id: channel.id,
- author_id: req.user_id,
- },
- });
- if (existing) {
- return res.json(existing);
- }
- }
-
- if (!req.rights.has(Rights.FLAGS.BYPASS_RATE_LIMITS)) {
- const limits = Config.get().limits;
- if (limits.absoluteRate.register.enabled) {
- const count = await Message.count({
- where: {
- 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"),
- },
- });
- }
-
- if (channel.rate_limit_per_user && channel.rate_limit_per_user > 0) {
- const lastMessage = await Message.findOne({
- where: {
- channel_id,
- author_id: req.user_id,
- },
- order: { timestamp: "DESC" },
- select: ["timestamp"],
- });
-
- if (lastMessage) {
- const timeSinceLastMessage = Date.now() - lastMessage.timestamp.getTime();
- const slowmodeMs = channel.rate_limit_per_user * 1000;
-
- if (timeSinceLastMessage < slowmodeMs) {
- throw DiscordApiErrors.SLOWMODE_RATE_LIMIT;
- }
- }
- }
- }
-
- const files = (req.files as Express.Multer.File[]) ?? [];
- for (const currFile of files) {
- try {
- const file = await uploadFile(
- `/attachments/${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 || [];
- if (body.embed) embeds.push(body.embed);
- const message = await handleMessage({
- ...body,
- type: 0,
- pinned: false,
- author_id: req.user_id,
- embeds,
- channel_id,
- attachments,
- timestamp: new Date(),
- });
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- //@ts-ignore dont care2
- message.edited_timestamp = null;
-
- channel.last_message_id = message.id;
-
- if (channel.isDm()) {
- const channel_dto = await DmChannelDTO.from(channel);
-
- // Only one recipients should be closed here, since in group DMs the recipient is deleted not closed
- await Promise.all(
- channel.recipients?.map((recipient) => {
- if (recipient.closed) {
- recipient.closed = false;
- return Promise.all([
- recipient.save(),
- emitEvent({
- event: "CHANNEL_CREATE",
- data: channel_dto.excludedRecipients([
- recipient.user_id,
- ]),
- user_id: recipient.user_id,
- }),
- ]);
- }
- }) || [],
- );
- }
-
- if (message.guild_id) {
- // handleMessage will fetch the Member, but only if they are not guild owner.
- // have to fetch ourselves otherwise.
- if (!message.member) {
- message.member = await Member.findOneOrFail({
- where: { id: req.user_id, guild_id: message.guild_id },
- relations: ["roles"],
- });
- }
-
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- //@ts-ignore
- message.member.roles = message.member.roles
- .filter((x) => x.id != x.guild_id)
- .map((x) => x.id);
- }
-
- let read_state = await ReadState.findOne({
- where: { user_id: req.user_id, channel_id },
- });
- if (!read_state)
- read_state = ReadState.create({ user_id: req.user_id, channel_id });
- read_state.last_message_id = message.id;
-
- await Promise.all([
- read_state.save(),
- message.save(),
- emitEvent({
- event: "MESSAGE_CREATE",
- channel_id: channel_id,
- data: message,
- } as MessageCreateEvent),
- message.guild_id
- ? Member.update(
- { id: req.user_id, guild_id: message.guild_id },
- { last_message_id: message.id },
- )
- : null,
- channel.save(),
- ]);
-
- // 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.withSignedAttachments(
- new NewUrlUserSignatureData({
- ip: req.ip,
- userAgent: req.headers["user-agent"] as string,
- }),
- ),
- );
- },
+ "/",
+ messageUpload.any(),
+ (req, res, next) => {
+ if (req.body.payload_json) {
+ req.body = JSON.parse(req.body.payload_json);
+ }
+
+ next();
+ },
+ route({
+ requestBody: "MessageCreateSchema",
+ permission: "SEND_MESSAGES",
+ right: "SEND_MESSAGES",
+ responses: {
+ 200: {
+ body: "Message",
+ },
+ 400: {
+ body: "APIErrorResponse",
+ },
+ 403: {},
+ 404: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { channel_id } = req.params;
+ const body = req.body as MessageCreateSchema;
+ const attachments: (Attachment | MessageCreateAttachment | MessageCreateCloudAttachment)[] = body.attachments ?? [];
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ relations: ["recipients", "recipients.user"],
+ });
+ if (!channel.isWritable()) {
+ throw new HTTPError(`Cannot send messages to channel of type ${channel.type}`, 400);
+ }
+
+ // handle blocked users in dms
+ if (channel.recipients?.length == 2) {
+ const otherUser = channel.recipients.find((r) => r.user_id != req.user_id)?.user;
+ if (otherUser) {
+ const relationship = await Relationship.findOne({
+ where: [
+ { from_id: req.user_id, to_id: otherUser.id },
+ { from_id: otherUser.id, to_id: req.user_id },
+ ],
+ });
+
+ if (relationship?.type === RelationshipType.blocked) {
+ throw DiscordApiErrors.CANNOT_MESSAGE_USER;
+ }
+ }
+ }
+
+ if (body.nonce) {
+ const existing = await Message.findOne({
+ where: {
+ nonce: body.nonce,
+ channel_id: channel.id,
+ author_id: req.user_id,
+ },
+ });
+ if (existing) {
+ return res.json(existing);
+ }
+ }
+
+ if (!req.rights.has(Rights.FLAGS.BYPASS_RATE_LIMITS)) {
+ const limits = Config.get().limits;
+ if (limits.absoluteRate.sendMessage.enabled) {
+ const count = await Message.count({
+ where: {
+ 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"),
+ },
+ });
+ }
+
+ if (channel.rate_limit_per_user && channel.rate_limit_per_user > 0) {
+ const lastMessage = await Message.findOne({
+ where: {
+ channel_id,
+ author_id: req.user_id,
+ },
+ order: { timestamp: "DESC" },
+ select: ["timestamp"],
+ });
+
+ if (lastMessage) {
+ const timeSinceLastMessage = Date.now() - lastMessage.timestamp.getTime();
+ const slowmodeMs = channel.rate_limit_per_user * 1000;
+
+ if (timeSinceLastMessage < slowmodeMs) {
+ throw DiscordApiErrors.SLOWMODE_RATE_LIMIT;
+ }
+ }
+ }
+ }
+
+ const files = (req.files as Express.Multer.File[]) ?? [];
+ for (const currFile of files) {
+ try {
+ const file = await uploadFile(`/attachments/${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 || [];
+ if (body.embed) embeds.push(body.embed);
+ console.log("messages/index.ts: attachments:", attachments);
+ const message = await handleMessage({
+ ...body,
+ type: 0,
+ pinned: false,
+ author_id: req.user_id,
+ embeds,
+ channel_id,
+ attachments,
+ timestamp: new Date(),
+ });
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ //@ts-ignore dont care2
+ message.edited_timestamp = null;
+
+ channel.last_message_id = message.id;
+
+ if (channel.isDm()) {
+ const channel_dto = await DmChannelDTO.from(channel);
+
+ // Only one recipients should be closed here, since in group DMs the recipient is deleted not closed
+ await Promise.all(
+ channel.recipients?.map((recipient) => {
+ if (recipient.closed) {
+ recipient.closed = false;
+ return Promise.all([
+ recipient.save(),
+ emitEvent({
+ event: "CHANNEL_CREATE",
+ data: channel_dto.excludedRecipients([recipient.user_id]),
+ user_id: recipient.user_id,
+ }),
+ ]);
+ }
+ }) || [],
+ );
+ }
+
+ if (message.guild_id) {
+ // handleMessage will fetch the Member, but only if they are not guild owner.
+ // have to fetch ourselves otherwise.
+ if (!message.member) {
+ message.member = await Member.findOneOrFail({
+ where: { id: req.user_id, guild_id: message.guild_id },
+ relations: ["roles"],
+ });
+ }
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ message.member.roles = message.member.roles.filter((x) => x.id != x.guild_id).map((x) => x.id);
+
+ if (message.content)
+ try {
+ const matchingRules = await AutomodRule.find({
+ where: { guild_id: message.guild_id, enabled: true, event_type: AutomodRuleEventType.MESSAGE_SEND },
+ order: { position: "ASC" },
+ });
+ for (const rule of matchingRules) {
+ if (rule.exempt_channels.includes(channel_id)) continue;
+ if (message.member.roles.some((x) => rule.exempt_roles.includes(x.id))) continue;
+
+ if (rule.trigger_type == AutomodTriggerTypes.CUSTOM_WORDS) {
+ const triggerMeta = rule.trigger_metadata as AutomodCustomWordsRule;
+ const regexes = triggerMeta.regex_patterns.map((x) => new RegExp(x, "i")).concat(triggerMeta.keyword_filter.map((k) => k.globToRegexp("i")));
+ const allowedRegexes = triggerMeta.allow_list.map((k) => k.globToRegexp("i"));
+
+ const matches = regexes
+ .map((r) => message.content!.match(r))
+ .filter((x) => x !== null && x.length > 0)
+ .filter((x) => !allowedRegexes.some((ar) => ar.test(x![0])));
+
+ if (matches.length > 0) {
+ console.log("Automod triggered by message:", message.id, "matches:", matches);
+ if (rule.actions.some((x) => x.type == AutomodRuleActionType.SEND_ALERT_MESSAGE && x.metadata.channel_id)) {
+ const alertActions = rule.actions.filter((x) => x.type == AutomodRuleActionType.SEND_ALERT_MESSAGE);
+ for (const action of alertActions) {
+ const alertChannel = await Channel.findOne({ where: { id: action.metadata.channel_id } });
+ if (!alertChannel) continue;
+ const msg = await Message.createWithDefaults({
+ content: `Automod Alert: Message ${message.id} by <@${message.author_id}> in <#${channel.id}> triggered automod rule "${rule.name}".\nMatched terms: ${matches
+ .map((x) => `\`${x![0]}\``)
+ .join(", ")}`,
+ author: message.author,
+ channel_id: alertChannel.id,
+ guild_id: message.guild_id,
+ member_id: message.member_id,
+ author_id: message.author_id,
+ });
+
+ await message.save();
+ // await Promise.all([
+ await emitEvent({
+ event: "MESSAGE_CREATE",
+ channel_id: msg.channel_id,
+ data: msg.toJSON(),
+ } as MessageCreateEvent);
+ // ]);
+ }
+ }
+ }
+ }
+ }
+ } catch (e) {
+ console.log("[Automod] failed to process message:", e);
+ }
+ }
+
+ let read_state = await ReadState.findOne({
+ where: { user_id: req.user_id, channel_id },
+ });
+ if (!read_state) read_state = ReadState.create({ user_id: req.user_id, channel_id });
+ read_state.last_message_id = message.id;
+
+ await Promise.all([
+ read_state.save(),
+ message.save(),
+ emitEvent({
+ event: "MESSAGE_CREATE",
+ channel_id: channel_id,
+ data: message,
+ } as MessageCreateEvent),
+ message.guild_id ? Member.update({ id: req.user_id, guild_id: message.guild_id }, { last_message_id: message.id }) : null,
+ channel.save(),
+ ]);
+
+ // 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.withSignedAttachments(
+ new NewUrlUserSignatureData({
+ ip: req.ip,
+ userAgent: req.headers["user-agent"] as string,
+ }),
+ ),
+ );
+ },
);
export default router;
diff --git a/src/api/routes/channels/#channel_id/messages/pins/index.ts b/src/api/routes/channels/#channel_id/messages/pins/index.ts
index 3316d1564..291d7c8a9 100644
--- a/src/api/routes/channels/#channel_id/messages/pins/index.ts
+++ b/src/api/routes/channels/#channel_id/messages/pins/index.ts
@@ -20,173 +20,182 @@ import { route } from "@spacebar/api";
import { ChannelPinsUpdateEvent, Config, DiscordApiErrors, emitEvent, Message, MessageCreateEvent, MessageUpdateEvent, User } from "@spacebar/util";
import { Request, Response, Router } from "express";
import { IsNull, Not } from "typeorm";
+import { resolveMessageInChannel, computeProjectionsForMessage } from "../../../../../util/helpers/MessageProjection";
const router: Router = Router({ mergeParams: true });
router.put(
- "/:message_id",
- route({
- permission: "VIEW_CHANNEL",
- responses: {
- 204: {},
- 403: {},
- 404: {},
- 400: {
- body: "APIErrorResponse",
- },
- },
- }),
- async (req: Request, res: Response) => {
- const { channel_id, message_id } = req.params;
-
- const message = await Message.findOneOrFail({
- where: { id: message_id },
- relations: { author: true },
- });
-
- // * in dm channels anyone can pin messages -> only check for guilds
- if (message.guild_id) req.permission?.hasThrow("MANAGE_MESSAGES");
-
- const pinned_count = await Message.count({
- where: { channel: { id: channel_id }, pinned_at: Not(IsNull()) },
- });
-
- const { maxPins } = Config.get().limits.channel;
- if (pinned_count >= maxPins) throw DiscordApiErrors.MAXIMUM_PINS.withParams(maxPins);
-
- message.pinned_at = new Date();
-
- const author = await User.getPublicUser(req.user_id);
-
- const systemPinMessage = Message.create({
- timestamp: new Date(),
- type: 6,
- guild_id: message.guild_id,
- channel_id: message.channel_id,
- author,
- message_reference: {
- message_id: message.id,
- channel_id: message.channel_id,
- guild_id: message.guild_id,
- },
- reactions: [],
- attachments: [],
- embeds: [],
- sticker_items: [],
- edited_timestamp: undefined,
- mentions: [],
- mention_channels: [],
- mention_roles: [],
- mention_everyone: false,
- });
-
- await Promise.all([
- message.save(),
- emitEvent({
- event: "MESSAGE_UPDATE",
- channel_id,
- data: message,
- } as MessageUpdateEvent),
- emitEvent({
- event: "CHANNEL_PINS_UPDATE",
- channel_id,
- data: {
- channel_id,
- guild_id: message.guild_id,
- last_pin_timestamp: undefined,
- },
- } as ChannelPinsUpdateEvent),
- systemPinMessage.save(),
- emitEvent({
- event: "MESSAGE_CREATE",
- channel_id: message.channel_id,
- data: systemPinMessage,
- } as MessageCreateEvent),
- ]);
-
- res.sendStatus(204);
- },
+ "/:message_id",
+ route({
+ permission: "VIEW_CHANNEL",
+ responses: {
+ 204: {},
+ 403: {},
+ 404: {},
+ 400: {
+ body: "APIErrorResponse",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { channel_id, message_id } = req.params;
+
+ const message = await resolveMessageInChannel(message_id, channel_id, req.user_id);
+
+ // * in dm channels anyone can pin messages -> only check for guilds
+ if (message.guild_id) req.permission?.hasThrow("PIN_MESSAGES");
+
+ const pinned_count = await Message.count({
+ where: { channel: { id: channel_id }, pinned_at: Not(IsNull()) },
+ });
+
+ const { maxPins } = Config.get().limits.channel;
+ if (pinned_count >= maxPins) throw DiscordApiErrors.MAXIMUM_PINS.withParams(maxPins);
+
+ message.pinned_at = new Date();
+
+ const author = await User.getPublicUser(req.user_id);
+
+ const systemPinMessage = Message.create({
+ timestamp: new Date(),
+ type: 6,
+ guild_id: message.guild_id,
+ channel_id: message.channel_id,
+ author,
+ message_reference: {
+ message_id: message.id,
+ channel_id: message.channel_id,
+ guild_id: message.guild_id,
+ },
+ reactions: [],
+ attachments: [],
+ embeds: [],
+ sticker_items: [],
+ edited_timestamp: undefined,
+ mentions: [],
+ mention_channels: [],
+ mention_roles: [],
+ mention_everyone: false,
+ });
+
+ await message.save();
+ await systemPinMessage.save();
+
+ const projections = await computeProjectionsForMessage(message);
+
+ await Promise.all([
+ ...projections.map((projection) =>
+ emitEvent({
+ event: "MESSAGE_UPDATE",
+ channel_id: projection.channelId,
+ data: message.toProjectedJSON(projection.channelId),
+ } as MessageUpdateEvent),
+ ),
+ ...projections.map((projection) =>
+ emitEvent({
+ event: "CHANNEL_PINS_UPDATE",
+ channel_id: projection.channelId,
+ data: {
+ channel_id: projection.channelId,
+ guild_id: message.guild_id,
+ last_pin_timestamp: undefined,
+ },
+ } as ChannelPinsUpdateEvent),
+ ),
+ emitEvent({
+ event: "MESSAGE_CREATE",
+ channel_id: message.channel_id,
+ data: systemPinMessage,
+ } as MessageCreateEvent),
+ ]);
+
+ res.sendStatus(204);
+ },
);
router.delete(
- "/:message_id",
- route({
- permission: "VIEW_CHANNEL",
- responses: {
- 204: {},
- 403: {},
- 404: {},
- 400: {
- body: "APIErrorResponse",
- },
- },
- }),
- async (req: Request, res: Response) => {
- const { channel_id, message_id } = req.params;
-
- const message = await Message.findOneOrFail({
- where: { id: message_id },
- relations: { author: true },
- });
-
- if (message.guild_id) req.permission?.hasThrow("MANAGE_MESSAGES");
-
- message.pinned_at = null;
-
- await Promise.all([
- message.save(),
- emitEvent({
- event: "MESSAGE_UPDATE",
- channel_id,
- data: message,
- } as MessageUpdateEvent),
- emitEvent({
- event: "CHANNEL_PINS_UPDATE",
- channel_id,
- data: {
- channel_id,
- guild_id: message.guild_id,
- last_pin_timestamp: undefined,
- },
- } as ChannelPinsUpdateEvent),
- ]);
-
- res.sendStatus(204);
- },
+ "/:message_id",
+ route({
+ permission: "VIEW_CHANNEL",
+ responses: {
+ 204: {},
+ 403: {},
+ 404: {},
+ 400: {
+ body: "APIErrorResponse",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { channel_id, message_id } = req.params;
+
+ const message = await resolveMessageInChannel(message_id, channel_id, req.user_id);
+
+ if (message.guild_id) req.permission?.hasThrow("PIN_MESSAGES");
+
+ message.pinned_at = null;
+
+ await message.save();
+
+ const projections = await computeProjectionsForMessage(message);
+
+ await Promise.all([
+ ...projections.map((projection) =>
+ emitEvent({
+ event: "MESSAGE_UPDATE",
+ channel_id: projection.channelId,
+ data: message.toProjectedJSON(projection.channelId),
+ } as MessageUpdateEvent),
+ ),
+ ...projections.map((projection) =>
+ emitEvent({
+ event: "CHANNEL_PINS_UPDATE",
+ channel_id: projection.channelId,
+ data: {
+ channel_id: projection.channelId,
+ guild_id: message.guild_id,
+ last_pin_timestamp: undefined,
+ },
+ } as ChannelPinsUpdateEvent),
+ ),
+ ]);
+
+ res.sendStatus(204);
+ },
);
router.get(
- "/",
- route({
- permission: ["READ_MESSAGE_HISTORY"],
- responses: {
- 200: {
- body: "APIMessageArray",
- },
- 400: {
- body: "APIErrorResponse",
- },
- },
- }),
- async (req: Request, res: Response) => {
- const { channel_id } = req.params;
-
- const pins = await Message.find({
- where: { channel_id: channel_id, pinned_at: Not(IsNull()) },
- relations: { author: true },
- order: { pinned_at: "DESC" },
- });
-
- const items = pins.map((message: Message) => ({
- message,
- pinned_at: message.pinned_at,
- }));
-
- res.send({
- items,
- has_more: false,
- });
- },
+ "/",
+ route({
+ permission: ["READ_MESSAGE_HISTORY"],
+ responses: {
+ 200: {
+ body: "APIMessageArray",
+ },
+ 400: {
+ body: "APIErrorResponse",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { channel_id } = req.params;
+
+ const pins = await Message.find({
+ where: { channel_id: channel_id, pinned_at: Not(IsNull()) },
+ relations: ["author"],
+ order: { pinned_at: "DESC" },
+ });
+
+ const items = pins.map((message: Message) => ({
+ message,
+ pinned_at: message.pinned_at,
+ }));
+
+ res.send({
+ items,
+ has_more: false,
+ });
+ },
);
export default router;
diff --git a/src/api/routes/routing-rules.ts b/src/api/routes/routing-rules.ts
new file mode 100644
index 000000000..cf1dd75d9
--- /dev/null
+++ b/src/api/routes/routing-rules.ts
@@ -0,0 +1,242 @@
+/*
+ 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 .
+*/
+
+import { route } from "@spacebar/api";
+import { RoutingRule, Snowflake, Channel, getPermission, getRights } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+import { HTTPError } from "lambert-server";
+
+const router: Router = Router();
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "RoutingRule[]",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const user_id = req.user_id;
+ if (!user_id) throw new HTTPError("Unauthorized", 401);
+
+ const rights = await getRights(user_id);
+ const hasManageRoutingRight = rights.has("MANAGE_ROUTING");
+
+ let rules: RoutingRule[];
+
+ if (hasManageRoutingRight) {
+ rules = await RoutingRule.find({
+ relations: ["source_channel", "storage_channel", "sink_channel"],
+ });
+ } else {
+ const allRules = await RoutingRule.find({
+ relations: ["source_channel", "storage_channel", "sink_channel"],
+ });
+
+ rules = [];
+ for (const rule of allRules) {
+ try {
+ if (rule.source_channel?.guild_id) {
+ const permission = await getPermission(user_id, rule.source_channel.guild_id, rule.source_channel_id);
+ if (permission.has("VIEW_CHANNEL")) {
+ let canViewStorage = true;
+ let canViewSink = true;
+
+ if (rule.storage_channel?.guild_id) {
+ try {
+ const storagePermission = await getPermission(user_id, rule.storage_channel.guild_id, rule.storage_channel_id);
+ canViewStorage = storagePermission.has("VIEW_CHANNEL");
+ } catch (e) {
+ canViewStorage = false;
+ }
+ }
+
+ if (rule.sink_channel?.guild_id) {
+ try {
+ const sinkPermission = await getPermission(user_id, rule.sink_channel.guild_id, rule.sink_channel_id);
+ canViewSink = sinkPermission.has("VIEW_CHANNEL");
+ } catch (e) {
+ canViewSink = false;
+ }
+ }
+
+ if (!canViewStorage) {
+ rule.storage_channel = undefined;
+ }
+ if (!canViewSink) {
+ rule.sink_channel = undefined;
+ }
+
+ rules.push(rule);
+ }
+ }
+ } catch (e) {
+ continue;
+ }
+ }
+ }
+
+ return res.json(rules);
+ },
+);
+
+router.post(
+ "/",
+ route({
+ right: "MANAGE_ROUTING",
+ responses: {
+ 201: {
+ body: "RoutingRule",
+ },
+ 400: {
+ body: "APIErrorResponse",
+ },
+ 403: {
+ body: "APIErrorResponse",
+ },
+ 404: {
+ body: "APIErrorResponse",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { source_channel_id, storage_channel_id, sink_channel_id, source_users, target_users, valid_since, valid_until } = req.body;
+
+ if (!source_channel_id || !storage_channel_id || !sink_channel_id || !source_users || !target_users || !valid_since || !valid_until) {
+ throw new HTTPError("Missing required fields", 400);
+ }
+
+ const sourceChannel = await Channel.findOne({
+ where: { id: source_channel_id },
+ });
+ const storageChannel = await Channel.findOne({
+ where: { id: storage_channel_id },
+ });
+ const sinkChannel = await Channel.findOne({
+ where: { id: sink_channel_id },
+ });
+
+ if (!sourceChannel || !storageChannel || !sinkChannel) {
+ throw new HTTPError("One or more channels not found", 404);
+ }
+
+ const rule = RoutingRule.create({
+ registration_timestamp: Snowflake.generate(),
+ source_channel_id,
+ storage_channel_id,
+ sink_channel_id,
+ source_users: Array.isArray(source_users) ? source_users : [source_users],
+ target_users: Array.isArray(target_users) ? target_users : [target_users],
+ valid_since,
+ valid_until,
+ });
+
+ await RoutingRule.validateInvariants(rule);
+
+ await rule.save();
+
+ return res.status(201).json(rule);
+ },
+);
+
+router.patch(
+ "/:rule_id",
+ route({
+ right: "MANAGE_ROUTING",
+ responses: {
+ 200: {
+ body: "RoutingRule",
+ },
+ 400: {
+ body: "APIErrorResponse",
+ },
+ 403: {
+ body: "APIErrorResponse",
+ },
+ 404: {
+ body: "APIErrorResponse",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { rule_id } = req.params;
+ const { valid_until } = req.body;
+
+ if (!valid_until) {
+ throw new HTTPError("Only valid_until can be updated on routing rules", 400);
+ }
+
+ const rule = await RoutingRule.findOne({
+ where: { id: rule_id },
+ });
+
+ if (!rule) {
+ throw new HTTPError("Routing rule not found", 404);
+ }
+
+ const now = Snowflake.generate();
+ if (BigInt(rule.valid_since) > BigInt(now) || BigInt(now) > BigInt(rule.valid_until)) {
+ throw new HTTPError("Cannot update routing rule that is not currently in effect", 400);
+ }
+
+ if (BigInt(valid_until) < BigInt(rule.valid_since)) {
+ throw new HTTPError("valid_until cannot be earlier than valid_since", 400);
+ }
+
+ rule.valid_until = valid_until;
+ await rule.save();
+
+ return res.json(rule);
+ },
+);
+
+router.delete(
+ "/:rule_id",
+ route({
+ right: "MANAGE_ROUTING",
+ responses: {
+ 204: {},
+ 400: {
+ body: "APIErrorResponse",
+ },
+ 403: {
+ body: "APIErrorResponse",
+ },
+ 404: {
+ body: "APIErrorResponse",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { rule_id } = req.params;
+
+ const canDelete = await RoutingRule.canDelete(rule_id);
+
+ if (!canDelete) {
+ throw new HTTPError("Cannot delete routing rule: rule is in effect or has affected messages", 400);
+ }
+
+ await RoutingRule.delete({ id: rule_id });
+
+ return res.status(204).send();
+ },
+);
+
+export default router;
diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts
index 6adbf9ff5..fe2d54d17 100644
--- a/src/api/util/handlers/Message.ts
+++ b/src/api/util/handlers/Message.ts
@@ -1,24 +1,22 @@
/*
- Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
- Copyright (C) 2023 Spacebar and Spacebar Contributors
+ 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 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.
+ 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 .
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
*/
-import * as Sentry from "@sentry/node";
import { AutomodEvaluator, AutomodActionExecutor } from "@spacebar/util";
-
import { EmbedHandlers } from "@spacebar/api";
import {
Application,
@@ -45,16 +43,15 @@ import {
handleFile,
Permissions,
normalizeUrl,
- DiscordApiErrors,
- CloudAttachment,
- ReadState,
- Member,
- Session,
- MessageFlags,
+ RoutingRule,
+ Snowflake,
} from "@spacebar/util";
import { HTTPError } from "lambert-server";
-import { In, Or, Equal, IsNull } from "typeorm";
-import { ChannelType, Embed, EmbedType, MessageCreateAttachment, MessageCreateCloudAttachment, MessageCreateSchema, MessageType, Reaction } from "@spacebar/schemas";
+import { In, LessThanOrEqual, MoreThanOrEqual } from "typeorm";
+import fetch from "node-fetch-commonjs";
+import { CloudAttachment } from "../../../util/entities/CloudAttachment";
+import { Embed, MessageCreateAttachment, MessageCreateCloudAttachment, MessageCreateSchema, MessageType, Reaction } from "@spacebar/schemas";
+import { computeProjectionsForMessage } from "../helpers/MessageProjection";
const allow_empty = false;
// TODO: check webhook, application, system author, stickers
// TODO: embed gifs/videos/images
@@ -64,25 +61,10 @@ const LINK_REGEX = / {
const channel = await Channel.findOneOrFail({
where: { id: opts.channel_id },
- relations: { recipients: true },
+ relations: ["recipients"],
});
if (!channel || !opts.channel_id) throw new HTTPError("Channel not found", 404);
- let permission: undefined | Permissions;
- const limit = channel.rate_limit_per_user;
-
- if (limit) {
- const lastMsgTime = (await Message.findOne({ where: { channel_id: channel.id, author_id: opts.author_id }, select: { timestamp: true }, order: { timestamp: "DESC" } }))
- ?.timestamp;
- if (lastMsgTime && Date.now() - limit * 1000 < +lastMsgTime) {
- permission ||= await getPermission(opts.author_id, channel.guild_id, channel);
- //FIXME MANAGE_MESSAGES and MANAGE_CHANNELS will need to be removed once they're gone as checks
- if (!permission.has("MANAGE_MESSAGES") && !permission.has("MANAGE_CHANNELS") && !permission.has("BYPASS_SLOWMODE")) {
- throw DiscordApiErrors.SLOWMODE_RATE_LIMIT;
- }
- }
- }
-
const stickers = opts.sticker_ids ? await Sticker.find({ where: { id: In(opts.sticker_ids) } }) : undefined;
// cloud attachments with indexes
const cloudAttachments = opts.attachments?.reduce(
@@ -108,7 +90,6 @@ export async function handleMessage(opts: MessageOptions): Promise {
mentions: [],
components: opts.components ?? undefined, // Fix Discord-Go?
});
- const ephermal = (message.flags & (1 << 6)) !== 0;
if (cloudAttachments && cloudAttachments.length > 0) {
console.log("[Message] Processing attachments for message", message.id, ":", message.attachments);
@@ -153,17 +134,16 @@ export async function handleMessage(opts: MessageOptions): Promise {
for (const att of uploadedAttachments) {
message.attachments![att.index] = att.attachment;
}
- }
- // else console.log("[Message] No cloud attachments to process for message", message.id, ":", message.attachments);
+ } else console.log("[Message] No cloud attachments to process for message", message.id, ":", message.attachments);
+
+ console.log("opts:", opts.attachments, "\nmessage:", message.attachments);
if (message.content && message.content.length > Config.get().limits.message.maxCharacters) {
throw new HTTPError("Content length over max character limit");
}
if (opts.author_id) {
- message.author = await User.findOneOrFail({
- where: { id: opts.author_id },
- });
+ message.author = await User.getPublicUser(opts.author_id);
const rights = await getRights(opts.author_id);
rights.hasThrow("SEND_MESSAGES");
}
@@ -173,6 +153,7 @@ export async function handleMessage(opts: MessageOptions): Promise {
});
}
+ let permission: undefined | Permissions;
if (opts.webhook_id) {
message.webhook = await Webhook.findOneOrFail({
where: { id: opts.webhook_id },
@@ -210,7 +191,7 @@ export async function handleMessage(opts: MessageOptions): Promise {
}
if (opts.avatar_url) {
const avatarData = await fetch(opts.avatar_url);
- const base64 = await avatarData.arrayBuffer().then((x) => Buffer.from(x).toString("base64"));
+ const base64 = await avatarData.buffer().then((x) => x.toString("base64"));
const dataUri = "data:" + avatarData.headers.get("content-type") + ";base64," + base64;
@@ -218,7 +199,7 @@ export async function handleMessage(opts: MessageOptions): Promise {
message.author.avatar = message.avatar;
}
} else {
- permission ||= await getPermission(opts.author_id, channel.guild_id, channel);
+ permission = await getPermission(opts.author_id, channel.guild_id, opts.channel_id);
permission.hasThrow("SEND_MESSAGES");
if (permission.cache.member) {
message.member = permission.cache.member;
@@ -235,7 +216,7 @@ export async function handleMessage(opts: MessageOptions): Promise {
if (!opts.message_reference.guild_id) opts.message_reference.guild_id = channel.guild_id;
if (!opts.message_reference.channel_id) opts.message_reference.channel_id = opts.channel_id;
- if (opts.message_reference.type != 1) {
+ if (!guild.features.includes("CROSS_CHANNEL_REPLIES")) {
if (opts.message_reference.guild_id !== channel.guild_id) throw new HTTPError("You can only reference messages from this guild");
if (opts.message_reference.channel_id !== opts.channel_id) throw new HTTPError("You can only reference messages from this channel");
}
@@ -245,16 +226,7 @@ export async function handleMessage(opts: MessageOptions): Promise {
where: {
id: opts.message_reference.message_id,
},
- relations: {
- author: true,
- webhook: true,
- application: true,
- mentions: true,
- mention_roles: true,
- mention_channels: true,
- sticker_items: true,
- attachments: true,
- },
+ relations: ["author", "webhook", "application", "mentions", "mention_roles", "mention_channels", "sticker_items", "attachments"],
});
if (message.referenced_message.channel_id && message.referenced_message.channel_id !== opts.message_reference.channel_id)
@@ -263,22 +235,13 @@ export async function handleMessage(opts: MessageOptions): Promise {
throw new HTTPError("Referenced message not found in the specified channel", 404);
}
/** Q: should be checked if the referenced message exists? ANSWER: NO
- otherwise backfilling won't work **/
+ otherwise backfilling won't work **/
message.type = MessageType.REPLY;
}
}
// TODO: stickers/activity
- if (
- !allow_empty &&
- !opts.content &&
- !opts.embeds?.length &&
- !opts.attachments?.length &&
- !opts.sticker_ids?.length &&
- !opts.poll &&
- !opts.components?.length &&
- opts.message_reference?.type != 1
- ) {
+ if (!allow_empty && !opts.content && !opts.embeds?.length && !opts.attachments?.length && !opts.sticker_ids?.length && !opts.poll && !opts.components?.length) {
console.log("[Message] Rejecting empty message:", opts, message);
throw new HTTPError("Empty messages are not allowed", 50006);
}
@@ -297,9 +260,9 @@ export async function handleMessage(opts: MessageOptions): Promise {
content = content.replace(/ *`[^)]*` */g, ""); // remove codeblocks
// root@Rory - 20/02/2023 - This breaks channel mentions in test client. We're not sure this was used in older clients.
/*for (const [, mention] of content.matchAll(CHANNEL_MENTION)) {
- if (!mention_channel_ids.includes(mention))
- mention_channel_ids.push(mention);
- }*/
+ if (!mention_channel_ids.includes(mention))
+ mention_channel_ids.push(mention);
+ }*/
for (const [, mention] of content.matchAll(USER_MENTION)) {
if (!mention_user_ids.includes(mention)) mention_user_ids.push(mention);
@@ -336,152 +299,48 @@ export async function handleMessage(opts: MessageOptions): Promise {
);
}
- // FORWARD
- if (message.message_reference.type === 1) {
- message.type = MessageType.DEFAULT;
-
- if (message.referenced_message) {
- const mention_roles: string[] = [];
- const mentions: string[] = [];
-
- // TODO: mention_roles and mentions arrays - not needed it seems, but discord still returns that
-
- message.message_snapshots = [
- {
- message: {
- attachments: message.referenced_message.attachments,
- components: message.referenced_message.components,
- content: message.referenced_message.content!,
- edited_timestamp: message.referenced_message.edited_timestamp,
- embeds: message.referenced_message.embeds,
- flags: message.referenced_message.flags,
- mention_roles,
- mentions,
- timestamp: message.referenced_message.timestamp,
- type: message.referenced_message.type,
- },
- },
- ];
+ if (referencedMessage) {
+ if (!referencedMessage.reply_ids) {
+ referencedMessage.reply_ids = [];
+ }
+ if (!referencedMessage.reply_ids.includes(message.id)) {
+ referencedMessage.reply_ids.push(message.id);
+ await Message.update({ id: referencedMessage.id }, { reply_ids: referencedMessage.reply_ids });
}
}
}
// root@Rory - 20/02/2023 - This breaks channel mentions in test client. We're not sure this was used in older clients.
/*message.mention_channels = mention_channel_ids.map((x) =>
- Channel.create({ id: x }),
- );*/
- message.mention_roles = (
- await Promise.all(
- mention_role_ids.map((x) => {
- return Role.findOne({ where: { id: x } });
- }),
- )
- ).filter((role) => role !== null);
-
- message.mentions = [
- ...message.mentions,
- ...(
- await Promise.all(
- mention_user_ids.map((x) => {
- return User.findOne({ where: { id: x } });
- }),
- )
- ).filter((user) => user !== null),
- ];
+ Channel.create({ id: x }),
+ );*/
+ message.mention_roles = mention_role_ids.map((x) => Role.create({ id: x }));
+ message.mentions = [...message.mentions, ...mention_user_ids.map((x) => User.create({ id: x }))];
message.mention_everyone = mention_everyone;
- async function fillInMissingIDs(ids: string[]) {
- const states = await ReadState.findBy({
- user_id: Or(...ids.map((id) => Equal(id))),
- channel_id: channel.id,
- });
- const users = new Set(ids);
- states.forEach((state) => users.delete(state.user_id));
- if (!users.size) {
- return;
- }
- return Promise.all(
- [...users].map((user_id) => {
- return ReadState.create({ user_id, channel_id: channel.id }).save();
- }),
- );
- }
- if (ephermal) {
- const id = message.interaction_metadata?.user_id;
- if (id) {
- let pinged = mention_everyone || channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM;
- if (!pinged) pinged = !!message.mentions.find((user) => user.id === id);
- if (!pinged) pinged = !!(await Member.find({ where: { id, roles: Or(...message.mention_roles.map(({ id }) => Equal(id))) } }));
- if (pinged) {
- //stuff
- }
- }
- } else if ((!!message.content?.match(EVERYONE_MENTION) && permission?.has("MENTION_EVERYONE")) || channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM) {
- if (channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM) {
- if (channel.recipients) {
- await fillInMissingIDs(channel.recipients.map(({ user_id }) => user_id));
- }
- } else {
- console.log(channel.guild_id);
- await fillInMissingIDs((await Member.find({ where: { guild_id: channel.guild_id } })).map(({ id }) => id));
- }
- const repository = ReadState.getRepository();
- const condition = { channel_id: channel.id };
- await repository.update({ ...condition, mention_count: IsNull() }, { mention_count: 0 });
- await repository.increment(condition, "mention_count", 1);
- } else {
- const users = new Set([
- ...(message.mention_roles.length
- ? await Member.find({
- where: [
- ...message.mention_roles.map((role) => {
- return { roles: { id: role.id } };
- }),
- ],
- })
- : []
- ).map((member) => member.id),
- ...message.mentions.map((user) => user.id),
- ]);
- if (!!message.content?.match(HERE_MENTION) && permission?.has("MENTION_EVERYONE")) {
- const ids = (await Member.find({ where: { guild_id: channel.guild_id } })).map(({ id }) => id);
- (await Session.find({ where: { user_id: Or(...ids.map((id) => Equal(id))) } })).forEach(({ user_id }) => users.add(user_id));
- }
- if (users.size) {
- const repository = ReadState.getRepository();
- const condition = { user_id: Or(...[...users].map((id) => Equal(id))), channel_id: channel.id };
-
- await fillInMissingIDs([...users]);
-
- await repository.update({ ...condition, mention_count: IsNull() }, { mention_count: 0 });
- await repository.increment(condition, "mention_count", 1);
- }
- }
+ // TODO: check and put it all in the body
- // Automod enforcement - evaluate message against guild automod rules
if (message.guild_id && message.content && message.author) {
- const automodResult = await AutomodEvaluator.evaluateMessage({
- content: message.content,
- channel,
- author: message.author,
- guild_id: message.guild_id,
- member_roles: permission?.cache.member?.roles?.map((r) => r.id),
- });
-
- if (automodResult.triggered && automodResult.rule) {
- await AutomodActionExecutor.executeActions(automodResult.actions, {
- message,
- channel,
- member: permission?.cache.member,
- rule_name: automodResult.rule.name,
- matched_content: automodResult.matched_content,
- keyword: automodResult.keyword,
- });
- }
- }
+ const automodResult = await AutomodEvaluator.evaluateMessage({
+ content: message.content,
+ channel,
+ author: message.author,
+ guild_id: message.guild_id,
+ member_roles: permission?.cache.member?.roles?.map((r) => r.id),
+ });
- // TODO: check and put it all in the body
+ if (automodResult.triggered && automodResult.rule) {
+ await AutomodActionExecutor.executeActions(automodResult.actions, {
+ message,
+ channel,
+ member: permission?.cache.member,
+ rule_name: automodResult.rule.name,
+ matched_content: automodResult.matched_content,
+ keyword: automodResult.keyword,
+ });
+ }
+ }
return message;
}
@@ -508,11 +367,6 @@ export async function postHandleMessage(message: Message) {
}
}
- data.embeds.forEach((embed) => {
- if (!embed.type) {
- embed.type = EmbedType.rich;
- }
- });
// Filter out embeds that could be links, start from scratch
data.embeds = data.embeds.filter((embed) => embed.type === "rich");
@@ -539,7 +393,10 @@ export async function postHandleMessage(message: Message) {
if (uniqueLinks.length === 0) {
// No valid unique links found, update message to remove old embeds
- data.embeds = data.embeds.filter((embed) => embed.type === "rich");
+ data.embeds = data.embeds.filter((embed) => {
+ const hasUrl = !!embed.url;
+ return !hasUrl;
+ });
const author = data.author?.toPublicUser();
const event = {
event: "MESSAGE_UPDATE",
@@ -577,8 +434,8 @@ export async function postHandleMessage(message: Message) {
}
// bit gross, but whatever!
- const endpointPublic = Config.get().cdn.endpointPublic; // lol
- const handler = url.hostname === new URL(endpointPublic!).hostname ? EmbedHandlers["self"] : EmbedHandlers[url.hostname] || EmbedHandlers["default"];
+ const endpointPublic = Config.get().cdn.endpointPublic || "http://127.0.0.1"; // lol
+ const handler = url.hostname === new URL(endpointPublic).hostname ? EmbedHandlers["self"] : EmbedHandlers[url.hostname] || EmbedHandlers["default"];
try {
let res = await handler(url);
@@ -611,20 +468,82 @@ export async function postHandleMessage(message: Message) {
]);
}
+async function evaluateRoutingForMessage(
+ sourceChannelId: string,
+ authorId: string,
+ messageId: string,
+): Promise<{ storageChannelId: string; sourceChannelId: string; isIntimacyBroadcast: boolean }> {
+ const rules = await RoutingRule.find({
+ where: {
+ source_channel_id: sourceChannelId,
+ },
+ });
+
+ for (const rule of rules) {
+ const isValid = BigInt(rule.valid_since) <= BigInt(messageId) && BigInt(messageId) <= BigInt(rule.valid_until);
+
+ if (!isValid) {
+ continue;
+ }
+
+ if (rule.source_users.length > 0 && !rule.source_users.includes(authorId)) {
+ continue;
+ }
+
+ const isIntimacyBroadcast = rule.isIntimacyBroadcast();
+
+ if (isIntimacyBroadcast) {
+ return {
+ storageChannelId: sourceChannelId,
+ sourceChannelId: sourceChannelId,
+ isIntimacyBroadcast: true,
+ };
+ }
+
+ return {
+ storageChannelId: rule.storage_channel_id,
+ sourceChannelId: sourceChannelId,
+ isIntimacyBroadcast: false,
+ };
+ }
+
+ return {
+ storageChannelId: sourceChannelId,
+ sourceChannelId: sourceChannelId,
+ isIntimacyBroadcast: false,
+ };
+}
+
export async function sendMessage(opts: MessageOptions) {
- const message = await handleMessage({ ...opts, timestamp: new Date() });
+ const messageId = opts.id || Snowflake.generate();
+ const sourceChannelId = opts.channel_id!;
+ const authorId = opts.author_id!;
- const ephemeral = (message.flags & Number(MessageFlags.FLAGS.EPHEMERAL)) !== 0;
- await Promise.all([
- Message.insert(message),
- emitEvent({
- event: "MESSAGE_CREATE",
- ...(ephemeral ? { user_id: message.interaction_metadata?.user_id } : { channel_id: message.channel_id }),
- data: message.toJSON(),
- } as MessageCreateEvent),
- ]);
+ const routing = await evaluateRoutingForMessage(sourceChannelId, authorId, messageId);
+
+ const message = await handleMessage({
+ ...opts,
+ id: messageId,
+ timestamp: new Date(),
+ });
+
+ message.channel_id = routing.storageChannelId;
+ message.source_channel_id = routing.sourceChannelId;
+
+ await Message.insert(message);
+
+ const projections = await computeProjectionsForMessage(message);
+
+ await Promise.all(
+ projections.map((projection) =>
+ emitEvent({
+ event: "MESSAGE_CREATE",
+ channel_id: projection.channelId,
+ data: message.toProjectedJSON(projection.channelId),
+ } as MessageCreateEvent),
+ ),
+ );
- // no await as it should catch error non-blockingly
postHandleMessage(message).catch((e) => console.error("[Message] post-message handler failed", e));
return message;
diff --git a/src/api/util/helpers/MessageProjection.ts b/src/api/util/helpers/MessageProjection.ts
new file mode 100644
index 000000000..fb376e125
--- /dev/null
+++ b/src/api/util/helpers/MessageProjection.ts
@@ -0,0 +1,323 @@
+/*
+ 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 .
+*/
+
+import { Message, RoutingRule, Snowflake, Channel, getPermission } from "@spacebar/util";
+import { HTTPError } from "lambert-server";
+import { FindOperator, In, IsNull } from "typeorm";
+import { Reaction } from "@spacebar/schemas";
+
+export interface MessageProjection {
+ channelId: string;
+ allowedUserIds?: string[];
+ isIntimacyBroadcast?: boolean;
+ ruleId?: string;
+}
+
+export interface ProjectedMessagesQuery {
+ around?: string;
+ before?: string;
+ after?: string;
+ limit: number;
+}
+
+export async function computeProjectionsForMessage(message: Message): Promise {
+ const projections: MessageProjection[] = [];
+ const messageTimestamp = message.id;
+ const sourceChannelId = message.source_channel_id || message.channel_id;
+
+ if (!sourceChannelId) {
+ return projections;
+ }
+
+ projections.push({
+ channelId: sourceChannelId,
+ allowedUserIds: undefined,
+ isIntimacyBroadcast: false,
+ });
+
+ const rules = await RoutingRule.find({
+ where: {
+ source_channel_id: sourceChannelId,
+ },
+ });
+
+ for (const rule of rules) {
+ const isValid = BigInt(rule.valid_since) <= BigInt(messageTimestamp) && BigInt(messageTimestamp) <= BigInt(rule.valid_until);
+
+ if (!isValid) {
+ continue;
+ }
+
+ if (rule.source_users.length > 0 && message.author_id && !rule.source_users.includes(message.author_id)) {
+ continue;
+ }
+
+ const isIntimacyBroadcast = rule.isIntimacyBroadcast();
+
+ if (isIntimacyBroadcast) {
+ projections[0].isIntimacyBroadcast = true;
+ projections[0].ruleId = rule.id;
+ continue;
+ }
+
+ if (rule.sink_channel_id) {
+ projections.push({
+ channelId: rule.sink_channel_id,
+ allowedUserIds: rule.target_users.length > 0 ? rule.target_users : undefined,
+ isIntimacyBroadcast: false,
+ ruleId: rule.id,
+ });
+ }
+ }
+
+ return projections;
+}
+
+export async function isMessageVisibleInChannelForUser(message: Message, channelId: string, userId: string): Promise {
+ const projections = await computeProjectionsForMessage(message);
+
+ for (const projection of projections) {
+ if (projection.channelId === channelId) {
+ const channel = await Channel.findOne({ where: { id: channelId } });
+ if (!channel) {
+ return false;
+ }
+
+ try {
+ const permission = await getPermission(userId, channel.guild_id, channelId);
+ if (!permission.has("VIEW_CHANNEL")) {
+ return false;
+ }
+ } catch (e) {
+ return false;
+ }
+
+ if (!projection.allowedUserIds) {
+ return true;
+ }
+ return projection.allowedUserIds.includes(userId);
+ }
+ }
+
+ return false;
+}
+
+export async function resolveMessageInChannel(messageId: string, channelId: string, userId: string): Promise {
+ const message = await Message.findOne({
+ where: { id: messageId },
+ relations: ["author", "webhook", "application", "attachments"],
+ });
+
+ if (!message) {
+ throw new HTTPError("Message not found", 404);
+ }
+
+ const isVisible = await isMessageVisibleInChannelForUser(message, channelId, userId);
+
+ if (!isVisible) {
+ throw new HTTPError("Message not found", 404);
+ }
+
+ return message;
+}
+
+export async function getProjectedMessagesForChannel(channelId: string, userId: string, query: ProjectedMessagesQuery): Promise {
+ const identityMessages = await Message.find({
+ where: {
+ channel_id: channelId,
+ source_channel_id: channelId,
+ },
+ order: { timestamp: "DESC" },
+ take: query.limit * 2,
+ relations: [
+ "author",
+ "webhook",
+ "application",
+ "mentions",
+ "mention_roles",
+ "mention_channels",
+ "sticker_items",
+ "attachments",
+ "referenced_message",
+ "referenced_message.author",
+ ],
+ });
+
+ const legacyMessages = await Message.find({
+ where: {
+ channel_id: channelId,
+ source_channel_id: IsNull(),
+ },
+ order: { timestamp: "DESC" },
+ take: query.limit * 2,
+ relations: [
+ "author",
+ "webhook",
+ "application",
+ "mentions",
+ "mention_roles",
+ "mention_channels",
+ "sticker_items",
+ "attachments",
+ "referenced_message",
+ "referenced_message.author",
+ ],
+ });
+
+ const sourceRules = await RoutingRule.find({
+ where: {
+ source_channel_id: channelId,
+ },
+ });
+
+ const sourceProjectedMessages: Message[] = [];
+ for (const rule of sourceRules) {
+ const messages = await Message.find({
+ where: {
+ channel_id: rule.storage_channel_id,
+ source_channel_id: channelId,
+ },
+ order: { timestamp: "DESC" },
+ take: query.limit,
+ relations: [
+ "author",
+ "webhook",
+ "application",
+ "mentions",
+ "mention_roles",
+ "mention_channels",
+ "sticker_items",
+ "attachments",
+ "referenced_message",
+ "referenced_message.author",
+ ],
+ });
+
+ for (const message of messages) {
+ const isValid = BigInt(rule.valid_since) <= BigInt(message.id) && BigInt(message.id) <= BigInt(rule.valid_until);
+
+ if (isValid) {
+ sourceProjectedMessages.push(message);
+ }
+ }
+ }
+
+ const sinkRules = await RoutingRule.find({
+ where: {
+ sink_channel_id: channelId,
+ },
+ });
+
+ const sinkProjectedMessages: Message[] = [];
+ for (const rule of sinkRules) {
+ const messages = await Message.find({
+ where: {
+ channel_id: rule.storage_channel_id,
+ source_channel_id: rule.source_channel_id,
+ },
+ order: { timestamp: "DESC" },
+ take: query.limit,
+ relations: [
+ "author",
+ "webhook",
+ "application",
+ "mentions",
+ "mention_roles",
+ "mention_channels",
+ "sticker_items",
+ "attachments",
+ "referenced_message",
+ "referenced_message.author",
+ ],
+ });
+
+ for (const message of messages) {
+ const isValid = BigInt(rule.valid_since) <= BigInt(message.id) && BigInt(message.id) <= BigInt(rule.valid_until);
+
+ if (!isValid) {
+ continue;
+ }
+
+ if (rule.source_users.length > 0 && message.author_id && !rule.source_users.includes(message.author_id)) {
+ continue;
+ }
+
+ if (rule.target_users.length > 0 && !rule.target_users.includes(userId)) {
+ continue;
+ }
+
+ sinkProjectedMessages.push(message);
+ }
+ }
+
+ const allMessages = [...identityMessages, ...legacyMessages, ...sourceProjectedMessages, ...sinkProjectedMessages];
+
+ const messageMap = new Map();
+ for (const message of allMessages) {
+ if (!messageMap.has(message.id)) {
+ messageMap.set(message.id, message);
+ }
+ }
+
+ const dedupedMessages = Array.from(messageMap.values());
+
+ dedupedMessages.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
+
+ return dedupedMessages.slice(0, query.limit);
+}
+
+export function filterReactionsForIntimacyBroadcast(reactions: Reaction[], viewingUserId: string, messageAuthorId?: string): Reaction[] {
+ if (!reactions || reactions.length === 0) {
+ return [];
+ }
+
+ const isAuthor = viewingUserId === messageAuthorId;
+ if (isAuthor) {
+ return reactions;
+ }
+
+ return reactions
+ .map((reaction) => {
+ const userReacted = reaction.user_ids?.includes(viewingUserId);
+ if (!userReacted) {
+ return null;
+ }
+ return {
+ ...reaction,
+ count: 1,
+ user_ids: [viewingUserId],
+ };
+ })
+ .filter((r): r is Reaction => r !== null);
+}
+
+export async function getIntimacyBroadcastRuleForChannel(channelId: string): Promise {
+ const rules = await RoutingRule.find({
+ where: {
+ source_channel_id: channelId,
+ },
+ });
+
+ for (const rule of rules) {
+ if (rule.isIntimacyBroadcast() && rule.isActive()) {
+ return rule;
+ }
+ }
+
+ return null;
+}
diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts
index 41028ee5e..4e688c99f 100644
--- a/src/util/entities/Message.ts
+++ b/src/util/entities/Message.ts
@@ -22,58 +22,17 @@ import { Role } from "./Role";
import { Channel } from "./Channel";
import { InteractionType } from "../interfaces/Interaction";
import { Application } from "./Application";
-import {
- Column,
- CreateDateColumn,
- Entity,
- Index,
- JoinColumn,
- JoinTable,
- ManyToMany,
- ManyToOne,
- OneToMany,
- RelationId,
-} from "typeorm";
+import { Column, CreateDateColumn, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { Webhook } from "./Webhook";
import { Sticker } from "./Sticker";
import { Attachment } from "./Attachment";
-import { dbEngine } from "../util/Database";
import { NewUrlUserSignatureData } from "../Signing";
-
-export enum MessageType {
- DEFAULT = 0,
- RECIPIENT_ADD = 1,
- RECIPIENT_REMOVE = 2,
- CALL = 3,
- CHANNEL_NAME_CHANGE = 4,
- CHANNEL_ICON_CHANGE = 5,
- CHANNEL_PINNED_MESSAGE = 6,
- GUILD_MEMBER_JOIN = 7,
- USER_PREMIUM_GUILD_SUBSCRIPTION = 8,
- USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1 = 9,
- USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2 = 10,
- USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3 = 11,
- CHANNEL_FOLLOW_ADD = 12,
- ACTION = 13, // /me messages
- GUILD_DISCOVERY_DISQUALIFIED = 14,
- GUILD_DISCOVERY_REQUALIFIED = 15,
- ENCRYPTED = 16,
- REPLY = 19,
- APPLICATION_COMMAND = 20, // application command or self command invocation
- AUTO_MODERATION_ACTION = 24,
- ROUTE_ADDED = 41, // custom message routing: new route affecting that channel
- ROUTE_DISABLED = 42, // custom message routing: given route no longer affecting that channel
- SELF_COMMAND_SCRIPT = 43, // self command scripts
- ENCRYPTION = 50,
- CUSTOM_START = 63,
- UNHANDLED = 255,
-}
+import { ActionRowComponent, ApplicationCommandType, Embed, MessageType, PartialMessage, Poll, Reaction } from "@spacebar/schemas";
@Entity({
name: "messages",
- engine: dbEngine,
})
@Index(["channel_id", "id"], { unique: true })
export class Message extends BaseClass {
@@ -88,6 +47,10 @@ export class Message extends BaseClass {
})
channel: Channel;
+ @Column({ nullable: true })
+ @Index()
+ source_channel_id?: string;
+
@Column({ nullable: true })
@RelationId((message: Message) => message.guild)
guild_id?: string;
@@ -167,14 +130,10 @@ export class Message extends BaseClass {
@ManyToMany(() => Sticker, { cascade: true, onDelete: "CASCADE" })
sticker_items?: Sticker[];
- @OneToMany(
- () => Attachment,
- (attachment: Attachment) => attachment.message,
- {
- cascade: true,
- orphanedRowAction: "delete",
- },
- )
+ @OneToMany(() => Attachment, (attachment: Attachment) => attachment.message, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
attachments?: Attachment[];
@Column({ type: "simple-json" })
@@ -186,8 +145,12 @@ export class Message extends BaseClass {
@Column({ type: "text", nullable: true })
nonce?: string;
- @Column({ nullable: true })
- pinned?: boolean;
+ @Column({ nullable: true, type: Date })
+ pinned_at?: Date | null;
+
+ get pinned(): boolean {
+ return this.pinned_at != null;
+ }
@Column({ type: "int" })
type: MessageType;
@@ -206,19 +169,28 @@ export class Message extends BaseClass {
message_id: string;
channel_id?: string;
guild_id?: string;
+ type?: number; // 0 = DEFAULT, 1 = FORWARD
};
@JoinColumn({ name: "message_reference_id" })
- @ManyToOne(() => Message)
- referenced_message?: Message;
+ @ManyToOne(() => Message, { onDelete: "SET NULL" })
+ referenced_message?: Message | null;
@Column({ type: "simple-json", nullable: true })
interaction?: {
id: string;
type: InteractionType;
name: string;
- user_id: string; // the user who invoked the interaction
- // user: User; // TODO: autopopulate user
+ };
+
+ @Column({ type: "simple-json", nullable: true })
+ interaction_metadata?: {
+ id: string;
+ type: InteractionType;
+ user_id: string;
+ authorizing_integration_owners: object;
+ name: string;
+ command_type: ApplicationCommandType;
};
@Column({ type: "simple-json", nullable: true })
@@ -243,12 +215,14 @@ export class Message extends BaseClass {
member_id: undefined,
webhook_id: this.webhook_id ?? undefined,
application_id: undefined,
+ source_channel_id: undefined,
nonce: this.nonce ?? undefined,
tts: this.tts ?? false,
guild: this.guild ?? undefined,
webhook: this.webhook ?? undefined,
interaction: this.interaction ?? undefined,
+ interaction_metadata: this.interaction_metadata ?? undefined,
reactions: this.reactions ?? undefined,
sticker_items: this.sticker_items ?? undefined,
message_reference: this.message_reference ?? undefined,
@@ -264,210 +238,86 @@ export class Message extends BaseClass {
poll: this.poll ?? undefined,
content: this.content ?? "",
reply_ids: this.reply_ids ?? undefined,
+ pinned: this.pinned,
+ };
+ }
+
+ toPartialMessage(): PartialMessage {
+ return {
+ id: this.id,
+ // lobby_id: this.lobby_id,
+ channel_id: this.channel_id!,
+ type: this.type,
+ content: this.content!,
+ author: { ...this.author!, avatar: this.author?.avatar ?? null },
+ flags: this.flags,
+ application_id: this.application_id,
+ //channel: this.channel, // TODO: ephemeral DM channels
+ // recipient_id: this.recipient_id, // TODO: ephemeral DM channels
};
}
withSignedAttachments(data: NewUrlUserSignatureData) {
return {
...this,
- attachments: this.attachments?.map((attachment: Attachment) =>
- Attachment.prototype.signUrls.call(attachment, data),
- ),
+ attachments: this.attachments?.map((attachment: Attachment) => Attachment.prototype.signUrls.call(attachment, data)),
};
}
-}
-
-export interface MessageComponent {
- 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;
-}
-
-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 {
- title?: string; //title of embed
- type?: EmbedType; // type of embed (always "rich" for webhook embeds)
- description?: string; // description of embed
- url?: string; // url of embed
- timestamp?: Date; // timestamp of embed content
- color?: number; // color code of the embed
- footer?: {
- text: string;
- icon_url?: string;
- proxy_icon_url?: string;
- }; // footer object footer information
- image?: EmbedImage; // image object image information
- thumbnail?: EmbedImage; // thumbnail object thumbnail information
- video?: EmbedImage; // video object video information
- provider?: {
- name?: string;
- url?: string;
- }; // provider object provider information
- author?: {
- name?: string;
- url?: string;
- icon_url?: string;
- proxy_icon_url?: string;
- }; // author object author information
- fields?: {
- name: string;
- value: string;
- inline?: boolean;
- }[];
-}
-
-export enum EmbedType {
- rich = "rich",
- image = "image",
- video = "video",
- gifv = "gifv",
- article = "article",
- link = "link",
- auto_moderation_message = "auto_moderation_message",
-}
-
-export interface EmbedImage {
- url?: string;
- proxy_url?: string;
- height?: number;
- width?: number;
-}
-
-export interface Reaction {
- count: number;
- //// not saved in the database // me: boolean; // whether the current user reacted using this emoji
- emoji: PartialEmoji;
- user_ids: string[];
-}
-
-export interface PartialEmoji {
- id?: string;
- name: string;
- animated?: boolean;
-}
-
-export interface AllowedMentions {
- parse?: ("users" | "roles" | "everyone")[];
- roles?: string[];
- users?: string[];
- replied_user?: boolean;
-}
-
-export interface Poll {
- question: PollMedia;
- answers: PollAnswer[];
- expiry: Date;
- allow_multiselect: boolean;
- results?: PollResult;
-}
-
-export interface PollMedia {
- text?: string;
- emoji?: PartialEmoji;
-}
-
-export interface PollAnswer {
- answer_id?: string;
- poll_media: PollMedia;
-}
-
-export interface PollResult {
- is_finalized: boolean;
- answer_counts: PollAnswerCount[];
-}
+ toProjectedJSON(projectionChannelId: string) {
+ const json = this.toJSON();
+ return {
+ ...json,
+ channel_id: projectionChannelId,
+ };
+ }
-export interface PollAnswerCount {
- id: string;
- count: number;
- me_voted: boolean;
+ static async createWithDefaults(opts: Partial): Promise {
+ const message = Message.create();
+
+ if (!opts.author) {
+ if (!opts.author_id) throw new Error("Either author or author_id must be provided to create a Message");
+ opts.author = await User.findOneOrFail({ where: { id: opts.author_id! } });
+ }
+
+ if (!opts.channel) {
+ if (!opts.channel_id) throw new Error("Either channel or channel_id must be provided to create a Message");
+ opts.channel = await Channel.findOneOrFail({ where: { id: opts.channel_id! } });
+ opts.guild_id ??= opts.channel.guild_id;
+ }
+
+ if (!opts.member_id) opts.member_id = message.author_id;
+ if (!opts.member) opts.member = await Member.findOneOrFail({ where: { id: opts.member_id! } });
+
+ if (!opts.guild) {
+ if (opts.guild_id) opts.guild = await Guild.findOneOrFail({ where: { id: opts.guild_id! } });
+ else if (opts.channel?.guild?.id) opts.guild = opts.channel.guild;
+ else if (opts.channel?.guild_id) opts.guild = await Guild.findOneOrFail({ where: { id: opts.channel.guild_id! } });
+ else if (opts.member?.guild?.id) opts.guild = opts.member.guild;
+ else if (opts.member?.guild_id) opts.guild = await Guild.findOneOrFail({ where: { id: opts.member.guild_id! } });
+ else throw new Error("Either guild, guild_id, channel.guild, channel.guild_id, member.guild or member.guild_id must be provided to create a Message");
+ }
+
+ // try 2 now that we have a guild
+ if (!opts.member) opts.member = await Member.findOneOrFail({ where: { id: opts.author!.id, guild_id: opts.guild!.id } });
+
+ // backpropagate ids
+ opts.channel_id = opts.channel.id;
+ opts.guild_id = opts.guild.id;
+ opts.author_id = opts.author.id;
+ opts.member_id = opts.member.id;
+ opts.webhook_id = opts.webhook?.id;
+ opts.application_id = opts.application?.id;
+
+ Object.assign(message, {
+ tts: false,
+ embeds: [],
+ reactions: [],
+ flags: 0,
+ type: 0,
+ timestamp: new Date(),
+ ...opts,
+ });
+ return message;
+ }
}
diff --git a/src/util/entities/RoutingRule.ts b/src/util/entities/RoutingRule.ts
new file mode 100644
index 000000000..3105045a9
--- /dev/null
+++ b/src/util/entities/RoutingRule.ts
@@ -0,0 +1,169 @@
+/*
+ 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 .
+*/
+
+import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
+import { BaseClass } from "./BaseClass";
+import { Channel } from "./Channel";
+import { Role } from "./Role";
+import { Message } from "./Message";
+import { HTTPError } from "lambert-server";
+import { Snowflake } from "../util/Snowflake";
+import { getPermission } from "../util/Permissions";
+import { dbEngine } from "../util/Database";
+
+@Entity({
+ name: "routing_rules",
+ engine: dbEngine,
+})
+export class RoutingRule extends BaseClass {
+ @Column()
+ registration_timestamp: string;
+
+ @Column()
+ source_channel_id: string;
+
+ @Column()
+ storage_channel_id: string;
+
+ @Column({ nullable: true })
+ sink_channel_id?: string;
+
+ @Column({ nullable: true })
+ target_role_id?: string;
+
+ @Column({ nullable: true, type: "varchar" })
+ target_role_guild_id?: string | null;
+
+ @Column({ type: "simple-json" })
+ source_users: string[];
+
+ @Column({ type: "simple-json" })
+ target_users: string[];
+
+ @Column()
+ valid_since: string;
+
+ @Column()
+ valid_until: string;
+
+ @ManyToOne(() => Channel, { onDelete: "CASCADE" })
+ @JoinColumn({ name: "source_channel_id" })
+ source_channel?: Channel;
+
+ @ManyToOne(() => Channel, { onDelete: "CASCADE" })
+ @JoinColumn({ name: "storage_channel_id" })
+ storage_channel?: Channel;
+
+ @ManyToOne(() => Channel, { onDelete: "CASCADE" })
+ @JoinColumn({ name: "sink_channel_id" })
+ sink_channel?: Channel;
+
+ @ManyToOne(() => Role, { onDelete: "CASCADE" })
+ @JoinColumn({ name: "target_role_id" })
+ target_role?: Role;
+
+ isIntimacyBroadcast(): boolean {
+ return this.target_role_id !== undefined && this.target_role_id !== null && this.target_role_guild_id === null;
+ }
+
+ static async validateInvariants(rule: Partial, isUpdate = false): Promise {
+ if (!rule.registration_timestamp && !isUpdate) {
+ rule.registration_timestamp = Snowflake.generate();
+ }
+
+ if (rule.valid_since && rule.registration_timestamp && BigInt(rule.valid_since) < BigInt(rule.registration_timestamp)) {
+ throw new HTTPError("valid_since cannot be earlier than registration_timestamp", 400);
+ }
+
+ if (rule.valid_until && rule.valid_since && BigInt(rule.valid_until) < BigInt(rule.valid_since)) {
+ throw new HTTPError("valid_until cannot be earlier than valid_since", 400);
+ }
+
+ if (!isUpdate && rule.source_channel_id && rule.source_users) {
+ const sourceChannel = await Channel.findOne({
+ where: { id: rule.source_channel_id },
+ });
+ if (!sourceChannel) {
+ throw new HTTPError("Source channel not found", 404);
+ }
+
+ for (const userId of rule.source_users) {
+ try {
+ const permission = await getPermission(userId, sourceChannel.guild_id, rule.source_channel_id);
+ permission.hasThrow("SEND_MESSAGES");
+ } catch (e) {
+ throw new HTTPError(`User ${userId} does not have SEND_MESSAGES permission in source channel`, 403);
+ }
+ }
+ }
+
+ if (!isUpdate && rule.source_channel_id && rule.target_users) {
+ const sourceChannel = await Channel.findOne({
+ where: { id: rule.source_channel_id },
+ });
+ if (!sourceChannel) {
+ throw new HTTPError("Source channel not found", 404);
+ }
+
+ for (const userId of rule.target_users) {
+ try {
+ const permission = await getPermission(userId, sourceChannel.guild_id, rule.source_channel_id);
+ permission.hasThrow("READ_MESSAGE_HISTORY");
+ } catch (e) {
+ throw new HTTPError(`User ${userId} does not have READ_MESSAGE_HISTORY permission in source channel`, 403);
+ }
+ }
+ }
+ }
+
+ static async canDelete(rule_id: string): Promise {
+ const rule = await RoutingRule.findOne({
+ where: { id: rule_id },
+ relations: ["source_channel", "storage_channel", "sink_channel"],
+ });
+
+ if (!rule) {
+ return true;
+ }
+
+ if (!rule.source_channel || !rule.storage_channel || !rule.sink_channel) {
+ return true;
+ }
+
+ const now = Snowflake.generate();
+ const isInEffect = BigInt(rule.valid_since) <= BigInt(now) && BigInt(now) <= BigInt(rule.valid_until);
+
+ if (isInEffect) {
+ return false;
+ }
+
+ const affectedMessages = await Message.count({
+ where: {
+ channel_id: rule.storage_channel_id,
+ source_channel_id: rule.source_channel_id,
+ },
+ });
+
+ return affectedMessages === 0;
+ }
+
+ isActive(): boolean {
+ const now = Snowflake.generate();
+ return BigInt(this.valid_since) <= BigInt(now) && BigInt(now) <= BigInt(this.valid_until);
+ }
+}
diff --git a/src/util/entities/index.ts b/src/util/entities/index.ts
index 63e20a3a8..4c099d22d 100644
--- a/src/util/entities/index.ts
+++ b/src/util/entities/index.ts
@@ -47,6 +47,7 @@ export * from "./ReadState";
export * from "./Recipient";
export * from "./Relationship";
export * from "./Role";
+export * from "./RoutingRule";
export * from "./SecurityKey";
export * from "./Session";
export * from "./Sticker";
diff --git a/src/util/migration/mariadb/1763660098-messageSourceChannel.ts b/src/util/migration/mariadb/1763660098-messageSourceChannel.ts
new file mode 100644
index 000000000..ddf88fd9a
--- /dev/null
+++ b/src/util/migration/mariadb/1763660098-messageSourceChannel.ts
@@ -0,0 +1,15 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class MessageSourceChannel1763660098 implements MigrationInterface {
+ name = "MessageSourceChannel1763660098";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query("ALTER TABLE messages ADD source_channel_id varchar(255) NULL");
+ await queryRunner.query("CREATE INDEX IDX_messages_source_channel_id ON messages (source_channel_id)");
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query("DROP INDEX IDX_messages_source_channel_id ON messages");
+ await queryRunner.query("ALTER TABLE messages DROP COLUMN source_channel_id");
+ }
+}
diff --git a/src/util/migration/mysql/1763660098-messageSourceChannel.ts b/src/util/migration/mysql/1763660098-messageSourceChannel.ts
new file mode 100644
index 000000000..ddf88fd9a
--- /dev/null
+++ b/src/util/migration/mysql/1763660098-messageSourceChannel.ts
@@ -0,0 +1,15 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class MessageSourceChannel1763660098 implements MigrationInterface {
+ name = "MessageSourceChannel1763660098";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query("ALTER TABLE messages ADD source_channel_id varchar(255) NULL");
+ await queryRunner.query("CREATE INDEX IDX_messages_source_channel_id ON messages (source_channel_id)");
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query("DROP INDEX IDX_messages_source_channel_id ON messages");
+ await queryRunner.query("ALTER TABLE messages DROP COLUMN source_channel_id");
+ }
+}
diff --git a/src/util/migration/postgres/1763660098-messageSourceChannel.ts b/src/util/migration/postgres/1763660098-messageSourceChannel.ts
new file mode 100644
index 000000000..4b68f87a0
--- /dev/null
+++ b/src/util/migration/postgres/1763660098-messageSourceChannel.ts
@@ -0,0 +1,15 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class MessageSourceChannel1763660098 implements MigrationInterface {
+ name = "MessageSourceChannel1763660098";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query("ALTER TABLE messages ADD source_channel_id varchar NULL");
+ await queryRunner.query("CREATE INDEX IDX_messages_source_channel_id ON messages (source_channel_id)");
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query("DROP INDEX IDX_messages_source_channel_id");
+ await queryRunner.query("ALTER TABLE messages DROP COLUMN source_channel_id");
+ }
+}
diff --git a/src/util/util/Permissions.ts b/src/util/util/Permissions.ts
index c6f810676..dda5f6d28 100644
--- a/src/util/util/Permissions.ts
+++ b/src/util/util/Permissions.ts
@@ -73,18 +73,14 @@ export class Permissions extends BitField {
// TODO: what is permission 33?
MANAGE_THREADS: BitFlag(34),
USE_PUBLIC_THREADS: BitFlag(35),
-<<<<<<< HEAD
USE_PRIVATE_THREADS: BitFlag(36),
USE_EXTERNAL_STICKERS: BitFlag(37),
// TODO: what is permission 38?
- // TODO: what are permissions 39-50?
+ MANAGE_ROUTING: BitFlag(39),
+ // TODO: what are permissions 40-50?
PIN_MESSAGES: BitFlag(51),
-=======
- USE_PRIVATE_THREADS: BitFlag(36),
- USE_EXTERNAL_STICKERS: BitFlag(37),
- PIN_MESSAGES: BitFlag(38),
- MANAGE_TICKETS: BitFlag(55),
->>>>>>> 2a9d7c9c (feat(tickets): add ticket routes, flags, initiator exposure; MANAGE_TICKETS; tracker thread creation)
+ // TODO: what are permissions 52-54?
+ MANAGE_TICKETS: BitFlag(55),
/**
* CUSTOM PERMISSIONS ideas: