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";