Implement fetching DM messages by user ID

This commit is contained in:
Rory& 2025-09-23 17:17:42 +02:00
parent f78a9412b8
commit 0db40d7a0a
9 changed files with 183 additions and 65 deletions

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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;

View File

@ -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

View File

@ -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<Message, "id">
// & Pick<Message, "lobby_id">
& Pick<Message, "channel_id">
& Pick<Message, "type">
& Pick<Message, "content">
& Pick<Message, "author">
& Pick<Message, "flags">
& Pick<Message, "application_id">
& { channel?: Channel }
// & Pick<Message, "recipient_id"> // TODO: ephemeral DM channels
;
export interface MessageComponent {
type: MessageComponentType;
}

View File

@ -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<User, PublicUserKeys>;
@ -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<string | undefined> {
public static async generateDiscriminator(username: string): Promise<string | undefined> {
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);

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
import { PartialMessage } from "@spacebar/util";
export type DmMessagesResponseSchema = PartialMessage[];

View File

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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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;
}

View File

View File

@ -1,2 +1,3 @@
export * from "./Array";
export * from "./Url";
export * from "./Math";
export * from "./Url";