Implement fetching DM messages by user ID
This commit is contained in:
parent
f78a9412b8
commit
0db40d7a0a
54
src/api/routes/users/#id/messages.ts
Normal file
54
src/api/routes/users/#id/messages.ts
Normal 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;
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
21
src/util/schemas/responses/DmMessagesResponseSchema.ts
Normal file
21
src/util/schemas/responses/DmMessagesResponseSchema.ts
Normal 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[];
|
||||
@ -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";
|
||||
|
||||
32
src/util/util/extensions/Math.ts
Normal file
32
src/util/util/extensions/Math.ts
Normal 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;
|
||||
}
|
||||
0
src/util/util/extensions/Url.d.ts
vendored
0
src/util/util/extensions/Url.d.ts
vendored
@ -1,2 +1,3 @@
|
||||
export * from "./Array";
|
||||
export * from "./Url";
|
||||
export * from "./Math";
|
||||
export * from "./Url";
|
||||
|
||||
Reference in New Issue
Block a user