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 { Webhook } from "./Webhook";
|
||||||
import { dbEngine } from "../util/Database";
|
import { dbEngine } from "../util/Database";
|
||||||
import { Member } from "./Member";
|
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 {
|
export enum ChannelType {
|
||||||
GUILD_TEXT = 0, // a text channel within a guild
|
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) {
|
withSignedAttachments(data: NewUrlUserSignatureData) {
|
||||||
return {
|
return {
|
||||||
...this,
|
...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 {
|
export interface MessageComponent {
|
||||||
type: MessageComponentType;
|
type: MessageComponentType;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,15 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import {
|
import { Column, Entity, FindOneOptions, JoinColumn, OneToMany, OneToOne } from "typeorm";
|
||||||
Column,
|
import { Channel, ChannelType, Config, Email, FieldErrors, Recipient, Snowflake, trimSpecial } from "..";
|
||||||
Entity,
|
|
||||||
FindOneOptions,
|
|
||||||
JoinColumn,
|
|
||||||
OneToMany,
|
|
||||||
OneToOne,
|
|
||||||
} from "typeorm";
|
|
||||||
import { Config, Email, FieldErrors, Snowflake, trimSpecial } from "..";
|
|
||||||
import { BitField } from "../util/BitField";
|
import { BitField } from "../util/BitField";
|
||||||
import { BaseClass } from "./BaseClass";
|
import { BaseClass } from "./BaseClass";
|
||||||
import { ConnectedAccount } from "./ConnectedAccount";
|
import { ConnectedAccount } from "./ConnectedAccount";
|
||||||
@ -71,13 +64,8 @@ export enum PrivateUserEnum {
|
|||||||
}
|
}
|
||||||
export type PrivateUserKeys = keyof typeof PrivateUserEnum | PublicUserKeys;
|
export type PrivateUserKeys = keyof typeof PrivateUserEnum | PublicUserKeys;
|
||||||
|
|
||||||
export const PublicUserProjection = Object.values(PublicUserEnum).filter(
|
export const PublicUserProjection = Object.values(PublicUserEnum).filter((x) => typeof x === "string") as PublicUserKeys[];
|
||||||
(x) => typeof x === "string",
|
export const PrivateUserProjection = [...PublicUserProjection, ...Object.values(PrivateUserEnum).filter((x) => typeof x === "string")] as PrivateUserKeys[];
|
||||||
) 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
|
// Private user data that should never get sent to the client
|
||||||
export type PublicUser = Pick<User, PublicUserKeys>;
|
export type PublicUser = Pick<User, PublicUserKeys>;
|
||||||
@ -191,25 +179,17 @@ export class User extends BaseClass {
|
|||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
|
|
||||||
@JoinColumn({ name: "relationship_ids" })
|
@JoinColumn({ name: "relationship_ids" })
|
||||||
@OneToMany(
|
@OneToMany(() => Relationship, (relationship: Relationship) => relationship.from, {
|
||||||
() => Relationship,
|
cascade: true,
|
||||||
(relationship: Relationship) => relationship.from,
|
orphanedRowAction: "delete",
|
||||||
{
|
})
|
||||||
cascade: true,
|
|
||||||
orphanedRowAction: "delete",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
relationships: Relationship[];
|
relationships: Relationship[];
|
||||||
|
|
||||||
@JoinColumn({ name: "connected_account_ids" })
|
@JoinColumn({ name: "connected_account_ids" })
|
||||||
@OneToMany(
|
@OneToMany(() => ConnectedAccount, (account: ConnectedAccount) => account.user, {
|
||||||
() => ConnectedAccount,
|
cascade: true,
|
||||||
(account: ConnectedAccount) => account.user,
|
orphanedRowAction: "delete",
|
||||||
{
|
})
|
||||||
cascade: true,
|
|
||||||
orphanedRowAction: "delete",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
connected_accounts: ConnectedAccount[];
|
connected_accounts: ConnectedAccount[];
|
||||||
|
|
||||||
@Column({ type: "simple-json", select: false })
|
@Column({ type: "simple-json", select: false })
|
||||||
@ -243,13 +223,7 @@ export class User extends BaseClass {
|
|||||||
validate() {
|
validate() {
|
||||||
if (this.discriminator) {
|
if (this.discriminator) {
|
||||||
const discrim = Number(this.discriminator);
|
const discrim = Number(this.discriminator);
|
||||||
if (
|
if (isNaN(discrim) || !(typeof discrim == "number") || !Number.isInteger(discrim) || discrim <= 0 || discrim >= 10000)
|
||||||
isNaN(discrim) ||
|
|
||||||
!(typeof discrim == "number") ||
|
|
||||||
!Number.isInteger(discrim) ||
|
|
||||||
discrim <= 0 ||
|
|
||||||
discrim >= 10000
|
|
||||||
)
|
|
||||||
throw FieldErrors({
|
throw FieldErrors({
|
||||||
discriminator: {
|
discriminator: {
|
||||||
message: "Discriminator must be a number.",
|
message: "Discriminator must be a number.",
|
||||||
@ -289,9 +263,7 @@ export class User extends BaseClass {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async generateDiscriminator(
|
public static async generateDiscriminator(username: string): Promise<string | undefined> {
|
||||||
username: string,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
if (Config.get().register.incrementingDiscriminators) {
|
if (Config.get().register.incrementingDiscriminators) {
|
||||||
// discriminator will be incrementally generated
|
// discriminator will be incrementally generated
|
||||||
|
|
||||||
@ -300,10 +272,7 @@ export class User extends BaseClass {
|
|||||||
where: { username },
|
where: { username },
|
||||||
select: ["discriminator"],
|
select: ["discriminator"],
|
||||||
});
|
});
|
||||||
const highestDiscriminator = Math.max(
|
const highestDiscriminator = Math.max(0, ...users.map((u) => Number(u.discriminator)));
|
||||||
0,
|
|
||||||
...users.map((u) => Number(u.discriminator)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const discriminator = highestDiscriminator + 1;
|
const discriminator = highestDiscriminator + 1;
|
||||||
if (discriminator >= 10000) {
|
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
|
// 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?
|
// 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++) {
|
for (let tries = 0; tries < 5; tries++) {
|
||||||
const discriminator = Math.randomIntBetween(1, 9999)
|
const discriminator = Math.randomIntBetween(1, 9999).toString().padStart(4, "0");
|
||||||
.toString()
|
|
||||||
.padStart(4, "0");
|
|
||||||
const exists = await User.findOne({
|
const exists = await User.findOne({
|
||||||
where: { discriminator, username: username },
|
where: { discriminator, username: username },
|
||||||
select: ["id"],
|
select: ["id"],
|
||||||
@ -354,8 +321,7 @@ export class User extends BaseClass {
|
|||||||
throw FieldErrors({
|
throw FieldErrors({
|
||||||
username: {
|
username: {
|
||||||
code: "USERNAME_TOO_MANY_USERS",
|
code: "USERNAME_TOO_MANY_USERS",
|
||||||
message:
|
message: req?.t("auth:register.USERNAME_TOO_MANY_USERS") || "",
|
||||||
req?.t("auth:register.USERNAME_TOO_MANY_USERS") || "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -363,8 +329,7 @@ export class User extends BaseClass {
|
|||||||
// TODO: save date_of_birth
|
// TODO: save date_of_birth
|
||||||
// apparently discord doesn't save the date of birth and just calculate if nsfw is allowed
|
// 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
|
// if nsfw_allowed is null/undefined it'll require date_of_birth to set it to true/false
|
||||||
const language =
|
const language = req?.language === "en" ? "en-US" : req?.language || "en-US";
|
||||||
req?.language === "en" ? "en-US" : req?.language || "en-US";
|
|
||||||
|
|
||||||
const settings = UserSettings.create({
|
const settings = UserSettings.create({
|
||||||
locale: language,
|
locale: language,
|
||||||
@ -382,9 +347,7 @@ export class User extends BaseClass {
|
|||||||
extended_settings: "{}",
|
extended_settings: "{}",
|
||||||
settings: settings,
|
settings: settings,
|
||||||
|
|
||||||
premium_since: Config.get().defaults.user.premium
|
premium_since: Config.get().defaults.user.premium ? new Date() : undefined,
|
||||||
? new Date()
|
|
||||||
: undefined,
|
|
||||||
rights: Config.get().register.defaultRights,
|
rights: Config.get().register.defaultRights,
|
||||||
premium: Config.get().defaults.user.premium ?? false,
|
premium: Config.get().defaults.user.premium ?? false,
|
||||||
premium_type: Config.get().defaults.user.premiumType ?? 0,
|
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
|
// send verification email if users aren't verified by default and we have an email
|
||||||
if (!Config.get().defaults.user.verified && email) {
|
if (!Config.get().defaults.user.verified && email) {
|
||||||
await Email.sendVerifyEmail(user, email).catch((e) => {
|
await Email.sendVerifyEmail(user, email).catch((e) => {
|
||||||
console.error(
|
console.error(`Failed to send verification email to ${user.username}#${user.discriminator}: ${e}`);
|
||||||
`Failed to send verification email to ${user.username}#${user.discriminator}: ${e}`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setImmediate(async () => {
|
setImmediate(async () => {
|
||||||
if (Config.get().guild.autoJoin.enabled) {
|
if (Config.get().guild.autoJoin.enabled) {
|
||||||
for (const guild of Config.get().guild.autoJoin.guilds || []) {
|
for (const guild of Config.get().guild.autoJoin.guilds || []) {
|
||||||
await Member.addToGuild(user.id, guild).catch((e) =>
|
await Member.addToGuild(user.id, guild).catch((e) => console.error("[Autojoin]", e));
|
||||||
console.error("[Autojoin]", e),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return user;
|
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);
|
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 "./CollectiblesMarketingResponse";
|
||||||
export * from "./CollectiblesShopResponse";
|
export * from "./CollectiblesShopResponse";
|
||||||
export * from "./DiscoverableGuildsResponse";
|
export * from "./DiscoverableGuildsResponse";
|
||||||
|
export * from "./DmMessagesResponseSchema";
|
||||||
export * from "./EmailDomainLookupResponse";
|
export * from "./EmailDomainLookupResponse";
|
||||||
export * from "./EmailDomainLookupVerifyCodeResponse";
|
export * from "./EmailDomainLookupVerifyCodeResponse";
|
||||||
export * from "./EmojiSourceResponse";
|
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 "./Array";
|
||||||
export * from "./Url";
|
export * from "./Math";
|
||||||
|
export * from "./Url";
|
||||||
|
|||||||
Reference in New Issue
Block a user