From 0db40d7a0a104085e6c6aa88deea12bcb80c7791 Mon Sep 17 00:00:00 2001 From: Rory& Date: Tue, 23 Sep 2025 17:17:42 +0200 Subject: [PATCH] Implement fetching DM messages by user ID --- src/api/routes/users/#id/messages.ts | 54 +++++++++ src/util/entities/Channel.ts | 2 - src/util/entities/Message.ts | 30 +++++ src/util/entities/User.ts | 105 +++++++----------- .../responses/DmMessagesResponseSchema.ts | 21 ++++ src/util/schemas/responses/index.ts | 1 + src/util/util/extensions/Math.ts | 32 ++++++ src/util/util/extensions/Url.d.ts | 0 src/util/util/extensions/index.ts | 3 +- 9 files changed, 183 insertions(+), 65 deletions(-) create mode 100644 src/api/routes/users/#id/messages.ts create mode 100644 src/util/schemas/responses/DmMessagesResponseSchema.ts create mode 100644 src/util/util/extensions/Math.ts delete mode 100644 src/util/util/extensions/Url.d.ts diff --git a/src/api/routes/users/#id/messages.ts b/src/api/routes/users/#id/messages.ts new file mode 100644 index 00000000..c71b8764 --- /dev/null +++ b/src/api/routes/users/#id/messages.ts @@ -0,0 +1,54 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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 { Channel, Config, DmMessagesResponseSchema, Message, User } from "@spacebar/util"; +import { Request, Response, Router } from "express"; +const router = Router(); + +router.get( + "/", + route({ + responses: { + 200: { + body: "DmMessagesResponseSchema", + }, + 400: { + body: "APIErrorResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const user = await User.findOneOrFail({ where: { id: req.params.id } }); + const channel = await user.getDmChannelWith(req.user_id); + + const messages = ( + await Message.find({ + where: { channel_id: channel?.id }, + order: { timestamp: "DESC" }, + take: Math.clamp(req.query.limit ? Number(req.query.limit) : 50, 1, Config.get().limits.message.maxPreloadCount), + }) + ).filter((x) => x !== null) as Message[]; + + const filteredMessages = messages.map((message) => message.toPartialMessage()) as DmMessagesResponseSchema; + + return res.status(200).send(filteredMessages); + }, +); + +export default router; diff --git a/src/util/entities/Channel.ts b/src/util/entities/Channel.ts index a83f4db4..c7d84055 100644 --- a/src/util/entities/Channel.ts +++ b/src/util/entities/Channel.ts @@ -39,8 +39,6 @@ import { VoiceState } from "./VoiceState"; import { Webhook } from "./Webhook"; import { dbEngine } from "../util/Database"; import { Member } from "./Member"; -import user_id from "../../api/routes/guilds/#guild_id/voice-states/#user_id"; -import permissions from "../../api/routes/channels/#channel_id/permissions"; export enum ChannelType { GUILD_TEXT = 0, // a text channel within a guild diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts index ad96634e..74fda7e5 100644 --- a/src/util/entities/Message.ts +++ b/src/util/entities/Message.ts @@ -268,6 +268,21 @@ export class Message extends BaseClass { }; } + 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, + flags: this.flags, + application_id: this.application_id, + channel: this.channel, + // recipient_id: this.recipient_id, // TODO: ephemeral DM channels + } + } + withSignedAttachments(data: NewUrlUserSignatureData) { return { ...this, @@ -278,6 +293,21 @@ export class Message extends BaseClass { } } +/** + * https://docs.discord.food/resources/message#partial-message-structure + */ +export type PartialMessage = Pick + // & Pick + & Pick + & Pick + & Pick + & Pick + & Pick + & Pick + & { channel?: Channel } +// & Pick // TODO: ephemeral DM channels + ; + export interface MessageComponent { type: MessageComponentType; } diff --git a/src/util/entities/User.ts b/src/util/entities/User.ts index 7f79f008..42b46146 100644 --- a/src/util/entities/User.ts +++ b/src/util/entities/User.ts @@ -17,15 +17,8 @@ */ import { Request } from "express"; -import { - Column, - Entity, - FindOneOptions, - JoinColumn, - OneToMany, - OneToOne, -} from "typeorm"; -import { Config, Email, FieldErrors, Snowflake, trimSpecial } from ".."; +import { Column, Entity, FindOneOptions, JoinColumn, OneToMany, OneToOne } from "typeorm"; +import { Channel, ChannelType, Config, Email, FieldErrors, Recipient, Snowflake, trimSpecial } from ".."; import { BitField } from "../util/BitField"; import { BaseClass } from "./BaseClass"; import { ConnectedAccount } from "./ConnectedAccount"; @@ -71,13 +64,8 @@ export enum PrivateUserEnum { } export type PrivateUserKeys = keyof typeof PrivateUserEnum | PublicUserKeys; -export const PublicUserProjection = Object.values(PublicUserEnum).filter( - (x) => typeof x === "string", -) as PublicUserKeys[]; -export const PrivateUserProjection = [ - ...PublicUserProjection, - ...Object.values(PrivateUserEnum).filter((x) => typeof x === "string"), -] as PrivateUserKeys[]; +export const PublicUserProjection = Object.values(PublicUserEnum).filter((x) => typeof x === "string") as PublicUserKeys[]; +export const PrivateUserProjection = [...PublicUserProjection, ...Object.values(PrivateUserEnum).filter((x) => typeof x === "string")] as PrivateUserKeys[]; // Private user data that should never get sent to the client export type PublicUser = Pick; @@ -191,25 +179,17 @@ export class User extends BaseClass { sessions: Session[]; @JoinColumn({ name: "relationship_ids" }) - @OneToMany( - () => Relationship, - (relationship: Relationship) => relationship.from, - { - cascade: true, - orphanedRowAction: "delete", - }, - ) + @OneToMany(() => Relationship, (relationship: Relationship) => relationship.from, { + cascade: true, + orphanedRowAction: "delete", + }) relationships: Relationship[]; @JoinColumn({ name: "connected_account_ids" }) - @OneToMany( - () => ConnectedAccount, - (account: ConnectedAccount) => account.user, - { - cascade: true, - orphanedRowAction: "delete", - }, - ) + @OneToMany(() => ConnectedAccount, (account: ConnectedAccount) => account.user, { + cascade: true, + orphanedRowAction: "delete", + }) connected_accounts: ConnectedAccount[]; @Column({ type: "simple-json", select: false }) @@ -243,13 +223,7 @@ export class User extends BaseClass { validate() { if (this.discriminator) { const discrim = Number(this.discriminator); - if ( - isNaN(discrim) || - !(typeof discrim == "number") || - !Number.isInteger(discrim) || - discrim <= 0 || - discrim >= 10000 - ) + if (isNaN(discrim) || !(typeof discrim == "number") || !Number.isInteger(discrim) || discrim <= 0 || discrim >= 10000) throw FieldErrors({ discriminator: { message: "Discriminator must be a number.", @@ -289,9 +263,7 @@ export class User extends BaseClass { }); } - public static async generateDiscriminator( - username: string, - ): Promise { + public static async generateDiscriminator(username: string): Promise { if (Config.get().register.incrementingDiscriminators) { // discriminator will be incrementally generated @@ -300,10 +272,7 @@ export class User extends BaseClass { where: { username }, select: ["discriminator"], }); - const highestDiscriminator = Math.max( - 0, - ...users.map((u) => Number(u.discriminator)), - ); + const highestDiscriminator = Math.max(0, ...users.map((u) => Number(u.discriminator))); const discriminator = highestDiscriminator + 1; if (discriminator >= 10000) { @@ -317,9 +286,7 @@ export class User extends BaseClass { // randomly generates a discriminator between 1 and 9999 and checks max five times if it already exists // TODO: is there any better way to generate a random discriminator only once, without checking if it already exists in the database? for (let tries = 0; tries < 5; tries++) { - const discriminator = Math.randomIntBetween(1, 9999) - .toString() - .padStart(4, "0"); + const discriminator = Math.randomIntBetween(1, 9999).toString().padStart(4, "0"); const exists = await User.findOne({ where: { discriminator, username: username }, select: ["id"], @@ -354,8 +321,7 @@ export class User extends BaseClass { throw FieldErrors({ username: { code: "USERNAME_TOO_MANY_USERS", - message: - req?.t("auth:register.USERNAME_TOO_MANY_USERS") || "", + message: req?.t("auth:register.USERNAME_TOO_MANY_USERS") || "", }, }); } @@ -363,8 +329,7 @@ export class User extends BaseClass { // TODO: save date_of_birth // apparently discord doesn't save the date of birth and just calculate if nsfw is allowed // if nsfw_allowed is null/undefined it'll require date_of_birth to set it to true/false - const language = - req?.language === "en" ? "en-US" : req?.language || "en-US"; + const language = req?.language === "en" ? "en-US" : req?.language || "en-US"; const settings = UserSettings.create({ locale: language, @@ -382,9 +347,7 @@ export class User extends BaseClass { extended_settings: "{}", settings: settings, - premium_since: Config.get().defaults.user.premium - ? new Date() - : undefined, + premium_since: Config.get().defaults.user.premium ? new Date() : undefined, rights: Config.get().register.defaultRights, premium: Config.get().defaults.user.premium ?? false, premium_type: Config.get().defaults.user.premiumType ?? 0, @@ -398,24 +361,42 @@ export class User extends BaseClass { // send verification email if users aren't verified by default and we have an email if (!Config.get().defaults.user.verified && email) { await Email.sendVerifyEmail(user, email).catch((e) => { - console.error( - `Failed to send verification email to ${user.username}#${user.discriminator}: ${e}`, - ); + console.error(`Failed to send verification email to ${user.username}#${user.discriminator}: ${e}`); }); } setImmediate(async () => { if (Config.get().guild.autoJoin.enabled) { for (const guild of Config.get().guild.autoJoin.guilds || []) { - await Member.addToGuild(user.id, guild).catch((e) => - console.error("[Autojoin]", e), - ); + await Member.addToGuild(user.id, guild).catch((e) => console.error("[Autojoin]", e)); } } }); return user; } + + async getDmChannelWith(user_id: string) { + const qry = await Channel.getRepository() + .createQueryBuilder() + .leftJoinAndSelect("Channel.recipients", "rcp") + .where("Channel.type = :type", { type: ChannelType.DM }) + .andWhere("rcp.user_id IN (:...user_ids)", { user_ids: [this.id, user_id] }) + .groupBy("Channel.id") + .having("COUNT(rcp.user_id) = 2") + .getMany(); + + // Emma [it/its]@Rory&: is this technically a bug, or am I being too over-cautious? + if (qry.length > 1) { + console.warn(`[WARN] User(${this.id})#getDmChannel(${user_id}) returned multiple channels:`); + for (const channel of qry) { + console.warn(JSON.stringify(channel)); + } + } + + // throw if multiple + return qry.single((_) => true); + } } export const CUSTOM_USER_FLAG_OFFSET = BigInt(1) << BigInt(32); diff --git a/src/util/schemas/responses/DmMessagesResponseSchema.ts b/src/util/schemas/responses/DmMessagesResponseSchema.ts new file mode 100644 index 00000000..8bce6e63 --- /dev/null +++ b/src/util/schemas/responses/DmMessagesResponseSchema.ts @@ -0,0 +1,21 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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 { PartialMessage } from "@spacebar/util"; + +export type DmMessagesResponseSchema = PartialMessage[]; diff --git a/src/util/schemas/responses/index.ts b/src/util/schemas/responses/index.ts index c4f5245b..8ca6fc61 100644 --- a/src/util/schemas/responses/index.ts +++ b/src/util/schemas/responses/index.ts @@ -25,6 +25,7 @@ export * from "./CollectiblesCategoriesResponse"; export * from "./CollectiblesMarketingResponse"; export * from "./CollectiblesShopResponse"; export * from "./DiscoverableGuildsResponse"; +export * from "./DmMessagesResponseSchema"; export * from "./EmailDomainLookupResponse"; export * from "./EmailDomainLookupVerifyCodeResponse"; export * from "./EmojiSourceResponse"; diff --git a/src/util/util/extensions/Math.ts b/src/util/util/extensions/Math.ts new file mode 100644 index 00000000..76587bc5 --- /dev/null +++ b/src/util/util/extensions/Math.ts @@ -0,0 +1,32 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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 . +*/ + +declare global { + interface Math { + clamp(value: number, min: number, max: number): number; + } +} + +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +// register extensions +if (!Math.clamp) { + Math.clamp = clamp; +} \ No newline at end of file diff --git a/src/util/util/extensions/Url.d.ts b/src/util/util/extensions/Url.d.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/util/util/extensions/index.ts b/src/util/util/extensions/index.ts index 46c26f4c..540c47dd 100644 --- a/src/util/util/extensions/index.ts +++ b/src/util/util/extensions/index.ts @@ -1,2 +1,3 @@ export * from "./Array"; -export * from "./Url"; \ No newline at end of file +export * from "./Math"; +export * from "./Url";