initial commit
This commit is contained in:
75
fluxer_api/src/channel/AttachmentDTOs.ts
Normal file
75
fluxer_api/src/channel/AttachmentDTOs.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {coerceNumberFromString, createStringType, FilenameType, Int32Type, z} from '~/Schema';
|
||||
|
||||
export const ClientAttachmentRequest = z.object({
|
||||
id: coerceNumberFromString(Int32Type),
|
||||
filename: FilenameType,
|
||||
title: createStringType(1, 1024).nullish(),
|
||||
description: createStringType(1, 1024).nullish(),
|
||||
flags: coerceNumberFromString(Int32Type).default(0),
|
||||
});
|
||||
export type ClientAttachmentRequest = z.infer<typeof ClientAttachmentRequest>;
|
||||
|
||||
export interface UploadedAttachment {
|
||||
id: number;
|
||||
filename: string;
|
||||
upload_filename: string;
|
||||
file_size: number;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export interface AttachmentToProcess {
|
||||
id: number;
|
||||
filename: string;
|
||||
upload_filename: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
flags: number;
|
||||
file_size: number;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export const ClientAttachmentReferenceRequest = z.object({
|
||||
id: coerceNumberFromString(Int32Type),
|
||||
filename: FilenameType.optional(),
|
||||
title: createStringType(1, 1024).nullish(),
|
||||
description: createStringType(1, 1024).nullish(),
|
||||
flags: coerceNumberFromString(Int32Type).default(0),
|
||||
});
|
||||
export type ClientAttachmentReferenceRequest = z.infer<typeof ClientAttachmentReferenceRequest>;
|
||||
|
||||
export function mergeUploadWithClientData(
|
||||
uploaded: UploadedAttachment,
|
||||
clientData?: ClientAttachmentRequest | ClientAttachmentReferenceRequest,
|
||||
): AttachmentToProcess {
|
||||
return {
|
||||
id: uploaded.id,
|
||||
filename: uploaded.filename,
|
||||
upload_filename: uploaded.upload_filename,
|
||||
file_size: uploaded.file_size,
|
||||
content_type: uploaded.content_type,
|
||||
title: clientData?.title ?? null,
|
||||
description: clientData?.description ?? null,
|
||||
flags: 'flags' in (clientData ?? {}) ? (clientData as ClientAttachmentRequest).flags : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export type AttachmentRequestData = AttachmentToProcess | ClientAttachmentRequest | ClientAttachmentReferenceRequest;
|
||||
25
fluxer_api/src/channel/ChannelController.ts
Normal file
25
fluxer_api/src/channel/ChannelController.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '~/App';
|
||||
import {registerChannelControllers} from './controllers';
|
||||
|
||||
export const ChannelController = (app: HonoApp) => {
|
||||
registerChannelControllers(app);
|
||||
};
|
||||
226
fluxer_api/src/channel/ChannelMappers.ts
Normal file
226
fluxer_api/src/channel/ChannelMappers.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes} from '~/Constants';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Channel} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {getCachedUserPartialResponses} from '~/user/UserCacheHelpers';
|
||||
import type {ChannelOverwriteResponse, ChannelPartialResponse, ChannelResponse} from './ChannelTypes';
|
||||
|
||||
interface MapChannelToResponseParams {
|
||||
channel: Channel;
|
||||
currentUserId: UserID | null;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
function serializeBaseChannelFields(channel: Channel) {
|
||||
return {
|
||||
id: channel.id.toString(),
|
||||
type: channel.type,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeMessageableFields(channel: Channel) {
|
||||
return {
|
||||
last_message_id: channel.lastMessageId ? channel.lastMessageId.toString() : null,
|
||||
last_pin_timestamp: channel.lastPinTimestamp ? channel.lastPinTimestamp.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeGuildChannelFields(channel: Channel) {
|
||||
return {
|
||||
guild_id: channel.guildId?.toString(),
|
||||
name: channel.name ?? undefined,
|
||||
position: channel.position ?? undefined,
|
||||
permission_overwrites: serializePermissionOverwrites(channel),
|
||||
};
|
||||
}
|
||||
|
||||
function serializePositionableGuildChannelFields(channel: Channel) {
|
||||
return {
|
||||
...serializeGuildChannelFields(channel),
|
||||
parent_id: channel.parentId ? channel.parentId.toString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function serializePermissionOverwrites(channel: Channel): Array<ChannelOverwriteResponse> {
|
||||
if (!channel.permissionOverwrites) return [];
|
||||
return Array.from(channel.permissionOverwrites).map(([targetId, overwrite]) => ({
|
||||
id: targetId.toString(),
|
||||
type: overwrite.type,
|
||||
allow: (overwrite.allow ?? 0n).toString(),
|
||||
deny: (overwrite.deny ?? 0n).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
function serializeGuildTextChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeMessageableFields(channel),
|
||||
...serializePositionableGuildChannelFields(channel),
|
||||
topic: channel.topic,
|
||||
nsfw: channel.isNsfw,
|
||||
rate_limit_per_user: channel.rateLimitPerUser,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeGuildVoiceChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializePositionableGuildChannelFields(channel),
|
||||
bitrate: channel.bitrate,
|
||||
user_limit: channel.userLimit,
|
||||
rtc_region: channel.rtcRegion,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeGuildCategoryChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeGuildChannelFields(channel),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeGuildLinkChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializePositionableGuildChannelFields(channel),
|
||||
url: channel.url,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeDMChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeMessageableFields(channel),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeGroupDMChannel(channel: Channel): ChannelResponse {
|
||||
const nicknameMap = channel.nicknames ?? new Map<string, string>();
|
||||
const nicks: Record<string, string> = {};
|
||||
if (nicknameMap.size > 0) {
|
||||
for (const [userId, nickname] of nicknameMap) {
|
||||
const key = String(userId);
|
||||
nicks[key] = nickname;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeMessageableFields(channel),
|
||||
name: channel.name ?? undefined,
|
||||
icon: channel.iconHash ?? null,
|
||||
owner_id: channel.ownerId ? channel.ownerId.toString() : null,
|
||||
nicks: nicknameMap.size > 0 ? nicks : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeDMPersonalNotesChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeMessageableFields(channel),
|
||||
};
|
||||
}
|
||||
|
||||
async function addDMRecipients(
|
||||
response: ChannelResponse,
|
||||
channel: Channel,
|
||||
currentUserId: UserID | null,
|
||||
userCacheService: UserCacheService,
|
||||
requestCache: RequestCache,
|
||||
): Promise<void> {
|
||||
if (
|
||||
channel.guildId == null &&
|
||||
channel.type !== ChannelTypes.DM_PERSONAL_NOTES &&
|
||||
currentUserId != null &&
|
||||
channel.recipientIds &&
|
||||
channel.recipientIds.size > 0
|
||||
) {
|
||||
const recipientIds = Array.from(channel.recipientIds).filter((id) => id !== currentUserId);
|
||||
if (recipientIds.length > 0) {
|
||||
const userPartials = await getCachedUserPartialResponses({
|
||||
userIds: recipientIds,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
response.recipients = recipientIds.map((userId) => userPartials.get(userId)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: MapChannelToResponseParams): Promise<ChannelResponse> {
|
||||
let response: ChannelResponse;
|
||||
|
||||
switch (channel.type) {
|
||||
case ChannelTypes.GUILD_TEXT:
|
||||
response = serializeGuildTextChannel(channel);
|
||||
break;
|
||||
case ChannelTypes.GUILD_VOICE:
|
||||
response = serializeGuildVoiceChannel(channel);
|
||||
break;
|
||||
case ChannelTypes.GUILD_CATEGORY:
|
||||
response = serializeGuildCategoryChannel(channel);
|
||||
break;
|
||||
case ChannelTypes.GUILD_LINK:
|
||||
response = serializeGuildLinkChannel(channel);
|
||||
break;
|
||||
case ChannelTypes.DM:
|
||||
response = serializeDMChannel(channel);
|
||||
await addDMRecipients(response, channel, currentUserId, userCacheService, requestCache);
|
||||
break;
|
||||
case ChannelTypes.GROUP_DM:
|
||||
response = serializeGroupDMChannel(channel);
|
||||
await addDMRecipients(response, channel, currentUserId, userCacheService, requestCache);
|
||||
break;
|
||||
case ChannelTypes.DM_PERSONAL_NOTES:
|
||||
response = serializeDMPersonalNotesChannel(channel);
|
||||
break;
|
||||
default:
|
||||
response = {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeMessageableFields(channel),
|
||||
guild_id: channel.guildId?.toString(),
|
||||
name: channel.name ?? undefined,
|
||||
topic: channel.topic,
|
||||
url: channel.url ?? undefined,
|
||||
icon: channel.iconHash ?? null,
|
||||
owner_id: channel.ownerId ? channel.ownerId.toString() : null,
|
||||
position: channel.position ?? undefined,
|
||||
parent_id: channel.parentId ? channel.parentId.toString() : null,
|
||||
permission_overwrites: channel.guildId ? serializePermissionOverwrites(channel) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const mapChannelToPartialResponse = (channel: Channel): ChannelPartialResponse => ({
|
||||
id: channel.id.toString(),
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
});
|
||||
24
fluxer_api/src/channel/ChannelModel.ts
Normal file
24
fluxer_api/src/channel/ChannelModel.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export * from './ChannelMappers';
|
||||
export * from './ChannelTypes';
|
||||
export * from './EmbedTypes';
|
||||
export * from './MessageMappers';
|
||||
export * from './MessageTypes';
|
||||
232
fluxer_api/src/channel/ChannelRepository.ts
Normal file
232
fluxer_api/src/channel/ChannelRepository.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AttachmentID, ChannelID, EmojiID, GuildID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {AttachmentLookupRow, ChannelRow, MessageRow} from '~/database/CassandraTypes';
|
||||
import type {Channel, Message, MessageReaction} from '~/Models';
|
||||
import {IChannelRepository} from './IChannelRepository';
|
||||
import {ChannelRepository as NewChannelRepository} from './repositories/ChannelRepository';
|
||||
|
||||
export class ChannelRepository extends IChannelRepository {
|
||||
private repository: NewChannelRepository;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.repository = new NewChannelRepository();
|
||||
}
|
||||
|
||||
get channelData() {
|
||||
return this.repository.channelData;
|
||||
}
|
||||
|
||||
get messages() {
|
||||
return this.repository.messages;
|
||||
}
|
||||
|
||||
get messageInteractions() {
|
||||
return this.repository.messageInteractions;
|
||||
}
|
||||
|
||||
async findUnique(channelId: ChannelID): Promise<Channel | null> {
|
||||
return this.repository.channelData.findUnique(channelId);
|
||||
}
|
||||
|
||||
async upsert(data: ChannelRow): Promise<Channel> {
|
||||
return this.repository.channelData.upsert(data);
|
||||
}
|
||||
|
||||
async updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void> {
|
||||
return this.repository.channelData.updateLastMessageId(channelId, messageId);
|
||||
}
|
||||
|
||||
async delete(channelId: ChannelID, guildId?: GuildID): Promise<void> {
|
||||
return this.repository.channelData.delete(channelId, guildId);
|
||||
}
|
||||
|
||||
async listMessages(
|
||||
channelId: ChannelID,
|
||||
beforeMessageId?: MessageID,
|
||||
limit?: number,
|
||||
afterMessageId?: MessageID,
|
||||
): Promise<Array<Message>> {
|
||||
return this.repository.messages.listMessages(channelId, beforeMessageId, limit, afterMessageId);
|
||||
}
|
||||
|
||||
async getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null> {
|
||||
return this.repository.messages.getMessage(channelId, messageId);
|
||||
}
|
||||
|
||||
async upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message> {
|
||||
return this.repository.messages.upsertMessage(data, oldData);
|
||||
}
|
||||
|
||||
async deleteMessage(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
authorId: UserID,
|
||||
pinnedTimestamp?: Date,
|
||||
): Promise<void> {
|
||||
return this.repository.messages.deleteMessage(channelId, messageId, authorId, pinnedTimestamp);
|
||||
}
|
||||
|
||||
async bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void> {
|
||||
return this.repository.messages.bulkDeleteMessages(channelId, messageIds);
|
||||
}
|
||||
|
||||
async listChannelPins(channelId: ChannelID, beforePinnedTimestamp: Date, limit?: number): Promise<Array<Message>> {
|
||||
return this.repository.messageInteractions.listChannelPins(channelId, beforePinnedTimestamp, limit);
|
||||
}
|
||||
|
||||
async listMessageReactions(channelId: ChannelID, messageId: MessageID): Promise<Array<MessageReaction>> {
|
||||
return this.repository.messageInteractions.listMessageReactions(channelId, messageId);
|
||||
}
|
||||
|
||||
async listReactionUsers(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
limit?: number,
|
||||
after?: UserID,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<Array<MessageReaction>> {
|
||||
return this.repository.messageInteractions.listReactionUsers(
|
||||
channelId,
|
||||
messageId,
|
||||
emojiName,
|
||||
limit,
|
||||
after,
|
||||
emojiId,
|
||||
);
|
||||
}
|
||||
|
||||
async addReaction(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
emojiAnimated?: boolean,
|
||||
): Promise<MessageReaction> {
|
||||
return this.repository.messageInteractions.addReaction(
|
||||
channelId,
|
||||
messageId,
|
||||
userId,
|
||||
emojiName,
|
||||
emojiId,
|
||||
emojiAnimated,
|
||||
);
|
||||
}
|
||||
|
||||
async removeReaction(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<void> {
|
||||
return this.repository.messageInteractions.removeReaction(channelId, messageId, userId, emojiName, emojiId);
|
||||
}
|
||||
|
||||
async removeAllReactions(channelId: ChannelID, messageId: MessageID): Promise<void> {
|
||||
return this.repository.messageInteractions.removeAllReactions(channelId, messageId);
|
||||
}
|
||||
|
||||
async removeAllReactionsForEmoji(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<void> {
|
||||
return this.repository.messageInteractions.removeAllReactionsForEmoji(channelId, messageId, emojiName, emojiId);
|
||||
}
|
||||
|
||||
async countReactionUsers(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<number> {
|
||||
return this.repository.messageInteractions.countReactionUsers(channelId, messageId, emojiName, emojiId);
|
||||
}
|
||||
|
||||
async countUniqueReactions(channelId: ChannelID, messageId: MessageID): Promise<number> {
|
||||
return this.repository.messageInteractions.countUniqueReactions(channelId, messageId);
|
||||
}
|
||||
|
||||
async checkUserReactionExists(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<boolean> {
|
||||
return this.repository.messageInteractions.checkUserReactionExists(
|
||||
channelId,
|
||||
messageId,
|
||||
userId,
|
||||
emojiName,
|
||||
emojiId,
|
||||
);
|
||||
}
|
||||
|
||||
async listGuildChannels(guildId: GuildID): Promise<Array<Channel>> {
|
||||
return this.repository.channelData.listGuildChannels(guildId);
|
||||
}
|
||||
|
||||
async countGuildChannels(guildId: GuildID): Promise<number> {
|
||||
return this.repository.channelData.countGuildChannels(guildId);
|
||||
}
|
||||
|
||||
async lookupAttachmentByChannelAndFilename(
|
||||
channelId: ChannelID,
|
||||
attachmentId: AttachmentID,
|
||||
filename: string,
|
||||
): Promise<MessageID | null> {
|
||||
return this.repository.messages.lookupAttachmentByChannelAndFilename(channelId, attachmentId, filename);
|
||||
}
|
||||
|
||||
async listChannelAttachments(channelId: ChannelID): Promise<Array<AttachmentLookupRow>> {
|
||||
return this.repository.messages.listChannelAttachments(channelId);
|
||||
}
|
||||
|
||||
async listMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
limit?: number,
|
||||
lastChannelId?: ChannelID,
|
||||
lastMessageId?: MessageID,
|
||||
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>> {
|
||||
return this.repository.messages.listMessagesByAuthor(authorId, limit, lastChannelId, lastMessageId);
|
||||
}
|
||||
|
||||
async deleteMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
channelIds?: Array<ChannelID>,
|
||||
messageIds?: Array<MessageID>,
|
||||
): Promise<void> {
|
||||
return this.repository.messages.deleteMessagesByAuthor(authorId, channelIds, messageIds);
|
||||
}
|
||||
|
||||
async anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void> {
|
||||
return this.repository.messages.anonymizeMessage(channelId, messageId, newAuthorId);
|
||||
}
|
||||
|
||||
async deleteAllChannelMessages(channelId: ChannelID): Promise<void> {
|
||||
return this.repository.messages.deleteAllChannelMessages(channelId);
|
||||
}
|
||||
}
|
||||
160
fluxer_api/src/channel/ChannelTypes.ts
Normal file
160
fluxer_api/src/channel/ChannelTypes.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AVATAR_MAX_SIZE, ChannelTypes} from '~/Constants';
|
||||
import {createBase64StringType, createStringType, GeneralChannelNameType, Int64Type, URLType, z} from '~/Schema';
|
||||
import {UserPartialResponse} from '~/user/UserModel';
|
||||
|
||||
const ChannelOverwriteResponse = z.object({
|
||||
id: z.string(),
|
||||
type: z.number().int(),
|
||||
allow: z.string(),
|
||||
deny: z.string(),
|
||||
});
|
||||
export type ChannelOverwriteResponse = z.infer<typeof ChannelOverwriteResponse>;
|
||||
|
||||
export const ChannelResponse = z.object({
|
||||
id: z.string(),
|
||||
guild_id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
topic: z.string().nullish(),
|
||||
url: z.url().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
owner_id: z.string().nullish(),
|
||||
type: z.number().int(),
|
||||
position: z.number().int().optional(),
|
||||
parent_id: z.string().nullish(),
|
||||
bitrate: z.number().int().nullish(),
|
||||
user_limit: z.number().int().nullish(),
|
||||
rtc_region: z.string().nullish(),
|
||||
last_message_id: z.string().nullish(),
|
||||
last_pin_timestamp: z.iso.datetime().nullish(),
|
||||
permission_overwrites: z.array(ChannelOverwriteResponse).optional(),
|
||||
recipients: z.array(z.lazy(() => UserPartialResponse)).optional(),
|
||||
nsfw: z.boolean().optional(),
|
||||
rate_limit_per_user: z.number().int().optional(),
|
||||
nicks: z.record(z.string(), createStringType(1, 32)).optional(),
|
||||
});
|
||||
export type ChannelResponse = z.infer<typeof ChannelResponse>;
|
||||
|
||||
export const ChannelPartialResponse = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullish(),
|
||||
type: z.number().int(),
|
||||
recipients: z
|
||||
.array(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
export type ChannelPartialResponse = z.infer<typeof ChannelPartialResponse>;
|
||||
|
||||
const ChannelOverwriteRequest = z.object({
|
||||
id: Int64Type,
|
||||
type: z.union([z.literal(0), z.literal(1)]),
|
||||
allow: Int64Type.optional(),
|
||||
deny: Int64Type.optional(),
|
||||
});
|
||||
|
||||
const CreateCommon = z.object({
|
||||
topic: createStringType(1, 1024).nullish(),
|
||||
url: URLType.nullish(),
|
||||
parent_id: Int64Type.nullish(),
|
||||
bitrate: z.number().int().min(8000).max(320000).nullish(),
|
||||
user_limit: z.number().int().min(0).max(99).nullish(),
|
||||
nsfw: z.boolean().default(false),
|
||||
permission_overwrites: z.array(ChannelOverwriteRequest).optional(),
|
||||
});
|
||||
|
||||
const UpdateCommon = z.object({
|
||||
topic: createStringType(1, 1024).nullish(),
|
||||
url: URLType.nullish(),
|
||||
parent_id: Int64Type.nullish(),
|
||||
bitrate: z.number().int().min(8000).max(320000).nullish(),
|
||||
user_limit: z.number().int().min(0).max(99).nullish(),
|
||||
nsfw: z.boolean().nullish(),
|
||||
rate_limit_per_user: z.number().int().min(0).max(21600).nullish(),
|
||||
icon: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
|
||||
owner_id: Int64Type.nullish(),
|
||||
permission_overwrites: z.array(ChannelOverwriteRequest).optional(),
|
||||
nicks: z.record(createStringType(0, 32), z.union([createStringType(0, 32), z.null()])).optional(),
|
||||
rtc_region: createStringType(1, 64).nullish(),
|
||||
});
|
||||
|
||||
const CreateText = CreateCommon.extend({
|
||||
type: z.literal(ChannelTypes.GUILD_TEXT),
|
||||
name: GeneralChannelNameType,
|
||||
});
|
||||
|
||||
const CreateVoice = CreateCommon.extend({
|
||||
type: z.literal(ChannelTypes.GUILD_VOICE),
|
||||
name: GeneralChannelNameType,
|
||||
});
|
||||
|
||||
const CreateCat = CreateCommon.extend({
|
||||
type: z.literal(ChannelTypes.GUILD_CATEGORY),
|
||||
name: GeneralChannelNameType,
|
||||
});
|
||||
|
||||
const CreateLink = CreateCommon.extend({
|
||||
type: z.literal(ChannelTypes.GUILD_LINK),
|
||||
name: GeneralChannelNameType,
|
||||
});
|
||||
|
||||
export const ChannelCreateRequest = z.discriminatedUnion('type', [CreateText, CreateVoice, CreateCat, CreateLink]);
|
||||
export type ChannelCreateRequest = z.infer<typeof ChannelCreateRequest>;
|
||||
|
||||
const UpdateText = UpdateCommon.extend({
|
||||
type: z.literal(ChannelTypes.GUILD_TEXT),
|
||||
name: GeneralChannelNameType.nullish(),
|
||||
});
|
||||
|
||||
const UpdateVoice = UpdateCommon.extend({
|
||||
type: z.literal(ChannelTypes.GUILD_VOICE),
|
||||
name: GeneralChannelNameType.nullish(),
|
||||
});
|
||||
|
||||
const UpdateCat = UpdateCommon.extend({
|
||||
type: z.literal(ChannelTypes.GUILD_CATEGORY),
|
||||
name: GeneralChannelNameType.nullish(),
|
||||
});
|
||||
|
||||
const UpdateLink = UpdateCommon.extend({
|
||||
type: z.literal(ChannelTypes.GUILD_LINK),
|
||||
name: GeneralChannelNameType.nullish(),
|
||||
});
|
||||
|
||||
const UpdateGroupDm = z.object({
|
||||
type: z.literal(ChannelTypes.GROUP_DM),
|
||||
name: GeneralChannelNameType.nullish(),
|
||||
icon: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
|
||||
owner_id: Int64Type.nullish(),
|
||||
nicks: z.record(createStringType(0, 32), z.union([createStringType(0, 32), z.null()])).nullish(),
|
||||
});
|
||||
|
||||
export const ChannelUpdateRequest = z.discriminatedUnion('type', [
|
||||
UpdateText,
|
||||
UpdateVoice,
|
||||
UpdateCat,
|
||||
UpdateLink,
|
||||
UpdateGroupDm,
|
||||
]);
|
||||
export type ChannelUpdateRequest = z.infer<typeof ChannelUpdateRequest>;
|
||||
124
fluxer_api/src/channel/EmbedTypes.ts
Normal file
124
fluxer_api/src/channel/EmbedTypes.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AttachmentURLType, ColorType, createStringType, DateTimeType, URLType, z} from '~/Schema';
|
||||
|
||||
export const RichEmbedAuthorRequest = z.object({
|
||||
name: createStringType(),
|
||||
url: URLType.nullish(),
|
||||
icon_url: URLType.nullish(),
|
||||
});
|
||||
export type RichEmbedAuthorRequest = z.infer<typeof RichEmbedAuthorRequest>;
|
||||
|
||||
export const RichEmbedMediaRequest = z.object({
|
||||
url: AttachmentURLType,
|
||||
description: createStringType(1, 4096).nullish(),
|
||||
});
|
||||
export type RichEmbedMediaRequest = z.infer<typeof RichEmbedMediaRequest>;
|
||||
|
||||
export interface RichEmbedMediaWithMetadata extends RichEmbedMediaRequest {
|
||||
_attachmentMetadata?: {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
content_type: string;
|
||||
content_hash: string | null;
|
||||
placeholder: string | null;
|
||||
flags: number;
|
||||
duration: number | null;
|
||||
nsfw: boolean | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const RichEmbedFooterRequest = z.object({
|
||||
text: createStringType(1, 2048),
|
||||
icon_url: URLType.nullish(),
|
||||
});
|
||||
export type RichEmbedFooterRequest = z.infer<typeof RichEmbedFooterRequest>;
|
||||
|
||||
const RichEmbedFieldRequest = z.object({
|
||||
name: createStringType(),
|
||||
value: createStringType(1, 1024),
|
||||
inline: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const RichEmbedRequest = z.object({
|
||||
url: URLType.nullish(),
|
||||
title: createStringType().nullish(),
|
||||
color: ColorType.nullish(),
|
||||
timestamp: DateTimeType.nullish(),
|
||||
description: createStringType(1, 4096).nullish(),
|
||||
author: RichEmbedAuthorRequest.nullish(),
|
||||
image: RichEmbedMediaRequest.nullish(),
|
||||
thumbnail: RichEmbedMediaRequest.nullish(),
|
||||
footer: RichEmbedFooterRequest.nullish(),
|
||||
fields: z.array(RichEmbedFieldRequest).max(25).nullish(),
|
||||
});
|
||||
export type RichEmbedRequest = z.infer<typeof RichEmbedRequest>;
|
||||
|
||||
const EmbedAuthorResponse = z.object({
|
||||
name: z.string(),
|
||||
url: z.url().nullish(),
|
||||
icon_url: z.url().nullish(),
|
||||
proxy_icon_url: z.url().nullish(),
|
||||
});
|
||||
|
||||
const EmbedFooterResponse = z.object({
|
||||
text: z.string(),
|
||||
icon_url: z.url().nullish(),
|
||||
proxy_icon_url: z.url().nullish(),
|
||||
});
|
||||
|
||||
const EmbedMediaResponse = z.object({
|
||||
url: z.string(),
|
||||
proxy_url: z.url().nullish(),
|
||||
content_type: z.string().nullish(),
|
||||
content_hash: z.string().nullish(),
|
||||
width: z.number().int().nullish(),
|
||||
height: z.number().int().nullish(),
|
||||
description: z.string().nullish(),
|
||||
placeholder: z.string().nullish(),
|
||||
duration: z.number().int().nullish(),
|
||||
flags: z.number().int(),
|
||||
});
|
||||
|
||||
const EmbedFieldResponse = z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
inline: z.boolean(),
|
||||
});
|
||||
export type EmbedFieldResponse = z.infer<typeof EmbedFieldResponse>;
|
||||
|
||||
export const MessageEmbedResponse = z.object({
|
||||
type: z.string(),
|
||||
url: z.url().nullish(),
|
||||
title: z.string().nullish(),
|
||||
color: z.number().int().nullish(),
|
||||
timestamp: z.iso.datetime().nullish(),
|
||||
description: z.string().nullish(),
|
||||
author: EmbedAuthorResponse.nullish(),
|
||||
image: EmbedMediaResponse.nullish(),
|
||||
thumbnail: EmbedMediaResponse.nullish(),
|
||||
footer: EmbedFooterResponse.nullish(),
|
||||
fields: z.array(EmbedFieldResponse).nullish(),
|
||||
provider: EmbedAuthorResponse.nullish(),
|
||||
video: EmbedMediaResponse.nullish(),
|
||||
audio: EmbedMediaResponse.nullish(),
|
||||
nsfw: z.boolean().nullish(),
|
||||
});
|
||||
export type MessageEmbedResponse = z.infer<typeof MessageEmbedResponse>;
|
||||
115
fluxer_api/src/channel/IChannelRepository.ts
Normal file
115
fluxer_api/src/channel/IChannelRepository.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AttachmentID, ChannelID, EmojiID, GuildID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {AttachmentLookupRow, ChannelRow, MessageRow} from '~/database/CassandraTypes';
|
||||
import type {Channel, Message, MessageReaction} from '~/Models';
|
||||
import {IChannelRepositoryAggregate} from './repositories/IChannelRepositoryAggregate';
|
||||
|
||||
export abstract class IChannelRepository extends IChannelRepositoryAggregate {
|
||||
abstract findUnique(channelId: ChannelID): Promise<Channel | null>;
|
||||
abstract upsert(data: ChannelRow): Promise<Channel>;
|
||||
abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void>;
|
||||
abstract delete(channelId: ChannelID, guildId?: GuildID): Promise<void>;
|
||||
abstract listGuildChannels(guildId: GuildID): Promise<Array<Channel>>;
|
||||
abstract countGuildChannels(guildId: GuildID): Promise<number>;
|
||||
|
||||
abstract listMessages(
|
||||
channelId: ChannelID,
|
||||
beforeMessageId?: MessageID,
|
||||
limit?: number,
|
||||
afterMessageId?: MessageID,
|
||||
): Promise<Array<Message>>;
|
||||
abstract getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null>;
|
||||
abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message>;
|
||||
abstract deleteMessage(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
authorId: UserID,
|
||||
pinnedTimestamp?: Date,
|
||||
): Promise<void>;
|
||||
abstract bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void>;
|
||||
|
||||
abstract listChannelPins(channelId: ChannelID, beforePinnedTimestamp: Date, limit?: number): Promise<Array<Message>>;
|
||||
|
||||
abstract listMessageReactions(channelId: ChannelID, messageId: MessageID): Promise<Array<MessageReaction>>;
|
||||
abstract listReactionUsers(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
limit?: number,
|
||||
after?: UserID,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<Array<MessageReaction>>;
|
||||
abstract addReaction(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
emojiAnimated?: boolean,
|
||||
): Promise<MessageReaction>;
|
||||
abstract removeReaction(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<void>;
|
||||
abstract removeAllReactions(channelId: ChannelID, messageId: MessageID): Promise<void>;
|
||||
abstract removeAllReactionsForEmoji(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<void>;
|
||||
abstract countReactionUsers(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<number>;
|
||||
abstract countUniqueReactions(channelId: ChannelID, messageId: MessageID): Promise<number>;
|
||||
abstract checkUserReactionExists(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<boolean>;
|
||||
abstract lookupAttachmentByChannelAndFilename(
|
||||
channelId: ChannelID,
|
||||
attachmentId: AttachmentID,
|
||||
filename: string,
|
||||
): Promise<MessageID | null>;
|
||||
abstract listChannelAttachments(channelId: ChannelID): Promise<Array<AttachmentLookupRow>>;
|
||||
abstract listMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
limit?: number,
|
||||
lastChannelId?: ChannelID,
|
||||
lastMessageId?: MessageID,
|
||||
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>>;
|
||||
abstract deleteMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
channelIds?: Array<ChannelID>,
|
||||
messageIds?: Array<MessageID>,
|
||||
): Promise<void>;
|
||||
abstract anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void>;
|
||||
abstract deleteAllChannelMessages(channelId: ChannelID): Promise<void>;
|
||||
}
|
||||
394
fluxer_api/src/channel/MessageMappers.ts
Normal file
394
fluxer_api/src/channel/MessageMappers.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {makeAttachmentCdnUrl} from '~/channel/services/message/MessageHelpers';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Attachment, Embed, EmbedField, Message, MessageReaction, MessageRef, StickerItem, User} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {AttachmentDecayRow} from '~/types/AttachmentDecayTypes';
|
||||
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '~/user/UserCacheHelpers';
|
||||
import type {UserPartialResponse} from '~/user/UserModel';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
import type {EmbedFieldResponse, MessageEmbedResponse} from './EmbedTypes';
|
||||
import type {
|
||||
MessageAttachmentResponse,
|
||||
MessageReactionResponse,
|
||||
MessageReferenceResponse,
|
||||
MessageResponse,
|
||||
MessageStickerResponse,
|
||||
} from './MessageTypes';
|
||||
|
||||
interface MapMessageToResponseParams {
|
||||
message: Message;
|
||||
currentUserId?: UserID;
|
||||
nonce?: string;
|
||||
tts?: boolean;
|
||||
withMessageReference?: boolean;
|
||||
getAuthor?: (userId: UserID) => Promise<User | null>;
|
||||
getReactions?: (channelId: ChannelID, messageId: MessageID) => Promise<Array<MessageReaction>>;
|
||||
getReferencedMessage?: (channelId: ChannelID, messageId: MessageID) => Promise<Message | null>;
|
||||
setHasReaction?: (channelId: ChannelID, messageId: MessageID, hasReaction: boolean) => Promise<void>;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
mediaService: IMediaService;
|
||||
attachmentDecayMap?: Map<string, AttachmentDecayRow>;
|
||||
}
|
||||
|
||||
interface MapEmbedFieldToResponseParams {
|
||||
field: EmbedField;
|
||||
}
|
||||
|
||||
interface MapMessageEmbedToResponseParams {
|
||||
embed: Embed;
|
||||
mediaService: IMediaService;
|
||||
}
|
||||
|
||||
interface MapMessageAttachmentToResponseParams {
|
||||
channelId: ChannelID;
|
||||
attachment: Attachment;
|
||||
mediaService: IMediaService;
|
||||
}
|
||||
|
||||
interface MapReactionsParams {
|
||||
reactions: Array<MessageReaction>;
|
||||
currentUserId: UserID;
|
||||
}
|
||||
|
||||
interface MapMessageReferenceParams {
|
||||
reference: MessageRef;
|
||||
}
|
||||
|
||||
const mapEmbedFieldToResponse = ({field}: MapEmbedFieldToResponseParams): EmbedFieldResponse => ({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
inline: field.inline ?? false,
|
||||
});
|
||||
|
||||
const mapMessageEmbedToResponse = ({embed, mediaService}: MapMessageEmbedToResponseParams): MessageEmbedResponse => ({
|
||||
type: embed.type ?? 'rich',
|
||||
url: embed.url ?? undefined,
|
||||
title: embed.title ?? undefined,
|
||||
color: embed.color ?? undefined,
|
||||
timestamp: embed.timestamp?.toISOString() ?? undefined,
|
||||
description: embed.description ?? undefined,
|
||||
author: embed.author
|
||||
? {
|
||||
name: embed.author.name!,
|
||||
url: embed.author.url ?? undefined,
|
||||
icon_url: embed.author.iconUrl ?? undefined,
|
||||
proxy_icon_url: embed.author.iconUrl ? mediaService.getExternalMediaProxyURL(embed.author.iconUrl) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
image: embed.image
|
||||
? {
|
||||
url: embed.image.url!,
|
||||
proxy_url: mediaService.getExternalMediaProxyURL(embed.image.url!),
|
||||
content_type: embed.image.contentType ?? undefined,
|
||||
content_hash: embed.image.contentHash ?? undefined,
|
||||
width: embed.image.width ?? undefined,
|
||||
height: embed.image.height ?? undefined,
|
||||
description: embed.image.description ?? undefined,
|
||||
placeholder: embed.image.placeholder ?? undefined,
|
||||
flags: embed.image.flags,
|
||||
}
|
||||
: undefined,
|
||||
thumbnail: embed.thumbnail
|
||||
? {
|
||||
url: embed.thumbnail.url!,
|
||||
proxy_url: mediaService.getExternalMediaProxyURL(embed.thumbnail.url!),
|
||||
content_type: embed.thumbnail.contentType ?? undefined,
|
||||
content_hash: embed.thumbnail.contentHash ?? undefined,
|
||||
width: embed.thumbnail.width ?? undefined,
|
||||
height: embed.thumbnail.height ?? undefined,
|
||||
description: embed.thumbnail.description ?? undefined,
|
||||
placeholder: embed.thumbnail.placeholder ?? undefined,
|
||||
flags: embed.thumbnail.flags,
|
||||
}
|
||||
: undefined,
|
||||
footer: embed.footer
|
||||
? {
|
||||
text: embed.footer.text!,
|
||||
icon_url: embed.footer.iconUrl ?? undefined,
|
||||
proxy_icon_url: embed.footer.iconUrl ? mediaService.getExternalMediaProxyURL(embed.footer.iconUrl) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
fields:
|
||||
embed.fields && embed.fields.length > 0 ? embed.fields.map((field) => mapEmbedFieldToResponse({field})) : undefined,
|
||||
provider: embed.provider
|
||||
? {
|
||||
name: embed.provider.name!,
|
||||
url: embed.provider.url ?? undefined,
|
||||
icon_url: undefined,
|
||||
proxy_icon_url: undefined,
|
||||
}
|
||||
: undefined,
|
||||
video: embed.video
|
||||
? {
|
||||
url: embed.video.url!,
|
||||
proxy_url: mediaService.getExternalMediaProxyURL(embed.video.url!),
|
||||
content_type: embed.video.contentType ?? undefined,
|
||||
content_hash: embed.video.contentHash ?? undefined,
|
||||
width: embed.video.width ?? undefined,
|
||||
height: embed.video.height ?? undefined,
|
||||
description: embed.video.description ?? undefined,
|
||||
placeholder: embed.video.placeholder ?? undefined,
|
||||
flags: embed.video.flags,
|
||||
duration: embed.video.duration,
|
||||
}
|
||||
: undefined,
|
||||
audio: undefined,
|
||||
nsfw: embed.nsfw ?? undefined,
|
||||
});
|
||||
|
||||
const mapMessageAttachmentToResponse = ({
|
||||
channelId,
|
||||
attachment,
|
||||
mediaService,
|
||||
attachmentDecayMap,
|
||||
}: MapMessageAttachmentToResponseParams & {
|
||||
attachmentDecayMap?: Map<string, AttachmentDecayRow>;
|
||||
}): MessageAttachmentResponse => {
|
||||
const url = makeAttachmentCdnUrl(channelId, attachment.id, attachment.filename);
|
||||
const decay = attachmentDecayMap?.get(attachment.id.toString());
|
||||
const expired = decay ? decay.expires_at.getTime() <= Date.now() : false;
|
||||
return {
|
||||
id: attachment.id.toString(),
|
||||
filename: attachment.filename,
|
||||
title: attachment.title ?? undefined,
|
||||
description: attachment.description ?? undefined,
|
||||
content_type: attachment.contentType ?? undefined,
|
||||
content_hash: attachment.contentHash ?? undefined,
|
||||
size: Number(attachment.size),
|
||||
url: expired ? null : url,
|
||||
proxy_url: expired ? null : mediaService.getExternalMediaProxyURL(url),
|
||||
width: attachment.width ?? undefined,
|
||||
height: attachment.height ?? undefined,
|
||||
placeholder: attachment.placeholder ?? undefined,
|
||||
flags: attachment.flags,
|
||||
nsfw: attachment.nsfw ?? undefined,
|
||||
duration: attachment.duration,
|
||||
expires_at: decay?.expires_at?.toISOString?.() ?? null,
|
||||
expired: expired || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const mapStickerItemToResponse = (sticker: StickerItem): MessageStickerResponse => ({
|
||||
id: sticker.id.toString(),
|
||||
name: sticker.name,
|
||||
format_type: sticker.formatType,
|
||||
});
|
||||
|
||||
const mapReactions = ({reactions, currentUserId}: MapReactionsParams): Array<MessageReactionResponse> => {
|
||||
if (!reactions?.length) return [];
|
||||
|
||||
const reactionMap = new Map<
|
||||
string,
|
||||
{emoji: {id?: string; name: string; animated?: boolean}; count: number; me: boolean}
|
||||
>();
|
||||
|
||||
for (const reaction of reactions) {
|
||||
const {emojiId, emojiName, isEmojiAnimated, userId} = reaction;
|
||||
const isCustomEmoji = emojiId !== 0n;
|
||||
const emojiKey = isCustomEmoji ? `custom_${emojiId}` : `unicode_${emojiName}`;
|
||||
const existing = reactionMap.get(emojiKey);
|
||||
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
if (userId === currentUserId) {
|
||||
existing.me = true;
|
||||
}
|
||||
} else {
|
||||
reactionMap.set(emojiKey, {
|
||||
emoji: {
|
||||
id: isCustomEmoji ? emojiId.toString() : undefined,
|
||||
name: emojiName,
|
||||
animated: isEmojiAnimated || undefined,
|
||||
},
|
||||
count: 1,
|
||||
me: userId === currentUserId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(reactionMap.values()).map(({emoji, count, me}) => ({
|
||||
emoji,
|
||||
count,
|
||||
me: me || undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const mapMessageReference = ({reference}: MapMessageReferenceParams): MessageReferenceResponse => ({
|
||||
channel_id: reference.channelId.toString(),
|
||||
message_id: reference.messageId.toString(),
|
||||
guild_id: reference.guildId?.toString(),
|
||||
type: reference.type,
|
||||
});
|
||||
|
||||
export async function mapMessageToResponse({
|
||||
message,
|
||||
currentUserId,
|
||||
nonce,
|
||||
tts,
|
||||
withMessageReference = true,
|
||||
getAuthor,
|
||||
getReactions,
|
||||
getReferencedMessage,
|
||||
setHasReaction,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
mediaService,
|
||||
attachmentDecayMap,
|
||||
}: MapMessageToResponseParams): Promise<MessageResponse> {
|
||||
let author: UserPartialResponse;
|
||||
|
||||
if (message.authorId) {
|
||||
author = await getCachedUserPartialResponse({userId: message.authorId, userCacheService, requestCache});
|
||||
} else if (message.webhookId && message.webhookName) {
|
||||
author = {
|
||||
id: message.webhookId.toString(),
|
||||
username: message.webhookName,
|
||||
discriminator: '0000',
|
||||
avatar: message.webhookAvatarHash,
|
||||
bot: true,
|
||||
flags: 0,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Message ${message.id} has neither authorId nor webhookId`);
|
||||
}
|
||||
|
||||
const response: Partial<MessageResponse> = {
|
||||
id: message.id.toString(),
|
||||
type: message.type,
|
||||
content: message.content ?? '',
|
||||
channel_id: message.channelId.toString(),
|
||||
author,
|
||||
attachments:
|
||||
message.attachments?.map((att) =>
|
||||
mapMessageAttachmentToResponse({
|
||||
channelId: message.channelId,
|
||||
attachment: att,
|
||||
mediaService,
|
||||
attachmentDecayMap,
|
||||
}),
|
||||
) ?? [],
|
||||
stickers: message.stickers?.map((sticker) => mapStickerItemToResponse(sticker)) ?? [],
|
||||
embeds: message.embeds?.map((embed) => mapMessageEmbedToResponse({embed, mediaService})) ?? [],
|
||||
timestamp: new Date(SnowflakeUtils.extractTimestamp(message.id)).toISOString(),
|
||||
edited_timestamp: message.editedTimestamp?.toISOString() ?? null,
|
||||
flags: message.flags ?? 0,
|
||||
mention_everyone: message.mentionEveryone,
|
||||
pinned: message.pinnedTimestamp != null,
|
||||
};
|
||||
|
||||
if (message.webhookId) {
|
||||
response.webhook_id = message.webhookId.toString();
|
||||
}
|
||||
|
||||
if (tts) {
|
||||
response.tts = true;
|
||||
}
|
||||
|
||||
if (message.mentionedUserIds.size > 0) {
|
||||
const mentionedUserIds = Array.from(message.mentionedUserIds);
|
||||
const mentionedUserPartials = await getCachedUserPartialResponses({
|
||||
userIds: mentionedUserIds,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
response.mentions = mentionedUserIds.map((userId) => mentionedUserPartials.get(userId)!);
|
||||
}
|
||||
|
||||
if (message.mentionedRoleIds.size > 0) {
|
||||
response.mention_roles = Array.from(message.mentionedRoleIds).map(String);
|
||||
}
|
||||
|
||||
if (currentUserId != null && getReactions && message.hasReaction !== false) {
|
||||
const reactions = await getReactions(message.channelId, message.id);
|
||||
const hasReactions = reactions && reactions.length > 0;
|
||||
|
||||
if (message.hasReaction == null && setHasReaction) {
|
||||
void setHasReaction(message.channelId, message.id, hasReactions);
|
||||
}
|
||||
|
||||
if (hasReactions) {
|
||||
const mappedReactions = mapReactions({reactions, currentUserId});
|
||||
if (mappedReactions.length > 0) {
|
||||
response.reactions = mappedReactions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message.reference) {
|
||||
response.message_reference = mapMessageReference({reference: message.reference});
|
||||
if (withMessageReference && getReferencedMessage) {
|
||||
const referencedMessage = await getReferencedMessage(message.reference.channelId, message.reference.messageId);
|
||||
if (referencedMessage) {
|
||||
response.referenced_message = await mapMessageToResponse({
|
||||
message: referencedMessage,
|
||||
withMessageReference: false,
|
||||
getAuthor,
|
||||
getReactions,
|
||||
getReferencedMessage,
|
||||
setHasReaction,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
mediaService,
|
||||
attachmentDecayMap,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message.messageSnapshots && message.messageSnapshots.length > 0) {
|
||||
response.message_snapshots = message.messageSnapshots.map((snapshot) => ({
|
||||
content: snapshot.content ?? undefined,
|
||||
timestamp: snapshot.timestamp.toISOString(),
|
||||
edited_timestamp: snapshot.editedTimestamp?.toISOString() ?? undefined,
|
||||
mentions: snapshot.mentionedUserIds.size > 0 ? Array.from(snapshot.mentionedUserIds).map(String) : undefined,
|
||||
mention_roles: snapshot.mentionedRoleIds.size > 0 ? Array.from(snapshot.mentionedRoleIds).map(String) : undefined,
|
||||
embeds: snapshot.embeds?.map((embed) => mapMessageEmbedToResponse({embed, mediaService})) ?? undefined,
|
||||
attachments:
|
||||
snapshot.attachments?.map((att) =>
|
||||
mapMessageAttachmentToResponse({
|
||||
channelId: message.channelId,
|
||||
attachment: att,
|
||||
mediaService,
|
||||
attachmentDecayMap,
|
||||
}),
|
||||
) ?? undefined,
|
||||
type: snapshot.type,
|
||||
flags: snapshot.flags,
|
||||
}));
|
||||
}
|
||||
|
||||
if (nonce) {
|
||||
response.nonce = nonce;
|
||||
}
|
||||
|
||||
if (message.call) {
|
||||
response.call = {
|
||||
participants: Array.from(message.call.participantIds).map(String),
|
||||
ended_timestamp: message.call.endedTimestamp?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return response as MessageResponse;
|
||||
}
|
||||
286
fluxer_api/src/channel/MessageTypes.ts
Normal file
286
fluxer_api/src/channel/MessageTypes.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MAX_ATTACHMENTS_PER_MESSAGE, MAX_EMBEDS_PER_MESSAGE} from '~/Constants';
|
||||
import {
|
||||
type AttachmentRequestData,
|
||||
ClientAttachmentReferenceRequest,
|
||||
ClientAttachmentRequest,
|
||||
} from '~/channel/AttachmentDTOs';
|
||||
import {createStringType, createUnboundedStringType, Int32Type, Int64Type, z} from '~/Schema';
|
||||
import {UserPartialResponse} from '~/user/UserModel';
|
||||
import {MessageEmbedResponse, RichEmbedRequest} from './EmbedTypes';
|
||||
|
||||
const AUTHOR_TYPES = ['user', 'bot', 'webhook'] as const;
|
||||
const MESSAGE_CONTENT_TYPES = [
|
||||
'image',
|
||||
'sound',
|
||||
'video',
|
||||
'file',
|
||||
'sticker',
|
||||
'embed',
|
||||
'link',
|
||||
'poll',
|
||||
'snapshot',
|
||||
] as const;
|
||||
const EMBED_TYPES = ['image', 'video', 'sound', 'article'] as const;
|
||||
const SORT_FIELDS = ['timestamp', 'relevance'] as const;
|
||||
const SORT_ORDERS = ['asc', 'desc'] as const;
|
||||
|
||||
const MessageAttachmentResponse = z.object({
|
||||
id: z.string(),
|
||||
filename: z.string(),
|
||||
title: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
content_type: z.string().nullish(),
|
||||
content_hash: z.string().nullish(),
|
||||
size: z.number().int(),
|
||||
url: z.string().nullish(),
|
||||
proxy_url: z.string().nullish(),
|
||||
width: z.number().int().nullish(),
|
||||
height: z.number().int().nullish(),
|
||||
placeholder: z.string().nullish(),
|
||||
flags: z.number().int(),
|
||||
nsfw: z.boolean().nullish(),
|
||||
duration: z.number().int().nullish(),
|
||||
expires_at: z.string().nullish(),
|
||||
expired: z.boolean().nullish(),
|
||||
});
|
||||
export type MessageAttachmentResponse = z.infer<typeof MessageAttachmentResponse>;
|
||||
|
||||
const MessageReferenceResponse = z.object({
|
||||
channel_id: z.string(),
|
||||
message_id: z.string(),
|
||||
guild_id: z.string().nullish(),
|
||||
type: z.number().int(),
|
||||
});
|
||||
export type MessageReferenceResponse = z.infer<typeof MessageReferenceResponse>;
|
||||
|
||||
const MessageReactionResponse = z.object({
|
||||
emoji: z.object({
|
||||
id: z.string().nullish(),
|
||||
name: z.string(),
|
||||
animated: z.boolean().nullish(),
|
||||
}),
|
||||
count: z.number().int(),
|
||||
me: z.literal(true).nullish(),
|
||||
});
|
||||
export type MessageReactionResponse = z.infer<typeof MessageReactionResponse>;
|
||||
|
||||
const MessageStickerResponse = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
format_type: z.number().int(),
|
||||
});
|
||||
export type MessageStickerResponse = z.infer<typeof MessageStickerResponse>;
|
||||
|
||||
const MessageSnapshotResponse = z.object({
|
||||
content: z.string().nullish(),
|
||||
timestamp: z.iso.datetime(),
|
||||
edited_timestamp: z.iso.datetime().nullish(),
|
||||
mentions: z.array(z.string()).nullish(),
|
||||
mention_roles: z.array(z.string()).nullish(),
|
||||
embeds: z.array(MessageEmbedResponse).nullish(),
|
||||
attachments: z.array(MessageAttachmentResponse).nullish(),
|
||||
stickers: z.array(MessageStickerResponse).nullish(),
|
||||
type: z.number().int(),
|
||||
flags: z.number().int(),
|
||||
});
|
||||
|
||||
const MessageCallResponse = z.object({
|
||||
participants: z.array(z.string()),
|
||||
ended_timestamp: z.iso.datetime().nullish(),
|
||||
});
|
||||
|
||||
const BaseMessageResponse = z.object({
|
||||
id: z.string(),
|
||||
channel_id: z.string(),
|
||||
author: z.lazy(() => UserPartialResponse),
|
||||
webhook_id: z.string().nullish(),
|
||||
type: z.number().int(),
|
||||
flags: z.number().int(),
|
||||
content: z.string(),
|
||||
timestamp: z.iso.datetime(),
|
||||
edited_timestamp: z.iso.datetime().nullish(),
|
||||
pinned: z.boolean(),
|
||||
mention_everyone: z.boolean(),
|
||||
tts: z.boolean().optional(),
|
||||
mentions: z.array(z.lazy(() => UserPartialResponse)).nullish(),
|
||||
mention_roles: z.array(z.string()).nullish(),
|
||||
embeds: z.array(MessageEmbedResponse).nullish(),
|
||||
attachments: z.array(MessageAttachmentResponse).nullish(),
|
||||
stickers: z.array(MessageStickerResponse).nullish(),
|
||||
reactions: z.array(MessageReactionResponse).nullish(),
|
||||
message_reference: MessageReferenceResponse.nullish(),
|
||||
message_snapshots: z.array(MessageSnapshotResponse).nullish(),
|
||||
nonce: z.string().nullish(),
|
||||
call: MessageCallResponse.nullish(),
|
||||
});
|
||||
export interface MessageResponse extends z.infer<typeof BaseMessageResponse> {
|
||||
referenced_message?: MessageResponse | null;
|
||||
}
|
||||
|
||||
const MessageResponseSchema = z.object({
|
||||
...BaseMessageResponse.shape,
|
||||
referenced_message: BaseMessageResponse.nullish(),
|
||||
});
|
||||
|
||||
const MessageReferenceRequest = z
|
||||
.object({
|
||||
message_id: Int64Type,
|
||||
channel_id: Int64Type.optional(),
|
||||
guild_id: Int64Type.optional(),
|
||||
type: z.number().int().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.type === 1) {
|
||||
return data.channel_id !== undefined && data.message_id !== undefined;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Forward message reference must include channel_id and message_id',
|
||||
},
|
||||
);
|
||||
|
||||
export const ALLOWED_MENTIONS_PARSE = ['users', 'roles', 'everyone'] as const;
|
||||
|
||||
export const AllowedMentionsRequest = z.object({
|
||||
parse: z.array(z.enum(ALLOWED_MENTIONS_PARSE)).optional(),
|
||||
users: z.array(Int64Type).max(100).optional(),
|
||||
roles: z.array(Int64Type).max(100).optional(),
|
||||
replied_user: z.boolean().optional(),
|
||||
});
|
||||
export type AllowedMentionsRequest = z.infer<typeof AllowedMentionsRequest>;
|
||||
|
||||
export const MessageAttachmentRequest = ClientAttachmentRequest;
|
||||
export type MessageAttachmentRequest = z.infer<typeof ClientAttachmentRequest>;
|
||||
|
||||
export const MessageUpdateAttachmentRequest = ClientAttachmentReferenceRequest;
|
||||
export type MessageUpdateAttachmentRequest = z.infer<typeof MessageUpdateAttachmentRequest>;
|
||||
|
||||
const MessageRequestSchema = z
|
||||
.object({
|
||||
content: createUnboundedStringType().nullish(),
|
||||
embeds: z.array(RichEmbedRequest).max(MAX_EMBEDS_PER_MESSAGE),
|
||||
attachments: z.array(ClientAttachmentRequest).max(MAX_ATTACHMENTS_PER_MESSAGE),
|
||||
message_reference: MessageReferenceRequest.nullish(),
|
||||
allowed_mentions: AllowedMentionsRequest.nullish(),
|
||||
flags: Int32Type.default(0),
|
||||
nonce: createStringType(1, 32),
|
||||
favorite_meme_id: Int64Type.nullish(),
|
||||
sticker_ids: z.array(Int64Type).max(3).nullish(),
|
||||
tts: z.boolean().optional(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const MessageRequest = MessageRequestSchema;
|
||||
|
||||
interface BaseMessageRequestType extends Omit<z.infer<typeof MessageRequestSchema>, 'attachments'> {}
|
||||
export interface MessageRequest extends BaseMessageRequestType {
|
||||
attachments?: Array<AttachmentRequestData>;
|
||||
}
|
||||
|
||||
const MessageUpdateRequestSchema = MessageRequestSchema.pick({
|
||||
content: true,
|
||||
embeds: true,
|
||||
allowed_mentions: true,
|
||||
}).extend({
|
||||
flags: Int32Type.optional(),
|
||||
attachments: z.array(MessageUpdateAttachmentRequest).max(MAX_ATTACHMENTS_PER_MESSAGE).optional(),
|
||||
});
|
||||
|
||||
export const MessageUpdateRequest = MessageUpdateRequestSchema;
|
||||
|
||||
interface BaseMessageUpdateRequestType extends Omit<z.infer<typeof MessageUpdateRequestSchema>, 'attachments'> {}
|
||||
export interface MessageUpdateRequest extends BaseMessageUpdateRequestType {
|
||||
attachments?: Array<AttachmentRequestData>;
|
||||
}
|
||||
|
||||
export const ChannelPinResponse = z.object({
|
||||
message: MessageResponseSchema.omit({referenced_message: true, reactions: true}),
|
||||
pinned_at: z.iso.datetime(),
|
||||
});
|
||||
export type ChannelPinResponse = z.infer<typeof ChannelPinResponse>;
|
||||
|
||||
export const MessageSearchScope = z.enum([
|
||||
'current',
|
||||
'open_dms',
|
||||
'all_dms',
|
||||
'all_guilds',
|
||||
'all',
|
||||
'open_dms_and_all_guilds',
|
||||
]);
|
||||
export type MessageSearchScope = z.infer<typeof MessageSearchScope>;
|
||||
|
||||
export const MessageSearchRequest = z.object({
|
||||
hits_per_page: z.number().int().min(1).max(25).default(25),
|
||||
page: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).default(1),
|
||||
max_id: Int64Type.optional(),
|
||||
min_id: Int64Type.optional(),
|
||||
|
||||
content: createStringType(1, 1024).optional(),
|
||||
contents: z.array(createStringType(1, 1024)).max(100).optional(),
|
||||
|
||||
channel_id: z.array(Int64Type).max(500).optional(),
|
||||
exclude_channel_id: z.array(Int64Type).max(500).optional(),
|
||||
|
||||
author_type: z.array(z.enum(AUTHOR_TYPES)).optional(),
|
||||
exclude_author_type: z.array(z.enum(AUTHOR_TYPES)).optional(),
|
||||
author_id: z.array(Int64Type).optional(),
|
||||
exclude_author_id: z.array(Int64Type).optional(),
|
||||
|
||||
mentions: z.array(Int64Type).optional(),
|
||||
exclude_mentions: z.array(Int64Type).optional(),
|
||||
mention_everyone: z.boolean().optional(),
|
||||
|
||||
pinned: z.boolean().optional(),
|
||||
|
||||
has: z.array(z.enum(MESSAGE_CONTENT_TYPES)).optional(),
|
||||
exclude_has: z.array(z.enum(MESSAGE_CONTENT_TYPES)).optional(),
|
||||
|
||||
embed_type: z.array(z.enum(EMBED_TYPES)).optional(),
|
||||
exclude_embed_type: z.array(z.enum(EMBED_TYPES)).optional(),
|
||||
embed_provider: z.array(createStringType(1, 256)).optional(),
|
||||
exclude_embed_provider: z.array(createStringType(1, 256)).optional(),
|
||||
|
||||
link_hostname: z.array(createStringType(1, 255)).optional(),
|
||||
exclude_link_hostname: z.array(createStringType(1, 255)).optional(),
|
||||
|
||||
attachment_filename: z.array(createStringType(1, 1024)).optional(),
|
||||
exclude_attachment_filename: z.array(createStringType(1, 1024)).optional(),
|
||||
attachment_extension: z.array(createStringType(1, 32)).optional(),
|
||||
exclude_attachment_extension: z.array(createStringType(1, 32)).optional(),
|
||||
|
||||
sort_by: z.enum(SORT_FIELDS).default('timestamp'),
|
||||
sort_order: z.enum(SORT_ORDERS).default('desc'),
|
||||
|
||||
include_nsfw: z.boolean().default(false),
|
||||
scope: MessageSearchScope.optional(),
|
||||
});
|
||||
export type MessageSearchRequest = z.infer<typeof MessageSearchRequest>;
|
||||
|
||||
export const MessageSearchResponse = z.object({
|
||||
messages: z.array(MessageResponseSchema.omit({referenced_message: true})),
|
||||
total: z.number().int(),
|
||||
hits_per_page: z.number().int(),
|
||||
page: z.number().int(),
|
||||
});
|
||||
export type MessageSearchResponse = z.infer<typeof MessageSearchResponse>;
|
||||
116
fluxer_api/src/channel/controllers/CallController.ts
Normal file
116
fluxer_api/src/channel/controllers/CallController.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '~/App';
|
||||
import {createChannelID, createUserID} from '~/BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createStringType, Int64Type, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
export const CallController = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/channels/:channel_id/call',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CALL_GET),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const channelService = ctx.get('channelService');
|
||||
|
||||
const {ringable, silent} = await channelService.checkCallEligibility({userId, channelId});
|
||||
|
||||
return ctx.json({ringable, silent});
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/channels/:channel_id/call',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CALL_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
Validator('json', z.object({region: createStringType(1, 64).optional()})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const {region} = ctx.req.valid('json');
|
||||
const channelService = ctx.get('channelService');
|
||||
|
||||
await channelService.updateCall({userId, channelId, region});
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/call/ring',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CALL_RING),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
Validator('json', z.object({recipients: z.array(Int64Type).optional()})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const {recipients} = ctx.req.valid('json');
|
||||
const channelService = ctx.get('channelService');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
|
||||
const recipientIds = recipients ? recipients.map(createUserID) : undefined;
|
||||
|
||||
await channelService.ringCallRecipients({
|
||||
userId,
|
||||
channelId,
|
||||
recipients: recipientIds,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/call/stop-ringing',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CALL_STOP_RINGING),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
Validator('json', z.object({recipients: z.array(Int64Type).optional()})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const {recipients} = ctx.req.valid('json');
|
||||
const channelService = ctx.get('channelService');
|
||||
|
||||
const recipientIds = recipients ? recipients.map(createUserID) : undefined;
|
||||
|
||||
await channelService.stopRingingCallRecipients({
|
||||
userId,
|
||||
channelId,
|
||||
recipients: recipientIds,
|
||||
});
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
};
|
||||
232
fluxer_api/src/channel/controllers/ChannelController.ts
Normal file
232
fluxer_api/src/channel/controllers/ChannelController.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Context} from 'hono';
|
||||
import type {HonoApp, HonoEnv} from '~/App';
|
||||
import {createChannelID, createUserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes} from '~/Constants';
|
||||
import {ChannelUpdateRequest, mapChannelToResponse} from '~/channel/ChannelModel';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Int64Type, QueryBooleanType, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
export const ChannelController = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/channels/:channel_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_GET),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const channel = await ctx.get('channelService').getChannel({userId, channelId});
|
||||
return ctx.json(
|
||||
await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/channels/:channel_id/rtc-regions',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_GET),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
|
||||
const regions = await ctx.get('channelService').getAvailableRtcRegions({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
|
||||
return ctx.json(
|
||||
regions.map((region) => ({
|
||||
id: region.id,
|
||||
name: region.name,
|
||||
emoji: region.emoji,
|
||||
})),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/channels/:channel_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
Validator('json', ChannelUpdateRequest, {
|
||||
pre: async (raw: unknown, ctx: Context<HonoEnv>) => {
|
||||
const userId = ctx.get('user').id;
|
||||
// @ts-expect-error not well typed
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const existing = await ctx.get('channelService').getChannel({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
const body = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
return {...body, type: existing.type};
|
||||
},
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const data = ctx.req.valid('json');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const channel = await ctx.get('channelService').editChannel({userId, channelId, data, requestCache});
|
||||
return ctx.json(
|
||||
await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
Validator('query', z.object({silent: QueryBooleanType})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const {silent} = ctx.req.valid('query');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
|
||||
const channel = await ctx.get('channelService').getChannel({userId, channelId});
|
||||
if (channel.type === ChannelTypes.GROUP_DM) {
|
||||
await ctx.get('channelService').removeRecipientFromChannel({
|
||||
userId,
|
||||
channelId,
|
||||
recipientId: userId,
|
||||
requestCache,
|
||||
silent,
|
||||
});
|
||||
} else {
|
||||
await ctx.get('channelService').deleteChannel({userId, channelId, requestCache});
|
||||
}
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/channels/:channel_id/recipients/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type, user_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const recipientId = createUserID(ctx.req.valid('param').user_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
|
||||
await ctx.get('channelService').groupDms.addRecipientToChannel({
|
||||
userId,
|
||||
channelId,
|
||||
recipientId,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id/recipients/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type, user_id: Int64Type})),
|
||||
Validator('query', z.object({silent: QueryBooleanType})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const recipientId = createUserID(ctx.req.valid('param').user_id);
|
||||
const {silent} = ctx.req.valid('query');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
await ctx
|
||||
.get('channelService')
|
||||
.removeRecipientFromChannel({userId, channelId, recipientId, requestCache, silent});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/channels/:channel_id/permissions/:overwrite_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type, overwrite_id: Int64Type})),
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
type: z.union([z.literal(0), z.literal(1)]),
|
||||
allow: Int64Type.nullish(),
|
||||
deny: Int64Type.nullish(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const overwriteId = ctx.req.valid('param').overwrite_id;
|
||||
const data = ctx.req.valid('json');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
|
||||
await ctx.get('channelService').setChannelPermissionOverwrite({
|
||||
userId,
|
||||
channelId,
|
||||
overwriteId,
|
||||
overwrite: {
|
||||
type: data.type,
|
||||
allow_: data.allow ? data.allow : 0n,
|
||||
deny_: data.deny ? data.deny : 0n,
|
||||
},
|
||||
requestCache,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id/permissions/:overwrite_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type, overwrite_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const overwriteId = ctx.req.valid('param').overwrite_id;
|
||||
const requestCache = ctx.get('requestCache');
|
||||
await ctx.get('channelService').deleteChannelPermissionOverwrite({userId, channelId, overwriteId, requestCache});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
};
|
||||
500
fluxer_api/src/channel/controllers/MessageController.ts
Normal file
500
fluxer_api/src/channel/controllers/MessageController.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Context} from 'hono';
|
||||
import type {HonoApp, HonoEnv} from '~/App';
|
||||
import {AttachmentDecayService} from '~/attachment/AttachmentDecayService';
|
||||
import type {ChannelID, UserID} from '~/BrandedTypes';
|
||||
import {createAttachmentID, createChannelID, createMessageID} from '~/BrandedTypes';
|
||||
import {MAX_ATTACHMENTS_PER_MESSAGE} from '~/Constants';
|
||||
import {
|
||||
type AttachmentRequestData,
|
||||
type ClientAttachmentReferenceRequest,
|
||||
type ClientAttachmentRequest,
|
||||
mergeUploadWithClientData,
|
||||
type UploadedAttachment,
|
||||
} from '~/channel/AttachmentDTOs';
|
||||
import {MessageRequest, MessageUpdateRequest, mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import {collectMessageAttachments, isPersonalNotesChannel} from '~/channel/services/message/MessageHelpers';
|
||||
import {InputValidationError, UnclaimedAccountRestrictedError} from '~/Errors';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createQueryIntegerType, Int32Type, Int64Type, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
const DEFAULT_ATTACHMENT_UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface ParseMultipartMessageDataOptions {
|
||||
uploadExpiresAt?: Date;
|
||||
onPayloadParsed?: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
export async function parseMultipartMessageData(
|
||||
ctx: Context<HonoEnv>,
|
||||
userId: UserID,
|
||||
channelId: ChannelID,
|
||||
schema: z.ZodTypeAny,
|
||||
options?: ParseMultipartMessageDataOptions,
|
||||
): Promise<MessageRequest | MessageUpdateRequest> {
|
||||
let body: Record<string, string | File | Array<string | File>>;
|
||||
try {
|
||||
body = await ctx.req.parseBody();
|
||||
} catch (_error) {
|
||||
throw InputValidationError.create(
|
||||
'multipart_form',
|
||||
'Failed to parse multipart form data. Please check that all field names and filenames are properly formatted.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!body.payload_json || typeof body.payload_json !== 'string') {
|
||||
throw InputValidationError.create('payload_json', 'payload_json field is required for multipart messages');
|
||||
}
|
||||
|
||||
let jsonData: unknown;
|
||||
try {
|
||||
jsonData = JSON.parse(body.payload_json);
|
||||
} catch (_error) {
|
||||
throw InputValidationError.create('payload_json', 'Invalid JSON in payload_json');
|
||||
}
|
||||
|
||||
options?.onPayloadParsed?.(jsonData);
|
||||
const validationResult = schema.safeParse(jsonData);
|
||||
if (!validationResult.success) {
|
||||
throw InputValidationError.create('message_data', 'Invalid message data');
|
||||
}
|
||||
|
||||
const data = validationResult.data as Partial<MessageRequest> &
|
||||
Partial<MessageUpdateRequest> & {
|
||||
attachments?: Array<AttachmentRequestData>;
|
||||
};
|
||||
|
||||
const filesWithIndices: Array<{file: File; index: number}> = [];
|
||||
const seenIndices = new Set<number>();
|
||||
const fieldNamePattern = /^files\[(\d+)\]$/;
|
||||
|
||||
Object.keys(body).forEach((key) => {
|
||||
if (key.startsWith('files[')) {
|
||||
const match = fieldNamePattern.exec(key);
|
||||
if (!match) {
|
||||
throw InputValidationError.create(
|
||||
'files',
|
||||
`Invalid file field name: ${key}. Expected format: files[N] where N is a number`,
|
||||
);
|
||||
}
|
||||
|
||||
const index = parseInt(match[1], 10);
|
||||
|
||||
if (index >= MAX_ATTACHMENTS_PER_MESSAGE) {
|
||||
throw InputValidationError.create(
|
||||
'files',
|
||||
`File index ${index} exceeds maximum allowed index of ${MAX_ATTACHMENTS_PER_MESSAGE - 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (seenIndices.has(index)) {
|
||||
throw InputValidationError.create('files', `Duplicate file index: ${index}`);
|
||||
}
|
||||
|
||||
const file = body[key];
|
||||
if (file instanceof File) {
|
||||
filesWithIndices.push({file, index});
|
||||
seenIndices.add(index);
|
||||
} else if (Array.isArray(file)) {
|
||||
const validFiles = file.filter((f) => f instanceof File);
|
||||
if (validFiles.length > 0) {
|
||||
throw InputValidationError.create('files', `Multiple files for index ${index} not allowed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (filesWithIndices.length > MAX_ATTACHMENTS_PER_MESSAGE) {
|
||||
throw InputValidationError.create('files', `Too many files. Maximum ${MAX_ATTACHMENTS_PER_MESSAGE} files allowed`);
|
||||
}
|
||||
|
||||
if (filesWithIndices.length > 0) {
|
||||
if (!data.attachments || !Array.isArray(data.attachments) || data.attachments.length === 0) {
|
||||
throw InputValidationError.create('attachments', 'Attachments metadata array is required when uploading files');
|
||||
}
|
||||
|
||||
type AttachmentMetadata = ClientAttachmentRequest | ClientAttachmentReferenceRequest;
|
||||
const attachmentMetadata = data.attachments as Array<AttachmentMetadata>;
|
||||
|
||||
const newAttachments = attachmentMetadata.filter(
|
||||
(a): a is ClientAttachmentRequest => 'filename' in a && a.filename !== undefined,
|
||||
);
|
||||
const existingAttachments = attachmentMetadata.filter(
|
||||
(a): a is ClientAttachmentReferenceRequest => !('filename' in a) || a.filename === undefined,
|
||||
);
|
||||
|
||||
const metadataIds = new Set(newAttachments.map((a) => a.id));
|
||||
const fileIds = new Set(filesWithIndices.map((f) => f.index));
|
||||
|
||||
for (const fileId of fileIds) {
|
||||
if (!metadataIds.has(fileId)) {
|
||||
throw InputValidationError.create('attachments', `No metadata provided for file with ID ${fileId}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const att of newAttachments) {
|
||||
if (!fileIds.has(att.id)) {
|
||||
throw InputValidationError.create('attachments', `No file uploaded for attachment metadata with ID ${att.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const uploadExpiresAt = options?.uploadExpiresAt ?? new Date(Date.now() + DEFAULT_ATTACHMENT_UPLOAD_TTL_MS);
|
||||
|
||||
const uploadedAttachments: Array<UploadedAttachment> = await ctx.get('channelService').uploadFormDataAttachments({
|
||||
userId,
|
||||
channelId,
|
||||
files: filesWithIndices,
|
||||
attachmentMetadata: newAttachments,
|
||||
expiresAt: uploadExpiresAt,
|
||||
});
|
||||
|
||||
const uploadedMap = new Map(uploadedAttachments.map((u) => [u.id, u]));
|
||||
|
||||
const processedNewAttachments = newAttachments.map((clientData) => {
|
||||
const uploaded = uploadedMap.get(clientData.id);
|
||||
if (!uploaded) {
|
||||
throw InputValidationError.create('attachments', `No file uploaded for attachment with ID ${clientData.id}`);
|
||||
}
|
||||
|
||||
if (clientData.filename !== uploaded.filename) {
|
||||
throw InputValidationError.create(
|
||||
'attachments',
|
||||
`Filename mismatch for attachment ${clientData.id}: metadata specifies "${clientData.filename}" but this doesn't match`,
|
||||
);
|
||||
}
|
||||
|
||||
return mergeUploadWithClientData(uploaded, clientData);
|
||||
});
|
||||
|
||||
data.attachments = [...existingAttachments, ...processedNewAttachments];
|
||||
} else if (
|
||||
data.attachments?.some((a: unknown) => {
|
||||
const attachment = a as ClientAttachmentRequest | ClientAttachmentReferenceRequest;
|
||||
return 'filename' in attachment && attachment.filename;
|
||||
})
|
||||
) {
|
||||
throw InputValidationError.create(
|
||||
'attachments',
|
||||
'Attachment metadata with filename provided but no files uploaded',
|
||||
);
|
||||
}
|
||||
|
||||
return data as MessageRequest | MessageUpdateRequest;
|
||||
}
|
||||
|
||||
export const MessageController = (app: HonoApp) => {
|
||||
const decayService = new AttachmentDecayService();
|
||||
|
||||
app.get(
|
||||
'/channels/:channel_id/messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGES_GET),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
Validator(
|
||||
'query',
|
||||
z.object({
|
||||
limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 100}),
|
||||
before: z.optional(Int64Type),
|
||||
after: z.optional(Int64Type),
|
||||
around: z.optional(Int64Type),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const {limit, before, after, around} = ctx.req.valid('query');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const messages = await ctx.get('channelService').getMessages({
|
||||
userId,
|
||||
channelId,
|
||||
limit,
|
||||
before: before ? createMessageID(before) : undefined,
|
||||
after: after ? createMessageID(after) : undefined,
|
||||
around: around ? createMessageID(around) : undefined,
|
||||
});
|
||||
const allAttachments = messages.flatMap((message) => collectMessageAttachments(message));
|
||||
const attachmentDecayMap =
|
||||
allAttachments.length > 0
|
||||
? await decayService.fetchMetadata(allAttachments.map((att) => ({attachmentId: att.id})))
|
||||
: undefined;
|
||||
return ctx.json(
|
||||
await Promise.all(
|
||||
messages.map((message) =>
|
||||
mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache,
|
||||
mediaService: ctx.get('mediaService'),
|
||||
attachmentDecayMap,
|
||||
getReactions: (channelId, messageId) =>
|
||||
ctx.get('channelService').getMessageReactions({userId, channelId, messageId}),
|
||||
setHasReaction: (channelId, messageId, hasReaction) =>
|
||||
ctx.get('channelService').setHasReaction(channelId, messageId, hasReaction),
|
||||
getReferencedMessage: (channelId, messageId) =>
|
||||
ctx.get('channelRepository').getMessage(channelId, messageId),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/channels/:channel_id/messages/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_GET),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const message = await ctx.get('channelService').getMessage({userId, channelId, messageId});
|
||||
const messageAttachments = collectMessageAttachments(message);
|
||||
const attachmentDecayMap =
|
||||
messageAttachments.length > 0
|
||||
? await decayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
|
||||
: undefined;
|
||||
return ctx.json(
|
||||
await mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache,
|
||||
mediaService: ctx.get('mediaService'),
|
||||
attachmentDecayMap,
|
||||
getReactions: (channelId, messageId) =>
|
||||
ctx.get('channelService').getMessageReactions({userId, channelId, messageId}),
|
||||
setHasReaction: (channelId, messageId, hasReaction) =>
|
||||
ctx.get('channelService').setHasReaction(channelId, messageId, hasReaction),
|
||||
getReferencedMessage: (channelId, messageId) => ctx.get('channelRepository').getMessage(channelId, messageId),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_CREATE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
|
||||
if (!user.passwordHash && !isPersonalNotesChannel({userId: user.id, channelId})) {
|
||||
throw new UnclaimedAccountRestrictedError('send messages');
|
||||
}
|
||||
|
||||
const contentType = ctx.req.header('content-type');
|
||||
const validatedData = contentType?.includes('multipart/form-data')
|
||||
? ((await parseMultipartMessageData(ctx, user.id, channelId, MessageRequest)) as MessageRequest)
|
||||
: await (async () => {
|
||||
const data: unknown = await ctx.req.json();
|
||||
const validationResult = MessageRequest.safeParse(data);
|
||||
if (!validationResult.success) {
|
||||
throw InputValidationError.create('message_data', 'Invalid message data');
|
||||
}
|
||||
return validationResult.data;
|
||||
})();
|
||||
const message = await ctx
|
||||
.get('channelService')
|
||||
.sendMessage({user, channelId, data: validatedData as MessageRequest, requestCache});
|
||||
const messageAttachments = collectMessageAttachments(message);
|
||||
const attachmentDecayMap =
|
||||
messageAttachments.length > 0
|
||||
? await decayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
|
||||
: undefined;
|
||||
return ctx.json(
|
||||
await mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: user.id,
|
||||
nonce: validatedData.nonce,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache,
|
||||
mediaService: ctx.get('mediaService'),
|
||||
attachmentDecayMap,
|
||||
getReferencedMessage: (channelId, messageId) => ctx.get('channelRepository').getMessage(channelId, messageId),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/channels/:channel_id/messages/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
|
||||
const contentType = ctx.req.header('content-type');
|
||||
const validatedData = contentType?.includes('multipart/form-data')
|
||||
? ((await parseMultipartMessageData(ctx, userId, channelId, MessageUpdateRequest)) as MessageUpdateRequest)
|
||||
: await (async () => {
|
||||
const data: unknown = await ctx.req.json();
|
||||
const validationResult = MessageUpdateRequest.safeParse(data);
|
||||
if (!validationResult.success) {
|
||||
throw InputValidationError.create('message_data', 'Invalid message data');
|
||||
}
|
||||
return validationResult.data;
|
||||
})();
|
||||
const message = await ctx.get('channelService').editMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
data: validatedData as MessageUpdateRequest,
|
||||
requestCache,
|
||||
});
|
||||
const messageAttachments = collectMessageAttachments(message);
|
||||
const attachmentDecayMap =
|
||||
messageAttachments.length > 0
|
||||
? await decayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
|
||||
: undefined;
|
||||
return ctx.json(
|
||||
await mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache,
|
||||
mediaService: ctx.get('mediaService'),
|
||||
attachmentDecayMap,
|
||||
getReferencedMessage: (channelId, messageId) => ctx.get('channelRepository').getMessage(channelId, messageId),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id/messages/ack',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_READ_STATE_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
await ctx.get('channelService').deleteReadState({userId, channelId});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id/messages/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
await ctx.get('channelService').deleteMessage({userId, channelId, messageId, requestCache});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id/messages/:message_id/attachments/:attachment_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type, attachment_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id, attachment_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const attachmentId = createAttachmentID(attachment_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
await ctx.get('channelService').deleteAttachment({
|
||||
userId,
|
||||
channelId,
|
||||
messageId: messageId,
|
||||
attachmentId,
|
||||
requestCache,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/messages/bulk-delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_BULK_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
Validator('json', z.object({message_ids: z.array(Int64Type)})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const messageIds = ctx.req.valid('json').message_ids.map(createMessageID);
|
||||
await ctx.get('channelService').bulkDeleteMessages({userId, channelId, messageIds});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/typing',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_TYPING),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
await ctx.get('channelService').startTyping({userId, channelId});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/messages/:message_id/ack',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_ACK),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
|
||||
Validator('json', z.object({mention_count: Int32Type.optional(), manual: z.optional(z.boolean())})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const {mention_count: mentionCount, manual} = ctx.req.valid('json');
|
||||
await ctx.get('channelService').ackMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
mentionCount: mentionCount ?? 0,
|
||||
manual,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '~/App';
|
||||
import {createChannelID, createMessageID, createUserID} from '~/BrandedTypes';
|
||||
import {isPersonalNotesChannel} from '~/channel/services/message/MessageHelpers';
|
||||
import {UnclaimedAccountRestrictedError} from '~/Errors';
|
||||
import {LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createStringType, Int64Type, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
export const MessageInteractionController = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/channels/:channel_id/messages/pins',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
Validator(
|
||||
'query',
|
||||
z.object({
|
||||
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||
before: z.coerce.date().optional(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const {limit, before} = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await ctx
|
||||
.get('channelService')
|
||||
.getChannelPins({userId, channelId, requestCache, limit, beforeTimestamp: before}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/pins/ack',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const {channel} = await ctx.get('channelService').getChannelAuthenticated({userId, channelId});
|
||||
const timestamp = channel.lastPinTimestamp;
|
||||
if (timestamp != null) {
|
||||
await ctx.get('channelService').ackPins({userId, channelId, timestamp});
|
||||
}
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/channels/:channel_id/pins/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
await ctx.get('channelService').pinMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
requestCache,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id/pins/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
await ctx.get('channelService').unpinMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
requestCache,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/channels/:channel_id/messages/:message_id/reactions/:emoji',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
|
||||
LoginRequired,
|
||||
Validator(
|
||||
'param',
|
||||
z.object({
|
||||
channel_id: Int64Type,
|
||||
message_id: Int64Type,
|
||||
emoji: createStringType(1, 64),
|
||||
}),
|
||||
),
|
||||
Validator(
|
||||
'query',
|
||||
z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
after: Int64Type.optional(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id, emoji} = ctx.req.valid('param');
|
||||
const {limit, after} = ctx.req.valid('query');
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const afterUserId = after ? createUserID(after) : undefined;
|
||||
return ctx.json(
|
||||
await ctx
|
||||
.get('channelService')
|
||||
.getUsersForReaction({userId, channelId, messageId, emoji, limit, after: afterUserId}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/channels/:channel_id/messages/:message_id/reactions/:emoji/@me',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
|
||||
LoginRequired,
|
||||
Validator(
|
||||
'param',
|
||||
z.object({
|
||||
channel_id: Int64Type,
|
||||
message_id: Int64Type,
|
||||
emoji: createStringType(1, 64),
|
||||
}),
|
||||
),
|
||||
Validator('query', z.object({session_id: z.optional(createStringType(1, 64))})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id, emoji} = ctx.req.valid('param');
|
||||
const user = ctx.get('user');
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const sessionId = ctx.req.valid('query').session_id;
|
||||
const requestCache = ctx.get('requestCache');
|
||||
|
||||
if (!user.passwordHash && !isPersonalNotesChannel({userId: user.id, channelId})) {
|
||||
throw new UnclaimedAccountRestrictedError('add reactions');
|
||||
}
|
||||
|
||||
await ctx.get('channelService').addReaction({
|
||||
userId: user.id,
|
||||
sessionId,
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
requestCache,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id/messages/:message_id/reactions/:emoji/@me',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
|
||||
LoginRequired,
|
||||
Validator(
|
||||
'param',
|
||||
z.object({
|
||||
channel_id: Int64Type,
|
||||
message_id: Int64Type,
|
||||
emoji: createStringType(1, 64),
|
||||
}),
|
||||
),
|
||||
Validator('query', z.object({session_id: z.optional(createStringType(1, 64))})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id, emoji} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const sessionId = ctx.req.valid('query').session_id;
|
||||
const requestCache = ctx.get('requestCache');
|
||||
await ctx.get('channelService').removeOwnReaction({
|
||||
userId,
|
||||
sessionId,
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
requestCache,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id/messages/:message_id/reactions/:emoji/:target_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
|
||||
LoginRequired,
|
||||
Validator(
|
||||
'param',
|
||||
z.object({
|
||||
channel_id: Int64Type,
|
||||
message_id: Int64Type,
|
||||
emoji: createStringType(1, 64),
|
||||
target_id: Int64Type,
|
||||
}),
|
||||
),
|
||||
Validator('query', z.object({session_id: z.optional(createStringType(1, 64))})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id, emoji, target_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const targetId = createUserID(target_id);
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const sessionId = ctx.req.valid('query').session_id;
|
||||
const requestCache = ctx.get('requestCache');
|
||||
await ctx.get('channelService').removeReaction({
|
||||
userId,
|
||||
sessionId,
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
targetId,
|
||||
requestCache,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id/messages/:message_id/reactions/:emoji',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
|
||||
LoginRequired,
|
||||
Validator(
|
||||
'param',
|
||||
z.object({
|
||||
channel_id: Int64Type,
|
||||
message_id: Int64Type,
|
||||
emoji: createStringType(1, 64),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id, emoji} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
await ctx.get('channelService').removeAllReactionsForEmoji({userId, channelId, messageId, emoji});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id/messages/:message_id/reactions',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
await ctx.get('channelService').removeAllReactions({userId, channelId, messageId});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '~/App';
|
||||
import {createChannelID} from '~/BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Int64Type, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
import {parseScheduledMessageInput} from './ScheduledMessageParsing';
|
||||
|
||||
export const ScheduledMessageController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/channels/:channel_id/messages/schedule',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
|
||||
const {message, scheduledLocalAt, timezone} = await parseScheduledMessageInput({
|
||||
ctx,
|
||||
userId: user.id,
|
||||
channelId,
|
||||
});
|
||||
|
||||
const scheduledMessage = await scheduledMessageService.createScheduledMessage({
|
||||
user,
|
||||
channelId,
|
||||
data: message,
|
||||
scheduledLocalAt,
|
||||
timezone,
|
||||
});
|
||||
|
||||
return ctx.json(scheduledMessage.toResponse(), 201);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Context} from 'hono';
|
||||
import type {HonoEnv} from '~/App';
|
||||
import type {ChannelID, UserID} from '~/BrandedTypes';
|
||||
import type {MessageRequest} from '~/channel/ChannelModel';
|
||||
import {MessageRequest as MessageRequestSchema} from '~/channel/ChannelModel';
|
||||
import {InputValidationError} from '~/Errors';
|
||||
import {createStringType, type z} from '~/Schema';
|
||||
import {parseMultipartMessageData} from './MessageController';
|
||||
|
||||
export const SCHEDULED_ATTACHMENT_TTL_MS = 32 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const ScheduledMessageSchema = MessageRequestSchema.extend({
|
||||
scheduled_local_at: createStringType(1, 64),
|
||||
timezone: createStringType(1, 128),
|
||||
});
|
||||
|
||||
export type ScheduledMessageSchemaType = z.infer<typeof ScheduledMessageSchema>;
|
||||
|
||||
export function extractScheduleFields(data: ScheduledMessageSchemaType): {
|
||||
scheduled_local_at: string;
|
||||
timezone: string;
|
||||
message: MessageRequest;
|
||||
} {
|
||||
const {scheduled_local_at, timezone, ...messageData} = data;
|
||||
return {
|
||||
scheduled_local_at,
|
||||
timezone,
|
||||
message: messageData as MessageRequest,
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseScheduledMessageInput({
|
||||
ctx,
|
||||
userId,
|
||||
channelId,
|
||||
}: {
|
||||
ctx: Context<HonoEnv>;
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
}): Promise<{message: MessageRequest; scheduledLocalAt: string; timezone: string}> {
|
||||
const contentType = ctx.req.header('content-type') ?? '';
|
||||
const isMultipart = contentType.includes('multipart/form-data');
|
||||
|
||||
if (isMultipart) {
|
||||
let parsedPayload: unknown = null;
|
||||
const message = (await parseMultipartMessageData(ctx, userId, channelId, MessageRequestSchema, {
|
||||
uploadExpiresAt: new Date(Date.now() + SCHEDULED_ATTACHMENT_TTL_MS),
|
||||
onPayloadParsed(payload) {
|
||||
parsedPayload = payload;
|
||||
},
|
||||
})) as MessageRequest;
|
||||
|
||||
if (!parsedPayload) {
|
||||
throw InputValidationError.create('scheduled_message', 'Failed to parse multipart payload');
|
||||
}
|
||||
|
||||
const validation = ScheduledMessageSchema.safeParse(parsedPayload);
|
||||
if (!validation.success) {
|
||||
throw InputValidationError.create('scheduled_message', 'Invalid scheduled message payload');
|
||||
}
|
||||
|
||||
const {scheduled_local_at, timezone} = extractScheduleFields(validation.data);
|
||||
return {message, scheduledLocalAt: scheduled_local_at, timezone};
|
||||
}
|
||||
|
||||
const body: unknown = await ctx.req.json();
|
||||
const validation = ScheduledMessageSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
throw InputValidationError.create('scheduled_message', 'Invalid scheduled message payload');
|
||||
}
|
||||
|
||||
const {scheduled_local_at, timezone, message} = extractScheduleFields(validation.data);
|
||||
return {message, scheduledLocalAt: scheduled_local_at, timezone};
|
||||
}
|
||||
162
fluxer_api/src/channel/controllers/StreamController.ts
Normal file
162
fluxer_api/src/channel/controllers/StreamController.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '~/App';
|
||||
import {createChannelID} from '~/BrandedTypes';
|
||||
import {APIErrorCodes} from '~/constants/API';
|
||||
import {Permissions} from '~/constants/Channel';
|
||||
import {BadRequestError, MissingPermissionsError} from '~/Errors';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createStringType, Int64Type, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
const streamKeyParam = z.object({stream_key: createStringType(1, 256)});
|
||||
|
||||
const parseStreamKey = (
|
||||
streamKey: string,
|
||||
): {scope: 'guild' | 'dm'; guildId?: string; channelId: string; connectionId: string} | null => {
|
||||
const parts = streamKey.split(':');
|
||||
if (parts.length !== 3) return null;
|
||||
const [scopeRaw, channelId, connectionId] = parts;
|
||||
if (!channelId || !connectionId) return null;
|
||||
if (scopeRaw === 'dm') {
|
||||
return {scope: 'dm', channelId, connectionId};
|
||||
}
|
||||
if (!/^[0-9]+$/.test(scopeRaw) || !/^[0-9]+$/.test(channelId)) return null;
|
||||
return {scope: 'guild', guildId: scopeRaw, channelId, connectionId};
|
||||
};
|
||||
|
||||
export const StreamController = (app: HonoApp) => {
|
||||
app.patch(
|
||||
'/streams/:stream_key/stream',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_STREAM_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', z.object({region: createStringType(1, 64).optional()})),
|
||||
Validator('param', streamKeyParam),
|
||||
async (ctx) => {
|
||||
const {region} = ctx.req.valid('json');
|
||||
const streamKey = ctx.req.valid('param').stream_key;
|
||||
await ctx.get('cacheService').set(`stream_region:${streamKey}`, {region, updatedAt: Date.now()}, 60 * 60 * 24);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/streams/:stream_key/preview',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_STREAM_PREVIEW_GET),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', streamKeyParam),
|
||||
async (ctx) => {
|
||||
const streamKey = ctx.req.valid('param').stream_key;
|
||||
if (!parseStreamKey(streamKey)) {
|
||||
throw new BadRequestError({code: APIErrorCodes.INVALID_REQUEST, message: 'Invalid stream key format'});
|
||||
}
|
||||
const preview = await ctx.get('streamPreviewService').getPreview(streamKey);
|
||||
if (!preview) {
|
||||
return ctx.body(null, 404);
|
||||
}
|
||||
const payload: ArrayBuffer = preview.buffer.slice().buffer;
|
||||
const headers = {
|
||||
'Content-Type': preview.contentType || 'image/jpeg',
|
||||
'Cache-Control': 'no-store, private',
|
||||
Pragma: 'no-cache',
|
||||
};
|
||||
return ctx.newResponse(payload, 200, headers);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/streams/:stream_key/preview',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_STREAM_PREVIEW_POST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
channel_id: Int64Type,
|
||||
thumbnail: createStringType(1, 2_000_000),
|
||||
content_type: createStringType(1, 64).optional(),
|
||||
}),
|
||||
),
|
||||
Validator('param', streamKeyParam),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {thumbnail, channel_id, content_type} = ctx.req.valid('json');
|
||||
const streamKey = ctx.req.valid('param').stream_key;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const userId = user.id;
|
||||
|
||||
const parsedKey = parseStreamKey(streamKey);
|
||||
if (!parsedKey) {
|
||||
throw new BadRequestError({code: APIErrorCodes.INVALID_REQUEST, message: 'Invalid stream key format'});
|
||||
}
|
||||
|
||||
const channel = await ctx.get('channelService').getChannel({userId, channelId});
|
||||
|
||||
if (channel.guildId) {
|
||||
const hasConnect = await ctx.get('gatewayService').checkPermission({
|
||||
guildId: channel.guildId,
|
||||
channelId,
|
||||
userId,
|
||||
permission: Permissions.CONNECT,
|
||||
});
|
||||
if (!hasConnect) throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
if (channel.guildId && parsedKey.scope !== 'guild') {
|
||||
throw new BadRequestError({code: APIErrorCodes.INVALID_REQUEST, message: 'Stream key scope mismatch'});
|
||||
}
|
||||
if (!channel.guildId && parsedKey.scope !== 'dm') {
|
||||
throw new BadRequestError({code: APIErrorCodes.INVALID_REQUEST, message: 'Stream key scope mismatch'});
|
||||
}
|
||||
if (parsedKey.channelId !== channelId.toString()) {
|
||||
throw new BadRequestError({code: APIErrorCodes.INVALID_REQUEST, message: 'Stream key channel mismatch'});
|
||||
}
|
||||
|
||||
let body: Uint8Array;
|
||||
try {
|
||||
body = Uint8Array.from(Buffer.from(thumbnail, 'base64'));
|
||||
} catch {
|
||||
throw new BadRequestError({
|
||||
code: APIErrorCodes.INVALID_REQUEST,
|
||||
message: 'Invalid thumbnail payload',
|
||||
});
|
||||
}
|
||||
if (body.byteLength === 0) {
|
||||
throw new BadRequestError({
|
||||
code: APIErrorCodes.INVALID_REQUEST,
|
||||
message: 'Empty thumbnail payload',
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.get('streamPreviewService').uploadPreview({
|
||||
streamKey,
|
||||
channelId,
|
||||
userId,
|
||||
body,
|
||||
contentType: content_type,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
};
|
||||
35
fluxer_api/src/channel/controllers/index.ts
Normal file
35
fluxer_api/src/channel/controllers/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '~/App';
|
||||
import {CallController} from './CallController';
|
||||
import {ChannelController} from './ChannelController';
|
||||
import {MessageController} from './MessageController';
|
||||
import {MessageInteractionController} from './MessageInteractionController';
|
||||
import {ScheduledMessageController} from './ScheduledMessageController';
|
||||
import {StreamController} from './StreamController';
|
||||
|
||||
export const registerChannelControllers = (app: HonoApp) => {
|
||||
ChannelController(app);
|
||||
MessageInteractionController(app);
|
||||
MessageController(app);
|
||||
ScheduledMessageController(app);
|
||||
CallController(app);
|
||||
StreamController(app);
|
||||
};
|
||||
162
fluxer_api/src/channel/repositories/ChannelDataRepository.ts
Normal file
162
fluxer_api/src/channel/repositories/ChannelDataRepository.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, MessageID} from '~/BrandedTypes';
|
||||
import {
|
||||
BatchBuilder,
|
||||
buildPatchFromData,
|
||||
Db,
|
||||
executeConditional,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchManyInChunks,
|
||||
fetchOne,
|
||||
} from '~/database/Cassandra';
|
||||
import {CHANNEL_COLUMNS, type ChannelRow} from '~/database/CassandraTypes';
|
||||
import {Logger} from '~/Logger';
|
||||
import {Channel} from '~/Models';
|
||||
import {Channels, ChannelsByGuild} from '~/Tables';
|
||||
import {IChannelDataRepository} from './IChannelDataRepository';
|
||||
|
||||
const FETCH_CHANNEL_BY_ID = Channels.select({
|
||||
where: [Channels.where.eq('channel_id'), Channels.where.eq('soft_deleted')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_CHANNELS_BY_IDS = Channels.select({
|
||||
where: [Channels.where.in('channel_id', 'channel_ids'), Channels.where.eq('soft_deleted')],
|
||||
});
|
||||
|
||||
const FETCH_GUILD_CHANNELS_BY_GUILD_ID = ChannelsByGuild.select({
|
||||
where: ChannelsByGuild.where.eq('guild_id'),
|
||||
});
|
||||
|
||||
const DEFAULT_CAS_RETRIES = 8;
|
||||
|
||||
export class ChannelDataRepository extends IChannelDataRepository {
|
||||
async findUnique(channelId: ChannelID): Promise<Channel | null> {
|
||||
const channel = await fetchOne<ChannelRow>(
|
||||
FETCH_CHANNEL_BY_ID.bind({
|
||||
channel_id: channelId,
|
||||
soft_deleted: false,
|
||||
}),
|
||||
);
|
||||
return channel ? new Channel(channel) : null;
|
||||
}
|
||||
|
||||
async upsert(data: ChannelRow, oldData?: ChannelRow | null): Promise<Channel> {
|
||||
const channelId = data.channel_id;
|
||||
|
||||
const result = await executeVersionedUpdate<ChannelRow, 'channel_id' | 'soft_deleted'>(
|
||||
async () => {
|
||||
if (oldData !== undefined) return oldData;
|
||||
return await fetchOne<ChannelRow>(FETCH_CHANNEL_BY_ID.bind({channel_id: channelId, soft_deleted: false}));
|
||||
},
|
||||
(current) => ({
|
||||
pk: {channel_id: channelId, soft_deleted: false},
|
||||
patch: buildPatchFromData(data, current, CHANNEL_COLUMNS, ['channel_id', 'soft_deleted']),
|
||||
}),
|
||||
Channels,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
if (data.guild_id) {
|
||||
await fetchOne(
|
||||
ChannelsByGuild.upsertAll({
|
||||
guild_id: data.guild_id,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return new Channel({...data, version: result.finalVersion ?? 0});
|
||||
}
|
||||
|
||||
async updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void> {
|
||||
for (let i = 0; i < DEFAULT_CAS_RETRIES; i++) {
|
||||
const existing = await fetchOne<ChannelRow>(
|
||||
FETCH_CHANNEL_BY_ID.bind({
|
||||
channel_id: channelId,
|
||||
soft_deleted: false,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!existing) return;
|
||||
|
||||
const prev = existing.last_message_id ?? null;
|
||||
if (prev !== null && messageId <= prev) return;
|
||||
|
||||
const q = Channels.patchByPkIf(
|
||||
{channel_id: channelId, soft_deleted: false},
|
||||
{last_message_id: Db.set(messageId)},
|
||||
{col: 'last_message_id', expectedParam: 'prev_last_message_id', expectedValue: prev},
|
||||
);
|
||||
|
||||
const res = await executeConditional(q);
|
||||
if (res.applied) return;
|
||||
}
|
||||
|
||||
Logger.warn(
|
||||
{channelId: channelId.toString(), messageId: messageId.toString()},
|
||||
'Failed to advance Channels.last_message_id after retries',
|
||||
);
|
||||
}
|
||||
|
||||
async delete(channelId: ChannelID, guildId?: GuildID): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
Channels.deleteByPk({
|
||||
channel_id: channelId,
|
||||
soft_deleted: false,
|
||||
}),
|
||||
);
|
||||
if (guildId) {
|
||||
batch.addPrepared(
|
||||
ChannelsByGuild.deleteByPk({
|
||||
guild_id: guildId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async listGuildChannels(guildId: GuildID): Promise<Array<Channel>> {
|
||||
const guildChannels = await fetchMany<{channel_id: bigint}>(
|
||||
FETCH_GUILD_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId}),
|
||||
);
|
||||
if (guildChannels.length === 0) return [];
|
||||
|
||||
const channelIds = guildChannels.map((c) => c.channel_id);
|
||||
|
||||
const channels = await fetchManyInChunks<ChannelRow>(FETCH_CHANNELS_BY_IDS, channelIds, (chunk) => ({
|
||||
channel_ids: chunk,
|
||||
soft_deleted: false,
|
||||
}));
|
||||
|
||||
return channels.map((channel) => new Channel(channel));
|
||||
}
|
||||
|
||||
async countGuildChannels(guildId: GuildID): Promise<number> {
|
||||
const guildChannels = await fetchMany<{channel_id: bigint}>(
|
||||
FETCH_GUILD_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId}),
|
||||
);
|
||||
return guildChannels.length;
|
||||
}
|
||||
}
|
||||
36
fluxer_api/src/channel/repositories/ChannelRepository.ts
Normal file
36
fluxer_api/src/channel/repositories/ChannelRepository.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ChannelDataRepository} from './ChannelDataRepository';
|
||||
import {IChannelRepositoryAggregate} from './IChannelRepositoryAggregate';
|
||||
import {MessageInteractionRepository} from './MessageInteractionRepository';
|
||||
import {MessageRepository} from './MessageRepository';
|
||||
|
||||
export class ChannelRepository extends IChannelRepositoryAggregate {
|
||||
readonly channelData: ChannelDataRepository;
|
||||
readonly messages: MessageRepository;
|
||||
readonly messageInteractions: MessageInteractionRepository;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.channelData = new ChannelDataRepository();
|
||||
this.messages = new MessageRepository(this.channelData);
|
||||
this.messageInteractions = new MessageInteractionRepository(this.messages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, MessageID} from '~/BrandedTypes';
|
||||
import type {ChannelRow} from '~/database/CassandraTypes';
|
||||
import type {Channel} from '~/Models';
|
||||
|
||||
export abstract class IChannelDataRepository {
|
||||
abstract findUnique(channelId: ChannelID): Promise<Channel | null>;
|
||||
abstract upsert(data: ChannelRow): Promise<Channel>;
|
||||
abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void>;
|
||||
abstract delete(channelId: ChannelID, guildId?: GuildID): Promise<void>;
|
||||
abstract listGuildChannels(guildId: GuildID): Promise<Array<Channel>>;
|
||||
abstract countGuildChannels(guildId: GuildID): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IChannelDataRepository} from './IChannelDataRepository';
|
||||
import type {IMessageInteractionRepository} from './IMessageInteractionRepository';
|
||||
import type {IMessageRepository} from './IMessageRepository';
|
||||
|
||||
export abstract class IChannelRepositoryAggregate {
|
||||
abstract readonly channelData: IChannelDataRepository;
|
||||
abstract readonly messages: IMessageRepository;
|
||||
abstract readonly messageInteractions: IMessageInteractionRepository;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, EmojiID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {Message, MessageReaction} from '~/Models';
|
||||
|
||||
export abstract class IMessageInteractionRepository {
|
||||
abstract listChannelPins(channelId: ChannelID, beforePinnedTimestamp: Date, limit?: number): Promise<Array<Message>>;
|
||||
abstract addChannelPin(channelId: ChannelID, messageId: MessageID, pinnedTimestamp: Date): Promise<void>;
|
||||
abstract removeChannelPin(channelId: ChannelID, messageId: MessageID): Promise<void>;
|
||||
|
||||
abstract listMessageReactions(channelId: ChannelID, messageId: MessageID): Promise<Array<MessageReaction>>;
|
||||
abstract listReactionUsers(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
limit?: number,
|
||||
after?: UserID,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<Array<MessageReaction>>;
|
||||
abstract addReaction(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
emojiAnimated?: boolean,
|
||||
): Promise<MessageReaction>;
|
||||
abstract removeReaction(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<void>;
|
||||
abstract removeAllReactions(channelId: ChannelID, messageId: MessageID): Promise<void>;
|
||||
abstract removeAllReactionsForEmoji(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<void>;
|
||||
abstract countReactionUsers(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<number>;
|
||||
abstract countUniqueReactions(channelId: ChannelID, messageId: MessageID): Promise<number>;
|
||||
abstract checkUserReactionExists(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<boolean>;
|
||||
|
||||
abstract setHasReaction(channelId: ChannelID, messageId: MessageID, hasReaction: boolean): Promise<void>;
|
||||
}
|
||||
66
fluxer_api/src/channel/repositories/IMessageRepository.ts
Normal file
66
fluxer_api/src/channel/repositories/IMessageRepository.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AttachmentID, ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {AttachmentLookupRow, MessageRow} from '~/database/CassandraTypes';
|
||||
import type {Message} from '~/Models';
|
||||
|
||||
export interface ListMessagesOptions {
|
||||
restrictToBeforeBucket?: boolean;
|
||||
immediateAfter?: boolean;
|
||||
}
|
||||
|
||||
export abstract class IMessageRepository {
|
||||
abstract listMessages(
|
||||
channelId: ChannelID,
|
||||
beforeMessageId?: MessageID,
|
||||
limit?: number,
|
||||
afterMessageId?: MessageID,
|
||||
options?: ListMessagesOptions,
|
||||
): Promise<Array<Message>>;
|
||||
abstract getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null>;
|
||||
abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message>;
|
||||
abstract deleteMessage(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
authorId: UserID,
|
||||
pinnedTimestamp?: Date,
|
||||
): Promise<void>;
|
||||
abstract bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void>;
|
||||
abstract deleteAllChannelMessages(channelId: ChannelID): Promise<void>;
|
||||
abstract listMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
limit?: number,
|
||||
lastChannelId?: ChannelID,
|
||||
lastMessageId?: MessageID,
|
||||
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>>;
|
||||
abstract deleteMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
channelIds?: Array<ChannelID>,
|
||||
messageIds?: Array<MessageID>,
|
||||
): Promise<void>;
|
||||
abstract anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void>;
|
||||
abstract authorHasMessage(authorId: UserID, channelId: ChannelID, messageId: MessageID): Promise<boolean>;
|
||||
abstract lookupAttachmentByChannelAndFilename(
|
||||
channelId: ChannelID,
|
||||
attachmentId: AttachmentID,
|
||||
filename: string,
|
||||
): Promise<MessageID | null>;
|
||||
abstract listChannelAttachments(channelId: ChannelID): Promise<Array<AttachmentLookupRow>>;
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, EmojiID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {createEmojiID} from '~/BrandedTypes';
|
||||
import {MAX_USERS_PER_MESSAGE_REACTION} from '~/Constants';
|
||||
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {ChannelPinRow, MessageReactionRow} from '~/database/CassandraTypes';
|
||||
import {type Message, MessageReaction} from '~/Models';
|
||||
import {ChannelPins, MessageReactions, Messages} from '~/Tables';
|
||||
import * as BucketUtils from '~/utils/BucketUtils';
|
||||
import {IMessageInteractionRepository} from './IMessageInteractionRepository';
|
||||
import type {MessageRepository} from './MessageRepository';
|
||||
|
||||
const createFetchChannelPinsQuery = (limit: number) =>
|
||||
ChannelPins.selectCql({
|
||||
where: [ChannelPins.where.eq('channel_id'), ChannelPins.where.lt('pinned_timestamp', 'before_pinned_timestamp')],
|
||||
limit,
|
||||
});
|
||||
|
||||
const FETCH_MESSAGE_REACTIONS_BY_CHANNEL_AND_MESSAGE_QUERY = MessageReactions.selectCql({
|
||||
where: [
|
||||
MessageReactions.where.eq('channel_id'),
|
||||
MessageReactions.where.eq('bucket'),
|
||||
MessageReactions.where.eq('message_id'),
|
||||
],
|
||||
});
|
||||
|
||||
const CHECK_MESSAGE_HAS_REACTIONS_QUERY = MessageReactions.selectCql({
|
||||
columns: ['channel_id'],
|
||||
where: [
|
||||
MessageReactions.where.eq('channel_id'),
|
||||
MessageReactions.where.eq('bucket'),
|
||||
MessageReactions.where.eq('message_id'),
|
||||
],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const createFetchReactionUsersByEmojiQuery = (limit: number, hasAfter: boolean = false) =>
|
||||
MessageReactions.selectCql({
|
||||
where: hasAfter
|
||||
? [
|
||||
MessageReactions.where.eq('channel_id'),
|
||||
MessageReactions.where.eq('bucket'),
|
||||
MessageReactions.where.eq('message_id'),
|
||||
MessageReactions.where.eq('emoji_id'),
|
||||
MessageReactions.where.eq('emoji_name'),
|
||||
MessageReactions.where.gt('user_id', 'after_user_id'),
|
||||
]
|
||||
: [
|
||||
MessageReactions.where.eq('channel_id'),
|
||||
MessageReactions.where.eq('bucket'),
|
||||
MessageReactions.where.eq('message_id'),
|
||||
MessageReactions.where.eq('emoji_id'),
|
||||
MessageReactions.where.eq('emoji_name'),
|
||||
],
|
||||
limit,
|
||||
});
|
||||
|
||||
const CHECK_USER_REACTION_EXISTS_QUERY = MessageReactions.selectCql({
|
||||
columns: ['channel_id', 'bucket', 'message_id', 'user_id', 'emoji_id', 'emoji_name'],
|
||||
where: [
|
||||
MessageReactions.where.eq('channel_id'),
|
||||
MessageReactions.where.eq('bucket'),
|
||||
MessageReactions.where.eq('message_id'),
|
||||
MessageReactions.where.eq('user_id'),
|
||||
MessageReactions.where.eq('emoji_id'),
|
||||
MessageReactions.where.eq('emoji_name'),
|
||||
],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class MessageInteractionRepository extends IMessageInteractionRepository {
|
||||
private messageRepository: MessageRepository;
|
||||
|
||||
constructor(messageRepository: MessageRepository) {
|
||||
super();
|
||||
this.messageRepository = messageRepository;
|
||||
}
|
||||
|
||||
async listChannelPins(
|
||||
channelId: ChannelID,
|
||||
beforePinnedTimestamp: Date,
|
||||
limit: number = 50,
|
||||
): Promise<Array<Message>> {
|
||||
const pins = await fetchMany<ChannelPinRow>(createFetchChannelPinsQuery(limit), {
|
||||
channel_id: channelId,
|
||||
before_pinned_timestamp: beforePinnedTimestamp,
|
||||
});
|
||||
|
||||
const messages: Array<Message> = [];
|
||||
for (const pin of pins) {
|
||||
const message = await this.messageRepository.getMessage(channelId, pin.message_id);
|
||||
if (message) {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
async addChannelPin(channelId: ChannelID, messageId: MessageID, pinnedTimestamp: Date): Promise<void> {
|
||||
await upsertOne(
|
||||
ChannelPins.upsertAll({
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
pinned_timestamp: pinnedTimestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async removeChannelPin(channelId: ChannelID, messageId: MessageID): Promise<void> {
|
||||
const message = await this.messageRepository.getMessage(channelId, messageId);
|
||||
|
||||
if (!message || !message.pinnedTimestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteOneOrMany(
|
||||
ChannelPins.deleteByPk({
|
||||
channel_id: channelId,
|
||||
pinned_timestamp: message.pinnedTimestamp,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listMessageReactions(channelId: ChannelID, messageId: MessageID): Promise<Array<MessageReaction>> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
const reactions = await fetchMany<MessageReactionRow>(FETCH_MESSAGE_REACTIONS_BY_CHANNEL_AND_MESSAGE_QUERY, {
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
});
|
||||
return reactions.map((reaction) => new MessageReaction(reaction));
|
||||
}
|
||||
|
||||
async listReactionUsers(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
limit: number = 25,
|
||||
after?: UserID,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<Array<MessageReaction>> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
const normalizedEmojiId = emojiId ?? createEmojiID(0n);
|
||||
const hasAfter = !!after;
|
||||
|
||||
const reactions = hasAfter
|
||||
? await fetchMany<MessageReactionRow>(
|
||||
createFetchReactionUsersByEmojiQuery(Math.min(limit, MAX_USERS_PER_MESSAGE_REACTION), true),
|
||||
{
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
emoji_id: normalizedEmojiId,
|
||||
emoji_name: emojiName,
|
||||
after_user_id: after!,
|
||||
},
|
||||
)
|
||||
: await fetchMany<MessageReactionRow>(
|
||||
createFetchReactionUsersByEmojiQuery(Math.min(limit, MAX_USERS_PER_MESSAGE_REACTION), false),
|
||||
{
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
emoji_id: normalizedEmojiId,
|
||||
emoji_name: emojiName,
|
||||
},
|
||||
);
|
||||
|
||||
return reactions.map((reaction) => new MessageReaction(reaction));
|
||||
}
|
||||
|
||||
async addReaction(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
emojiAnimated: boolean = false,
|
||||
): Promise<MessageReaction> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
const normalizedEmojiId = emojiId ? emojiId : createEmojiID(0n);
|
||||
|
||||
const reactionData: MessageReactionRow = {
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
user_id: userId,
|
||||
emoji_id: normalizedEmojiId,
|
||||
emoji_name: emojiName,
|
||||
emoji_animated: emojiAnimated,
|
||||
};
|
||||
|
||||
await upsertOne(MessageReactions.upsertAll(reactionData));
|
||||
await this.setHasReaction(channelId, messageId, true);
|
||||
return new MessageReaction(reactionData);
|
||||
}
|
||||
|
||||
async removeReaction(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<void> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
const normalizedEmojiId = emojiId ?? createEmojiID(0n);
|
||||
|
||||
await deleteOneOrMany(
|
||||
MessageReactions.deleteByPk({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
user_id: userId,
|
||||
emoji_id: normalizedEmojiId,
|
||||
emoji_name: emojiName,
|
||||
}),
|
||||
);
|
||||
|
||||
const hasReactions = await this.messageHasAnyReactions(channelId, messageId);
|
||||
await this.setHasReaction(channelId, messageId, hasReactions);
|
||||
}
|
||||
|
||||
async removeAllReactions(channelId: ChannelID, messageId: MessageID): Promise<void> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
const deleteQuery = MessageReactions.deleteCql({
|
||||
where: [
|
||||
MessageReactions.where.eq('channel_id'),
|
||||
MessageReactions.where.eq('bucket'),
|
||||
MessageReactions.where.eq('message_id'),
|
||||
],
|
||||
});
|
||||
await deleteOneOrMany(deleteQuery, {
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
});
|
||||
|
||||
const hasReactions = await this.messageHasAnyReactions(channelId, messageId);
|
||||
await this.setHasReaction(channelId, messageId, hasReactions);
|
||||
}
|
||||
|
||||
async removeAllReactionsForEmoji(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<void> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
const normalizedEmojiId = emojiId ?? createEmojiID(0n);
|
||||
|
||||
const deleteQuery = MessageReactions.deleteCql({
|
||||
where: [
|
||||
MessageReactions.where.eq('channel_id'),
|
||||
MessageReactions.where.eq('bucket'),
|
||||
MessageReactions.where.eq('message_id'),
|
||||
MessageReactions.where.eq('emoji_id'),
|
||||
MessageReactions.where.eq('emoji_name'),
|
||||
],
|
||||
});
|
||||
await deleteOneOrMany(deleteQuery, {
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
emoji_id: normalizedEmojiId,
|
||||
emoji_name: emojiName,
|
||||
});
|
||||
|
||||
const hasReactions = await this.messageHasAnyReactions(channelId, messageId);
|
||||
await this.setHasReaction(channelId, messageId, hasReactions);
|
||||
}
|
||||
|
||||
async countReactionUsers(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<number> {
|
||||
const reactions = await this.listReactionUsers(channelId, messageId, emojiName, undefined, undefined, emojiId);
|
||||
return reactions.length;
|
||||
}
|
||||
|
||||
async countUniqueReactions(channelId: ChannelID, messageId: MessageID): Promise<number> {
|
||||
const reactions = await this.listMessageReactions(channelId, messageId);
|
||||
const uniqueEmojis = new Set<string>();
|
||||
|
||||
for (const reaction of reactions) {
|
||||
const emojiKey = `${reaction.emojiId}:${reaction.emojiName}`;
|
||||
uniqueEmojis.add(emojiKey);
|
||||
}
|
||||
|
||||
return uniqueEmojis.size;
|
||||
}
|
||||
|
||||
async checkUserReactionExists(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<boolean> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
const normalizedEmojiId = emojiId ?? createEmojiID(0n);
|
||||
|
||||
const reaction = await fetchOne<MessageReactionRow>(CHECK_USER_REACTION_EXISTS_QUERY, {
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
user_id: userId,
|
||||
emoji_id: normalizedEmojiId,
|
||||
emoji_name: emojiName,
|
||||
});
|
||||
|
||||
return !!reaction;
|
||||
}
|
||||
|
||||
async setHasReaction(channelId: ChannelID, messageId: MessageID, hasReaction: boolean): Promise<void> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
|
||||
await upsertOne(
|
||||
Messages.patchByPk(
|
||||
{
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
},
|
||||
{
|
||||
has_reaction: Db.set(hasReaction),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async messageHasAnyReactions(channelId: ChannelID, messageId: MessageID): Promise<boolean> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
const row = await fetchOne<Pick<MessageReactionRow, 'channel_id'>>(CHECK_MESSAGE_HAS_REACTIONS_QUERY, {
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
});
|
||||
return Boolean(row);
|
||||
}
|
||||
}
|
||||
121
fluxer_api/src/channel/repositories/MessageRepository.ts
Normal file
121
fluxer_api/src/channel/repositories/MessageRepository.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AttachmentID, ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {AttachmentLookupRow, MessageRow} from '~/database/CassandraTypes';
|
||||
import type {Message} from '~/Models';
|
||||
import type {ChannelDataRepository} from './ChannelDataRepository';
|
||||
import {IMessageRepository, type ListMessagesOptions} from './IMessageRepository';
|
||||
import {MessageAttachmentRepository} from './message/MessageAttachmentRepository';
|
||||
import {MessageAuthorRepository} from './message/MessageAuthorRepository';
|
||||
import {MessageDataRepository} from './message/MessageDataRepository';
|
||||
import {MessageDeletionRepository} from './message/MessageDeletionRepository';
|
||||
|
||||
export class MessageRepository extends IMessageRepository {
|
||||
private dataRepo: MessageDataRepository;
|
||||
private deletionRepo: MessageDeletionRepository;
|
||||
private attachmentRepo: MessageAttachmentRepository;
|
||||
private authorRepo: MessageAuthorRepository;
|
||||
private channelDataRepo: ChannelDataRepository;
|
||||
|
||||
constructor(channelDataRepo: ChannelDataRepository) {
|
||||
super();
|
||||
this.dataRepo = new MessageDataRepository();
|
||||
this.deletionRepo = new MessageDeletionRepository(this.dataRepo);
|
||||
this.attachmentRepo = new MessageAttachmentRepository();
|
||||
this.authorRepo = new MessageAuthorRepository(this.dataRepo, this.deletionRepo);
|
||||
this.channelDataRepo = channelDataRepo;
|
||||
}
|
||||
|
||||
async listMessages(
|
||||
channelId: ChannelID,
|
||||
beforeMessageId?: MessageID,
|
||||
limit?: number,
|
||||
afterMessageId?: MessageID,
|
||||
options?: ListMessagesOptions,
|
||||
): Promise<Array<Message>> {
|
||||
return this.dataRepo.listMessages(channelId, beforeMessageId, limit, afterMessageId, options);
|
||||
}
|
||||
|
||||
async getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null> {
|
||||
return this.dataRepo.getMessage(channelId, messageId);
|
||||
}
|
||||
|
||||
async upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message> {
|
||||
const message = await this.dataRepo.upsertMessage(data, oldData);
|
||||
if (!oldData) {
|
||||
void this.channelDataRepo.updateLastMessageId(data.channel_id, data.message_id);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
async deleteMessage(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
authorId: UserID,
|
||||
pinnedTimestamp?: Date,
|
||||
): Promise<void> {
|
||||
return this.deletionRepo.deleteMessage(channelId, messageId, authorId, pinnedTimestamp);
|
||||
}
|
||||
|
||||
async bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void> {
|
||||
return this.deletionRepo.bulkDeleteMessages(channelId, messageIds);
|
||||
}
|
||||
|
||||
async deleteAllChannelMessages(channelId: ChannelID): Promise<void> {
|
||||
return this.deletionRepo.deleteAllChannelMessages(channelId);
|
||||
}
|
||||
|
||||
async listMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
limit?: number,
|
||||
lastChannelId?: ChannelID,
|
||||
lastMessageId?: MessageID,
|
||||
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>> {
|
||||
return this.authorRepo.listMessagesByAuthor(authorId, limit, lastChannelId, lastMessageId);
|
||||
}
|
||||
|
||||
async deleteMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
channelIds?: Array<ChannelID>,
|
||||
messageIds?: Array<MessageID>,
|
||||
): Promise<void> {
|
||||
return this.authorRepo.deleteMessagesByAuthor(authorId, channelIds, messageIds);
|
||||
}
|
||||
|
||||
async anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void> {
|
||||
return this.authorRepo.anonymizeMessage(channelId, messageId, newAuthorId);
|
||||
}
|
||||
|
||||
async authorHasMessage(authorId: UserID, channelId: ChannelID, messageId: MessageID): Promise<boolean> {
|
||||
return this.authorRepo.hasMessageByAuthor(authorId, channelId, messageId);
|
||||
}
|
||||
|
||||
async lookupAttachmentByChannelAndFilename(
|
||||
channelId: ChannelID,
|
||||
attachmentId: AttachmentID,
|
||||
filename: string,
|
||||
): Promise<MessageID | null> {
|
||||
return this.attachmentRepo.lookupAttachmentByChannelAndFilename(channelId, attachmentId, filename);
|
||||
}
|
||||
|
||||
async listChannelAttachments(channelId: ChannelID): Promise<Array<AttachmentLookupRow>> {
|
||||
return this.attachmentRepo.listChannelAttachments(channelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
import {
|
||||
BucketScanDirection,
|
||||
type BucketScanTraceEvent,
|
||||
BucketScanTraceKind,
|
||||
scanBucketsWithIndex,
|
||||
} from './BucketScanEngine';
|
||||
|
||||
interface FakeRow {
|
||||
id: bigint;
|
||||
}
|
||||
|
||||
function makeIndexBuckets(
|
||||
allBuckets: Array<number>,
|
||||
direction: BucketScanDirection,
|
||||
): (query: {minBucket: number; maxBucket: number; limit: number}) => Promise<Array<number>> {
|
||||
return async (query) => {
|
||||
const filtered = allBuckets.filter((b) => b >= query.minBucket && b <= query.maxBucket);
|
||||
filtered.sort((a, b) => (direction === BucketScanDirection.Desc ? b - a : a - b));
|
||||
return filtered.slice(0, query.limit);
|
||||
};
|
||||
}
|
||||
|
||||
describe('scanBucketsWithIndex', () => {
|
||||
it('scans buckets in DESC order and stops when limit is satisfied', async () => {
|
||||
const listBucketsFromIndex = vi.fn(makeIndexBuckets([5, 4, 3, 2, 1], BucketScanDirection.Desc));
|
||||
const fetchRowsForBucket = vi.fn(async (bucket: number) => ({rows: [{id: BigInt(bucket)}], unbounded: true}));
|
||||
|
||||
const trace: Array<BucketScanTraceEvent> = [];
|
||||
const result = await scanBucketsWithIndex<FakeRow>(
|
||||
{
|
||||
listBucketsFromIndex,
|
||||
fetchRowsForBucket,
|
||||
getRowId: (row) => row.id,
|
||||
trace: (event) => trace.push(event),
|
||||
},
|
||||
{
|
||||
minBucket: 1,
|
||||
maxBucket: 5,
|
||||
limit: 3,
|
||||
direction: BucketScanDirection.Desc,
|
||||
indexPageSize: 200,
|
||||
},
|
||||
);
|
||||
|
||||
expect(listBucketsFromIndex).toHaveBeenCalledTimes(1);
|
||||
expect(fetchRowsForBucket.mock.calls.map((c) => c[0])).toEqual([5, 4, 3]);
|
||||
expect(result.rows.map((r) => r.id)).toEqual([5n, 4n, 3n]);
|
||||
|
||||
const processed = trace.filter((e) => e.kind === BucketScanTraceKind.ProcessBucket).map((e) => e.bucket);
|
||||
expect(processed).toEqual([5, 4, 3]);
|
||||
});
|
||||
|
||||
it('falls back to the numeric scan when the index is missing buckets', async () => {
|
||||
const listBucketsFromIndex = vi.fn(makeIndexBuckets([5, 3, 1], BucketScanDirection.Desc));
|
||||
const fetchRowsForBucket = vi.fn(async (bucket: number) => ({rows: [{id: BigInt(bucket)}], unbounded: true}));
|
||||
|
||||
const trace: Array<BucketScanTraceEvent> = [];
|
||||
const result = await scanBucketsWithIndex<FakeRow>(
|
||||
{
|
||||
listBucketsFromIndex,
|
||||
fetchRowsForBucket,
|
||||
getRowId: (row) => row.id,
|
||||
trace: (event) => trace.push(event),
|
||||
},
|
||||
{
|
||||
minBucket: 1,
|
||||
maxBucket: 5,
|
||||
limit: 5,
|
||||
direction: BucketScanDirection.Desc,
|
||||
indexPageSize: 200,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.rows.map((r) => r.id)).toEqual([5n, 3n, 1n, 4n, 2n]);
|
||||
|
||||
const processed = trace.filter((e) => e.kind === BucketScanTraceKind.ProcessBucket).map((e) => e.bucket);
|
||||
expect(processed).toEqual([5, 3, 1, 4, 2]);
|
||||
});
|
||||
|
||||
it('honors stopAfterBucket in DESC scans', async () => {
|
||||
const listBucketsFromIndex = vi.fn(makeIndexBuckets([5, 4, 3, 2, 1], BucketScanDirection.Desc));
|
||||
const fetchRowsForBucket = vi.fn(async (bucket: number) => ({rows: [{id: BigInt(bucket)}], unbounded: true}));
|
||||
|
||||
const trace: Array<BucketScanTraceEvent> = [];
|
||||
const result = await scanBucketsWithIndex<FakeRow>(
|
||||
{
|
||||
listBucketsFromIndex,
|
||||
fetchRowsForBucket,
|
||||
getRowId: (row) => row.id,
|
||||
trace: (event) => trace.push(event),
|
||||
},
|
||||
{
|
||||
minBucket: 1,
|
||||
maxBucket: 5,
|
||||
limit: 100,
|
||||
direction: BucketScanDirection.Desc,
|
||||
indexPageSize: 200,
|
||||
stopAfterBucket: 4,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.rows.map((r) => r.id)).toEqual([5n, 4n]);
|
||||
|
||||
const processed = trace.filter((e) => e.kind === BucketScanTraceKind.ProcessBucket).map((e) => e.bucket);
|
||||
expect(processed).toEqual([5, 4]);
|
||||
});
|
||||
|
||||
it('scans buckets in ASC order and returns closest rows first', async () => {
|
||||
const listBucketsFromIndex = vi.fn(makeIndexBuckets([400, 401], BucketScanDirection.Asc));
|
||||
const fetchRowsForBucket = vi.fn(async (bucket: number, limit: number) => {
|
||||
const rowsByBucket = new Map<number, Array<FakeRow>>([
|
||||
[400, [{id: 51n}, {id: 52n}, {id: 53n}]],
|
||||
[401, [{id: 100n}, {id: 101n}]],
|
||||
]);
|
||||
return {rows: (rowsByBucket.get(bucket) ?? []).slice(0, limit), unbounded: true};
|
||||
});
|
||||
|
||||
const result = await scanBucketsWithIndex<FakeRow>(
|
||||
{
|
||||
listBucketsFromIndex,
|
||||
fetchRowsForBucket,
|
||||
getRowId: (row) => row.id,
|
||||
},
|
||||
{
|
||||
minBucket: 400,
|
||||
maxBucket: 401,
|
||||
limit: 4,
|
||||
direction: BucketScanDirection.Asc,
|
||||
indexPageSize: 200,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.rows.map((r) => r.id)).toEqual([51n, 52n, 53n, 100n]);
|
||||
expect(fetchRowsForBucket.mock.calls.map((c) => c[0])).toEqual([400, 401]);
|
||||
});
|
||||
|
||||
it('deduplicates rows using getRowId', async () => {
|
||||
const listBucketsFromIndex = vi.fn(makeIndexBuckets([5, 4], BucketScanDirection.Desc));
|
||||
const fetchRowsForBucket = vi.fn(async (bucket: number) => {
|
||||
const rowsByBucket = new Map<number, Array<FakeRow>>([
|
||||
[5, [{id: 1n}, {id: 2n}]],
|
||||
[4, [{id: 2n}, {id: 3n}]],
|
||||
]);
|
||||
return {rows: rowsByBucket.get(bucket) ?? [], unbounded: true};
|
||||
});
|
||||
|
||||
const result = await scanBucketsWithIndex<FakeRow>(
|
||||
{
|
||||
listBucketsFromIndex,
|
||||
fetchRowsForBucket,
|
||||
getRowId: (row) => row.id,
|
||||
},
|
||||
{
|
||||
minBucket: 4,
|
||||
maxBucket: 5,
|
||||
limit: 10,
|
||||
direction: BucketScanDirection.Desc,
|
||||
indexPageSize: 200,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.rows.map((r) => r.id)).toEqual([1n, 2n, 3n]);
|
||||
});
|
||||
|
||||
it('marks empty buckets only when the query was unbounded', async () => {
|
||||
const listBucketsFromIndex = vi.fn(makeIndexBuckets([3, 2, 1], BucketScanDirection.Desc));
|
||||
|
||||
const emptied: Array<number> = [];
|
||||
const fetchRowsForBucket = vi.fn(async (bucket: number) => {
|
||||
if (bucket === 3) return {rows: [], unbounded: false};
|
||||
if (bucket === 2) return {rows: [], unbounded: true};
|
||||
return {rows: [{id: 1n}], unbounded: true};
|
||||
});
|
||||
|
||||
await scanBucketsWithIndex<FakeRow>(
|
||||
{
|
||||
listBucketsFromIndex,
|
||||
fetchRowsForBucket,
|
||||
getRowId: (row) => row.id,
|
||||
onEmptyUnboundedBucket: async (bucket) => {
|
||||
emptied.push(bucket);
|
||||
},
|
||||
},
|
||||
{
|
||||
minBucket: 1,
|
||||
maxBucket: 3,
|
||||
limit: 1,
|
||||
direction: BucketScanDirection.Desc,
|
||||
indexPageSize: 200,
|
||||
},
|
||||
);
|
||||
|
||||
expect(emptied).toEqual([2]);
|
||||
});
|
||||
});
|
||||
358
fluxer_api/src/channel/repositories/message/BucketScanEngine.ts
Normal file
358
fluxer_api/src/channel/repositories/message/BucketScanEngine.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export enum BucketScanDirection {
|
||||
Asc = 'asc',
|
||||
Desc = 'desc',
|
||||
}
|
||||
|
||||
export enum BucketScanTraceKind {
|
||||
Start = 'start',
|
||||
ListBucketsFromIndex = 'listBucketsFromIndex',
|
||||
ProcessBucket = 'processBucket',
|
||||
FetchBucket = 'fetchBucket',
|
||||
MarkBucketEmpty = 'markBucketEmpty',
|
||||
TouchBucket = 'touchBucket',
|
||||
StopAfterBucketReached = 'stopAfterBucketReached',
|
||||
Complete = 'complete',
|
||||
}
|
||||
|
||||
export interface BucketScanTraceEvent {
|
||||
kind: BucketScanTraceKind;
|
||||
minBucket: number;
|
||||
maxBucket: number;
|
||||
limit: number;
|
||||
direction: BucketScanDirection;
|
||||
bucket?: number;
|
||||
remaining?: number;
|
||||
indexQuery?: {minBucket: number; maxBucket: number; limit: number};
|
||||
indexResult?: Array<number>;
|
||||
fetchResult?: {rowCount: number; unbounded: boolean};
|
||||
}
|
||||
|
||||
export interface BucketScanIndexQuery {
|
||||
minBucket: number;
|
||||
maxBucket: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface BucketScanBucketFetchResult<Row> {
|
||||
rows: Array<Row>;
|
||||
unbounded: boolean;
|
||||
}
|
||||
|
||||
export interface BucketScanDeps<Row> {
|
||||
listBucketsFromIndex: (query: BucketScanIndexQuery) => Promise<Array<number>>;
|
||||
fetchRowsForBucket: (bucket: number, limit: number) => Promise<BucketScanBucketFetchResult<Row>>;
|
||||
getRowId: (row: Row) => bigint;
|
||||
onEmptyUnboundedBucket?: (bucket: number) => Promise<void>;
|
||||
onBucketHasRows?: (bucket: number) => Promise<void>;
|
||||
trace?: (event: BucketScanTraceEvent) => void;
|
||||
}
|
||||
|
||||
export interface BucketScanOptions {
|
||||
minBucket: number;
|
||||
maxBucket: number;
|
||||
limit: number;
|
||||
direction: BucketScanDirection;
|
||||
indexPageSize: number;
|
||||
stopAfterBucket?: number;
|
||||
}
|
||||
|
||||
export interface BucketScanResult<Row> {
|
||||
rows: Array<Row>;
|
||||
}
|
||||
|
||||
export async function scanBucketsWithIndex<Row>(
|
||||
deps: BucketScanDeps<Row>,
|
||||
opts: BucketScanOptions,
|
||||
): Promise<BucketScanResult<Row>> {
|
||||
const trace = deps.trace;
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.Start,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
});
|
||||
|
||||
if (opts.limit <= 0) {
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.Complete,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
});
|
||||
return {rows: []};
|
||||
}
|
||||
|
||||
let remaining = opts.limit;
|
||||
const out: Array<Row> = [];
|
||||
const seenRowIds = new Set<bigint>();
|
||||
const processedBuckets = new Set<number>();
|
||||
|
||||
const processBucket = async (bucket: number) => {
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.ProcessBucket,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
bucket,
|
||||
remaining,
|
||||
});
|
||||
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.FetchBucket,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
bucket,
|
||||
remaining,
|
||||
});
|
||||
|
||||
const {rows, unbounded} = await deps.fetchRowsForBucket(bucket, remaining);
|
||||
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.FetchBucket,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
bucket,
|
||||
remaining,
|
||||
fetchResult: {rowCount: rows.length, unbounded},
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
if (unbounded && deps.onEmptyUnboundedBucket) {
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.MarkBucketEmpty,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
bucket,
|
||||
remaining,
|
||||
});
|
||||
await deps.onEmptyUnboundedBucket(bucket);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (deps.onBucketHasRows) {
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.TouchBucket,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
bucket,
|
||||
remaining,
|
||||
});
|
||||
await deps.onBucketHasRows(bucket);
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
if (remaining <= 0) break;
|
||||
const rowId = deps.getRowId(row);
|
||||
if (seenRowIds.has(rowId)) continue;
|
||||
seenRowIds.add(rowId);
|
||||
out.push(row);
|
||||
remaining--;
|
||||
}
|
||||
};
|
||||
|
||||
const stopAfterBucket = typeof opts.stopAfterBucket === 'number' ? opts.stopAfterBucket : null;
|
||||
const shouldStopAfterBucket = (bucket: number) => stopAfterBucket !== null && bucket === stopAfterBucket;
|
||||
|
||||
if (opts.direction === BucketScanDirection.Desc) {
|
||||
let cursorMax: number | null = opts.maxBucket;
|
||||
|
||||
while (remaining > 0 && cursorMax !== null && cursorMax >= opts.minBucket) {
|
||||
const query: BucketScanIndexQuery = {
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: cursorMax,
|
||||
limit: opts.indexPageSize,
|
||||
};
|
||||
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.ListBucketsFromIndex,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
indexQuery: query,
|
||||
});
|
||||
|
||||
const buckets = await deps.listBucketsFromIndex(query);
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.ListBucketsFromIndex,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
indexQuery: query,
|
||||
indexResult: buckets,
|
||||
});
|
||||
|
||||
if (buckets.length === 0) break;
|
||||
|
||||
for (const bucket of buckets) {
|
||||
if (remaining <= 0) break;
|
||||
if (bucket < opts.minBucket || bucket > opts.maxBucket) continue;
|
||||
if (processedBuckets.has(bucket)) continue;
|
||||
processedBuckets.add(bucket);
|
||||
|
||||
await processBucket(bucket);
|
||||
|
||||
if (shouldStopAfterBucket(bucket)) {
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.StopAfterBucketReached,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
bucket,
|
||||
remaining,
|
||||
});
|
||||
return {rows: out};
|
||||
}
|
||||
}
|
||||
|
||||
const last = buckets[buckets.length - 1];
|
||||
const nextCursor = last - 1;
|
||||
cursorMax = nextCursor >= opts.minBucket ? nextCursor : null;
|
||||
}
|
||||
|
||||
if (remaining > 0) {
|
||||
for (let bucket = opts.maxBucket; remaining > 0 && bucket >= opts.minBucket; bucket--) {
|
||||
if (processedBuckets.has(bucket)) continue;
|
||||
processedBuckets.add(bucket);
|
||||
|
||||
await processBucket(bucket);
|
||||
|
||||
if (shouldStopAfterBucket(bucket)) {
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.StopAfterBucketReached,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
bucket,
|
||||
remaining,
|
||||
});
|
||||
return {rows: out};
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let cursorMin: number | null = opts.minBucket;
|
||||
|
||||
while (remaining > 0 && cursorMin !== null && cursorMin <= opts.maxBucket) {
|
||||
const query: BucketScanIndexQuery = {
|
||||
minBucket: cursorMin,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.indexPageSize,
|
||||
};
|
||||
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.ListBucketsFromIndex,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
indexQuery: query,
|
||||
});
|
||||
|
||||
const buckets = await deps.listBucketsFromIndex(query);
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.ListBucketsFromIndex,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
indexQuery: query,
|
||||
indexResult: buckets,
|
||||
});
|
||||
|
||||
if (buckets.length === 0) break;
|
||||
|
||||
for (const bucket of buckets) {
|
||||
if (remaining <= 0) break;
|
||||
if (bucket < opts.minBucket || bucket > opts.maxBucket) continue;
|
||||
if (processedBuckets.has(bucket)) continue;
|
||||
processedBuckets.add(bucket);
|
||||
|
||||
await processBucket(bucket);
|
||||
|
||||
if (shouldStopAfterBucket(bucket)) {
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.StopAfterBucketReached,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
bucket,
|
||||
remaining,
|
||||
});
|
||||
return {rows: out};
|
||||
}
|
||||
}
|
||||
|
||||
const last = buckets[buckets.length - 1];
|
||||
const nextCursor = last + 1;
|
||||
cursorMin = nextCursor <= opts.maxBucket ? nextCursor : null;
|
||||
}
|
||||
|
||||
if (remaining > 0) {
|
||||
for (let bucket = opts.minBucket; remaining > 0 && bucket <= opts.maxBucket; bucket++) {
|
||||
if (processedBuckets.has(bucket)) continue;
|
||||
processedBuckets.add(bucket);
|
||||
|
||||
await processBucket(bucket);
|
||||
|
||||
if (shouldStopAfterBucket(bucket)) {
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.StopAfterBucketReached,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
bucket,
|
||||
remaining,
|
||||
});
|
||||
return {rows: out};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trace?.({
|
||||
kind: BucketScanTraceKind.Complete,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
limit: opts.limit,
|
||||
direction: opts.direction,
|
||||
});
|
||||
|
||||
return {rows: out};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AttachmentID, ChannelID, MessageID} from '~/BrandedTypes';
|
||||
import {fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {AttachmentLookupRow} from '~/database/CassandraTypes';
|
||||
import {AttachmentLookup} from '~/Tables';
|
||||
|
||||
const LOOKUP_ATTACHMENT_BY_CHANNEL_AND_FILENAME_QUERY = AttachmentLookup.selectCql({
|
||||
where: [
|
||||
AttachmentLookup.where.eq('channel_id'),
|
||||
AttachmentLookup.where.eq('attachment_id'),
|
||||
AttachmentLookup.where.eq('filename'),
|
||||
],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const LIST_CHANNEL_ATTACHMENTS_QUERY = AttachmentLookup.selectCql({
|
||||
where: AttachmentLookup.where.eq('channel_id'),
|
||||
});
|
||||
|
||||
export class MessageAttachmentRepository {
|
||||
async lookupAttachmentByChannelAndFilename(
|
||||
channelId: ChannelID,
|
||||
attachmentId: AttachmentID,
|
||||
filename: string,
|
||||
): Promise<MessageID | null> {
|
||||
const result = await fetchOne<AttachmentLookupRow>(LOOKUP_ATTACHMENT_BY_CHANNEL_AND_FILENAME_QUERY, {
|
||||
channel_id: channelId,
|
||||
attachment_id: attachmentId,
|
||||
filename,
|
||||
});
|
||||
return result ? result.message_id : null;
|
||||
}
|
||||
|
||||
async listChannelAttachments(channelId: ChannelID): Promise<Array<AttachmentLookupRow>> {
|
||||
const results = await fetchMany<AttachmentLookupRow>(LIST_CHANNEL_ATTACHMENTS_QUERY, {
|
||||
channel_id: channelId,
|
||||
});
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '~/BrandedTypes';
|
||||
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import {Messages, MessagesByAuthor} from '~/Tables';
|
||||
import * as BucketUtils from '~/utils/BucketUtils';
|
||||
import type {MessageDataRepository} from './MessageDataRepository';
|
||||
import type {MessageDeletionRepository} from './MessageDeletionRepository';
|
||||
|
||||
const SELECT_MESSAGE_BY_AUTHOR = MessagesByAuthor.select({
|
||||
where: [
|
||||
MessagesByAuthor.where.eq('author_id'),
|
||||
MessagesByAuthor.where.eq('channel_id'),
|
||||
MessagesByAuthor.where.eq('message_id'),
|
||||
],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
function listMessagesByAuthorQuery(limit: number, usePagination: boolean) {
|
||||
return MessagesByAuthor.select({
|
||||
columns: ['channel_id', 'message_id'],
|
||||
where: usePagination
|
||||
? [
|
||||
MessagesByAuthor.where.eq('author_id'),
|
||||
MessagesByAuthor.where.tupleGt(['channel_id', 'message_id'], ['last_channel_id', 'last_message_id']),
|
||||
]
|
||||
: [MessagesByAuthor.where.eq('author_id')],
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export class MessageAuthorRepository {
|
||||
constructor(
|
||||
private messageDataRepo: MessageDataRepository,
|
||||
private messageDeletionRepo: MessageDeletionRepository,
|
||||
) {}
|
||||
|
||||
async listMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
limit: number = 1000,
|
||||
lastChannelId?: ChannelID,
|
||||
lastMessageId?: MessageID,
|
||||
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>> {
|
||||
const usePagination = Boolean(lastChannelId && lastMessageId);
|
||||
|
||||
const q = listMessagesByAuthorQuery(limit, usePagination);
|
||||
|
||||
const results = await fetchMany<{channel_id: bigint; message_id: bigint}>(
|
||||
usePagination
|
||||
? q.bind({
|
||||
author_id: authorId,
|
||||
last_channel_id: lastChannelId!,
|
||||
last_message_id: lastMessageId!,
|
||||
})
|
||||
: q.bind({
|
||||
author_id: authorId,
|
||||
}),
|
||||
);
|
||||
|
||||
let filteredResults = results;
|
||||
if (lastChannelId && lastMessageId) {
|
||||
filteredResults = results.filter((r) => {
|
||||
const channelId = createChannelID(r.channel_id);
|
||||
const messageId = createMessageID(r.message_id);
|
||||
return channelId > lastChannelId || (channelId === lastChannelId && messageId > lastMessageId);
|
||||
});
|
||||
}
|
||||
|
||||
return filteredResults.map((r) => ({
|
||||
channelId: createChannelID(r.channel_id),
|
||||
messageId: createMessageID(r.message_id),
|
||||
}));
|
||||
}
|
||||
|
||||
async deleteMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
channelIds?: Array<ChannelID>,
|
||||
messageIds?: Array<MessageID>,
|
||||
): Promise<void> {
|
||||
const messagesToDelete = await this.listMessagesByAuthor(authorId);
|
||||
|
||||
for (const {channelId, messageId} of messagesToDelete) {
|
||||
if (channelIds && !channelIds.includes(channelId)) continue;
|
||||
if (messageIds && !messageIds.includes(messageId)) continue;
|
||||
|
||||
const message = await this.messageDataRepo.getMessage(channelId, messageId);
|
||||
if (message && message.authorId === authorId) {
|
||||
await this.messageDeletionRepo.deleteMessage(
|
||||
channelId,
|
||||
messageId,
|
||||
authorId,
|
||||
message.pinnedTimestamp || undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async hasMessageByAuthor(authorId: UserID, channelId: ChannelID, messageId: MessageID): Promise<boolean> {
|
||||
const result = await fetchOne<{channel_id: bigint; message_id: bigint}>(
|
||||
SELECT_MESSAGE_BY_AUTHOR.bind({
|
||||
author_id: authorId,
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
|
||||
const message = await this.messageDataRepo.getMessage(channelId, messageId);
|
||||
if (!message) return;
|
||||
|
||||
if (message.authorId) {
|
||||
await deleteOneOrMany(
|
||||
MessagesByAuthor.deleteByPk({
|
||||
author_id: message.authorId,
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await upsertOne(
|
||||
MessagesByAuthor.upsertAll({
|
||||
author_id: newAuthorId,
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
|
||||
await upsertOne(
|
||||
Messages.patchByPk(
|
||||
{
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
},
|
||||
{
|
||||
author_id: Db.set(newAuthorId),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID} from '~/BrandedTypes';
|
||||
import {
|
||||
BatchBuilder,
|
||||
buildPatchFromData,
|
||||
Db,
|
||||
deleteOneOrMany,
|
||||
executeConditional,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '~/database/Cassandra';
|
||||
import type {ChannelMessageBucketRow, ChannelStateRow, MessageRow} from '~/database/CassandraTypes';
|
||||
import {MESSAGE_COLUMNS} from '~/database/CassandraTypes';
|
||||
import {Logger} from '~/Logger';
|
||||
import {Message} from '~/Models';
|
||||
import {
|
||||
AttachmentLookup,
|
||||
ChannelEmptyBuckets,
|
||||
ChannelMessageBuckets,
|
||||
ChannelPins,
|
||||
ChannelState,
|
||||
Messages,
|
||||
MessagesByAuthor,
|
||||
} from '~/Tables';
|
||||
import * as BucketUtils from '~/utils/BucketUtils';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
import type {ListMessagesOptions} from '../IMessageRepository';
|
||||
import {BucketScanDirection, scanBucketsWithIndex} from './BucketScanEngine';
|
||||
|
||||
const logger = Logger.child({module: 'MessageDataRepository'});
|
||||
|
||||
const DEFAULT_MESSAGE_LIMIT = 50;
|
||||
const DEFAULT_BUCKET_INDEX_PAGE_SIZE = 200;
|
||||
const DEFAULT_CAS_RETRIES = 8;
|
||||
const LEGACY_BUCKETS_TO_CHECK = [0];
|
||||
|
||||
const FETCH_MESSAGE_BY_CHANNEL_BUCKET_AND_MESSAGE_ID = Messages.select({
|
||||
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket'), Messages.where.eq('message_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_CHANNEL_STATE = ChannelState.select({
|
||||
where: ChannelState.where.eq('channel_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class MessageDataRepository {
|
||||
async listMessages(
|
||||
channelId: ChannelID,
|
||||
beforeMessageId?: MessageID,
|
||||
limit: number = DEFAULT_MESSAGE_LIMIT,
|
||||
afterMessageId?: MessageID,
|
||||
options?: ListMessagesOptions,
|
||||
): Promise<Array<Message>> {
|
||||
if (limit <= 0) return [];
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
before: beforeMessageId?.toString() ?? null,
|
||||
after: afterMessageId?.toString() ?? null,
|
||||
limit,
|
||||
},
|
||||
'listMessages start',
|
||||
);
|
||||
|
||||
if (beforeMessageId && afterMessageId) {
|
||||
return this.listMessagesBetween(channelId, afterMessageId, beforeMessageId, limit, options);
|
||||
}
|
||||
if (beforeMessageId) {
|
||||
return this.listMessagesBefore(channelId, beforeMessageId, limit, options);
|
||||
}
|
||||
if (afterMessageId) {
|
||||
return this.listMessagesAfter(channelId, afterMessageId, limit, options);
|
||||
}
|
||||
return this.listMessagesLatest(channelId, limit);
|
||||
}
|
||||
|
||||
private makeFetchMessagesBefore(limit: number) {
|
||||
return Messages.select({
|
||||
where: [
|
||||
Messages.where.eq('channel_id'),
|
||||
Messages.where.eq('bucket'),
|
||||
Messages.where.lt('message_id', 'before_message_id'),
|
||||
],
|
||||
orderBy: {col: 'message_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private makeFetchMessagesAfterDesc(limit: number) {
|
||||
return Messages.select({
|
||||
where: [
|
||||
Messages.where.eq('channel_id'),
|
||||
Messages.where.eq('bucket'),
|
||||
Messages.where.gt('message_id', 'after_message_id'),
|
||||
],
|
||||
orderBy: {col: 'message_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private makeFetchMessagesBetween(limit: number) {
|
||||
return Messages.select({
|
||||
where: [
|
||||
Messages.where.eq('channel_id'),
|
||||
Messages.where.eq('bucket'),
|
||||
Messages.where.gt('message_id', 'after_message_id'),
|
||||
Messages.where.lt('message_id', 'before_message_id'),
|
||||
],
|
||||
orderBy: {col: 'message_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private makeFetchMessagesLatestDesc(limit: number) {
|
||||
return Messages.select({
|
||||
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket')],
|
||||
orderBy: {col: 'message_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private makeFetchMessagesAfterAsc(limit: number) {
|
||||
return Messages.select({
|
||||
where: [
|
||||
Messages.where.eq('channel_id'),
|
||||
Messages.where.eq('bucket'),
|
||||
Messages.where.gt('message_id', 'after_message_id'),
|
||||
],
|
||||
orderBy: {col: 'message_id', direction: 'ASC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private makeFetchMessagesOldestAsc(limit: number) {
|
||||
return Messages.select({
|
||||
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket')],
|
||||
orderBy: {col: 'message_id', direction: 'ASC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private async listMessagesLatest(channelId: ChannelID, limit: number): Promise<Array<Message>> {
|
||||
const state = await this.getChannelState(channelId);
|
||||
|
||||
const nowId = SnowflakeUtils.getSnowflake();
|
||||
const maxBucket = BucketUtils.makeBucket(nowId);
|
||||
const minBucket = state?.created_bucket ?? BucketUtils.makeBucket(channelId);
|
||||
|
||||
return this.scanBucketsDescForMessages(channelId, {
|
||||
limit,
|
||||
minBucket,
|
||||
maxBucket,
|
||||
});
|
||||
}
|
||||
|
||||
private async listMessagesBefore(
|
||||
channelId: ChannelID,
|
||||
before: MessageID,
|
||||
limit: number,
|
||||
options?: ListMessagesOptions,
|
||||
): Promise<Array<Message>> {
|
||||
const state = await this.getChannelState(channelId);
|
||||
|
||||
const maxBucket = BucketUtils.makeBucket(before);
|
||||
const minBucket = state?.created_bucket ?? BucketUtils.makeBucket(channelId);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
before: before.toString(),
|
||||
limit,
|
||||
maxBucket,
|
||||
minBucket,
|
||||
stateCreatedBucket: state?.created_bucket ?? null,
|
||||
restrictToBeforeBucket: options?.restrictToBeforeBucket ?? null,
|
||||
},
|
||||
'listMessagesBefore: computed bucket range',
|
||||
);
|
||||
|
||||
return this.scanBucketsDescForMessages(channelId, {
|
||||
limit,
|
||||
minBucket,
|
||||
maxBucket,
|
||||
before,
|
||||
restrictToBeforeBucket: options?.restrictToBeforeBucket,
|
||||
});
|
||||
}
|
||||
|
||||
private async listMessagesAfter(
|
||||
channelId: ChannelID,
|
||||
after: MessageID,
|
||||
limit: number,
|
||||
options?: ListMessagesOptions,
|
||||
): Promise<Array<Message>> {
|
||||
const state = await this.getChannelState(channelId);
|
||||
|
||||
const afterBucket = BucketUtils.makeBucket(after);
|
||||
const createdMin = state?.created_bucket ?? BucketUtils.makeBucket(channelId);
|
||||
const minBucket = Math.max(afterBucket, createdMin);
|
||||
|
||||
const nowBucket = BucketUtils.makeBucket(SnowflakeUtils.getSnowflake());
|
||||
const maxBucket = Math.max(nowBucket, minBucket);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
action: 'listMessagesAfter',
|
||||
after: after.toString(),
|
||||
minBucket,
|
||||
maxBucket,
|
||||
limit,
|
||||
immediateAfter: options?.immediateAfter ?? false,
|
||||
},
|
||||
'listMessagesAfter parameters',
|
||||
);
|
||||
|
||||
if (options?.immediateAfter) {
|
||||
const asc = await this.scanBucketsAscForMessages(channelId, {
|
||||
limit,
|
||||
minBucket,
|
||||
maxBucket,
|
||||
after,
|
||||
});
|
||||
return asc.reverse();
|
||||
}
|
||||
|
||||
return this.scanBucketsDescForMessages(channelId, {
|
||||
limit,
|
||||
minBucket,
|
||||
maxBucket,
|
||||
after,
|
||||
});
|
||||
}
|
||||
|
||||
private async listMessagesBetween(
|
||||
channelId: ChannelID,
|
||||
after: MessageID,
|
||||
before: MessageID,
|
||||
limit: number,
|
||||
options?: ListMessagesOptions,
|
||||
): Promise<Array<Message>> {
|
||||
const state = await this.getChannelState(channelId);
|
||||
|
||||
const afterBucket = BucketUtils.makeBucket(after);
|
||||
const beforeBucket = BucketUtils.makeBucket(before);
|
||||
|
||||
const high = Math.max(afterBucket, beforeBucket);
|
||||
const low = Math.min(afterBucket, beforeBucket);
|
||||
|
||||
const createdMin = state?.created_bucket ?? BucketUtils.makeBucket(channelId);
|
||||
const minBucket = Math.max(low, createdMin);
|
||||
const maxBucket = high;
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
action: 'listMessagesBetween',
|
||||
after: after.toString(),
|
||||
before: before.toString(),
|
||||
minBucket,
|
||||
maxBucket,
|
||||
limit,
|
||||
},
|
||||
'listMessagesBetween parameters',
|
||||
);
|
||||
|
||||
return this.scanBucketsDescForMessages(channelId, {
|
||||
limit,
|
||||
minBucket,
|
||||
maxBucket,
|
||||
after,
|
||||
before,
|
||||
restrictToBeforeBucket: options?.restrictToBeforeBucket,
|
||||
});
|
||||
}
|
||||
|
||||
private async scanBucketsDescForMessages(
|
||||
channelId: ChannelID,
|
||||
opts: {
|
||||
limit: number;
|
||||
minBucket: number;
|
||||
maxBucket: number;
|
||||
before?: MessageID;
|
||||
after?: MessageID;
|
||||
restrictToBeforeBucket?: boolean;
|
||||
},
|
||||
): Promise<Array<Message>> {
|
||||
const beforeBucket = opts.before ? BucketUtils.makeBucket(opts.before) : null;
|
||||
const afterBucket = opts.after ? BucketUtils.makeBucket(opts.after) : null;
|
||||
|
||||
const stopAfterBucket =
|
||||
opts.restrictToBeforeBucket === true && opts.before && !opts.after && beforeBucket !== null
|
||||
? beforeBucket
|
||||
: undefined;
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
beforeBucket,
|
||||
afterBucket,
|
||||
restrictToBeforeBucket: opts.restrictToBeforeBucket ?? null,
|
||||
stopAfterBucket: stopAfterBucket ?? null,
|
||||
},
|
||||
'scanBucketsDescForMessages: starting scan',
|
||||
);
|
||||
|
||||
const {rows: out} = await scanBucketsWithIndex<MessageRow>(
|
||||
{
|
||||
listBucketsFromIndex: async (query) =>
|
||||
this.listBucketsDescFromIndex(channelId, {
|
||||
minBucket: query.minBucket,
|
||||
maxBucket: query.maxBucket,
|
||||
limit: query.limit,
|
||||
}),
|
||||
fetchRowsForBucket: async (bucket, limit) =>
|
||||
this.fetchRowsForBucket(channelId, bucket, limit, {
|
||||
before: opts.before,
|
||||
after: opts.after,
|
||||
beforeBucket,
|
||||
afterBucket,
|
||||
}),
|
||||
getRowId: (row) => row.message_id,
|
||||
onEmptyUnboundedBucket: async (bucket) => this.markBucketEmpty(channelId, bucket),
|
||||
onBucketHasRows: async (bucket) => this.touchBucketWithMessages(channelId, bucket),
|
||||
},
|
||||
{
|
||||
limit: opts.limit,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
direction: BucketScanDirection.Desc,
|
||||
indexPageSize: DEFAULT_BUCKET_INDEX_PAGE_SIZE,
|
||||
stopAfterBucket,
|
||||
},
|
||||
);
|
||||
|
||||
if (out.length === 0) return [];
|
||||
|
||||
let maxId: MessageID = out[0].message_id;
|
||||
let maxBucketForId = out[0].bucket;
|
||||
|
||||
for (const row of out) {
|
||||
if (row.message_id > maxId) {
|
||||
maxId = row.message_id;
|
||||
maxBucketForId = row.bucket;
|
||||
}
|
||||
}
|
||||
|
||||
await this.touchChannelHasMessages(channelId);
|
||||
await this.advanceChannelStateLastMessageIfNewer(channelId, maxId, maxBucketForId);
|
||||
|
||||
return this.repairAndMapMessages(channelId, out);
|
||||
}
|
||||
|
||||
private async scanBucketsAscForMessages(
|
||||
channelId: ChannelID,
|
||||
opts: {
|
||||
limit: number;
|
||||
minBucket: number;
|
||||
maxBucket: number;
|
||||
after: MessageID;
|
||||
},
|
||||
): Promise<Array<Message>> {
|
||||
const afterBucket = BucketUtils.makeBucket(opts.after);
|
||||
|
||||
const {rows: out} = await scanBucketsWithIndex<MessageRow>(
|
||||
{
|
||||
listBucketsFromIndex: async (query) =>
|
||||
this.listBucketsAscFromIndex(channelId, {
|
||||
minBucket: query.minBucket,
|
||||
maxBucket: query.maxBucket,
|
||||
limit: query.limit,
|
||||
}),
|
||||
fetchRowsForBucket: async (bucket, limit) =>
|
||||
this.fetchRowsForBucketAsc(channelId, bucket, limit, {
|
||||
after: opts.after,
|
||||
afterBucket,
|
||||
}),
|
||||
getRowId: (row) => row.message_id,
|
||||
onEmptyUnboundedBucket: async (bucket) => this.markBucketEmpty(channelId, bucket),
|
||||
onBucketHasRows: async (bucket) => this.touchBucketWithMessages(channelId, bucket),
|
||||
},
|
||||
{
|
||||
limit: opts.limit,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
direction: BucketScanDirection.Asc,
|
||||
indexPageSize: DEFAULT_BUCKET_INDEX_PAGE_SIZE,
|
||||
},
|
||||
);
|
||||
|
||||
if (out.length === 0) return [];
|
||||
|
||||
let maxId: MessageID = out[0].message_id;
|
||||
let maxBucketForId = out[0].bucket;
|
||||
|
||||
for (const row of out) {
|
||||
if (row.message_id > maxId) {
|
||||
maxId = row.message_id;
|
||||
maxBucketForId = row.bucket;
|
||||
}
|
||||
}
|
||||
|
||||
await this.touchChannelHasMessages(channelId);
|
||||
await this.advanceChannelStateLastMessageIfNewer(channelId, maxId, maxBucketForId);
|
||||
|
||||
return this.repairAndMapMessages(channelId, out);
|
||||
}
|
||||
|
||||
private async fetchRowsForBucketAsc(
|
||||
channelId: ChannelID,
|
||||
bucket: number,
|
||||
limit: number,
|
||||
meta: {
|
||||
after: MessageID;
|
||||
afterBucket: number;
|
||||
},
|
||||
): Promise<{rows: Array<MessageRow>; unbounded: boolean}> {
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
bucket,
|
||||
limit,
|
||||
meta: {after: meta.after.toString(), afterBucket: meta.afterBucket},
|
||||
},
|
||||
'fetchRowsForBucketAsc parameters',
|
||||
);
|
||||
|
||||
if (bucket === meta.afterBucket) {
|
||||
const q = this.makeFetchMessagesAfterAsc(limit);
|
||||
const rows = await fetchMany<MessageRow>(
|
||||
q.bind({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
after_message_id: meta.after,
|
||||
}),
|
||||
);
|
||||
return {rows, unbounded: false};
|
||||
}
|
||||
|
||||
const q = this.makeFetchMessagesOldestAsc(limit);
|
||||
const rows = await fetchMany<MessageRow>(q.bind({channel_id: channelId, bucket}));
|
||||
return {rows, unbounded: true};
|
||||
}
|
||||
|
||||
private async fetchRowsForBucket(
|
||||
channelId: ChannelID,
|
||||
bucket: number,
|
||||
limit: number,
|
||||
meta: {
|
||||
before?: MessageID;
|
||||
after?: MessageID;
|
||||
beforeBucket: number | null;
|
||||
afterBucket: number | null;
|
||||
},
|
||||
): Promise<{rows: Array<MessageRow>; unbounded: boolean}> {
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
bucket,
|
||||
limit,
|
||||
meta: {
|
||||
before: meta.before?.toString() ?? null,
|
||||
after: meta.after?.toString() ?? null,
|
||||
beforeBucket: meta.beforeBucket,
|
||||
afterBucket: meta.afterBucket,
|
||||
},
|
||||
},
|
||||
'fetchRowsForBucket parameters',
|
||||
);
|
||||
|
||||
if (meta.before && meta.after && meta.beforeBucket === bucket && meta.afterBucket === bucket) {
|
||||
const q = this.makeFetchMessagesBetween(limit);
|
||||
const rows = await fetchMany<MessageRow>(
|
||||
q.bind({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
after_message_id: meta.after,
|
||||
before_message_id: meta.before,
|
||||
}),
|
||||
);
|
||||
return {rows, unbounded: false};
|
||||
}
|
||||
|
||||
if (meta.before && meta.beforeBucket === bucket) {
|
||||
const q = this.makeFetchMessagesBefore(limit);
|
||||
const rows = await fetchMany<MessageRow>(
|
||||
q.bind({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
before_message_id: meta.before,
|
||||
}),
|
||||
);
|
||||
return {rows, unbounded: false};
|
||||
}
|
||||
|
||||
if (meta.after && meta.afterBucket === bucket) {
|
||||
const q = this.makeFetchMessagesAfterDesc(limit);
|
||||
const rows = await fetchMany<MessageRow>(
|
||||
q.bind({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
after_message_id: meta.after,
|
||||
}),
|
||||
);
|
||||
return {rows, unbounded: false};
|
||||
}
|
||||
|
||||
const q = this.makeFetchMessagesLatestDesc(limit);
|
||||
const rows = await fetchMany<MessageRow>(q.bind({channel_id: channelId, bucket}));
|
||||
return {rows, unbounded: true};
|
||||
}
|
||||
|
||||
private async touchBucketWithMessages(channelId: ChannelID, bucket: number): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelMessageBuckets.upsertAll({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
updated_at: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelEmptyBuckets.deleteByPk({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
}),
|
||||
);
|
||||
|
||||
await batch.execute(true);
|
||||
}
|
||||
|
||||
private async markBucketEmpty(channelId: ChannelID, bucket: number): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelMessageBuckets.deleteByPk({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
}),
|
||||
);
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelEmptyBuckets.upsertAll({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
updated_at: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
await batch.execute(true);
|
||||
}
|
||||
|
||||
private async touchChannelHasMessages(channelId: ChannelID): Promise<void> {
|
||||
await upsertOne(
|
||||
ChannelState.patchByPk(
|
||||
{channel_id: channelId},
|
||||
{
|
||||
has_messages: Db.set(true),
|
||||
updated_at: Db.set(new Date()),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async advanceChannelStateLastMessageIfNewer(
|
||||
channelId: ChannelID,
|
||||
newLastMessageId: MessageID,
|
||||
newLastMessageBucket: number,
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < DEFAULT_CAS_RETRIES; i++) {
|
||||
const state = await this.getChannelState(channelId);
|
||||
const prev = state?.last_message_id ?? null;
|
||||
|
||||
if (prev !== null && newLastMessageId <= prev) return;
|
||||
|
||||
const q = ChannelState.patchByPkIf(
|
||||
{channel_id: channelId},
|
||||
{
|
||||
has_messages: Db.set(true),
|
||||
last_message_id: Db.set(newLastMessageId),
|
||||
last_message_bucket: Db.set(newLastMessageBucket),
|
||||
updated_at: Db.set(new Date()),
|
||||
},
|
||||
{col: 'last_message_id', expectedParam: 'prev_last_message_id', expectedValue: prev},
|
||||
);
|
||||
|
||||
const res = await executeConditional(q);
|
||||
if (res.applied) return;
|
||||
}
|
||||
|
||||
Logger.warn(
|
||||
{channelId: channelId.toString(), messageId: newLastMessageId.toString()},
|
||||
'Failed to advance ChannelState.last_message_id after retries',
|
||||
);
|
||||
}
|
||||
|
||||
private async getChannelState(channelId: ChannelID): Promise<ChannelStateRow | null> {
|
||||
return fetchOne<ChannelStateRow>(FETCH_CHANNEL_STATE.bind({channel_id: channelId}));
|
||||
}
|
||||
|
||||
private async listBucketsDescFromIndex(
|
||||
channelId: ChannelID,
|
||||
opts: {minBucket?: number; maxBucket?: number; limit: number},
|
||||
): Promise<Array<number>> {
|
||||
const where = [ChannelMessageBuckets.where.eq('channel_id')];
|
||||
|
||||
if (typeof opts.minBucket === 'number') where.push(ChannelMessageBuckets.where.gte('bucket', 'min_bucket'));
|
||||
if (typeof opts.maxBucket === 'number') where.push(ChannelMessageBuckets.where.lte('bucket', 'max_bucket'));
|
||||
|
||||
const q = ChannelMessageBuckets.select({
|
||||
columns: ['bucket'],
|
||||
where,
|
||||
orderBy: {col: 'bucket', direction: 'DESC'},
|
||||
limit: opts.limit,
|
||||
});
|
||||
|
||||
const params = {
|
||||
channel_id: channelId,
|
||||
...(typeof opts.minBucket === 'number' ? {min_bucket: opts.minBucket} : {}),
|
||||
...(typeof opts.maxBucket === 'number' ? {max_bucket: opts.maxBucket} : {}),
|
||||
};
|
||||
|
||||
const rows = await fetchMany<Pick<ChannelMessageBucketRow, 'bucket'>>(q.bind(params));
|
||||
const buckets = rows.map((r) => r.bucket);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
minBucket: opts.minBucket ?? null,
|
||||
maxBucket: opts.maxBucket ?? null,
|
||||
limit: opts.limit,
|
||||
bucketsFound: buckets,
|
||||
},
|
||||
'listBucketsDescFromIndex: query result',
|
||||
);
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
private async listBucketsAscFromIndex(
|
||||
channelId: ChannelID,
|
||||
opts: {minBucket?: number; maxBucket?: number; limit: number},
|
||||
): Promise<Array<number>> {
|
||||
const where = [ChannelMessageBuckets.where.eq('channel_id')];
|
||||
|
||||
if (typeof opts.minBucket === 'number') where.push(ChannelMessageBuckets.where.gte('bucket', 'min_bucket'));
|
||||
if (typeof opts.maxBucket === 'number') where.push(ChannelMessageBuckets.where.lte('bucket', 'max_bucket'));
|
||||
|
||||
const q = ChannelMessageBuckets.select({
|
||||
columns: ['bucket'],
|
||||
where,
|
||||
orderBy: {col: 'bucket', direction: 'ASC'},
|
||||
limit: opts.limit,
|
||||
});
|
||||
|
||||
const params = {
|
||||
channel_id: channelId,
|
||||
...(typeof opts.minBucket === 'number' ? {min_bucket: opts.minBucket} : {}),
|
||||
...(typeof opts.maxBucket === 'number' ? {max_bucket: opts.maxBucket} : {}),
|
||||
};
|
||||
|
||||
const rows = await fetchMany<Pick<ChannelMessageBucketRow, 'bucket'>>(q.bind(params));
|
||||
return rows.map((r) => r.bucket);
|
||||
}
|
||||
|
||||
async getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
|
||||
const message = await fetchOne<MessageRow>(
|
||||
FETCH_MESSAGE_BY_CHANNEL_BUCKET_AND_MESSAGE_ID.bind({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (message) return new Message(message);
|
||||
|
||||
const repairedMessage = await this.attemptBucketReadRepair(channelId, messageId, bucket);
|
||||
return repairedMessage;
|
||||
}
|
||||
|
||||
async upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message> {
|
||||
const expectedBucket = BucketUtils.makeBucket(data.message_id);
|
||||
if (data.bucket !== expectedBucket) {
|
||||
throw new Error(
|
||||
`Invalid message bucket for ${data.message_id.toString()}: expected ${expectedBucket}, received ${data.bucket}`,
|
||||
);
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelEmptyBuckets.deleteByPk({
|
||||
channel_id: data.channel_id,
|
||||
bucket: data.bucket,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executeVersionedUpdate<MessageRow, 'channel_id' | 'bucket' | 'message_id'>(
|
||||
async () => {
|
||||
if (oldData !== undefined) return oldData;
|
||||
const pk = {
|
||||
channel_id: data.channel_id,
|
||||
bucket: data.bucket,
|
||||
message_id: data.message_id,
|
||||
};
|
||||
const existingMessage = await fetchOne<MessageRow>(FETCH_MESSAGE_BY_CHANNEL_BUCKET_AND_MESSAGE_ID.bind(pk));
|
||||
return existingMessage ?? null;
|
||||
},
|
||||
(current) => ({
|
||||
pk: {
|
||||
channel_id: data.channel_id,
|
||||
bucket: data.bucket,
|
||||
message_id: data.message_id,
|
||||
},
|
||||
patch: buildPatchFromData(data, current, MESSAGE_COLUMNS, ['channel_id', 'bucket', 'message_id']),
|
||||
}),
|
||||
Messages,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
if (!result.applied) {
|
||||
throw new Error(`Failed to upsert message ${data.message_id} after LWT retries`);
|
||||
}
|
||||
|
||||
const finalVersion = result.finalVersion ?? 1;
|
||||
|
||||
if (data.author_id) {
|
||||
batch.addPrepared(
|
||||
MessagesByAuthor.upsertAll({
|
||||
author_id: data.author_id,
|
||||
channel_id: data.channel_id,
|
||||
message_id: data.message_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.pinned_timestamp) {
|
||||
batch.addPrepared(
|
||||
ChannelPins.upsertAll({
|
||||
channel_id: data.channel_id,
|
||||
message_id: data.message_id,
|
||||
pinned_timestamp: data.pinned_timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (oldData?.pinned_timestamp && !data.pinned_timestamp) {
|
||||
batch.addPrepared(
|
||||
ChannelPins.deleteByPk({
|
||||
channel_id: data.channel_id,
|
||||
message_id: data.message_id,
|
||||
pinned_timestamp: oldData.pinned_timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (oldData?.attachments) {
|
||||
for (const attachment of oldData.attachments) {
|
||||
batch.addPrepared(
|
||||
AttachmentLookup.deleteByPk({
|
||||
channel_id: data.channel_id,
|
||||
attachment_id: attachment.attachment_id,
|
||||
filename: attachment.filename,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.attachments) {
|
||||
for (const attachment of data.attachments) {
|
||||
batch.addPrepared(
|
||||
AttachmentLookup.upsertAll({
|
||||
channel_id: data.channel_id,
|
||||
attachment_id: attachment.attachment_id,
|
||||
filename: attachment.filename,
|
||||
message_id: data.message_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelMessageBuckets.upsertAll({
|
||||
channel_id: data.channel_id,
|
||||
bucket: data.bucket,
|
||||
updated_at: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
const createdBucket = BucketUtils.makeBucket(data.channel_id);
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelState.patchByPk(
|
||||
{channel_id: data.channel_id},
|
||||
{
|
||||
created_bucket: Db.set(createdBucket),
|
||||
has_messages: Db.set(true),
|
||||
updated_at: Db.set(new Date()),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await batch.execute();
|
||||
|
||||
await this.advanceChannelStateLastMessageIfNewer(data.channel_id, data.message_id, data.bucket);
|
||||
|
||||
return new Message({...data, version: finalVersion});
|
||||
}
|
||||
|
||||
private async attemptBucketReadRepair(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
expectedBucket: number,
|
||||
): Promise<Message | null> {
|
||||
for (const legacyBucket of LEGACY_BUCKETS_TO_CHECK) {
|
||||
if (legacyBucket === expectedBucket) continue;
|
||||
|
||||
const legacyRow = await fetchOne<MessageRow>(
|
||||
FETCH_MESSAGE_BY_CHANNEL_BUCKET_AND_MESSAGE_ID.bind({
|
||||
channel_id: channelId,
|
||||
bucket: legacyBucket,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!legacyRow) continue;
|
||||
|
||||
Logger.warn(
|
||||
{channelId: channelId.toString(), messageId: messageId.toString(), legacyBucket, expectedBucket},
|
||||
'Repairing message bucket mismatch',
|
||||
);
|
||||
|
||||
const repairedRow: MessageRow = {
|
||||
...legacyRow,
|
||||
bucket: expectedBucket,
|
||||
};
|
||||
|
||||
const repairedMessage = await this.upsertMessage(repairedRow, legacyRow);
|
||||
|
||||
await deleteOneOrMany(
|
||||
Messages.deleteByPk({
|
||||
channel_id: channelId,
|
||||
bucket: legacyBucket,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
|
||||
return repairedMessage;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async repairAndMapMessages(channelId: ChannelID, messages: Array<MessageRow>): Promise<Array<Message>> {
|
||||
if (messages.length === 0) return [];
|
||||
|
||||
const repaired: Array<Message> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const expectedBucket = BucketUtils.makeBucket(message.message_id);
|
||||
|
||||
if (message.bucket === expectedBucket) {
|
||||
repaired.push(new Message(message));
|
||||
continue;
|
||||
}
|
||||
|
||||
const repairedMessage = await this.attemptBucketReadRepair(channelId, message.message_id, expectedBucket);
|
||||
|
||||
if (repairedMessage) {
|
||||
repaired.push(repairedMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger.warn(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
messageId: message.message_id.toString(),
|
||||
legacyBucket: message.bucket,
|
||||
expectedBucket,
|
||||
},
|
||||
'Failed to repair message bucket mismatch during listMessages; returning legacy row',
|
||||
);
|
||||
|
||||
repaired.push(new Message(message));
|
||||
}
|
||||
|
||||
return repaired;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {ChannelMessageBucketRow, ChannelStateRow} from '~/database/CassandraTypes';
|
||||
import type {Message} from '~/Models';
|
||||
import {
|
||||
AttachmentLookup,
|
||||
ChannelEmptyBuckets,
|
||||
ChannelMessageBuckets,
|
||||
ChannelPins,
|
||||
ChannelState,
|
||||
MessageReactions,
|
||||
Messages,
|
||||
MessagesByAuthor,
|
||||
} from '~/Tables';
|
||||
import * as BucketUtils from '~/utils/BucketUtils';
|
||||
import type {MessageDataRepository} from './MessageDataRepository';
|
||||
|
||||
const BULK_DELETE_BATCH_SIZE = 100;
|
||||
const POST_DELETE_BUCKET_CHECK_LIMIT = 25;
|
||||
|
||||
const HAS_ANY_MESSAGE_IN_BUCKET = Messages.select({
|
||||
columns: ['message_id'],
|
||||
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_CHANNEL_STATE = ChannelState.select({
|
||||
where: ChannelState.where.eq('channel_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const LIST_BUCKETS_DESC = ChannelMessageBuckets.select({
|
||||
columns: ['bucket'],
|
||||
where: ChannelMessageBuckets.where.eq('channel_id'),
|
||||
orderBy: {col: 'bucket', direction: 'DESC'},
|
||||
limit: POST_DELETE_BUCKET_CHECK_LIMIT,
|
||||
});
|
||||
|
||||
const FETCH_LATEST_MESSAGE_ID_IN_BUCKET = Messages.select({
|
||||
columns: ['message_id'],
|
||||
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class MessageDeletionRepository {
|
||||
constructor(private messageDataRepo: MessageDataRepository) {}
|
||||
|
||||
private addMessageDeletionBatchQueries(
|
||||
batch: BatchBuilder,
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
bucket: number,
|
||||
message: Message | null,
|
||||
authorId?: UserID,
|
||||
pinnedTimestamp?: Date,
|
||||
): void {
|
||||
batch.addPrepared(
|
||||
Messages.deleteByPk({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
|
||||
const effectiveAuthorId = authorId ?? message?.authorId ?? null;
|
||||
if (effectiveAuthorId) {
|
||||
batch.addPrepared(
|
||||
MessagesByAuthor.deleteByPk({
|
||||
author_id: effectiveAuthorId,
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const effectivePinned = pinnedTimestamp ?? message?.pinnedTimestamp ?? null;
|
||||
if (effectivePinned) {
|
||||
batch.addPrepared(
|
||||
ChannelPins.deleteByPk({
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
pinned_timestamp: effectivePinned,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
batch.addPrepared(
|
||||
MessageReactions.deletePartition({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (message?.attachments) {
|
||||
for (const attachment of message.attachments) {
|
||||
batch.addPrepared(
|
||||
AttachmentLookup.deleteByPk({
|
||||
channel_id: channelId,
|
||||
attachment_id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async markBucketEmpty(channelId: ChannelID, bucket: number): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelMessageBuckets.deleteByPk({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
}),
|
||||
);
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelEmptyBuckets.upsertAll({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
updated_at: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
await batch.execute(true);
|
||||
}
|
||||
|
||||
private async isBucketEmpty(channelId: ChannelID, bucket: number): Promise<boolean> {
|
||||
const row = await fetchOne<{message_id: bigint}>(
|
||||
HAS_ANY_MESSAGE_IN_BUCKET.bind({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
}),
|
||||
);
|
||||
return row == null;
|
||||
}
|
||||
|
||||
private async reconcileChannelStateIfNeeded(
|
||||
channelId: ChannelID,
|
||||
deletedMessageIds: Array<MessageID>,
|
||||
emptiedBuckets: Set<number>,
|
||||
): Promise<void> {
|
||||
const state = await fetchOne<ChannelStateRow>(FETCH_CHANNEL_STATE.bind({channel_id: channelId}));
|
||||
if (!state) return;
|
||||
|
||||
const lastBucket = state.last_message_bucket as number | null | undefined;
|
||||
const lastId = state.last_message_id as MessageID | null | undefined;
|
||||
|
||||
const touchedLast =
|
||||
(lastBucket != null && emptiedBuckets.has(lastBucket)) || (lastId != null && deletedMessageIds.includes(lastId));
|
||||
|
||||
if (!touchedLast) return;
|
||||
|
||||
const bucketRows = await fetchMany<Pick<ChannelMessageBucketRow, 'bucket'>>(
|
||||
LIST_BUCKETS_DESC.bind({channel_id: channelId}),
|
||||
);
|
||||
|
||||
for (const {bucket} of bucketRows) {
|
||||
const latest = await fetchOne<{message_id: bigint}>(
|
||||
FETCH_LATEST_MESSAGE_ID_IN_BUCKET.bind({channel_id: channelId, bucket}),
|
||||
);
|
||||
|
||||
if (!latest) {
|
||||
await this.markBucketEmpty(channelId, bucket);
|
||||
continue;
|
||||
}
|
||||
|
||||
await upsertOne(
|
||||
ChannelState.patchByPk(
|
||||
{channel_id: channelId},
|
||||
{
|
||||
has_messages: Db.set(true),
|
||||
last_message_bucket: Db.set(bucket),
|
||||
last_message_id: Db.set(latest.message_id as MessageID),
|
||||
updated_at: Db.set(new Date()),
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertOne(
|
||||
ChannelState.patchByPk(
|
||||
{channel_id: channelId},
|
||||
{
|
||||
has_messages: Db.set(false),
|
||||
last_message_bucket: Db.clear(),
|
||||
last_message_id: Db.clear(),
|
||||
updated_at: Db.set(new Date()),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async postDeleteMaintenance(
|
||||
channelId: ChannelID,
|
||||
affectedBuckets: Set<number>,
|
||||
deletedMessageIds: Array<MessageID>,
|
||||
): Promise<void> {
|
||||
const emptiedBuckets = new Set<number>();
|
||||
|
||||
for (const bucket of affectedBuckets) {
|
||||
const empty = await this.isBucketEmpty(channelId, bucket);
|
||||
if (!empty) continue;
|
||||
|
||||
emptiedBuckets.add(bucket);
|
||||
await this.markBucketEmpty(channelId, bucket);
|
||||
}
|
||||
|
||||
if (emptiedBuckets.size > 0 || deletedMessageIds.length > 0) {
|
||||
await this.reconcileChannelStateIfNeeded(channelId, deletedMessageIds, emptiedBuckets);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMessage(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
authorId: UserID,
|
||||
pinnedTimestamp?: Date,
|
||||
): Promise<void> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
const message = await this.messageDataRepo.getMessage(channelId, messageId);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
this.addMessageDeletionBatchQueries(batch, channelId, messageId, bucket, message, authorId, pinnedTimestamp);
|
||||
await batch.execute();
|
||||
|
||||
await this.postDeleteMaintenance(channelId, new Set([bucket]), [messageId]);
|
||||
}
|
||||
|
||||
async bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void> {
|
||||
if (messageIds.length === 0) return;
|
||||
|
||||
for (let i = 0; i < messageIds.length; i += BULK_DELETE_BATCH_SIZE) {
|
||||
const chunk = messageIds.slice(i, i + BULK_DELETE_BATCH_SIZE);
|
||||
const messages = await Promise.all(chunk.map((id) => this.messageDataRepo.getMessage(channelId, id)));
|
||||
|
||||
const affectedBuckets = new Set<number>();
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
for (let j = 0; j < chunk.length; j++) {
|
||||
const messageId = chunk[j];
|
||||
const message = messages[j];
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
affectedBuckets.add(bucket);
|
||||
this.addMessageDeletionBatchQueries(batch, channelId, messageId, bucket, message);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
await this.postDeleteMaintenance(channelId, affectedBuckets, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAllChannelMessages(channelId: ChannelID): Promise<void> {
|
||||
const BATCH_SIZE = 50;
|
||||
let hasMore = true;
|
||||
let beforeMessageId: MessageID | undefined;
|
||||
|
||||
const allDeleted: Array<MessageID> = [];
|
||||
const affectedBuckets = new Set<number>();
|
||||
|
||||
while (hasMore) {
|
||||
const messages = await this.messageDataRepo.listMessages(channelId, beforeMessageId, 100);
|
||||
|
||||
if (messages.length === 0) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i += BATCH_SIZE) {
|
||||
const batch = new BatchBuilder();
|
||||
const messageBatch = messages.slice(i, i + BATCH_SIZE);
|
||||
|
||||
for (const message of messageBatch) {
|
||||
const bucket = BucketUtils.makeBucket(message.id);
|
||||
affectedBuckets.add(bucket);
|
||||
allDeleted.push(message.id);
|
||||
|
||||
this.addMessageDeletionBatchQueries(
|
||||
batch,
|
||||
channelId,
|
||||
message.id,
|
||||
bucket,
|
||||
message,
|
||||
message.authorId ?? undefined,
|
||||
message.pinnedTimestamp || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
if (messages.length < 100) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
beforeMessageId = messages[messages.length - 1].id;
|
||||
}
|
||||
}
|
||||
|
||||
await this.postDeleteMaintenance(channelId, affectedBuckets, allDeleted);
|
||||
|
||||
await deleteOneOrMany(
|
||||
ChannelMessageBuckets.deletePartition({
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
|
||||
await deleteOneOrMany(
|
||||
ChannelEmptyBuckets.deletePartition({
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
242
fluxer_api/src/channel/services/AttachmentUploadService.ts
Normal file
242
fluxer_api/src/channel/services/AttachmentUploadService.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AttachmentID, ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {GuildOperations, Permissions} from '~/Constants';
|
||||
import type {UploadedAttachment} from '~/channel/AttachmentDTOs';
|
||||
import {
|
||||
FeatureTemporarilyDisabledError,
|
||||
MissingPermissionsError,
|
||||
UnknownMessageError,
|
||||
UnknownUserError,
|
||||
} from '~/Errors';
|
||||
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {Attachment, Channel, Message} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from './AuthenticatedChannel';
|
||||
import {
|
||||
getContentType,
|
||||
isMessageEmpty,
|
||||
isOperationDisabled,
|
||||
makeAttachmentCdnKey,
|
||||
makeAttachmentCdnUrl,
|
||||
purgeMessageAttachments as purgeMessageAttachmentsHelper,
|
||||
validateAttachmentSizes,
|
||||
} from './message/MessageHelpers';
|
||||
|
||||
interface DeleteAttachmentParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
attachmentId: AttachmentID;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface UploadFormDataAttachmentsParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
files: Array<{file: File; index: number}>;
|
||||
attachmentMetadata: Array<{id: number; filename: string}>;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export class AttachmentUploadService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userRepository: IUserRepository,
|
||||
private storageService: IStorageService,
|
||||
private cloudflarePurgeQueue: ICloudflarePurgeQueue,
|
||||
private getChannelAuthenticated: (params: {userId: UserID; channelId: ChannelID}) => Promise<AuthenticatedChannel>,
|
||||
private ensureTextChannel: (channel: Channel) => void,
|
||||
private dispatchMessageUpdate: (params: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
requestCache: RequestCache;
|
||||
currentUserId?: UserID;
|
||||
}) => Promise<void>,
|
||||
private deleteMessage: (params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}) => Promise<void>,
|
||||
) {}
|
||||
|
||||
async uploadFormDataAttachments({
|
||||
userId,
|
||||
channelId,
|
||||
files,
|
||||
attachmentMetadata,
|
||||
expiresAt,
|
||||
}: UploadFormDataAttachmentsParams): Promise<Array<UploadedAttachment>> {
|
||||
const {channel, guild, checkPermission} = await this.getChannelAuthenticated({userId, channelId});
|
||||
this.ensureTextChannel(channel);
|
||||
|
||||
if (guild) {
|
||||
await checkPermission(Permissions.SEND_MESSAGES | Permissions.ATTACH_FILES);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
validateAttachmentSizes(
|
||||
files.map((fileWithIndex) => ({size: fileWithIndex.file.size})),
|
||||
user,
|
||||
);
|
||||
|
||||
const metadataMap = new Map(attachmentMetadata.map((m) => [m.id, m]));
|
||||
|
||||
const uploadedAttachments = await Promise.all(
|
||||
files.map(async (fileWithIndex) => {
|
||||
const {file, index} = fileWithIndex;
|
||||
|
||||
const metadata = metadataMap.get(index);
|
||||
if (!metadata) {
|
||||
throw new Error(`Internal error: metadata not found for file index ${index}`);
|
||||
}
|
||||
|
||||
const filename = metadata.filename;
|
||||
const uploadKey = crypto.randomUUID();
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const body = new Uint8Array(arrayBuffer);
|
||||
|
||||
const contentType = this.resolveContentType(file, filename);
|
||||
|
||||
await this.storageService.uploadObject({
|
||||
bucket: Config.s3.buckets.uploads,
|
||||
key: uploadKey,
|
||||
body,
|
||||
contentType,
|
||||
expiresAt: expiresAt ?? undefined,
|
||||
});
|
||||
|
||||
const uploaded: UploadedAttachment = {
|
||||
id: index,
|
||||
upload_filename: uploadKey,
|
||||
filename: filename,
|
||||
file_size: file.size,
|
||||
content_type: contentType,
|
||||
};
|
||||
|
||||
return uploaded;
|
||||
}),
|
||||
);
|
||||
|
||||
return uploadedAttachments;
|
||||
}
|
||||
|
||||
async deleteAttachment({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
attachmentId,
|
||||
requestCache,
|
||||
}: DeleteAttachmentParams): Promise<void> {
|
||||
const {channel, guild} = await this.getChannelAuthenticated({userId, channelId});
|
||||
|
||||
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
const message = await this.channelRepository.messages.getMessage(channelId, messageId);
|
||||
if (!message) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
if (message.authorId !== userId) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
if (!message.attachments || message.attachments.length === 0) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
const attachment = message.attachments.find((a: Attachment) => a.id === attachmentId);
|
||||
if (!attachment) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
const isLastAttachment = message.attachments.length === 1;
|
||||
const willBeEmpty = isLastAttachment && isMessageEmpty(message, true);
|
||||
|
||||
if (willBeEmpty) {
|
||||
await this.deleteMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
requestCache,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
|
||||
|
||||
if (Config.cloudflare.purgeEnabled) {
|
||||
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
|
||||
await this.cloudflarePurgeQueue.addUrls([cdnUrl]);
|
||||
}
|
||||
|
||||
const updatedAttachments = message.attachments.filter((a: Attachment) => a.id !== attachmentId);
|
||||
const updatedRowData = {
|
||||
...message.toRow(),
|
||||
attachments:
|
||||
updatedAttachments.length > 0 ? updatedAttachments.map((a: Attachment) => a.toMessageAttachment()) : null,
|
||||
};
|
||||
|
||||
const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedRowData, message.toRow());
|
||||
await this.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
}
|
||||
|
||||
async purgeChannelAttachments(channel: Channel): Promise<void> {
|
||||
const batchSize = 100;
|
||||
let beforeMessageId: MessageID | undefined;
|
||||
|
||||
while (true) {
|
||||
const messages = await this.channelRepository.messages.listMessages(channel.id, beforeMessageId, batchSize);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
messages.map((message: Message) =>
|
||||
purgeMessageAttachmentsHelper(message, this.storageService, this.cloudflarePurgeQueue),
|
||||
),
|
||||
);
|
||||
|
||||
if (messages.length < batchSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
beforeMessageId = messages[messages.length - 1].id;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveContentType(_file: File, normalizedFilename: string): string {
|
||||
return getContentType(normalizedFilename);
|
||||
}
|
||||
}
|
||||
29
fluxer_api/src/channel/services/AuthenticatedChannel.ts
Normal file
29
fluxer_api/src/channel/services/AuthenticatedChannel.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
|
||||
import type {Channel} from '~/Models';
|
||||
|
||||
export interface AuthenticatedChannel {
|
||||
channel: Channel;
|
||||
guild: GuildResponse | null;
|
||||
member: GuildMemberResponse | null;
|
||||
hasPermission: (permission: bigint) => Promise<boolean>;
|
||||
checkPermission: (permission: bigint) => Promise<void>;
|
||||
}
|
||||
236
fluxer_api/src/channel/services/BaseChannelAuthService.ts
Normal file
236
fluxer_api/src/channel/services/BaseChannelAuthService.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, channelIdToUserId, type UserID, userIdToChannelId} from '~/BrandedTypes';
|
||||
import {ChannelTypes, Permissions} from '~/Constants';
|
||||
import {
|
||||
MissingPermissionsError,
|
||||
NsfwContentRequiresAgeVerificationError,
|
||||
UnknownChannelError,
|
||||
UnknownUserError,
|
||||
} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import {Channel, type User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {isUserAdult} from '~/utils/AgeUtils';
|
||||
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from './AuthenticatedChannel';
|
||||
import {DMPermissionValidator} from './DMPermissionValidator';
|
||||
|
||||
export interface ChannelAuthOptions {
|
||||
errorOnMissingGuild: 'unknown_channel' | 'missing_permissions';
|
||||
validateNsfw: boolean;
|
||||
useVirtualPersonalNotes: boolean;
|
||||
}
|
||||
|
||||
export abstract class BaseChannelAuthService {
|
||||
protected abstract readonly options: ChannelAuthOptions;
|
||||
protected dmPermissionValidator: DMPermissionValidator;
|
||||
|
||||
constructor(
|
||||
protected channelRepository: IChannelRepositoryAggregate,
|
||||
protected userRepository: IUserRepository,
|
||||
protected guildRepository: IGuildRepository,
|
||||
protected gatewayService: IGatewayService,
|
||||
) {
|
||||
this.dmPermissionValidator = new DMPermissionValidator({
|
||||
userRepository: this.userRepository,
|
||||
guildRepository: this.guildRepository,
|
||||
});
|
||||
}
|
||||
|
||||
async getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
}): Promise<AuthenticatedChannel> {
|
||||
if (this.isPersonalNotesChannel({userId, channelId})) {
|
||||
if (this.options.useVirtualPersonalNotes) {
|
||||
return this.getVirtualPersonalNotesChannelAuth(channelId);
|
||||
}
|
||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
return this.getRealPersonalNotesChannelAuth({channel, userId});
|
||||
}
|
||||
|
||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
|
||||
if (!channel.guildId) {
|
||||
const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
|
||||
return this.getDMChannelAuth({channel, recipients, userId});
|
||||
}
|
||||
|
||||
return this.getGuildChannelAuth({channel, userId});
|
||||
}
|
||||
|
||||
isPersonalNotesChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): boolean {
|
||||
return userIdToChannelId(userId) === channelId;
|
||||
}
|
||||
|
||||
protected createVirtualPersonalNotesChannel(userId: UserID): Channel {
|
||||
return new Channel({
|
||||
channel_id: userIdToChannelId(userId),
|
||||
guild_id: null,
|
||||
type: ChannelTypes.DM_PERSONAL_NOTES,
|
||||
name: '',
|
||||
topic: null,
|
||||
icon_hash: null,
|
||||
url: null,
|
||||
parent_id: null,
|
||||
position: 0,
|
||||
owner_id: null,
|
||||
recipient_ids: new Set(),
|
||||
nsfw: false,
|
||||
rate_limit_per_user: 0,
|
||||
bitrate: null,
|
||||
user_limit: null,
|
||||
rtc_region: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
nicks: null,
|
||||
soft_deleted: false,
|
||||
indexed_at: null,
|
||||
version: 1,
|
||||
});
|
||||
}
|
||||
|
||||
protected getVirtualPersonalNotesChannelAuth(channelId: ChannelID): AuthenticatedChannel {
|
||||
const channel = this.createVirtualPersonalNotesChannel(channelIdToUserId(channelId));
|
||||
return {
|
||||
channel,
|
||||
guild: null,
|
||||
member: null,
|
||||
hasPermission: async () => true,
|
||||
checkPermission: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
protected async getRealPersonalNotesChannelAuth({
|
||||
channel,
|
||||
userId,
|
||||
}: {
|
||||
channel: Channel;
|
||||
userId: UserID;
|
||||
}): Promise<AuthenticatedChannel> {
|
||||
if (!this.isPersonalNotesChannel({userId, channelId: channel.id})) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
|
||||
if (channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
|
||||
return {
|
||||
channel,
|
||||
guild: null,
|
||||
member: null,
|
||||
hasPermission: async () => true,
|
||||
checkPermission: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
protected async getDMChannelAuth({
|
||||
channel,
|
||||
recipients,
|
||||
userId,
|
||||
}: {
|
||||
channel: Channel;
|
||||
recipients: Array<User>;
|
||||
userId: UserID;
|
||||
}): Promise<AuthenticatedChannel> {
|
||||
const isRecipient = recipients.some((recipient) => recipient.id === userId);
|
||||
if (!isRecipient) throw new UnknownChannelError();
|
||||
|
||||
return {
|
||||
channel,
|
||||
guild: null,
|
||||
member: null,
|
||||
hasPermission: async () => true,
|
||||
checkPermission: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
async validateDMSendPermissions({channelId, userId}: {channelId: ChannelID; userId: UserID}): Promise<void> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
|
||||
await this.dmPermissionValidator.validate({recipients, userId});
|
||||
}
|
||||
|
||||
protected async getGuildChannelAuth({
|
||||
channel,
|
||||
userId,
|
||||
}: {
|
||||
channel: Channel;
|
||||
userId: UserID;
|
||||
}): Promise<AuthenticatedChannel> {
|
||||
const guildId = channel.guildId!;
|
||||
const [guildDataResult, guildMemberResult] = await Promise.all([
|
||||
this.gatewayService.getGuildData({guildId, userId}),
|
||||
this.gatewayService.getGuildMember({guildId, userId}),
|
||||
]);
|
||||
|
||||
if (!guildDataResult) {
|
||||
this.throwGuildAccessError();
|
||||
}
|
||||
if (!guildMemberResult.success || !guildMemberResult.memberData) {
|
||||
this.throwGuildAccessError();
|
||||
}
|
||||
|
||||
const hasPermission = async (permission: bigint): Promise<boolean> => {
|
||||
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
|
||||
};
|
||||
|
||||
const checkPermission = async (permission: bigint): Promise<void> => {
|
||||
const allowed = await hasPermission(permission);
|
||||
if (!allowed) throw new MissingPermissionsError();
|
||||
};
|
||||
|
||||
await checkPermission(Permissions.VIEW_CHANNEL);
|
||||
|
||||
if (this.options.validateNsfw && channel.type === ChannelTypes.GUILD_TEXT && channel.isNsfw) {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) throw new UnknownUserError();
|
||||
|
||||
if (!isUserAdult(user.dateOfBirth)) {
|
||||
throw new NsfwContentRequiresAgeVerificationError();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
channel,
|
||||
guild: guildDataResult!,
|
||||
member: guildMemberResult.memberData!,
|
||||
hasPermission,
|
||||
checkPermission,
|
||||
};
|
||||
}
|
||||
|
||||
protected throwGuildAccessError(): never {
|
||||
if (this.options.errorOnMissingGuild === 'missing_permissions') {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
}
|
||||
505
fluxer_api/src/channel/services/CallService.ts
Normal file
505
fluxer_api/src/channel/services/CallService.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, createMessageID, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes, IncomingCallFlags, MessageTypes, RelationshipTypes} from '~/Constants';
|
||||
import {mapChannelToResponse, mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import {CallAlreadyExistsError, InvalidChannelTypeForCallError, NoActiveCallError, UnknownChannelError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {CallData, IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {ReadStateService} from '~/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import * as BucketUtils from '~/utils/BucketUtils';
|
||||
import {calculateDistance} from '~/utils/GeoUtils';
|
||||
import type {VoiceAccessContext, VoiceAvailabilityService} from '~/voice/VoiceAvailabilityService';
|
||||
import type {IChannelRepository} from '../IChannelRepository';
|
||||
import {DMPermissionValidator} from './DMPermissionValidator';
|
||||
import {incrementDmMentionCounts} from './message/ReadStateHelpers';
|
||||
|
||||
const FALLBACK_VOICE_REGION = 'us-east';
|
||||
|
||||
export class CallService {
|
||||
private dmPermissionValidator: DMPermissionValidator;
|
||||
|
||||
constructor(
|
||||
private channelRepository: IChannelRepository,
|
||||
private userRepository: IUserRepository,
|
||||
private guildRepository: IGuildRepository,
|
||||
private gatewayService: IGatewayService,
|
||||
private userCacheService: UserCacheService,
|
||||
private mediaService: IMediaService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private readStateService: ReadStateService,
|
||||
private voiceAvailabilityService?: VoiceAvailabilityService,
|
||||
) {
|
||||
this.dmPermissionValidator = new DMPermissionValidator({
|
||||
userRepository: this.userRepository,
|
||||
guildRepository: this.guildRepository,
|
||||
});
|
||||
}
|
||||
|
||||
async checkCallEligibility({
|
||||
userId,
|
||||
channelId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
}): Promise<{ringable: boolean; silent?: boolean}> {
|
||||
const channel = await this.channelRepository.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
|
||||
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw new InvalidChannelTypeForCallError();
|
||||
}
|
||||
|
||||
const call = await this.gatewayService.getCall(channelId);
|
||||
const alreadyInCall = call ? call.voice_states.some((vs) => vs.user_id === userId.toString()) : false;
|
||||
if (alreadyInCall) {
|
||||
return {ringable: false};
|
||||
}
|
||||
|
||||
if (channel.type === ChannelTypes.DM) {
|
||||
const recipientId = Array.from(channel.recipientIds).find((id) => id !== userId);
|
||||
if (!recipientId) {
|
||||
return {ringable: true};
|
||||
}
|
||||
|
||||
const recipientSettings = await this.userRepository.findSettings(recipientId);
|
||||
if (!recipientSettings) {
|
||||
return {ringable: true};
|
||||
}
|
||||
|
||||
const incomingCallFlags = recipientSettings.incomingCallFlags;
|
||||
|
||||
if ((incomingCallFlags & IncomingCallFlags.NOBODY) === IncomingCallFlags.NOBODY) {
|
||||
return {ringable: false};
|
||||
}
|
||||
|
||||
const friendship = await this.userRepository.getRelationship(userId, recipientId, RelationshipTypes.FRIEND);
|
||||
const areFriends = friendship !== null;
|
||||
|
||||
if ((incomingCallFlags & IncomingCallFlags.FRIENDS_ONLY) === IncomingCallFlags.FRIENDS_ONLY) {
|
||||
return {ringable: areFriends};
|
||||
}
|
||||
|
||||
if (areFriends) {
|
||||
return {ringable: true};
|
||||
}
|
||||
|
||||
const shouldBeSilent =
|
||||
(incomingCallFlags & IncomingCallFlags.SILENT_EVERYONE) === IncomingCallFlags.SILENT_EVERYONE;
|
||||
|
||||
if ((incomingCallFlags & IncomingCallFlags.FRIENDS_OF_FRIENDS) === IncomingCallFlags.FRIENDS_OF_FRIENDS) {
|
||||
const callerRelationships = await this.userRepository.listRelationships(userId);
|
||||
const recipientRelationships = await this.userRepository.listRelationships(recipientId);
|
||||
|
||||
const callerFriendIds = new Set(
|
||||
callerRelationships.filter((r) => r.type === RelationshipTypes.FRIEND).map((r) => r.targetUserId.toString()),
|
||||
);
|
||||
const hasMutualFriends = recipientRelationships
|
||||
.filter((r) => r.type === RelationshipTypes.FRIEND)
|
||||
.some((r) => callerFriendIds.has(r.targetUserId.toString()));
|
||||
|
||||
if (hasMutualFriends) {
|
||||
return {ringable: true, silent: shouldBeSilent};
|
||||
}
|
||||
}
|
||||
|
||||
if ((incomingCallFlags & IncomingCallFlags.GUILD_MEMBERS) === IncomingCallFlags.GUILD_MEMBERS) {
|
||||
const callerGuildIds = new Set(
|
||||
(await this.userRepository.getUserGuildIds(userId)).map((guildId) => guildId.toString()),
|
||||
);
|
||||
const recipientGuildIds = await this.userRepository.getUserGuildIds(recipientId);
|
||||
const hasMutualGuilds = recipientGuildIds.some((guildId) => callerGuildIds.has(guildId.toString()));
|
||||
|
||||
if (hasMutualGuilds) {
|
||||
return {ringable: true, silent: shouldBeSilent};
|
||||
}
|
||||
}
|
||||
|
||||
if ((incomingCallFlags & IncomingCallFlags.EVERYONE) === IncomingCallFlags.EVERYONE) {
|
||||
return {ringable: true, silent: shouldBeSilent};
|
||||
}
|
||||
|
||||
return {ringable: false};
|
||||
}
|
||||
|
||||
return {ringable: true};
|
||||
}
|
||||
|
||||
async createOrGetCall({
|
||||
userId,
|
||||
channelId,
|
||||
region,
|
||||
ringing,
|
||||
requestCache,
|
||||
latitude,
|
||||
longitude,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
region?: string;
|
||||
ringing: Array<UserID>;
|
||||
requestCache: RequestCache;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
}): Promise<CallData> {
|
||||
const channel = await this.channelRepository.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
|
||||
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw new InvalidChannelTypeForCallError();
|
||||
}
|
||||
|
||||
const recipientIds = Array.from(channel.recipientIds);
|
||||
const channelRecipients = recipientIds.length > 0 ? await this.userRepository.listUsers(recipientIds) : [];
|
||||
|
||||
if (channel.type === ChannelTypes.DM) {
|
||||
await this.dmPermissionValidator.validate({recipients: channelRecipients, userId});
|
||||
}
|
||||
|
||||
const existingCall = await this.gatewayService.getCall(channelId);
|
||||
if (existingCall) {
|
||||
throw new CallAlreadyExistsError();
|
||||
}
|
||||
|
||||
const selectedRegion = region || this.selectOptimalRegionForCall(userId, latitude, longitude);
|
||||
const allRecipients = Array.from(new Set([userId, ...Array.from(channel.recipientIds)]));
|
||||
const messageId = createMessageID(this.snowflakeService.generate());
|
||||
|
||||
await this.channelRepository.upsertMessage({
|
||||
channel_id: channelId,
|
||||
bucket: BucketUtils.makeBucket(messageId),
|
||||
message_id: messageId,
|
||||
author_id: userId,
|
||||
type: MessageTypes.CALL,
|
||||
webhook_id: null,
|
||||
webhook_name: null,
|
||||
webhook_avatar_hash: null,
|
||||
content: null,
|
||||
edited_timestamp: null,
|
||||
pinned_timestamp: null,
|
||||
flags: 0,
|
||||
mention_everyone: false,
|
||||
mention_users: new Set(),
|
||||
mention_roles: null,
|
||||
mention_channels: null,
|
||||
attachments: null,
|
||||
embeds: null,
|
||||
sticker_items: null,
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: {
|
||||
participant_ids: new Set(allRecipients),
|
||||
ended_timestamp: null,
|
||||
},
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const call = await this.gatewayService.createCall(
|
||||
channelId,
|
||||
messageId.toString(),
|
||||
selectedRegion,
|
||||
ringing.map((id) => id.toString()),
|
||||
allRecipients.map((id) => id.toString()),
|
||||
);
|
||||
|
||||
const author = await this.userRepository.findUnique(userId);
|
||||
await incrementDmMentionCounts({
|
||||
readStateService: this.readStateService,
|
||||
user: author,
|
||||
recipients: channelRecipients,
|
||||
channelId,
|
||||
});
|
||||
|
||||
const message = await this.channelRepository.getMessage(channelId, messageId);
|
||||
if (message) {
|
||||
const messageResponse = await mapMessageToResponse({
|
||||
message,
|
||||
userCacheService: this.userCacheService,
|
||||
mediaService: this.mediaService,
|
||||
requestCache,
|
||||
getReferencedMessage: (channelId, messageId) => this.channelRepository.getMessage(channelId, messageId),
|
||||
});
|
||||
|
||||
for (const recipientId of allRecipients) {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: recipientId,
|
||||
event: 'MESSAGE_CREATE',
|
||||
data: messageResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return call;
|
||||
}
|
||||
|
||||
async updateCall({channelId, region}: {userId: UserID; channelId: ChannelID; region?: string}): Promise<void> {
|
||||
const channel = await this.channelRepository.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
|
||||
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw new InvalidChannelTypeForCallError();
|
||||
}
|
||||
|
||||
const call = await this.gatewayService.getCall(channelId);
|
||||
if (!call) {
|
||||
throw new NoActiveCallError();
|
||||
}
|
||||
|
||||
if (region) {
|
||||
await this.gatewayService.updateCallRegion(channelId, region);
|
||||
}
|
||||
}
|
||||
|
||||
async ringCallRecipients({
|
||||
userId,
|
||||
channelId,
|
||||
recipients,
|
||||
requestCache,
|
||||
latitude,
|
||||
longitude,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
recipients?: Array<UserID>;
|
||||
requestCache: RequestCache;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
|
||||
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw new InvalidChannelTypeForCallError();
|
||||
}
|
||||
|
||||
if (channel.type === ChannelTypes.DM) {
|
||||
const channelRecipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
|
||||
await this.dmPermissionValidator.validate({recipients: channelRecipients, userId});
|
||||
}
|
||||
|
||||
const recipientsToNotify = (recipients || Array.from(channel.recipientIds)).filter((id) => id !== userId);
|
||||
|
||||
const recipientsToRing: Array<UserID> = [];
|
||||
|
||||
if (channel.type === ChannelTypes.DM) {
|
||||
const recipientId = Array.from(channel.recipientIds).find((id) => id !== userId);
|
||||
if (recipientId) {
|
||||
const recipientSettings = await this.userRepository.findSettings(recipientId);
|
||||
if (recipientSettings) {
|
||||
const incomingCallFlags = recipientSettings.incomingCallFlags;
|
||||
const friendship = await this.userRepository.getRelationship(userId, recipientId, RelationshipTypes.FRIEND);
|
||||
const areFriends = friendship !== null;
|
||||
|
||||
const shouldBeSilent =
|
||||
!areFriends &&
|
||||
(incomingCallFlags & IncomingCallFlags.SILENT_EVERYONE) === IncomingCallFlags.SILENT_EVERYONE;
|
||||
|
||||
if (!shouldBeSilent) {
|
||||
recipientsToRing.push(recipientId);
|
||||
}
|
||||
} else {
|
||||
recipientsToRing.push(recipientId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const recipientId of recipientsToNotify) {
|
||||
const recipientSettings = await this.userRepository.findSettings(recipientId);
|
||||
if (recipientSettings) {
|
||||
const incomingCallFlags = recipientSettings.incomingCallFlags;
|
||||
const friendship = await this.userRepository.getRelationship(userId, recipientId, RelationshipTypes.FRIEND);
|
||||
const areFriends = friendship !== null;
|
||||
|
||||
const shouldBeSilent =
|
||||
!areFriends &&
|
||||
(incomingCallFlags & IncomingCallFlags.SILENT_EVERYONE) === IncomingCallFlags.SILENT_EVERYONE;
|
||||
|
||||
if (!shouldBeSilent) {
|
||||
recipientsToRing.push(recipientId);
|
||||
}
|
||||
} else {
|
||||
recipientsToRing.push(recipientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (channel.type === ChannelTypes.DM) {
|
||||
const callerHasChannelOpen = await this.userRepository.isDmChannelOpen(userId, channelId);
|
||||
if (!callerHasChannelOpen) {
|
||||
await this.userRepository.openDmForUser(userId, channelId);
|
||||
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'CHANNEL_CREATE',
|
||||
data: channelResponse,
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipientId of recipientsToNotify) {
|
||||
const isOpen = await this.userRepository.isDmChannelOpen(recipientId, channelId);
|
||||
if (!isOpen) {
|
||||
await this.userRepository.openDmForUser(recipientId, channelId);
|
||||
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: recipientId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: recipientId,
|
||||
event: 'CHANNEL_CREATE',
|
||||
data: channelResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existingCall = await this.gatewayService.getCall(channelId);
|
||||
if (!existingCall) {
|
||||
await this.createOrGetCall({
|
||||
userId,
|
||||
channelId,
|
||||
ringing: recipientsToRing,
|
||||
requestCache,
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
} else {
|
||||
await this.gatewayService.ringCallRecipients(
|
||||
channelId,
|
||||
recipientsToRing.map((id) => id.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async stopRingingCallRecipients({
|
||||
userId,
|
||||
channelId,
|
||||
recipients,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
recipients?: Array<UserID>;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
|
||||
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw new InvalidChannelTypeForCallError();
|
||||
}
|
||||
|
||||
const call = await this.gatewayService.getCall(channelId);
|
||||
if (!call) {
|
||||
throw new NoActiveCallError();
|
||||
}
|
||||
|
||||
const toStop = recipients || [userId];
|
||||
await this.gatewayService.stopRingingCallRecipients(
|
||||
channelId,
|
||||
toStop.map((id) => id.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
private selectOptimalRegionForCall(userId: UserID, latitude?: string, longitude?: string): string {
|
||||
if (!this.voiceAvailabilityService) {
|
||||
return FALLBACK_VOICE_REGION;
|
||||
}
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: userId,
|
||||
};
|
||||
|
||||
const availableRegions = this.voiceAvailabilityService.getAvailableRegions(context);
|
||||
const accessibleRegions = availableRegions.filter((r) => r.isAccessible);
|
||||
|
||||
if (accessibleRegions.length === 0) {
|
||||
throw new Error('No accessible voice regions available');
|
||||
}
|
||||
|
||||
if (latitude && longitude) {
|
||||
const userLat = parseFloat(latitude);
|
||||
const userLon = parseFloat(longitude);
|
||||
|
||||
if (!Number.isNaN(userLat) && !Number.isNaN(userLon)) {
|
||||
let closestRegion: string | null = null;
|
||||
let minDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const region of accessibleRegions) {
|
||||
const distance = calculateDistance(userLat, userLon, region.latitude, region.longitude);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestRegion = region.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestRegion) {
|
||||
return closestRegion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRegion = accessibleRegions.find((r) => r.isDefault);
|
||||
return defaultRegion ? defaultRegion.id : accessibleRegions[0].id;
|
||||
}
|
||||
|
||||
async updateCallMessageEnded({
|
||||
channelId,
|
||||
messageId,
|
||||
participants,
|
||||
endedTimestamp,
|
||||
}: {
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
participants: Array<UserID>;
|
||||
endedTimestamp: Date;
|
||||
}): Promise<void> {
|
||||
const message = await this.channelRepository.getMessage(channelId, messageId);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type !== MessageTypes.CALL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageRow = message.toRow();
|
||||
await this.channelRepository.upsertMessage({
|
||||
...messageRow,
|
||||
call: {
|
||||
participant_ids: new Set(participants),
|
||||
ended_timestamp: endedTimestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
298
fluxer_api/src/channel/services/ChannelDataService.ts
Normal file
298
fluxer_api/src/channel/services/ChannelDataService.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes} from '~/Constants';
|
||||
import {
|
||||
type ChannelPartialResponse,
|
||||
type ChannelUpdateRequest,
|
||||
mapChannelToPartialResponse,
|
||||
} from '~/channel/ChannelModel';
|
||||
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {AvatarService} from '~/infrastructure/AvatarService';
|
||||
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {ILiveKitService} from '~/infrastructure/ILiveKitService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {IVoiceRoomStore} from '~/infrastructure/IVoiceRoomStore';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {IInviteRepository} from '~/invite/IInviteRepository';
|
||||
import type {Channel} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {VoiceAvailabilityService} from '~/voice/VoiceAvailabilityService';
|
||||
import type {VoiceRegionAvailability} from '~/voice/VoiceModel';
|
||||
import type {IWebhookRepository} from '~/webhook/IWebhookRepository';
|
||||
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from './AuthenticatedChannel';
|
||||
import {ChannelAuthService} from './channel_data/ChannelAuthService';
|
||||
import {ChannelOperationsService, type ChannelUpdateData} from './channel_data/ChannelOperationsService';
|
||||
import {ChannelUtilsService} from './channel_data/ChannelUtilsService';
|
||||
import {GroupDmUpdateService} from './channel_data/GroupDmUpdateService';
|
||||
import type {MessagePersistenceService} from './message/MessagePersistenceService';
|
||||
|
||||
type GuildChannelUpdateRequest = Exclude<ChannelUpdateRequest, {type: typeof ChannelTypes.GROUP_DM}>;
|
||||
type GuildChannelUpdatePayload = Omit<GuildChannelUpdateRequest, 'type'>;
|
||||
|
||||
export class ChannelDataService {
|
||||
private channelAuthService: ChannelAuthService;
|
||||
private channelOperationsService: ChannelOperationsService;
|
||||
public readonly groupDmUpdateService: GroupDmUpdateService;
|
||||
private channelUtilsService: ChannelUtilsService;
|
||||
|
||||
constructor(
|
||||
channelRepository: IChannelRepositoryAggregate,
|
||||
userRepository: IUserRepository,
|
||||
guildRepository: IGuildRepository,
|
||||
userCacheService: UserCacheService,
|
||||
storageService: IStorageService,
|
||||
gatewayService: IGatewayService,
|
||||
mediaService: IMediaService,
|
||||
avatarService: AvatarService,
|
||||
snowflakeService: SnowflakeService,
|
||||
cloudflarePurgeQueue: ICloudflarePurgeQueue,
|
||||
voiceRoomStore: IVoiceRoomStore,
|
||||
liveKitService: ILiveKitService,
|
||||
voiceAvailabilityService: VoiceAvailabilityService | undefined,
|
||||
messagePersistenceService: MessagePersistenceService,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
inviteRepository: IInviteRepository,
|
||||
webhookRepository: IWebhookRepository,
|
||||
) {
|
||||
this.channelUtilsService = new ChannelUtilsService(
|
||||
channelRepository,
|
||||
userCacheService,
|
||||
storageService,
|
||||
gatewayService,
|
||||
cloudflarePurgeQueue,
|
||||
mediaService,
|
||||
);
|
||||
|
||||
this.channelAuthService = new ChannelAuthService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
gatewayService,
|
||||
);
|
||||
|
||||
this.channelOperationsService = new ChannelOperationsService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
gatewayService,
|
||||
this.channelAuthService,
|
||||
this.channelUtilsService,
|
||||
voiceRoomStore,
|
||||
liveKitService,
|
||||
voiceAvailabilityService,
|
||||
guildAuditLogService,
|
||||
inviteRepository,
|
||||
webhookRepository,
|
||||
);
|
||||
|
||||
this.groupDmUpdateService = new GroupDmUpdateService(
|
||||
channelRepository,
|
||||
avatarService,
|
||||
snowflakeService,
|
||||
this.channelUtilsService,
|
||||
messagePersistenceService,
|
||||
);
|
||||
}
|
||||
|
||||
async getChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<Channel> {
|
||||
return this.channelOperationsService.getChannel({userId, channelId});
|
||||
}
|
||||
|
||||
async getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
}): Promise<AuthenticatedChannel> {
|
||||
return this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
}
|
||||
|
||||
async getPublicChannelData(channelId: ChannelID): Promise<ChannelPartialResponse> {
|
||||
const channel = await this.channelOperationsService.getPublicChannelData(channelId);
|
||||
return mapChannelToPartialResponse(channel);
|
||||
}
|
||||
|
||||
async getChannelMemberCount(channelId: ChannelID): Promise<number> {
|
||||
return this.channelOperationsService.getChannelMemberCount(channelId);
|
||||
}
|
||||
|
||||
async getChannelSystem(channelId: ChannelID): Promise<Channel | null> {
|
||||
return this.channelOperationsService.getChannelSystem(channelId);
|
||||
}
|
||||
|
||||
async editChannel({
|
||||
userId,
|
||||
channelId,
|
||||
data,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
data: Omit<ChannelUpdateRequest, 'type'>;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const {channel} = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
|
||||
if (channel.type === ChannelTypes.GROUP_DM) {
|
||||
return await this.updateGroupDmChannel({
|
||||
userId,
|
||||
channelId,
|
||||
name: data.name !== undefined ? data.name : undefined,
|
||||
icon: data.icon !== undefined ? data.icon : undefined,
|
||||
ownerId: data.owner_id ? createUserID(data.owner_id) : undefined,
|
||||
nicks: data.nicks,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
const guildChannelData = data as GuildChannelUpdatePayload;
|
||||
const channelUpdateData: ChannelUpdateData = {};
|
||||
|
||||
if ('name' in guildChannelData && guildChannelData.name !== undefined && guildChannelData.name !== null) {
|
||||
channelUpdateData.name = guildChannelData.name;
|
||||
}
|
||||
|
||||
if (guildChannelData.topic !== undefined) {
|
||||
channelUpdateData.topic = guildChannelData.topic ?? null;
|
||||
}
|
||||
|
||||
if (guildChannelData.url !== undefined) {
|
||||
channelUpdateData.url = guildChannelData.url ?? null;
|
||||
}
|
||||
|
||||
if (guildChannelData.parent_id !== undefined) {
|
||||
channelUpdateData.parent_id = guildChannelData.parent_id ?? null;
|
||||
}
|
||||
|
||||
if (guildChannelData.bitrate !== undefined) {
|
||||
channelUpdateData.bitrate = guildChannelData.bitrate ?? null;
|
||||
}
|
||||
|
||||
if (guildChannelData.user_limit !== undefined) {
|
||||
channelUpdateData.user_limit = guildChannelData.user_limit ?? null;
|
||||
}
|
||||
|
||||
if (guildChannelData.nsfw !== undefined) {
|
||||
channelUpdateData.nsfw = guildChannelData.nsfw ?? undefined;
|
||||
}
|
||||
|
||||
if (guildChannelData.rate_limit_per_user !== undefined) {
|
||||
channelUpdateData.rate_limit_per_user = guildChannelData.rate_limit_per_user ?? undefined;
|
||||
}
|
||||
|
||||
if (guildChannelData.permission_overwrites !== undefined) {
|
||||
channelUpdateData.permission_overwrites = guildChannelData.permission_overwrites ?? null;
|
||||
}
|
||||
|
||||
if (guildChannelData.rtc_region !== undefined) {
|
||||
channelUpdateData.rtc_region = guildChannelData.rtc_region ?? null;
|
||||
}
|
||||
|
||||
if (guildChannelData.icon !== undefined) {
|
||||
channelUpdateData.icon = guildChannelData.icon ?? null;
|
||||
}
|
||||
|
||||
if (guildChannelData.owner_id !== undefined) {
|
||||
channelUpdateData.owner_id = guildChannelData.owner_id ?? null;
|
||||
}
|
||||
|
||||
if (guildChannelData.nicks !== undefined) {
|
||||
channelUpdateData.nicks = guildChannelData.nicks ?? null;
|
||||
}
|
||||
|
||||
return this.channelOperationsService.editChannel({userId, channelId, data: channelUpdateData, requestCache});
|
||||
}
|
||||
|
||||
async deleteChannel({
|
||||
userId,
|
||||
channelId,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
return this.channelOperationsService.deleteChannel({userId, channelId, requestCache});
|
||||
}
|
||||
|
||||
async getAvailableRtcRegions({
|
||||
userId,
|
||||
channelId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
}): Promise<Array<VoiceRegionAvailability>> {
|
||||
return this.channelOperationsService.getAvailableRtcRegions({userId, channelId});
|
||||
}
|
||||
|
||||
async updateGroupDmChannel({
|
||||
userId,
|
||||
channelId,
|
||||
name,
|
||||
icon,
|
||||
ownerId,
|
||||
nicks,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
name?: string | null;
|
||||
icon?: string | null;
|
||||
ownerId?: UserID;
|
||||
nicks?: Record<string, string | null> | null;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
return this.groupDmUpdateService.updateGroupDmChannel({
|
||||
userId,
|
||||
channelId,
|
||||
name,
|
||||
icon,
|
||||
ownerId,
|
||||
nicks,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async setChannelPermissionOverwrite(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
overwriteId: bigint;
|
||||
overwrite: {type: number; allow_: bigint; deny_: bigint};
|
||||
requestCache: RequestCache;
|
||||
}) {
|
||||
return this.channelOperationsService.setChannelPermissionOverwrite(params);
|
||||
}
|
||||
|
||||
async deleteChannelPermissionOverwrite(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
overwriteId: bigint;
|
||||
requestCache: RequestCache;
|
||||
}) {
|
||||
return this.channelOperationsService.deleteChannelPermissionOverwrite(params);
|
||||
}
|
||||
}
|
||||
394
fluxer_api/src/channel/services/ChannelService.ts
Normal file
394
fluxer_api/src/channel/services/ChannelService.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID} from '~/BrandedTypes';
|
||||
import {TEXT_BASED_CHANNEL_TYPES} from '~/Constants';
|
||||
import type {MessageRequest} from '~/channel/ChannelModel';
|
||||
import {CannotSendMessageToNonTextChannelError} from '~/Errors';
|
||||
import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository';
|
||||
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {AvatarService} from '~/infrastructure/AvatarService';
|
||||
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
|
||||
import type {EmbedService} from '~/infrastructure/EmbedService';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {ILiveKitService} from '~/infrastructure/ILiveKitService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {IVirusScanService} from '~/infrastructure/IVirusScanService';
|
||||
import type {IVoiceRoomStore} from '~/infrastructure/IVoiceRoomStore';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {IInviteRepository} from '~/invite/IInviteRepository';
|
||||
import type {User} from '~/Models';
|
||||
import type {PackService} from '~/pack/PackService';
|
||||
import type {ReadStateService} from '~/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {VoiceAvailabilityService} from '~/voice/VoiceAvailabilityService';
|
||||
import type {IWebhookRepository} from '~/webhook/IWebhookRepository';
|
||||
import type {IWorkerService} from '~/worker/IWorkerService';
|
||||
import type {IChannelRepository} from '../IChannelRepository';
|
||||
import {AttachmentUploadService} from './AttachmentUploadService';
|
||||
import {CallService} from './CallService';
|
||||
import {ChannelDataService} from './ChannelDataService';
|
||||
import {GroupDmService} from './GroupDmService';
|
||||
import {MessageInteractionService} from './MessageInteractionService';
|
||||
import {MessageService} from './MessageService';
|
||||
import {MessagePersistenceService} from './message/MessagePersistenceService';
|
||||
|
||||
export class ChannelService {
|
||||
public readonly channelData: ChannelDataService;
|
||||
public readonly messages: MessageService;
|
||||
public readonly interactions: MessageInteractionService;
|
||||
public readonly attachments: AttachmentUploadService;
|
||||
public readonly groupDms: GroupDmService;
|
||||
public readonly calls: CallService;
|
||||
|
||||
constructor(
|
||||
channelRepository: IChannelRepository,
|
||||
userRepository: IUserRepository,
|
||||
guildRepository: IGuildRepository,
|
||||
packService: PackService,
|
||||
userCacheService: UserCacheService,
|
||||
embedService: EmbedService,
|
||||
readStateService: ReadStateService,
|
||||
cacheService: ICacheService,
|
||||
storageService: IStorageService,
|
||||
gatewayService: IGatewayService,
|
||||
mediaService: IMediaService,
|
||||
avatarService: AvatarService,
|
||||
workerService: IWorkerService,
|
||||
virusScanService: IVirusScanService,
|
||||
snowflakeService: SnowflakeService,
|
||||
rateLimitService: IRateLimitService,
|
||||
cloudflarePurgeQueue: ICloudflarePurgeQueue,
|
||||
favoriteMemeRepository: IFavoriteMemeRepository,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
voiceRoomStore: IVoiceRoomStore,
|
||||
liveKitService: ILiveKitService,
|
||||
inviteRepository: IInviteRepository,
|
||||
webhookRepository: IWebhookRepository,
|
||||
voiceAvailabilityService?: VoiceAvailabilityService,
|
||||
) {
|
||||
const messagePersistenceService = new MessagePersistenceService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
packService,
|
||||
embedService,
|
||||
storageService,
|
||||
mediaService,
|
||||
virusScanService,
|
||||
snowflakeService,
|
||||
readStateService,
|
||||
);
|
||||
|
||||
this.channelData = new ChannelDataService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
storageService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
avatarService,
|
||||
snowflakeService,
|
||||
cloudflarePurgeQueue,
|
||||
voiceRoomStore,
|
||||
liveKitService,
|
||||
voiceAvailabilityService,
|
||||
messagePersistenceService,
|
||||
guildAuditLogService,
|
||||
inviteRepository,
|
||||
webhookRepository,
|
||||
);
|
||||
|
||||
this.messages = new MessageService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
readStateService,
|
||||
cacheService,
|
||||
storageService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
workerService,
|
||||
snowflakeService,
|
||||
rateLimitService,
|
||||
cloudflarePurgeQueue,
|
||||
favoriteMemeRepository,
|
||||
guildAuditLogService,
|
||||
messagePersistenceService,
|
||||
);
|
||||
|
||||
this.interactions = new MessageInteractionService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
readStateService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
snowflakeService,
|
||||
messagePersistenceService,
|
||||
guildAuditLogService,
|
||||
);
|
||||
|
||||
this.attachments = new AttachmentUploadService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
storageService,
|
||||
cloudflarePurgeQueue,
|
||||
this.interactions.getChannelAuthenticated.bind(this.interactions),
|
||||
(channel) => {
|
||||
if (!channel) throw new Error('Channel is required');
|
||||
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
|
||||
throw new CannotSendMessageToNonTextChannelError();
|
||||
}
|
||||
},
|
||||
this.interactions.dispatchMessageUpdate.bind(this.interactions),
|
||||
this.messages.deleteMessage.bind(this.messages),
|
||||
);
|
||||
|
||||
this.groupDms = new GroupDmService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
snowflakeService,
|
||||
this.channelData.groupDmUpdateService,
|
||||
this.messages.getMessagePersistenceService(),
|
||||
);
|
||||
|
||||
this.calls = new CallService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
gatewayService,
|
||||
userCacheService,
|
||||
mediaService,
|
||||
snowflakeService,
|
||||
readStateService,
|
||||
voiceAvailabilityService,
|
||||
);
|
||||
}
|
||||
|
||||
async getChannel(...args: Parameters<ChannelDataService['getChannel']>) {
|
||||
return this.channelData.getChannel(...args);
|
||||
}
|
||||
|
||||
async getChannelAuthenticated(...args: Parameters<ChannelDataService['getChannelAuthenticated']>) {
|
||||
return this.channelData.getChannelAuthenticated(...args);
|
||||
}
|
||||
|
||||
async getPublicChannelData(...args: Parameters<ChannelDataService['getPublicChannelData']>) {
|
||||
return this.channelData.getPublicChannelData(...args);
|
||||
}
|
||||
|
||||
async getChannelMemberCount(...args: Parameters<ChannelDataService['getChannelMemberCount']>) {
|
||||
return this.channelData.getChannelMemberCount(...args);
|
||||
}
|
||||
|
||||
async getChannelSystem(...args: Parameters<ChannelDataService['getChannelSystem']>) {
|
||||
return this.channelData.getChannelSystem(...args);
|
||||
}
|
||||
|
||||
async editChannel(...args: Parameters<ChannelDataService['editChannel']>) {
|
||||
return this.channelData.editChannel(...args);
|
||||
}
|
||||
|
||||
async deleteChannel(...args: Parameters<ChannelDataService['deleteChannel']>) {
|
||||
return this.channelData.deleteChannel(...args);
|
||||
}
|
||||
|
||||
async getAvailableRtcRegions(...args: Parameters<ChannelDataService['getAvailableRtcRegions']>) {
|
||||
return this.channelData.getAvailableRtcRegions(...args);
|
||||
}
|
||||
|
||||
async sendMessage(...args: Parameters<MessageService['sendMessage']>) {
|
||||
return this.messages.sendMessage(...args);
|
||||
}
|
||||
|
||||
async sendWebhookMessage(...args: Parameters<MessageService['sendWebhookMessage']>) {
|
||||
return this.messages.sendWebhookMessage(...args);
|
||||
}
|
||||
|
||||
async validateMessageCanBeSent({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
}: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
data: MessageRequest;
|
||||
}): Promise<void> {
|
||||
return this.messages.validateMessageCanBeSent({user, channelId, data});
|
||||
}
|
||||
|
||||
async editMessage(...args: Parameters<MessageService['editMessage']>) {
|
||||
return this.messages.editMessage(...args);
|
||||
}
|
||||
|
||||
async deleteMessage(...args: Parameters<MessageService['deleteMessage']>) {
|
||||
return this.messages.deleteMessage(...args);
|
||||
}
|
||||
|
||||
async getMessage(...args: Parameters<MessageService['getMessage']>) {
|
||||
return this.messages.getMessage(...args);
|
||||
}
|
||||
|
||||
async getMessages(...args: Parameters<MessageService['getMessages']>) {
|
||||
return this.messages.getMessages(...args);
|
||||
}
|
||||
|
||||
async bulkDeleteMessages(...args: Parameters<MessageService['bulkDeleteMessages']>) {
|
||||
return this.messages.bulkDeleteMessages(...args);
|
||||
}
|
||||
|
||||
async deleteUserMessagesInGuild(...args: Parameters<MessageService['deleteUserMessagesInGuild']>) {
|
||||
return this.messages.deleteUserMessagesInGuild(...args);
|
||||
}
|
||||
|
||||
async sendJoinSystemMessage(...args: Parameters<MessageService['sendJoinSystemMessage']>) {
|
||||
return this.messages.sendJoinSystemMessage(...args);
|
||||
}
|
||||
|
||||
async searchMessages(...args: Parameters<MessageService['searchMessages']>) {
|
||||
return this.messages.searchMessages(...args);
|
||||
}
|
||||
|
||||
async anonymizeMessagesByAuthor(...args: Parameters<MessageService['anonymizeMessagesByAuthor']>) {
|
||||
return this.messages.anonymizeMessagesByAuthor(...args);
|
||||
}
|
||||
|
||||
async addReaction(...args: Parameters<MessageInteractionService['addReaction']>) {
|
||||
return this.interactions.addReaction(...args);
|
||||
}
|
||||
|
||||
async removeReaction(...args: Parameters<MessageInteractionService['removeReaction']>) {
|
||||
return this.interactions.removeReaction(...args);
|
||||
}
|
||||
|
||||
async removeOwnReaction(...args: Parameters<MessageInteractionService['removeOwnReaction']>) {
|
||||
return this.interactions.removeOwnReaction(...args);
|
||||
}
|
||||
|
||||
async removeAllReactions(...args: Parameters<MessageInteractionService['removeAllReactions']>) {
|
||||
return this.interactions.removeAllReactions(...args);
|
||||
}
|
||||
|
||||
async removeAllReactionsForEmoji(...args: Parameters<MessageInteractionService['removeAllReactionsForEmoji']>) {
|
||||
return this.interactions.removeAllReactionsForEmoji(...args);
|
||||
}
|
||||
|
||||
async getUsersForReaction(...args: Parameters<MessageInteractionService['getUsersForReaction']>) {
|
||||
return this.interactions.getUsersForReaction(...args);
|
||||
}
|
||||
|
||||
async getMessageReactions(...args: Parameters<MessageInteractionService['getMessageReactions']>) {
|
||||
return this.interactions.getMessageReactions(...args);
|
||||
}
|
||||
|
||||
async setHasReaction(...args: Parameters<MessageInteractionService['setHasReaction']>) {
|
||||
return this.interactions.setHasReaction(...args);
|
||||
}
|
||||
|
||||
async pinMessage(...args: Parameters<MessageInteractionService['pinMessage']>) {
|
||||
return this.interactions.pinMessage(...args);
|
||||
}
|
||||
|
||||
async unpinMessage(...args: Parameters<MessageInteractionService['unpinMessage']>) {
|
||||
return this.interactions.unpinMessage(...args);
|
||||
}
|
||||
|
||||
async getChannelPins(...args: Parameters<MessageInteractionService['getChannelPins']>) {
|
||||
return this.interactions.getChannelPins(...args);
|
||||
}
|
||||
|
||||
async startTyping(...args: Parameters<MessageInteractionService['startTyping']>) {
|
||||
return this.interactions.startTyping(...args);
|
||||
}
|
||||
|
||||
async ackMessage(...args: Parameters<MessageInteractionService['ackMessage']>) {
|
||||
return this.interactions.ackMessage(...args);
|
||||
}
|
||||
|
||||
async deleteReadState(...args: Parameters<MessageInteractionService['deleteReadState']>) {
|
||||
return this.interactions.deleteReadState(...args);
|
||||
}
|
||||
|
||||
async ackPins(...args: Parameters<MessageInteractionService['ackPins']>) {
|
||||
return this.interactions.ackPins(...args);
|
||||
}
|
||||
|
||||
async uploadFormDataAttachments(...args: Parameters<AttachmentUploadService['uploadFormDataAttachments']>) {
|
||||
return this.attachments.uploadFormDataAttachments(...args);
|
||||
}
|
||||
|
||||
async deleteAttachment(...args: Parameters<AttachmentUploadService['deleteAttachment']>) {
|
||||
return this.attachments.deleteAttachment(...args);
|
||||
}
|
||||
|
||||
async purgeChannelAttachments(...args: Parameters<AttachmentUploadService['purgeChannelAttachments']>) {
|
||||
return this.attachments.purgeChannelAttachments(...args);
|
||||
}
|
||||
|
||||
async removeRecipientFromChannel(...args: Parameters<GroupDmService['removeRecipientFromChannel']>) {
|
||||
return this.groupDms.removeRecipientFromChannel(...args);
|
||||
}
|
||||
|
||||
async updateGroupDmChannel(...args: Parameters<GroupDmService['updateGroupDmChannel']>) {
|
||||
return this.groupDms.updateGroupDmChannel(...args);
|
||||
}
|
||||
|
||||
async checkCallEligibility(...args: Parameters<CallService['checkCallEligibility']>) {
|
||||
return this.calls.checkCallEligibility(...args);
|
||||
}
|
||||
|
||||
async createOrGetCall(...args: Parameters<CallService['createOrGetCall']>) {
|
||||
return this.calls.createOrGetCall(...args);
|
||||
}
|
||||
|
||||
async updateCall(...args: Parameters<CallService['updateCall']>) {
|
||||
return this.calls.updateCall(...args);
|
||||
}
|
||||
|
||||
async ringCallRecipients(...args: Parameters<CallService['ringCallRecipients']>) {
|
||||
return this.calls.ringCallRecipients(...args);
|
||||
}
|
||||
|
||||
async stopRingingCallRecipients(...args: Parameters<CallService['stopRingingCallRecipients']>) {
|
||||
return this.calls.stopRingingCallRecipients(...args);
|
||||
}
|
||||
|
||||
async setChannelPermissionOverwrite(params: Parameters<ChannelDataService['setChannelPermissionOverwrite']>[0]) {
|
||||
return this.channelData.setChannelPermissionOverwrite(params);
|
||||
}
|
||||
|
||||
async deleteChannelPermissionOverwrite(
|
||||
params: Parameters<ChannelDataService['deleteChannelPermissionOverwrite']>[0],
|
||||
) {
|
||||
return this.channelData.deleteChannelPermissionOverwrite(params);
|
||||
}
|
||||
}
|
||||
122
fluxer_api/src/channel/services/DMPermissionValidator.ts
Normal file
122
fluxer_api/src/channel/services/DMPermissionValidator.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {RelationshipTypes, UserFlags} from '~/Constants';
|
||||
import {CannotSendMessagesToUserError, UnclaimedAccountRestrictedError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {checkGuildVerificationWithGuildModel} from '~/utils/GuildVerificationUtils';
|
||||
|
||||
interface DMPermissionValidatorDeps {
|
||||
userRepository: IUserRepository;
|
||||
guildRepository: IGuildRepository;
|
||||
}
|
||||
|
||||
export class DMPermissionValidator {
|
||||
constructor(private deps: DMPermissionValidatorDeps) {}
|
||||
|
||||
async validate({recipients, userId}: {recipients: Array<User>; userId: UserID}): Promise<void> {
|
||||
const senderUser = await this.deps.userRepository.findUnique(userId);
|
||||
if (senderUser && !senderUser.passwordHash && !senderUser.isBot) {
|
||||
throw new UnclaimedAccountRestrictedError('send direct messages');
|
||||
}
|
||||
|
||||
const targetUser = recipients.find((recipient) => recipient.id !== userId);
|
||||
if (!targetUser) return;
|
||||
|
||||
if (!targetUser.passwordHash && !targetUser.isBot) {
|
||||
throw new UnclaimedAccountRestrictedError('receive direct messages');
|
||||
}
|
||||
|
||||
const senderBlockedTarget = await this.deps.userRepository.getRelationship(
|
||||
userId,
|
||||
targetUser.id,
|
||||
RelationshipTypes.BLOCKED,
|
||||
);
|
||||
if (senderBlockedTarget) {
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
|
||||
const targetBlockedSender = await this.deps.userRepository.getRelationship(
|
||||
targetUser.id,
|
||||
userId,
|
||||
RelationshipTypes.BLOCKED,
|
||||
);
|
||||
if (targetBlockedSender) {
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
|
||||
const friendship = await this.deps.userRepository.getRelationship(userId, targetUser.id, RelationshipTypes.FRIEND);
|
||||
if (friendship) return;
|
||||
|
||||
if (targetUser.flags & UserFlags.APP_STORE_REVIEWER) {
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
|
||||
const targetSettings = await this.deps.userRepository.findSettings(targetUser.id);
|
||||
if (!targetSettings) return;
|
||||
|
||||
const dmRestrictionsEnabled = targetSettings.defaultGuildsRestricted || targetSettings.restrictedGuilds.size > 0;
|
||||
if (!dmRestrictionsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [userGuilds, targetGuilds] = await Promise.all([
|
||||
this.deps.guildRepository.listUserGuilds(userId),
|
||||
this.deps.guildRepository.listUserGuilds(targetUser.id),
|
||||
]);
|
||||
|
||||
if (!senderUser) {
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
|
||||
const userGuildIds = new Set(userGuilds.map((guild) => guild.id));
|
||||
const mutualGuilds = targetGuilds.filter((guild) => userGuildIds.has(guild.id));
|
||||
|
||||
if (mutualGuilds.length === 0) {
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
|
||||
const restrictedGuildIds = targetSettings.restrictedGuilds;
|
||||
|
||||
let hasValidMutualGuild = false;
|
||||
for (const guild of mutualGuilds) {
|
||||
if (restrictedGuildIds.has(guild.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const member = await this.deps.guildRepository.getMember(guild.id, userId);
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
checkGuildVerificationWithGuildModel({user: senderUser, guild, member});
|
||||
hasValidMutualGuild = true;
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!hasValidMutualGuild) {
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
}
|
||||
}
|
||||
100
fluxer_api/src/channel/services/GroupDmService.ts
Normal file
100
fluxer_api/src/channel/services/GroupDmService.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, UserID} from '~/BrandedTypes';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Channel} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {IChannelRepository} from '../IChannelRepository';
|
||||
import type {GroupDmUpdateService} from './channel_data/GroupDmUpdateService';
|
||||
import {GroupDmOperationsService} from './group_dm/GroupDmOperationsService';
|
||||
import type {MessagePersistenceService} from './message/MessagePersistenceService';
|
||||
|
||||
export class GroupDmService {
|
||||
private operationsService: GroupDmOperationsService;
|
||||
|
||||
constructor(
|
||||
channelRepository: IChannelRepository,
|
||||
userRepository: IUserRepository,
|
||||
guildRepository: IGuildRepository,
|
||||
userCacheService: UserCacheService,
|
||||
gatewayService: IGatewayService,
|
||||
mediaService: IMediaService,
|
||||
snowflakeService: SnowflakeService,
|
||||
groupDmUpdateService: GroupDmUpdateService,
|
||||
messagePersistenceService: MessagePersistenceService,
|
||||
) {
|
||||
this.operationsService = new GroupDmOperationsService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
snowflakeService,
|
||||
groupDmUpdateService,
|
||||
messagePersistenceService,
|
||||
);
|
||||
}
|
||||
|
||||
async addRecipientToChannel(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
recipientId: UserID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
return this.operationsService.addRecipientToChannel(params);
|
||||
}
|
||||
|
||||
async addRecipientViaInvite(params: {
|
||||
channelId: ChannelID;
|
||||
recipientId: UserID;
|
||||
inviterId?: UserID | null;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
return this.operationsService.addRecipientViaInvite(params);
|
||||
}
|
||||
|
||||
async removeRecipientFromChannel(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
recipientId: UserID;
|
||||
requestCache: RequestCache;
|
||||
silent?: boolean;
|
||||
}): Promise<void> {
|
||||
return this.operationsService.removeRecipientFromChannel(params);
|
||||
}
|
||||
|
||||
async updateGroupDmChannel(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
name?: string;
|
||||
icon?: string | null;
|
||||
ownerId?: UserID;
|
||||
nicks?: Record<string, string | null> | null;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
return this.operationsService.updateGroupDmChannel(params);
|
||||
}
|
||||
}
|
||||
349
fluxer_api/src/channel/services/MessageInteractionService.ts
Normal file
349
fluxer_api/src/channel/services/MessageInteractionService.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, channelIdToUserId, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes, type GatewayDispatchEvent, Permissions} from '~/Constants';
|
||||
import type {ChannelPinResponse} from '~/channel/ChannelModel';
|
||||
import {mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Channel, Message, MessageReaction} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {ReadStateService} from '~/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserPartialResponse} from '~/user/UserModel';
|
||||
import type {IChannelRepository} from '../IChannelRepository';
|
||||
import {MessageInteractionAuthService} from './interaction/MessageInteractionAuthService';
|
||||
import {MessagePinService} from './interaction/MessagePinService';
|
||||
import {MessageReactionService} from './interaction/MessageReactionService';
|
||||
import {MessageReadStateService} from './interaction/MessageReadStateService';
|
||||
import type {MessagePersistenceService} from './message/MessagePersistenceService';
|
||||
|
||||
export class MessageInteractionService {
|
||||
private authService: MessageInteractionAuthService;
|
||||
private readStateService: MessageReadStateService;
|
||||
private pinService: MessagePinService;
|
||||
private reactionService: MessageReactionService;
|
||||
|
||||
constructor(
|
||||
channelRepository: IChannelRepository,
|
||||
userRepository: IUserRepository,
|
||||
guildRepository: IGuildRepository,
|
||||
private userCacheService: UserCacheService,
|
||||
readStateService: ReadStateService,
|
||||
private gatewayService: IGatewayService,
|
||||
private mediaService: IMediaService,
|
||||
snowflakeService: SnowflakeService,
|
||||
messagePersistenceService: MessagePersistenceService,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
) {
|
||||
this.authService = new MessageInteractionAuthService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
gatewayService,
|
||||
);
|
||||
|
||||
this.readStateService = new MessageReadStateService(gatewayService, readStateService);
|
||||
|
||||
this.pinService = new MessagePinService(
|
||||
gatewayService,
|
||||
channelRepository,
|
||||
userCacheService,
|
||||
mediaService,
|
||||
snowflakeService,
|
||||
messagePersistenceService,
|
||||
guildAuditLogService,
|
||||
);
|
||||
|
||||
this.reactionService = new MessageReactionService(
|
||||
gatewayService,
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
);
|
||||
}
|
||||
|
||||
async startTyping({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
await authChannel.checkPermission(Permissions.SEND_MESSAGES);
|
||||
await this.readStateService.startTyping({authChannel, userId});
|
||||
}
|
||||
|
||||
async ackMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
mentionCount,
|
||||
manual,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
mentionCount: number;
|
||||
manual?: boolean;
|
||||
}): Promise<void> {
|
||||
await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
await this.readStateService.ackMessage({userId, channelId, messageId, mentionCount, manual});
|
||||
}
|
||||
|
||||
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
await this.readStateService.deleteReadState({userId, channelId});
|
||||
}
|
||||
|
||||
async ackPins({
|
||||
userId,
|
||||
channelId,
|
||||
timestamp,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
timestamp: Date;
|
||||
}): Promise<void> {
|
||||
await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
await this.readStateService.ackPins({userId, channelId, timestamp});
|
||||
}
|
||||
|
||||
async getChannelPins({
|
||||
userId,
|
||||
channelId,
|
||||
requestCache,
|
||||
beforeTimestamp,
|
||||
limit,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
requestCache: RequestCache;
|
||||
beforeTimestamp?: Date;
|
||||
limit?: number;
|
||||
}): Promise<{items: Array<ChannelPinResponse>; has_more: boolean}> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
return this.pinService.getChannelPins({authChannel, requestCache, beforeTimestamp, limit});
|
||||
}
|
||||
|
||||
async pinMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
await this.authService.validateDMSendPermissions({channelId, userId});
|
||||
}
|
||||
await this.pinService.pinMessage({authChannel, messageId, userId, requestCache});
|
||||
}
|
||||
|
||||
async unpinMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
await this.authService.validateDMSendPermissions({channelId, userId});
|
||||
}
|
||||
await this.pinService.unpinMessage({authChannel, messageId, userId, requestCache});
|
||||
}
|
||||
|
||||
async getUsersForReaction({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
limit,
|
||||
after,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
emoji: string;
|
||||
limit?: number;
|
||||
after?: UserID;
|
||||
}): Promise<Array<UserPartialResponse>> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
return this.reactionService.getUsersForReaction({authChannel, messageId, emoji, limit, after});
|
||||
}
|
||||
|
||||
async addReaction({
|
||||
userId,
|
||||
sessionId,
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
}: {
|
||||
userId: UserID;
|
||||
sessionId?: string;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
emoji: string;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
await this.reactionService.addReaction({authChannel, messageId, emoji, userId, sessionId});
|
||||
}
|
||||
|
||||
async removeReaction({
|
||||
userId,
|
||||
sessionId,
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
targetId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
sessionId?: string;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
emoji: string;
|
||||
targetId: UserID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
await this.reactionService.removeReaction({authChannel, messageId, emoji, targetId, sessionId, actorId: userId});
|
||||
}
|
||||
|
||||
async removeOwnReaction({
|
||||
userId,
|
||||
sessionId,
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
sessionId?: string;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
emoji: string;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
await this.removeReaction({userId, sessionId, channelId, messageId, emoji, targetId: userId, requestCache});
|
||||
}
|
||||
|
||||
async removeAllReactionsForEmoji({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
emoji: string;
|
||||
}): Promise<void> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
await this.reactionService.removeAllReactionsForEmoji({authChannel, messageId, emoji, actorId: userId});
|
||||
}
|
||||
|
||||
async removeAllReactions({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<void> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
await this.reactionService.removeAllReactions({authChannel, messageId, actorId: userId});
|
||||
}
|
||||
|
||||
async getMessageReactions({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<Array<MessageReaction>> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
return this.reactionService.getMessageReactions({authChannel, messageId});
|
||||
}
|
||||
|
||||
async setHasReaction(channelId: ChannelID, messageId: MessageID, hasReaction: boolean): Promise<void> {
|
||||
return this.reactionService.setHasReaction(channelId, messageId, hasReaction);
|
||||
}
|
||||
|
||||
async getChannelAuthenticated({userId, channelId}: {userId: UserID; channelId: ChannelID}) {
|
||||
return this.authService.getChannelAuthenticated({userId, channelId});
|
||||
}
|
||||
|
||||
async dispatchMessageUpdate({
|
||||
channel,
|
||||
message,
|
||||
requestCache,
|
||||
currentUserId,
|
||||
}: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
requestCache: RequestCache;
|
||||
currentUserId?: UserID;
|
||||
}): Promise<void> {
|
||||
const messageResponse = await mapMessageToResponse({
|
||||
message,
|
||||
currentUserId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
});
|
||||
|
||||
await this.dispatchEvent({
|
||||
channel,
|
||||
event: 'MESSAGE_UPDATE',
|
||||
data: messageResponse,
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchEvent(params: {channel: Channel; event: GatewayDispatchEvent; data: unknown}): Promise<void> {
|
||||
const {channel, event, data} = params;
|
||||
|
||||
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
return this.gatewayService.dispatchPresence({
|
||||
userId: channelIdToUserId(channel.id),
|
||||
event,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.guildId) {
|
||||
return this.gatewayService.dispatchGuild({guildId: channel.guildId, event, data});
|
||||
} else {
|
||||
for (const recipientId of channel.recipientIds) {
|
||||
await this.gatewayService.dispatchPresence({userId: recipientId, event, data});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
312
fluxer_api/src/channel/services/MessageService.ts
Normal file
312
fluxer_api/src/channel/services/MessageService.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {
|
||||
MessageRequest,
|
||||
MessageSearchRequest,
|
||||
MessageSearchResponse,
|
||||
MessageUpdateRequest,
|
||||
} from '~/channel/ChannelModel';
|
||||
import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository';
|
||||
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Message, User, Webhook} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {ReadStateService} from '~/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {IWorkerService} from '~/worker/IWorkerService';
|
||||
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
|
||||
import {MessageAnonymizationService} from './message/MessageAnonymizationService';
|
||||
import {MessageChannelAuthService} from './message/MessageChannelAuthService';
|
||||
import {MessageDispatchService} from './message/MessageDispatchService';
|
||||
import {MessageMentionService} from './message/MessageMentionService';
|
||||
import {MessageOperationsService} from './message/MessageOperationsService';
|
||||
import type {MessagePersistenceService} from './message/MessagePersistenceService';
|
||||
import {MessageProcessingService} from './message/MessageProcessingService';
|
||||
import {MessageRetrievalService} from './message/MessageRetrievalService';
|
||||
import {MessageSearchService} from './message/MessageSearchService';
|
||||
import {MessageSystemService} from './message/MessageSystemService';
|
||||
import {MessageValidationService} from './message/MessageValidationService';
|
||||
|
||||
export class MessageService {
|
||||
private validationService: MessageValidationService;
|
||||
private mentionService: MessageMentionService;
|
||||
private searchService: MessageSearchService;
|
||||
private persistenceService: MessagePersistenceService;
|
||||
private channelAuthService: MessageChannelAuthService;
|
||||
private dispatchService: MessageDispatchService;
|
||||
private processingService: MessageProcessingService;
|
||||
private systemService: MessageSystemService;
|
||||
private operationsService: MessageOperationsService;
|
||||
private retrievalService: MessageRetrievalService;
|
||||
private anonymizationService: MessageAnonymizationService;
|
||||
|
||||
constructor(
|
||||
channelRepository: IChannelRepositoryAggregate,
|
||||
userRepository: IUserRepository,
|
||||
guildRepository: IGuildRepository,
|
||||
userCacheService: UserCacheService,
|
||||
readStateService: ReadStateService,
|
||||
cacheService: ICacheService,
|
||||
storageService: IStorageService,
|
||||
gatewayService: IGatewayService,
|
||||
mediaService: IMediaService,
|
||||
workerService: IWorkerService,
|
||||
snowflakeService: SnowflakeService,
|
||||
rateLimitService: IRateLimitService,
|
||||
cloudflarePurgeQueue: ICloudflarePurgeQueue,
|
||||
favoriteMemeRepository: IFavoriteMemeRepository,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
persistenceService: MessagePersistenceService,
|
||||
) {
|
||||
this.validationService = new MessageValidationService(cacheService);
|
||||
this.mentionService = new MessageMentionService(userRepository, guildRepository, workerService);
|
||||
this.searchService = new MessageSearchService(userRepository, workerService);
|
||||
this.persistenceService = persistenceService;
|
||||
this.channelAuthService = new MessageChannelAuthService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
gatewayService,
|
||||
);
|
||||
this.dispatchService = new MessageDispatchService(
|
||||
gatewayService,
|
||||
userCacheService,
|
||||
mediaService,
|
||||
channelRepository,
|
||||
);
|
||||
this.processingService = new MessageProcessingService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
readStateService,
|
||||
this.mentionService,
|
||||
);
|
||||
this.systemService = new MessageSystemService(
|
||||
channelRepository,
|
||||
guildRepository,
|
||||
snowflakeService,
|
||||
this.persistenceService,
|
||||
);
|
||||
this.operationsService = new MessageOperationsService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
cacheService,
|
||||
storageService,
|
||||
gatewayService,
|
||||
snowflakeService,
|
||||
rateLimitService,
|
||||
cloudflarePurgeQueue,
|
||||
favoriteMemeRepository,
|
||||
this.validationService,
|
||||
this.mentionService,
|
||||
this.searchService,
|
||||
this.persistenceService,
|
||||
this.channelAuthService,
|
||||
this.processingService,
|
||||
guildAuditLogService,
|
||||
this.dispatchService,
|
||||
);
|
||||
this.retrievalService = new MessageRetrievalService(
|
||||
channelRepository,
|
||||
userCacheService,
|
||||
mediaService,
|
||||
this.channelAuthService,
|
||||
this.processingService,
|
||||
this.searchService,
|
||||
userRepository,
|
||||
);
|
||||
this.anonymizationService = new MessageAnonymizationService(channelRepository);
|
||||
}
|
||||
|
||||
async sendMessage({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
requestCache,
|
||||
}: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
data: MessageRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Message> {
|
||||
return this.operationsService.sendMessage({user, channelId, data, requestCache});
|
||||
}
|
||||
|
||||
async sendWebhookMessage({
|
||||
webhook,
|
||||
data,
|
||||
username,
|
||||
avatar,
|
||||
requestCache,
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
data: MessageRequest;
|
||||
username?: string | null;
|
||||
avatar?: string | null;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Message> {
|
||||
return this.operationsService.sendWebhookMessage({webhook, data, username, avatar, requestCache});
|
||||
}
|
||||
|
||||
async validateMessageCanBeSent({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
}: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
data: MessageRequest;
|
||||
}): Promise<void> {
|
||||
return this.operationsService.validateMessageCanBeSent({user, channelId, data});
|
||||
}
|
||||
|
||||
async editMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
data,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
data: MessageUpdateRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Message> {
|
||||
return this.operationsService.editMessage({userId, channelId, messageId, data, requestCache});
|
||||
}
|
||||
|
||||
async deleteMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
return this.operationsService.deleteMessage({userId, channelId, messageId, requestCache});
|
||||
}
|
||||
|
||||
async getMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<Message> {
|
||||
return this.retrievalService.getMessage({userId, channelId, messageId});
|
||||
}
|
||||
|
||||
async getMessages({
|
||||
userId,
|
||||
channelId,
|
||||
limit,
|
||||
before,
|
||||
after,
|
||||
around,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
limit: number;
|
||||
before?: MessageID;
|
||||
after?: MessageID;
|
||||
around?: MessageID;
|
||||
}): Promise<Array<Message>> {
|
||||
return this.retrievalService.getMessages({userId, channelId, limit, before, after, around});
|
||||
}
|
||||
|
||||
async bulkDeleteMessages({
|
||||
userId,
|
||||
channelId,
|
||||
messageIds,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageIds: Array<MessageID>;
|
||||
}): Promise<void> {
|
||||
return this.operationsService.bulkDeleteMessages({userId, channelId, messageIds});
|
||||
}
|
||||
|
||||
async deleteUserMessagesInGuild({
|
||||
userId,
|
||||
guildId,
|
||||
days,
|
||||
}: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
days: number;
|
||||
}): Promise<void> {
|
||||
return this.operationsService.deleteUserMessagesInGuild({userId, guildId, days});
|
||||
}
|
||||
|
||||
async sendJoinSystemMessage({
|
||||
guildId,
|
||||
userId,
|
||||
requestCache,
|
||||
}: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
return this.systemService.sendJoinSystemMessage({
|
||||
guildId,
|
||||
userId,
|
||||
requestCache,
|
||||
dispatchMessageCreate: this.dispatchService.dispatchMessageCreate.bind(this.dispatchService),
|
||||
});
|
||||
}
|
||||
|
||||
async searchMessages({
|
||||
userId,
|
||||
channelId,
|
||||
searchParams,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
searchParams: MessageSearchRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<MessageSearchResponse> {
|
||||
return this.retrievalService.searchMessages({userId, channelId, searchParams, requestCache});
|
||||
}
|
||||
|
||||
async anonymizeMessagesByAuthor(originalAuthorId: UserID, newAuthorId: UserID): Promise<void> {
|
||||
return this.anonymizationService.anonymizeMessagesByAuthor(originalAuthorId, newAuthorId);
|
||||
}
|
||||
|
||||
getMessagePersistenceService(): MessagePersistenceService {
|
||||
return this.persistenceService;
|
||||
}
|
||||
}
|
||||
223
fluxer_api/src/channel/services/ScheduledMessageService.ts
Normal file
223
fluxer_api/src/channel/services/ScheduledMessageService.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {DateTime, IANAZone} from 'luxon';
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {createMessageID} from '~/BrandedTypes';
|
||||
import type {MessageRequest} from '~/channel/ChannelModel';
|
||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import {FeatureFlags} from '~/constants/FeatureFlags';
|
||||
import {InputValidationError} from '~/Errors';
|
||||
import {FeatureTemporarilyDisabledError} from '~/errors/FeatureTemporarilyDisabledError';
|
||||
import type {FeatureFlagService} from '~/feature_flag/FeatureFlagService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {User} from '~/Models';
|
||||
import type {ScheduledMessagePayload} from '~/models/ScheduledMessage';
|
||||
import {ScheduledMessage} from '~/models/ScheduledMessage';
|
||||
import type {ScheduledMessageRepository} from '~/user/repositories/ScheduledMessageRepository';
|
||||
import type {WorkerService} from '~/worker/WorkerService';
|
||||
import type {ChannelService} from './ChannelService';
|
||||
|
||||
const MAX_SCHEDULE_DELAY_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
export const SCHEDULED_MESSAGE_TTL_SECONDS = 31 * 24 * 60 * 60;
|
||||
const DEFAULT_TIMEZONE = 'UTC';
|
||||
const WORKER_TASK_NAME = 'sendScheduledMessage';
|
||||
|
||||
interface ScheduleParams {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
data: MessageRequest;
|
||||
scheduledLocalAt: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
interface UpdateScheduleParams extends ScheduleParams {
|
||||
scheduledMessageId: MessageID;
|
||||
existing?: ScheduledMessage;
|
||||
}
|
||||
|
||||
interface SendScheduledMessageWorkerPayload {
|
||||
userId: string;
|
||||
scheduledMessageId: string;
|
||||
expectedScheduledAt: string;
|
||||
}
|
||||
|
||||
export class ScheduledMessageService {
|
||||
constructor(
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly scheduledMessageRepository: ScheduledMessageRepository,
|
||||
private readonly workerService: WorkerService,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly channelRepository?: IChannelRepository,
|
||||
private readonly featureFlagService?: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
async listScheduledMessages(userId: UserID): Promise<Array<ScheduledMessage>> {
|
||||
return await this.scheduledMessageRepository.listScheduledMessages(userId);
|
||||
}
|
||||
|
||||
async getScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<ScheduledMessage | null> {
|
||||
return await this.scheduledMessageRepository.getScheduledMessage(userId, scheduledMessageId);
|
||||
}
|
||||
|
||||
async createScheduledMessage(params: ScheduleParams): Promise<ScheduledMessage> {
|
||||
return await this.upsertScheduledMessage({
|
||||
...params,
|
||||
scheduledMessageId: createSnowflake(this.snowflakeService),
|
||||
});
|
||||
}
|
||||
|
||||
async updateScheduledMessage(params: UpdateScheduleParams): Promise<ScheduledMessage> {
|
||||
return await this.upsertScheduledMessage({
|
||||
...params,
|
||||
existing:
|
||||
params.existing ??
|
||||
(await this.getScheduledMessage(params.user.id as UserID, params.scheduledMessageId)) ??
|
||||
undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async cancelScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<void> {
|
||||
await this.scheduledMessageRepository.deleteScheduledMessage(userId, scheduledMessageId);
|
||||
}
|
||||
|
||||
async markInvalid(userId: UserID, scheduledMessageId: MessageID, reason: string): Promise<void> {
|
||||
await this.scheduledMessageRepository.markInvalid(
|
||||
userId,
|
||||
scheduledMessageId,
|
||||
reason,
|
||||
SCHEDULED_MESSAGE_TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
private async upsertScheduledMessage(params: UpdateScheduleParams): Promise<ScheduledMessage> {
|
||||
const {user, channelId, data, scheduledLocalAt, timezone} = params;
|
||||
|
||||
if (this.featureFlagService && this.channelRepository) {
|
||||
const channel = await this.channelRepository.findUnique(channelId);
|
||||
if (channel?.guildId) {
|
||||
const guildId = channel.guildId.toString();
|
||||
if (!this.featureFlagService.isFeatureEnabled(FeatureFlags.MESSAGE_SCHEDULING, guildId)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.channelService.messages.validateMessageCanBeSent({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
});
|
||||
|
||||
const scheduledAt = this.resolveScheduledAt(scheduledLocalAt, timezone);
|
||||
const payload = toScheduledPayload(data);
|
||||
const existing = params.existing;
|
||||
|
||||
const message = new ScheduledMessage({
|
||||
userId: user.id,
|
||||
id: params.scheduledMessageId,
|
||||
channelId,
|
||||
scheduledAt,
|
||||
scheduledLocalAt,
|
||||
timezone: timezone ?? DEFAULT_TIMEZONE,
|
||||
payload,
|
||||
status: 'pending',
|
||||
statusReason: null,
|
||||
createdAt: existing?.createdAt,
|
||||
invalidatedAt: null,
|
||||
});
|
||||
|
||||
await this.scheduledMessageRepository.upsertScheduledMessage(message, SCHEDULED_MESSAGE_TTL_SECONDS);
|
||||
await this.scheduleWorker(message);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private resolveScheduledAt(local: string, timezone?: string): Date {
|
||||
const zone = timezone?.trim() || DEFAULT_TIMEZONE;
|
||||
|
||||
if (!IANAZone.isValidZone(zone)) {
|
||||
throw InputValidationError.create('timezone', 'Invalid timezone identifier');
|
||||
}
|
||||
|
||||
const dt = DateTime.fromISO(local, {zone});
|
||||
if (!dt.isValid) {
|
||||
throw InputValidationError.create('scheduled_local_at', 'Invalid date/time for scheduled send');
|
||||
}
|
||||
|
||||
const scheduledAt = dt.toJSDate();
|
||||
const now = Date.now();
|
||||
|
||||
const diffMs = scheduledAt.getTime() - now;
|
||||
if (diffMs <= 0) {
|
||||
throw InputValidationError.create('scheduled_local_at', 'Scheduled time must be in the future');
|
||||
}
|
||||
|
||||
if (diffMs > MAX_SCHEDULE_DELAY_MS) {
|
||||
throw InputValidationError.create(
|
||||
'scheduled_local_at',
|
||||
'Scheduled messages can be at most 30 days in the future',
|
||||
);
|
||||
}
|
||||
|
||||
return scheduledAt;
|
||||
}
|
||||
|
||||
private async scheduleWorker(message: ScheduledMessage): Promise<void> {
|
||||
const payload: SendScheduledMessageWorkerPayload = {
|
||||
userId: message.userId.toString(),
|
||||
scheduledMessageId: message.id.toString(),
|
||||
expectedScheduledAt: message.scheduledAt.toISOString(),
|
||||
};
|
||||
await this.workerService.addJob(WORKER_TASK_NAME, payload, {runAt: message.scheduledAt});
|
||||
}
|
||||
}
|
||||
|
||||
function createSnowflake(snowflakeService: SnowflakeService): MessageID {
|
||||
return createMessageID(snowflakeService.generate());
|
||||
}
|
||||
|
||||
function toScheduledPayload(data: MessageRequest): ScheduledMessagePayload {
|
||||
return {
|
||||
content: data.content ?? null,
|
||||
embeds: data.embeds,
|
||||
attachments: data.attachments,
|
||||
message_reference: data.message_reference
|
||||
? {
|
||||
message_id: data.message_reference.message_id.toString(),
|
||||
channel_id: data.message_reference.channel_id?.toString(),
|
||||
guild_id: data.message_reference.guild_id?.toString(),
|
||||
type: data.message_reference.type,
|
||||
}
|
||||
: undefined,
|
||||
allowed_mentions: data.allowed_mentions
|
||||
? {
|
||||
parse: data.allowed_mentions.parse,
|
||||
users: data.allowed_mentions.users?.map((id) => id.toString()),
|
||||
roles: data.allowed_mentions.roles?.map((id) => id.toString()),
|
||||
replied_user: data.allowed_mentions.replied_user,
|
||||
}
|
||||
: undefined,
|
||||
flags: data.flags,
|
||||
nonce: data.nonce,
|
||||
favorite_meme_id: data.favorite_meme_id?.toString(),
|
||||
sticker_ids: data.sticker_ids?.map((id) => id.toString()),
|
||||
tts: data.tts,
|
||||
};
|
||||
}
|
||||
119
fluxer_api/src/channel/services/StreamPreviewService.ts
Normal file
119
fluxer_api/src/channel/services/StreamPreviewService.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {APIErrorCodes} from '~/constants/API';
|
||||
import {BadRequestError} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
|
||||
const MAX_PREVIEW_BYTES = 1_000_000;
|
||||
const PREVIEW_TTL_SECONDS = 60 * 60 * 24;
|
||||
|
||||
interface StreamPreviewMeta {
|
||||
bucket: string;
|
||||
key: string;
|
||||
updatedAt: number;
|
||||
ownerId: string;
|
||||
channelId: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
export class StreamPreviewService {
|
||||
constructor(
|
||||
private readonly storageService: IStorageService,
|
||||
private readonly cacheService: ICacheService,
|
||||
) {}
|
||||
|
||||
private getCacheKey(streamKey: string): string {
|
||||
return `stream_preview:${streamKey}`;
|
||||
}
|
||||
|
||||
private getObjectKey(streamKey: string): string {
|
||||
return `stream_previews/${streamKey}.jpg`;
|
||||
}
|
||||
|
||||
private assertJpeg(buffer: Uint8Array, contentType?: string) {
|
||||
const ct = (contentType || '').toLowerCase();
|
||||
const isJpeg = ct.includes('jpeg') || ct.includes('jpg') || this.looksLikeJpeg(buffer);
|
||||
if (!isJpeg) {
|
||||
throw new BadRequestError({
|
||||
code: APIErrorCodes.INVALID_REQUEST,
|
||||
message: 'Preview must be JPEG',
|
||||
});
|
||||
}
|
||||
if (buffer.byteLength > MAX_PREVIEW_BYTES) {
|
||||
throw new BadRequestError({
|
||||
code: APIErrorCodes.FILE_SIZE_TOO_LARGE,
|
||||
message: 'Preview too large (max 1MB)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private looksLikeJpeg(buffer: Uint8Array): boolean {
|
||||
return (
|
||||
buffer.length > 3 &&
|
||||
buffer[0] === 0xff &&
|
||||
buffer[1] === 0xd8 &&
|
||||
buffer[buffer.length - 2] === 0xff &&
|
||||
buffer[buffer.length - 1] === 0xd9
|
||||
);
|
||||
}
|
||||
|
||||
async uploadPreview(params: {
|
||||
streamKey: string;
|
||||
channelId: ChannelID;
|
||||
userId: UserID;
|
||||
body: Uint8Array;
|
||||
contentType?: string;
|
||||
}): Promise<void> {
|
||||
this.assertJpeg(params.body, params.contentType);
|
||||
|
||||
const bucket = Config.s3.buckets.uploads;
|
||||
const key = this.getObjectKey(params.streamKey);
|
||||
const expiresAt = new Date(Date.now() + PREVIEW_TTL_SECONDS * 1000);
|
||||
|
||||
await this.storageService.uploadObject({
|
||||
bucket,
|
||||
key,
|
||||
body: params.body,
|
||||
contentType: params.contentType ?? 'image/jpeg',
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const meta: StreamPreviewMeta = {
|
||||
bucket,
|
||||
key,
|
||||
updatedAt: Date.now(),
|
||||
ownerId: params.userId.toString(),
|
||||
channelId: params.channelId.toString(),
|
||||
contentType: params.contentType ?? 'image/jpeg',
|
||||
};
|
||||
|
||||
await this.cacheService.set(this.getCacheKey(params.streamKey), meta, PREVIEW_TTL_SECONDS);
|
||||
}
|
||||
|
||||
async getPreview(streamKey: string): Promise<{buffer: Uint8Array; contentType: string} | null> {
|
||||
const meta = await this.cacheService.get<StreamPreviewMeta>(this.getCacheKey(streamKey));
|
||||
if (!meta) return null;
|
||||
const buffer = await this.storageService.readObject(meta.bucket, meta.key);
|
||||
return {buffer, contentType: meta.contentType || 'image/jpeg'};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {BaseChannelAuthService, type ChannelAuthOptions} from '../BaseChannelAuthService';
|
||||
|
||||
export class ChannelAuthService extends BaseChannelAuthService {
|
||||
protected readonly options: ChannelAuthOptions = {
|
||||
errorOnMissingGuild: 'missing_permissions',
|
||||
validateNsfw: true,
|
||||
useVirtualPersonalNotes: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ChannelID,
|
||||
createChannelID,
|
||||
createGuildID,
|
||||
createRoleID,
|
||||
createUserID,
|
||||
type GuildID,
|
||||
type RoleID,
|
||||
type UserID,
|
||||
} from '~/BrandedTypes';
|
||||
import {ALL_PERMISSIONS, ChannelTypes, GuildFeatures, MAX_CHANNELS_PER_CATEGORY, Permissions} from '~/Constants';
|
||||
import {AuditLogActionType} from '~/constants/AuditLogActionType';
|
||||
import {
|
||||
CannotExecuteOnDmError,
|
||||
InputValidationError,
|
||||
InvalidChannelTypeError,
|
||||
MaxCategoryChannelsError,
|
||||
MissingPermissionsError,
|
||||
UnknownChannelError,
|
||||
} from '~/Errors';
|
||||
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
|
||||
import {ChannelHelpers} from '~/guild/services/channel/ChannelHelpers';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {ILiveKitService} from '~/infrastructure/ILiveKitService';
|
||||
import type {IVoiceRoomStore} from '~/infrastructure/IVoiceRoomStore';
|
||||
import type {IInviteRepository} from '~/invite/IInviteRepository';
|
||||
import {Logger} from '~/Logger';
|
||||
import {type Channel, ChannelPermissionOverwrite} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {ChannelNameType} from '~/Schema';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {serializeChannelForAudit} from '~/utils/AuditSerializationUtils';
|
||||
import type {VoiceAvailabilityService} from '~/voice/VoiceAvailabilityService';
|
||||
import type {VoiceRegionAvailability} from '~/voice/VoiceModel';
|
||||
import type {IWebhookRepository} from '~/webhook/IWebhookRepository';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import type {ChannelAuthService} from './ChannelAuthService';
|
||||
import type {ChannelUtilsService} from './ChannelUtilsService';
|
||||
|
||||
export interface ChannelUpdateData {
|
||||
name?: string;
|
||||
topic?: string | null;
|
||||
url?: string | null;
|
||||
parent_id?: bigint | null;
|
||||
bitrate?: number | null;
|
||||
user_limit?: number | null;
|
||||
nsfw?: boolean;
|
||||
rate_limit_per_user?: number;
|
||||
permission_overwrites?: Array<{
|
||||
id: bigint;
|
||||
type: number;
|
||||
allow?: bigint;
|
||||
deny?: bigint;
|
||||
}> | null;
|
||||
rtc_region?: string | null;
|
||||
icon?: string | null;
|
||||
owner_id?: bigint | null;
|
||||
nicks?: Record<string, string | null> | null;
|
||||
}
|
||||
|
||||
export class ChannelOperationsService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userRepository: IUserRepository,
|
||||
private gatewayService: IGatewayService,
|
||||
private channelAuthService: ChannelAuthService,
|
||||
private channelUtilsService: ChannelUtilsService,
|
||||
private voiceRoomStore: IVoiceRoomStore,
|
||||
private liveKitService: ILiveKitService,
|
||||
private voiceAvailabilityService: VoiceAvailabilityService | undefined,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private inviteRepository: IInviteRepository,
|
||||
private webhookRepository: IWebhookRepository,
|
||||
) {}
|
||||
|
||||
async getChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<Channel> {
|
||||
const {channel} = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
return channel;
|
||||
}
|
||||
|
||||
async getPublicChannelData(channelId: ChannelID) {
|
||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
return channel;
|
||||
}
|
||||
|
||||
async getChannelMemberCount(channelId: ChannelID): Promise<number> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
return channel.recipientIds.size;
|
||||
}
|
||||
|
||||
async getChannelSystem(channelId: ChannelID): Promise<Channel | null> {
|
||||
return await this.channelRepository.channelData.findUnique(channelId);
|
||||
}
|
||||
|
||||
async editChannel({
|
||||
userId,
|
||||
channelId,
|
||||
data,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
data: ChannelUpdateData;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
|
||||
if (channel.type === ChannelTypes.GROUP_DM) {
|
||||
throw new InvalidChannelTypeError();
|
||||
}
|
||||
|
||||
if (!guild) throw new MissingPermissionsError();
|
||||
await checkPermission(Permissions.MANAGE_CHANNELS);
|
||||
|
||||
const guildIdValue = createGuildID(BigInt(guild.id));
|
||||
|
||||
let channelName = data.name ?? channel.name;
|
||||
if (data.name !== undefined && channel.type === ChannelTypes.GUILD_TEXT) {
|
||||
const hasFlexibleNamesEnabled = guild.features?.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) ?? false;
|
||||
if (!hasFlexibleNamesEnabled) {
|
||||
channelName = ChannelNameType.parse(data.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.rtc_region !== undefined && channel.type === ChannelTypes.GUILD_VOICE) {
|
||||
await checkPermission(Permissions.UPDATE_RTC_REGION);
|
||||
|
||||
if (data.rtc_region !== null) {
|
||||
if (this.voiceAvailabilityService) {
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
const availableRegions = this.voiceAvailabilityService.getAvailableRegions({
|
||||
requestingUserId: userId,
|
||||
guildId,
|
||||
guildFeatures: new Set(guild.features ?? []),
|
||||
});
|
||||
const regionAllowed = availableRegions.some((region) => region.id === data.rtc_region && region.isAccessible);
|
||||
|
||||
if (!regionAllowed) {
|
||||
throw new InputValidationError([
|
||||
{
|
||||
path: 'rtc_region',
|
||||
message: `Invalid or restricted RTC region: ${data.rtc_region}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else if (this.liveKitService) {
|
||||
const availableRegions = this.liveKitService.getRegionMetadata().map((region) => region.id);
|
||||
if (!availableRegions.includes(data.rtc_region)) {
|
||||
throw new InputValidationError([
|
||||
{
|
||||
path: 'rtc_region',
|
||||
message: `Invalid RTC region: ${data.rtc_region}. Available regions: ${availableRegions.join(', ')}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const previousPermissionOverwrites = channel.permissionOverwrites;
|
||||
let permissionOverwrites = channel.permissionOverwrites;
|
||||
if (data.permission_overwrites !== undefined) {
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
const isOwner = guild.owner_id === userId.toString();
|
||||
const channelPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId,
|
||||
userId,
|
||||
channelId: channel.id,
|
||||
});
|
||||
|
||||
if (!isOwner) {
|
||||
for (const overwrite of data.permission_overwrites ?? []) {
|
||||
const allowPerms = overwrite.allow ? BigInt(overwrite.allow) : 0n;
|
||||
const denyPerms = overwrite.deny ? BigInt(overwrite.deny) : 0n;
|
||||
const combinedPerms = (allowPerms | denyPerms) & ALL_PERMISSIONS;
|
||||
|
||||
if ((combinedPerms & ~channelPermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
permissionOverwrites = new Map();
|
||||
for (const overwrite of data.permission_overwrites ?? []) {
|
||||
const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
|
||||
permissionOverwrites.set(
|
||||
targetId,
|
||||
new ChannelPermissionOverwrite({
|
||||
type: overwrite.type,
|
||||
allow_: (overwrite.allow ? BigInt(overwrite.allow) : 0n) & ALL_PERMISSIONS,
|
||||
deny_: (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const requestedParentId =
|
||||
data.parent_id !== undefined ? (data.parent_id ? createChannelID(data.parent_id) : null) : channel.parentId;
|
||||
|
||||
if (requestedParentId && requestedParentId !== (channel.parentId ?? null)) {
|
||||
await this.ensureCategoryHasCapacity({
|
||||
guildId: guildIdValue,
|
||||
categoryId: requestedParentId,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedChannelData = {
|
||||
...channel.toRow(),
|
||||
name: channelName,
|
||||
topic: data.topic !== undefined ? data.topic : channel.topic,
|
||||
url: data.url !== undefined && channel.type === ChannelTypes.GUILD_LINK ? data.url : channel.url,
|
||||
parent_id: requestedParentId,
|
||||
bitrate: data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE ? data.bitrate : channel.bitrate,
|
||||
user_limit:
|
||||
data.user_limit !== undefined && channel.type === ChannelTypes.GUILD_VOICE
|
||||
? data.user_limit
|
||||
: channel.userLimit,
|
||||
rate_limit_per_user:
|
||||
data.rate_limit_per_user !== undefined && channel.type === ChannelTypes.GUILD_TEXT
|
||||
? data.rate_limit_per_user
|
||||
: channel.rateLimitPerUser,
|
||||
nsfw: data.nsfw !== undefined && channel.type === ChannelTypes.GUILD_TEXT ? data.nsfw : channel.isNsfw,
|
||||
rtc_region:
|
||||
data.rtc_region !== undefined && channel.type === ChannelTypes.GUILD_VOICE
|
||||
? data.rtc_region
|
||||
: channel.rtcRegion,
|
||||
permission_overwrites: new Map(
|
||||
Array.from(permissionOverwrites.entries()).map(([targetId, overwrite]) => [
|
||||
targetId,
|
||||
overwrite.toPermissionOverwrite(),
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
const updatedChannel = await this.channelRepository.channelData.upsert(updatedChannelData);
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChannel, requestCache});
|
||||
|
||||
if (channel.type === ChannelTypes.GUILD_CATEGORY && data.permission_overwrites !== undefined && guild) {
|
||||
await this.propagatePermissionsToSyncedChildren({
|
||||
categoryChannel: updatedChannel,
|
||||
previousPermissionOverwrites,
|
||||
guildId: createGuildID(BigInt(guild.id)),
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
data.rtc_region !== undefined &&
|
||||
channel.type === ChannelTypes.GUILD_VOICE &&
|
||||
data.rtc_region !== channel.rtcRegion &&
|
||||
this.voiceRoomStore
|
||||
) {
|
||||
await this.handleRtcRegionSwitch({
|
||||
guildId: createGuildID(BigInt(guild.id)),
|
||||
channelId,
|
||||
});
|
||||
}
|
||||
|
||||
const beforeSnapshot = serializeChannelForAudit(channel);
|
||||
const afterSnapshot = serializeChannelForAudit(updatedChannel);
|
||||
const changes = this.guildAuditLogService.computeChanges(beforeSnapshot, afterSnapshot);
|
||||
|
||||
if (changes.length > 0) {
|
||||
const builder = this.guildAuditLogService
|
||||
.createBuilder(guildIdValue, userId)
|
||||
.withAction(AuditLogActionType.CHANNEL_UPDATE, channel.id.toString())
|
||||
.withReason(null)
|
||||
.withMetadata({
|
||||
type: updatedChannel.type.toString(),
|
||||
})
|
||||
.withChanges(changes);
|
||||
|
||||
try {
|
||||
await builder.commit();
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
error,
|
||||
guildId: guildIdValue.toString(),
|
||||
userId: userId.toString(),
|
||||
action: AuditLogActionType.CHANNEL_UPDATE,
|
||||
targetId: channel.id.toString(),
|
||||
},
|
||||
'Failed to record guild audit log',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedChannel;
|
||||
}
|
||||
|
||||
async deleteChannel({
|
||||
userId,
|
||||
channelId,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
|
||||
if (this.channelAuthService.isPersonalNotesChannel({userId, channelId})) {
|
||||
throw new CannotExecuteOnDmError();
|
||||
}
|
||||
|
||||
if (guild) {
|
||||
await checkPermission(Permissions.MANAGE_CHANNELS);
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
|
||||
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
||||
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId);
|
||||
const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === channelId);
|
||||
|
||||
for (const childChannel of childChannels) {
|
||||
await this.channelRepository.channelData.upsert({
|
||||
...childChannel.toRow(),
|
||||
parent_id: null,
|
||||
});
|
||||
const updatedChild = await this.channelRepository.channelData.findUnique(childChannel.id);
|
||||
if (updatedChild) {
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [channelInvites, channelWebhooks] = await Promise.all([
|
||||
this.inviteRepository.listChannelInvites(channelId),
|
||||
this.webhookRepository.listByChannel(channelId),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
...channelInvites.map((invite) => this.inviteRepository.delete(invite.code)),
|
||||
...channelWebhooks.map((webhook) => this.webhookRepository.delete(webhook.id)),
|
||||
]);
|
||||
|
||||
await this.channelUtilsService.purgeChannelAttachments(channel);
|
||||
await this.channelRepository.messages.deleteAllChannelMessages(channelId);
|
||||
await this.channelUtilsService.dispatchChannelDelete({channel, requestCache});
|
||||
|
||||
const guildIdValue = createGuildID(BigInt(guild.id));
|
||||
const changes = this.guildAuditLogService.computeChanges(ChannelHelpers.serializeChannelForAudit(channel), null);
|
||||
|
||||
const builder = this.guildAuditLogService
|
||||
.createBuilder(guildIdValue, userId)
|
||||
.withAction(AuditLogActionType.CHANNEL_DELETE, channel.id.toString())
|
||||
.withReason(null)
|
||||
.withMetadata({
|
||||
type: channel.type.toString(),
|
||||
})
|
||||
.withChanges(changes);
|
||||
|
||||
try {
|
||||
await builder.commit();
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
error,
|
||||
guildId: guildIdValue.toString(),
|
||||
userId: userId.toString(),
|
||||
action: AuditLogActionType.CHANNEL_DELETE,
|
||||
targetId: channel.id.toString(),
|
||||
},
|
||||
'Failed to record guild audit log',
|
||||
);
|
||||
}
|
||||
|
||||
await this.channelRepository.channelData.delete(channelId, guildId);
|
||||
} else {
|
||||
await this.userRepository.closeDmForUser(userId, channelId);
|
||||
await this.channelUtilsService.dispatchDmChannelDelete({channel, userId, requestCache});
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableRtcRegions({
|
||||
userId,
|
||||
channelId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
}): Promise<Array<VoiceRegionAvailability>> {
|
||||
if (!this.voiceAvailabilityService) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const {channel, guild} = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
|
||||
if (channel.type !== ChannelTypes.GUILD_VOICE) {
|
||||
throw new InvalidChannelTypeError();
|
||||
}
|
||||
|
||||
if (!guild) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
const regions = this.voiceAvailabilityService.getAvailableRegions({
|
||||
requestingUserId: userId,
|
||||
guildId,
|
||||
guildFeatures: new Set(guild.features ?? []),
|
||||
});
|
||||
|
||||
const accessibleRegions = regions.filter((region) => region.isAccessible);
|
||||
|
||||
return accessibleRegions.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private async propagatePermissionsToSyncedChildren({
|
||||
categoryChannel,
|
||||
previousPermissionOverwrites,
|
||||
guildId,
|
||||
requestCache,
|
||||
}: {
|
||||
categoryChannel: Channel;
|
||||
previousPermissionOverwrites: Map<RoleID | UserID, ChannelPermissionOverwrite>;
|
||||
guildId: GuildID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId);
|
||||
const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === categoryChannel.id);
|
||||
|
||||
const syncedChannels: Array<Channel> = [];
|
||||
for (const child of childChannels) {
|
||||
if (this.arePermissionsEqual(child.permissionOverwrites, previousPermissionOverwrites)) {
|
||||
syncedChannels.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
if (syncedChannels.length > 0) {
|
||||
await Promise.all(
|
||||
syncedChannels.map(async (child) => {
|
||||
const updatedChild = await this.channelRepository.channelData.upsert({
|
||||
...child.toRow(),
|
||||
permission_overwrites: new Map(
|
||||
Array.from(categoryChannel.permissionOverwrites.entries()).map(([targetId, overwrite]) => [
|
||||
targetId,
|
||||
overwrite.toPermissionOverwrite(),
|
||||
]),
|
||||
),
|
||||
});
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private arePermissionsEqual(
|
||||
perms1: Map<RoleID | UserID, ChannelPermissionOverwrite>,
|
||||
perms2: Map<RoleID | UserID, ChannelPermissionOverwrite>,
|
||||
): boolean {
|
||||
if (perms1.size !== perms2.size) return false;
|
||||
|
||||
for (const [targetId, overwrite1] of perms1.entries()) {
|
||||
const overwrite2 = perms2.get(targetId);
|
||||
if (!overwrite2) return false;
|
||||
|
||||
if (
|
||||
overwrite1.type !== overwrite2.type ||
|
||||
overwrite1.allow !== overwrite2.allow ||
|
||||
overwrite1.deny !== overwrite2.deny
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async handleRtcRegionSwitch({guildId, channelId}: {guildId: GuildID; channelId: ChannelID}): Promise<void> {
|
||||
if (!this.voiceRoomStore) {
|
||||
console.warn('[ChannelOperationsService] VoiceRoomStore not available, skipping region switch');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.voiceRoomStore.deleteRoomServer(guildId, channelId);
|
||||
|
||||
await this.gatewayService.switchVoiceRegion({guildId, channelId});
|
||||
}
|
||||
|
||||
private async ensureCategoryHasCapacity(params: {guildId: GuildID; categoryId: ChannelID}): Promise<void> {
|
||||
const count = await this.gatewayService.getCategoryChannelCount(params);
|
||||
if (count >= MAX_CHANNELS_PER_CATEGORY) {
|
||||
throw new MaxCategoryChannelsError();
|
||||
}
|
||||
}
|
||||
|
||||
async setChannelPermissionOverwrite(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
overwriteId: bigint;
|
||||
overwrite: {type: number; allow_: bigint; deny_: bigint};
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
|
||||
if (!channel || !channel.guildId) throw new UnknownChannelError();
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
|
||||
const userPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
channelId: channel.id,
|
||||
});
|
||||
const sanitizedAllow = params.overwrite.allow_ & ALL_PERMISSIONS;
|
||||
const sanitizedDeny = params.overwrite.deny_ & ALL_PERMISSIONS;
|
||||
const combined = sanitizedAllow | sanitizedDeny;
|
||||
if ((combined & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
|
||||
const overwrites = new Map(channel.permissionOverwrites ?? []);
|
||||
const targetId = params.overwrite.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
|
||||
overwrites.set(
|
||||
targetId,
|
||||
new ChannelPermissionOverwrite({
|
||||
type: params.overwrite.type,
|
||||
allow_: sanitizedAllow,
|
||||
deny_: sanitizedDeny,
|
||||
}),
|
||||
);
|
||||
|
||||
const updated = await this.channelRepository.channelData.upsert({
|
||||
...channel.toRow(),
|
||||
permission_overwrites: new Map(
|
||||
Array.from(overwrites.entries()).map(([id, ow]) => [
|
||||
id,
|
||||
(ow as ChannelPermissionOverwrite).toPermissionOverwrite(),
|
||||
]),
|
||||
),
|
||||
});
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
|
||||
}
|
||||
|
||||
async deleteChannelPermissionOverwrite(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
overwriteId: bigint;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
|
||||
if (!channel || !channel.guildId) throw new UnknownChannelError();
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
|
||||
const overwrites = new Map(channel.permissionOverwrites ?? []);
|
||||
overwrites.delete(createRoleID(params.overwriteId));
|
||||
overwrites.delete(createUserID(params.overwriteId));
|
||||
|
||||
const updated = await this.channelRepository.channelData.upsert({
|
||||
...channel.toRow(),
|
||||
permission_overwrites: new Map(
|
||||
Array.from(overwrites.entries()).map(([id, ow]) => [
|
||||
id,
|
||||
(ow as ChannelPermissionOverwrite).toPermissionOverwrite(),
|
||||
]),
|
||||
),
|
||||
});
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {channelIdToUserId, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {ChannelTypes, type GatewayDispatchEvent} from '~/Constants';
|
||||
import {mapChannelToResponse, mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '~/channel/services/message/MessageHelpers';
|
||||
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Channel, Message} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
|
||||
export class ChannelUtilsService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userCacheService: UserCacheService,
|
||||
private storageService: IStorageService,
|
||||
private gatewayService: IGatewayService,
|
||||
private cloudflarePurgeQueue: ICloudflarePurgeQueue,
|
||||
private mediaService: IMediaService,
|
||||
) {}
|
||||
|
||||
async purgeChannelAttachments(channel: Channel): Promise<void> {
|
||||
const batchSize = 100;
|
||||
let hasMore = true;
|
||||
let beforeMessageId: MessageID | undefined;
|
||||
|
||||
while (hasMore) {
|
||||
const messages = await this.channelRepository.messages.listMessages(channel.id, beforeMessageId, batchSize);
|
||||
|
||||
if (messages.length === 0) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
await Promise.all(messages.map((message: Message) => this.purgeMessageAttachments(message)));
|
||||
|
||||
if (messages.length < batchSize) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
beforeMessageId = messages[messages.length - 1].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async purgeMessageAttachments(message: Message): Promise<void> {
|
||||
const cdnUrls: Array<string> = [];
|
||||
|
||||
await Promise.all(
|
||||
message.attachments.map(async (attachment) => {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
|
||||
|
||||
if (Config.cloudflare.purgeEnabled) {
|
||||
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
|
||||
cdnUrls.push(cdnUrl);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (Config.cloudflare.purgeEnabled && cdnUrls.length > 0) {
|
||||
await this.cloudflarePurgeQueue.addUrls(cdnUrls);
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchEvent(params: {channel: Channel; event: GatewayDispatchEvent; data: unknown}): Promise<void> {
|
||||
const {channel, event, data} = params;
|
||||
|
||||
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
return this.gatewayService.dispatchPresence({
|
||||
userId: channelIdToUserId(channel.id),
|
||||
event,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.guildId) {
|
||||
return this.gatewayService.dispatchGuild({guildId: channel.guildId, event, data});
|
||||
} else {
|
||||
for (const recipientId of channel.recipientIds) {
|
||||
await this.gatewayService.dispatchPresence({userId: recipientId, event, data});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchChannelUpdate({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise<void> {
|
||||
if (channel.guildId) {
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
await this.dispatchEvent({
|
||||
channel,
|
||||
event: 'CHANNEL_UPDATE',
|
||||
data: channelResponse,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const userId of channel.recipientIds) {
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'CHANNEL_UPDATE',
|
||||
data: channelResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchChannelDelete({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise<void> {
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
await this.dispatchEvent({
|
||||
channel,
|
||||
event: 'CHANNEL_DELETE',
|
||||
data: channelResponse,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchDmChannelDelete({
|
||||
channel,
|
||||
userId,
|
||||
requestCache,
|
||||
}: {
|
||||
channel: Channel;
|
||||
userId: UserID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'CHANNEL_DELETE',
|
||||
data: await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchMessageCreate({
|
||||
channel,
|
||||
message,
|
||||
requestCache,
|
||||
}: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const messageResponse = await mapMessageToResponse({
|
||||
message,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
});
|
||||
|
||||
await this.dispatchEvent({
|
||||
channel,
|
||||
event: 'MESSAGE_CREATE',
|
||||
data: {...messageResponse, channel_type: channel.type},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, createMessageID, type UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes, MessageTypes} from '~/Constants';
|
||||
import {
|
||||
InvalidChannelTypeError,
|
||||
MissingAccessError,
|
||||
MissingPermissionsError,
|
||||
UnknownChannelError,
|
||||
UnknownUserError,
|
||||
} from '~/Errors';
|
||||
import type {AvatarService} from '~/infrastructure/AvatarService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {Channel} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import type {MessagePersistenceService} from '../message/MessagePersistenceService';
|
||||
import type {ChannelUtilsService} from './ChannelUtilsService';
|
||||
|
||||
export class GroupDmUpdateService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private avatarService: AvatarService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private channelUtilsService: ChannelUtilsService,
|
||||
private messagePersistenceService: MessagePersistenceService,
|
||||
) {}
|
||||
|
||||
async updateGroupDmChannel({
|
||||
userId,
|
||||
channelId,
|
||||
name,
|
||||
icon,
|
||||
ownerId,
|
||||
nicks,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
name?: string | null;
|
||||
icon?: string | null;
|
||||
ownerId?: UserID;
|
||||
nicks?: Record<string, string | null> | null;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
|
||||
if (channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw new InvalidChannelTypeError();
|
||||
}
|
||||
|
||||
if (!channel.recipientIds.has(userId)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
const updates: Partial<ReturnType<Channel['toRow']>> = {};
|
||||
|
||||
if (ownerId !== undefined) {
|
||||
if (channel.ownerId !== userId) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
if (!channel.recipientIds.has(ownerId)) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
updates.owner_id = ownerId;
|
||||
}
|
||||
|
||||
if (name !== undefined) {
|
||||
updates.name = name;
|
||||
}
|
||||
|
||||
if (nicks !== undefined) {
|
||||
if (nicks === null) {
|
||||
if (channel.ownerId !== userId) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
updates.nicks = null;
|
||||
} else {
|
||||
const isOwner = channel.ownerId === userId;
|
||||
|
||||
for (const targetUserId of Object.keys(nicks)) {
|
||||
const targetUserIdBigInt = BigInt(targetUserId) as UserID;
|
||||
|
||||
if (!channel.recipientIds.has(targetUserIdBigInt) && targetUserIdBigInt !== userId) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (!isOwner && targetUserId !== userId.toString()) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
|
||||
const updatedNicknames = new Map(channel.nicknames);
|
||||
|
||||
for (const [targetUserId, nickname] of Object.entries(nicks)) {
|
||||
if (nickname === null || nickname.trim() === '') {
|
||||
updatedNicknames.delete(targetUserId);
|
||||
} else {
|
||||
updatedNicknames.set(targetUserId, nickname.trim());
|
||||
}
|
||||
}
|
||||
|
||||
updates.nicks = updatedNicknames.size > 0 ? (updatedNicknames as Map<string, string>) : null;
|
||||
}
|
||||
}
|
||||
|
||||
let iconHash: string | null = null;
|
||||
if (icon !== undefined) {
|
||||
iconHash = await this.avatarService.uploadAvatar({
|
||||
prefix: 'icons',
|
||||
entityId: channelId,
|
||||
errorPath: 'icon',
|
||||
previousKey: channel.iconHash,
|
||||
base64Image: icon,
|
||||
});
|
||||
updates.icon_hash = iconHash;
|
||||
}
|
||||
|
||||
const updatedChannel = await this.channelRepository.channelData.upsert({
|
||||
...channel.toRow(),
|
||||
...updates,
|
||||
});
|
||||
|
||||
if (name !== undefined && name !== channel.name) {
|
||||
const messageId = createMessageID(this.snowflakeService.generate());
|
||||
const message = await this.messagePersistenceService.createSystemMessage({
|
||||
messageId,
|
||||
channelId,
|
||||
userId,
|
||||
type: MessageTypes.CHANNEL_NAME_CHANGE,
|
||||
content: name,
|
||||
});
|
||||
|
||||
await this.channelUtilsService.dispatchMessageCreate({
|
||||
channel: updatedChannel,
|
||||
message,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
if (icon !== undefined && iconHash !== channel.iconHash) {
|
||||
const messageId = createMessageID(this.snowflakeService.generate());
|
||||
const message = await this.messagePersistenceService.createSystemMessage({
|
||||
messageId,
|
||||
channelId,
|
||||
userId,
|
||||
type: MessageTypes.CHANNEL_ICON_CHANGE,
|
||||
content: iconHash,
|
||||
});
|
||||
|
||||
await this.channelUtilsService.dispatchMessageCreate({
|
||||
channel: updatedChannel,
|
||||
message,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChannel, requestCache});
|
||||
|
||||
return updatedChannel;
|
||||
}
|
||||
}
|
||||
129
fluxer_api/src/channel/services/group_dm/GroupDmHelpers.ts
Normal file
129
fluxer_api/src/channel/services/group_dm/GroupDmHelpers.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AttachmentDecayService} from '~/attachment/AttachmentDecayService';
|
||||
import {type ChannelID, channelIdToUserId, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes, type GatewayDispatchEvent} from '~/Constants';
|
||||
import {mapChannelToResponse} from '~/channel/ChannelModel';
|
||||
import {collectMessageAttachments} from '~/channel/services/message/MessageHelpers';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Channel, Message} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
|
||||
export async function dispatchChannelDelete({
|
||||
channel,
|
||||
requestCache,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
}: {
|
||||
channel: Channel;
|
||||
requestCache: RequestCache;
|
||||
userCacheService: UserCacheService;
|
||||
gatewayService: IGatewayService;
|
||||
}): Promise<void> {
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
await dispatchEvent({
|
||||
channel,
|
||||
event: 'CHANNEL_DELETE',
|
||||
data: channelResponse,
|
||||
gatewayService,
|
||||
});
|
||||
}
|
||||
|
||||
export async function dispatchMessageCreate({
|
||||
channel,
|
||||
message,
|
||||
requestCache,
|
||||
currentUserId,
|
||||
nonce,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
getReferencedMessage,
|
||||
}: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
requestCache: RequestCache;
|
||||
currentUserId?: UserID;
|
||||
nonce?: string;
|
||||
userCacheService: UserCacheService;
|
||||
gatewayService: IGatewayService;
|
||||
mediaService: IMediaService;
|
||||
getReferencedMessage?: (channelId: ChannelID, messageId: MessageID) => Promise<Message | null>;
|
||||
}): Promise<void> {
|
||||
const decayService = new AttachmentDecayService();
|
||||
const messageAttachments = collectMessageAttachments(message);
|
||||
const attachmentDecayMap =
|
||||
messageAttachments.length > 0
|
||||
? await decayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
|
||||
: undefined;
|
||||
const messageResponse = await (async () => {
|
||||
const {mapMessageToResponse} = await import('~/channel/ChannelModel');
|
||||
return mapMessageToResponse({
|
||||
message,
|
||||
currentUserId,
|
||||
nonce,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
mediaService,
|
||||
attachmentDecayMap,
|
||||
getReferencedMessage,
|
||||
});
|
||||
})();
|
||||
|
||||
await dispatchEvent({
|
||||
channel,
|
||||
event: 'MESSAGE_CREATE',
|
||||
data: {...messageResponse, channel_type: channel.type},
|
||||
gatewayService,
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchEvent(params: {
|
||||
channel: Channel;
|
||||
event: GatewayDispatchEvent;
|
||||
data: unknown;
|
||||
gatewayService: IGatewayService;
|
||||
}): Promise<void> {
|
||||
const {channel, event, data, gatewayService} = params;
|
||||
|
||||
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
return gatewayService.dispatchPresence({
|
||||
userId: channelIdToUserId(channel.id),
|
||||
event,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.guildId) {
|
||||
return gatewayService.dispatchGuild({guildId: channel.guildId, event, data});
|
||||
} else {
|
||||
for (const recipientId of channel.recipientIds) {
|
||||
await gatewayService.dispatchPresence({userId: recipientId, event, data});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {randomInt} from 'node:crypto';
|
||||
import {type ChannelID, createMessageID, type UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes, MAX_GROUP_DM_RECIPIENTS, MessageTypes, RelationshipTypes} from '~/Constants';
|
||||
import {mapChannelToResponse, mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import {
|
||||
CannotRemoveOtherRecipientsError,
|
||||
InputValidationError,
|
||||
InvalidChannelTypeError,
|
||||
MaxGroupDmRecipientsError,
|
||||
MissingAccessError,
|
||||
NotFriendsWithUserError,
|
||||
UnknownChannelError,
|
||||
} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Channel} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {UserPermissionUtils} from '~/utils/UserPermissionUtils';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import type {GroupDmUpdateService} from '../channel_data/GroupDmUpdateService';
|
||||
import type {MessagePersistenceService} from '../message/MessagePersistenceService';
|
||||
import {dispatchChannelDelete} from './GroupDmHelpers';
|
||||
|
||||
export class GroupDmOperationsService {
|
||||
private readonly userPermissionUtils: UserPermissionUtils;
|
||||
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userRepository: IUserRepository,
|
||||
guildRepository: IGuildRepository,
|
||||
private userCacheService: UserCacheService,
|
||||
private gatewayService: IGatewayService,
|
||||
private mediaService: IMediaService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private groupDmUpdateService: GroupDmUpdateService,
|
||||
private messagePersistenceService: MessagePersistenceService,
|
||||
) {
|
||||
this.userPermissionUtils = new UserPermissionUtils(userRepository, guildRepository);
|
||||
}
|
||||
|
||||
async addRecipientToChannel({
|
||||
userId,
|
||||
channelId,
|
||||
recipientId,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
recipientId: UserID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
|
||||
if (channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw new InvalidChannelTypeError();
|
||||
}
|
||||
|
||||
if (!channel.recipientIds.has(userId)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
const friendship = await this.userRepository.getRelationship(userId, recipientId, RelationshipTypes.FRIEND);
|
||||
if (!friendship) {
|
||||
throw new NotFriendsWithUserError();
|
||||
}
|
||||
|
||||
await this.userPermissionUtils.validateGroupDmAddPermissions({
|
||||
userId,
|
||||
targetId: recipientId,
|
||||
});
|
||||
|
||||
return this.addRecipientViaInvite({
|
||||
channelId,
|
||||
recipientId,
|
||||
inviterId: userId,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async addRecipientViaInvite({
|
||||
channelId,
|
||||
recipientId,
|
||||
inviterId,
|
||||
requestCache,
|
||||
}: {
|
||||
channelId: ChannelID;
|
||||
recipientId: UserID;
|
||||
inviterId?: UserID | null;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
|
||||
if (channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw new InvalidChannelTypeError();
|
||||
}
|
||||
|
||||
if (channel.recipientIds.has(recipientId)) {
|
||||
return channel;
|
||||
}
|
||||
|
||||
if (channel.recipientIds.size >= MAX_GROUP_DM_RECIPIENTS) {
|
||||
throw new MaxGroupDmRecipientsError();
|
||||
}
|
||||
|
||||
const updatedRecipientIds = new Set([...channel.recipientIds, recipientId]);
|
||||
const updatedChannel = await this.channelRepository.channelData.upsert({
|
||||
...channel.toRow(),
|
||||
recipient_ids: updatedRecipientIds,
|
||||
});
|
||||
|
||||
await this.userRepository.openDmForUser(recipientId, channelId);
|
||||
|
||||
const recipientUserResponse = await this.userCacheService.getUserPartialResponse(recipientId, requestCache);
|
||||
|
||||
const messageId = createMessageID(this.snowflakeService.generate());
|
||||
const systemMessage = await this.messagePersistenceService.createSystemMessage({
|
||||
messageId,
|
||||
channelId,
|
||||
userId: inviterId ?? channel.ownerId ?? recipientId,
|
||||
type: MessageTypes.RECIPIENT_ADD,
|
||||
mentionUserIds: [recipientId],
|
||||
});
|
||||
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel: updatedChannel,
|
||||
currentUserId: recipientId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: recipientId,
|
||||
event: 'CHANNEL_CREATE',
|
||||
data: channelResponse,
|
||||
});
|
||||
|
||||
for (const recId of channel.recipientIds) {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: recId,
|
||||
event: 'CHANNEL_RECIPIENT_ADD',
|
||||
data: {
|
||||
channel_id: channelId.toString(),
|
||||
user: recipientUserResponse,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const messageResponse = await mapMessageToResponse({
|
||||
message: systemMessage,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
getReferencedMessage: (channelId, messageId) => this.channelRepository.messages.getMessage(channelId, messageId),
|
||||
});
|
||||
|
||||
for (const recipientId of updatedRecipientIds) {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: recipientId,
|
||||
event: 'MESSAGE_CREATE',
|
||||
data: {...messageResponse, channel_type: updatedChannel.type},
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from(updatedRecipientIds).map(async (recId) => {
|
||||
await this.syncGroupDmRecipientsForUser(recId);
|
||||
}),
|
||||
);
|
||||
|
||||
return updatedChannel;
|
||||
}
|
||||
|
||||
async removeRecipientFromChannel({
|
||||
userId,
|
||||
channelId,
|
||||
recipientId,
|
||||
requestCache,
|
||||
silent,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
recipientId: UserID;
|
||||
requestCache: RequestCache;
|
||||
silent?: boolean;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel) throw new UnknownChannelError();
|
||||
|
||||
if (channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw new InvalidChannelTypeError();
|
||||
}
|
||||
|
||||
if (!channel.recipientIds.has(userId)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
if (!channel.recipientIds.has(recipientId)) {
|
||||
throw InputValidationError.create('user_id', 'User is not in this channel');
|
||||
}
|
||||
|
||||
if (recipientId !== userId && channel.ownerId !== userId) {
|
||||
throw new CannotRemoveOtherRecipientsError();
|
||||
}
|
||||
|
||||
const updatedRecipientIds = new Set(channel.recipientIds);
|
||||
updatedRecipientIds.delete(recipientId);
|
||||
|
||||
let newOwnerId = channel.ownerId;
|
||||
if (recipientId === channel.ownerId && updatedRecipientIds.size > 0) {
|
||||
const remaining = Array.from(updatedRecipientIds);
|
||||
newOwnerId = remaining[randomInt(remaining.length)] as UserID;
|
||||
}
|
||||
|
||||
if (updatedRecipientIds.size === 0) {
|
||||
await this.channelRepository.messages.deleteAllChannelMessages(channelId);
|
||||
await this.channelRepository.channelData.delete(channelId);
|
||||
await this.userRepository.closeDmForUser(recipientId, channelId);
|
||||
|
||||
await dispatchChannelDelete({
|
||||
channel,
|
||||
requestCache,
|
||||
userCacheService: this.userCacheService,
|
||||
gatewayService: this.gatewayService,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedNicknames = new Map(channel.nicknames);
|
||||
updatedNicknames.delete(recipientId.toString());
|
||||
|
||||
const updatedChannel = await this.channelRepository.channelData.upsert({
|
||||
...channel.toRow(),
|
||||
owner_id: newOwnerId,
|
||||
recipient_ids: updatedRecipientIds,
|
||||
nicks: updatedNicknames.size > 0 ? updatedNicknames : null,
|
||||
});
|
||||
|
||||
await this.userRepository.closeDmForUser(recipientId, channelId);
|
||||
|
||||
const recipientUserResponse = await this.userCacheService.getUserPartialResponse(recipientId, requestCache);
|
||||
for (const recId of updatedRecipientIds) {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: recId,
|
||||
event: 'CHANNEL_RECIPIENT_REMOVE',
|
||||
data: {
|
||||
channel_id: channelId.toString(),
|
||||
user: recipientUserResponse,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
const messageId = createMessageID(this.snowflakeService.generate());
|
||||
const systemMessage = await this.messagePersistenceService.createSystemMessage({
|
||||
messageId,
|
||||
channelId,
|
||||
userId,
|
||||
type: MessageTypes.RECIPIENT_REMOVE,
|
||||
mentionUserIds: [recipientId],
|
||||
});
|
||||
|
||||
const messageResponse = await mapMessageToResponse({
|
||||
message: systemMessage,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
getReferencedMessage: (channelId, messageId) =>
|
||||
this.channelRepository.messages.getMessage(channelId, messageId),
|
||||
});
|
||||
|
||||
for (const remainingRecipientId of updatedRecipientIds) {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: remainingRecipientId,
|
||||
event: 'MESSAGE_CREATE',
|
||||
data: {...messageResponse, channel_type: updatedChannel.type},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: recipientId,
|
||||
event: 'CHANNEL_DELETE',
|
||||
data: channelResponse,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
[...Array.from(updatedRecipientIds), recipientId].map(async (recId) => {
|
||||
await this.syncGroupDmRecipientsForUser(recId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async updateGroupDmChannel(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
name?: string;
|
||||
icon?: string | null;
|
||||
ownerId?: UserID;
|
||||
nicks?: Record<string, string | null> | null;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
return this.groupDmUpdateService.updateGroupDmChannel(params);
|
||||
}
|
||||
|
||||
private async syncGroupDmRecipientsForUser(userId: UserID): Promise<void> {
|
||||
const channels = await this.userRepository.listPrivateChannels(userId);
|
||||
const groupDmChannels = channels.filter((ch) => ch.type === ChannelTypes.GROUP_DM);
|
||||
|
||||
const recipientsByChannel: Record<string, Array<string>> = {};
|
||||
for (const channel of groupDmChannels) {
|
||||
const otherRecipients = Array.from(channel.recipientIds)
|
||||
.filter((recId) => recId !== userId)
|
||||
.map((recId) => recId.toString());
|
||||
|
||||
if (otherRecipients.length > 0) {
|
||||
recipientsByChannel[channel.id.toString()] = otherRecipients;
|
||||
}
|
||||
}
|
||||
|
||||
await this.gatewayService.syncGroupDmRecipients({
|
||||
userId,
|
||||
recipientsByChannel,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {BaseChannelAuthService, type ChannelAuthOptions} from '../BaseChannelAuthService';
|
||||
|
||||
export class MessageInteractionAuthService extends BaseChannelAuthService {
|
||||
protected readonly options: ChannelAuthOptions = {
|
||||
errorOnMissingGuild: 'unknown_channel',
|
||||
validateNsfw: false,
|
||||
useVirtualPersonalNotes: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {channelIdToUserId} from '~/BrandedTypes';
|
||||
import {ChannelTypes, type GatewayDispatchEvent, TEXT_BASED_CHANNEL_TYPES} from '~/Constants';
|
||||
import {CannotSendMessageToNonTextChannelError} from '~/Errors';
|
||||
import type {GuildResponse} from '~/guild/GuildModel';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {Channel} from '~/Models';
|
||||
|
||||
export interface ParsedEmoji {
|
||||
id?: string;
|
||||
name: string;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
export abstract class MessageInteractionBase {
|
||||
constructor(protected gatewayService: IGatewayService) {}
|
||||
|
||||
protected isOperationDisabled(guild: GuildResponse | null, operation: number): boolean {
|
||||
if (!guild) return false;
|
||||
return (guild.disabled_operations & operation) !== 0;
|
||||
}
|
||||
|
||||
protected ensureTextChannel(channel: Channel): void {
|
||||
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
|
||||
throw new CannotSendMessageToNonTextChannelError();
|
||||
}
|
||||
}
|
||||
|
||||
protected async dispatchEvent(params: {channel: Channel; event: GatewayDispatchEvent; data: unknown}): Promise<void> {
|
||||
const {channel, event, data} = params;
|
||||
|
||||
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
return this.gatewayService.dispatchPresence({
|
||||
userId: channelIdToUserId(channel.id),
|
||||
event,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.guildId) {
|
||||
return this.gatewayService.dispatchGuild({guildId: channel.guildId, event, data});
|
||||
} else {
|
||||
for (const recipientId of channel.recipientIds) {
|
||||
await this.gatewayService.dispatchPresence({userId: recipientId, event, data});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
314
fluxer_api/src/channel/services/interaction/MessagePinService.ts
Normal file
314
fluxer_api/src/channel/services/interaction/MessagePinService.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createMessageID, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {GuildOperations, MessageTypes, Permissions} from '~/Constants';
|
||||
import type {ChannelPinResponse} from '~/channel/ChannelModel';
|
||||
import {mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import {AuditLogActionType} from '~/constants/AuditLogActionType';
|
||||
import {CannotEditSystemMessageError, FeatureTemporarilyDisabledError, UnknownMessageError} from '~/Errors';
|
||||
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Channel, Message} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from '../AuthenticatedChannel';
|
||||
import type {MessagePersistenceService} from '../message/MessagePersistenceService';
|
||||
import {MessageInteractionBase} from './MessageInteractionBase';
|
||||
|
||||
export class MessagePinService extends MessageInteractionBase {
|
||||
constructor(
|
||||
gatewayService: IGatewayService,
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userCacheService: UserCacheService,
|
||||
private mediaService: IMediaService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private messagePersistenceService: MessagePersistenceService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
) {
|
||||
super(gatewayService);
|
||||
}
|
||||
|
||||
async getChannelPins({
|
||||
authChannel,
|
||||
requestCache,
|
||||
beforeTimestamp,
|
||||
limit,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
requestCache: RequestCache;
|
||||
beforeTimestamp?: Date;
|
||||
limit?: number;
|
||||
}): Promise<{items: Array<ChannelPinResponse>; has_more: boolean}> {
|
||||
const {channel} = authChannel;
|
||||
this.ensureTextChannel(channel);
|
||||
|
||||
if (authChannel.guild && !(await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY))) {
|
||||
return {items: [], has_more: false};
|
||||
}
|
||||
|
||||
const pageSize = Math.min(limit ?? 50, 50);
|
||||
const effectiveBefore = beforeTimestamp ?? new Date();
|
||||
const messages = await this.channelRepository.messageInteractions.listChannelPins(
|
||||
channel.id,
|
||||
effectiveBefore,
|
||||
pageSize + 1,
|
||||
);
|
||||
const sorted = messages.sort((a, b) => (b.pinnedTimestamp?.getTime() ?? 0) - (a.pinnedTimestamp?.getTime() ?? 0));
|
||||
const hasMore = sorted.length > pageSize;
|
||||
const trimmed = hasMore ? sorted.slice(0, pageSize) : sorted;
|
||||
|
||||
const items = await Promise.all(
|
||||
trimmed.map(async (message: Message) => ({
|
||||
message: await mapMessageToResponse({
|
||||
message,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
}),
|
||||
pinned_at: message.pinnedTimestamp!.toISOString(),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
has_more: hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
async pinMessage({
|
||||
authChannel,
|
||||
messageId,
|
||||
userId,
|
||||
requestCache,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
userId: UserID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {channel, guild, checkPermission} = authChannel;
|
||||
|
||||
if (guild) {
|
||||
await checkPermission(Permissions.PIN_MESSAGES);
|
||||
|
||||
if (this.isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
}
|
||||
|
||||
this.ensureTextChannel(channel);
|
||||
|
||||
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
||||
if (!message) throw new UnknownMessageError();
|
||||
|
||||
this.validateMessagePinnable(message);
|
||||
|
||||
if (message.pinnedTimestamp) return;
|
||||
|
||||
const now = new Date();
|
||||
const updatedMessageData = {...message.toRow(), pinned_timestamp: now};
|
||||
await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow());
|
||||
|
||||
await this.channelRepository.messageInteractions.addChannelPin(channel.id, messageId, now);
|
||||
|
||||
const updatedChannelData = {...channel.toRow(), last_pin_timestamp: now};
|
||||
const updatedChannel = await this.channelRepository.channelData.upsert(updatedChannelData);
|
||||
|
||||
await this.dispatchChannelPinsUpdate(updatedChannel);
|
||||
await this.sendPinSystemMessage({channel, message, userId, requestCache});
|
||||
|
||||
const updatedMessage = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
||||
if (!updatedMessage) throw new UnknownMessageError();
|
||||
|
||||
await this.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
|
||||
if (channel.guildId) {
|
||||
await this.guildAuditLogService
|
||||
.createBuilder(channel.guildId, userId)
|
||||
.withAction(AuditLogActionType.MESSAGE_PIN, messageId.toString())
|
||||
.withMetadata({
|
||||
channel_id: channel.id.toString(),
|
||||
message_id: messageId.toString(),
|
||||
})
|
||||
.withReason(null)
|
||||
.commit();
|
||||
}
|
||||
}
|
||||
|
||||
async unpinMessage({
|
||||
authChannel,
|
||||
messageId,
|
||||
userId,
|
||||
requestCache,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
userId: UserID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {channel, guild, checkPermission} = authChannel;
|
||||
|
||||
if (guild) {
|
||||
await checkPermission(Permissions.PIN_MESSAGES);
|
||||
|
||||
if (this.isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
}
|
||||
|
||||
this.ensureTextChannel(channel);
|
||||
|
||||
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
||||
if (!message) throw new UnknownMessageError();
|
||||
|
||||
this.validateMessagePinnable(message);
|
||||
|
||||
if (!message.pinnedTimestamp) return;
|
||||
|
||||
const updatedMessageData = {...message.toRow(), pinned_timestamp: null};
|
||||
await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow());
|
||||
|
||||
await this.channelRepository.messageInteractions.removeChannelPin(channel.id, messageId);
|
||||
|
||||
await this.dispatchChannelPinsUpdate(channel);
|
||||
|
||||
const updatedMessage = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
||||
if (!updatedMessage) throw new UnknownMessageError();
|
||||
|
||||
await this.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
|
||||
if (channel.guildId) {
|
||||
await this.guildAuditLogService
|
||||
.createBuilder(channel.guildId, userId)
|
||||
.withAction(AuditLogActionType.MESSAGE_UNPIN, messageId.toString())
|
||||
.withMetadata({
|
||||
channel_id: channel.id.toString(),
|
||||
message_id: messageId.toString(),
|
||||
})
|
||||
.withReason(null)
|
||||
.commit();
|
||||
}
|
||||
}
|
||||
|
||||
private validateMessagePinnable(message: Message): void {
|
||||
const pinnableTypes: ReadonlySet<Message['type']> = new Set([MessageTypes.DEFAULT, MessageTypes.REPLY]);
|
||||
if (!pinnableTypes.has(message.type)) {
|
||||
throw new CannotEditSystemMessageError();
|
||||
}
|
||||
}
|
||||
|
||||
private async sendPinSystemMessage({
|
||||
channel,
|
||||
message,
|
||||
userId,
|
||||
requestCache,
|
||||
}: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
userId: UserID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const messageId = createMessageID(this.snowflakeService.generate());
|
||||
const systemMessage = await this.messagePersistenceService.createSystemMessage({
|
||||
messageId,
|
||||
channelId: channel.id,
|
||||
userId,
|
||||
type: MessageTypes.CHANNEL_PINNED_MESSAGE,
|
||||
guildId: channel.guildId,
|
||||
messageReference: {
|
||||
channel_id: channel.id,
|
||||
message_id: message.id,
|
||||
guild_id: null,
|
||||
type: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await this.dispatchMessageCreate({channel, message: systemMessage, requestCache});
|
||||
}
|
||||
|
||||
private async dispatchChannelPinsUpdate(channel: Channel): Promise<void> {
|
||||
await this.dispatchEvent({
|
||||
channel,
|
||||
event: 'CHANNEL_PINS_UPDATE',
|
||||
data: {
|
||||
channel_id: channel.id.toString(),
|
||||
last_pin_timestamp: channel.lastPinTimestamp?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchMessageCreate({
|
||||
channel,
|
||||
message,
|
||||
requestCache,
|
||||
currentUserId,
|
||||
nonce,
|
||||
}: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
requestCache: RequestCache;
|
||||
currentUserId?: UserID;
|
||||
nonce?: string;
|
||||
}): Promise<void> {
|
||||
const messageResponse = await mapMessageToResponse({
|
||||
message,
|
||||
currentUserId,
|
||||
nonce,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
});
|
||||
|
||||
await this.dispatchEvent({
|
||||
channel,
|
||||
event: 'MESSAGE_CREATE',
|
||||
data: {...messageResponse, channel_type: channel.type},
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchMessageUpdate({
|
||||
channel,
|
||||
message,
|
||||
requestCache,
|
||||
currentUserId,
|
||||
}: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
requestCache: RequestCache;
|
||||
currentUserId?: UserID;
|
||||
}): Promise<void> {
|
||||
const messageResponse = await mapMessageToResponse({
|
||||
message,
|
||||
currentUserId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
});
|
||||
|
||||
await this.dispatchEvent({
|
||||
channel,
|
||||
event: 'MESSAGE_UPDATE',
|
||||
data: messageResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import emojiRegex from 'emoji-regex';
|
||||
import {type ChannelID, createEmojiID, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {GuildOperations, MAX_REACTIONS_PER_MESSAGE, MAX_USERS_PER_MESSAGE_REACTION, Permissions} from '~/Constants';
|
||||
import {
|
||||
CommunicationDisabledError,
|
||||
FeatureTemporarilyDisabledError,
|
||||
InputValidationError,
|
||||
MaxReactionsPerMessageError,
|
||||
MaxUsersPerMessageReactionError,
|
||||
MissingPermissionsError,
|
||||
UnknownMessageError,
|
||||
} from '~/Errors';
|
||||
import {isGuildMemberTimedOut} from '~/guild/GuildModel';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import {getMetricsService} from '~/infrastructure/MetricsService';
|
||||
import type {Channel, Message, MessageReaction} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {mapUserToPartialResponse, type UserPartialResponse} from '~/user/UserModel';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from '../AuthenticatedChannel';
|
||||
import {MessageInteractionBase, type ParsedEmoji} from './MessageInteractionBase';
|
||||
|
||||
const REACTION_CUSTOM_EMOJI_REGEX = /^(.+):(\d+)$/;
|
||||
|
||||
export class MessageReactionService extends MessageInteractionBase {
|
||||
constructor(
|
||||
gatewayService: IGatewayService,
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userRepository: IUserRepository,
|
||||
private guildRepository: IGuildRepository,
|
||||
) {
|
||||
super(gatewayService);
|
||||
}
|
||||
|
||||
async getUsersForReaction({
|
||||
authChannel,
|
||||
messageId,
|
||||
emoji,
|
||||
limit,
|
||||
after,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
emoji: string;
|
||||
limit?: number;
|
||||
after?: UserID;
|
||||
}): Promise<Array<UserPartialResponse>> {
|
||||
const {channel} = authChannel;
|
||||
this.ensureTextChannel(channel);
|
||||
|
||||
const validatedLimit = limit ? Math.min(Math.max(limit, 1), MAX_USERS_PER_MESSAGE_REACTION) : 25;
|
||||
|
||||
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
||||
if (!message) throw new UnknownMessageError();
|
||||
|
||||
const parsedEmoji = this.parseEmojiWithoutValidation(emoji);
|
||||
const afterUserId = after;
|
||||
|
||||
const reactions = await this.channelRepository.messageInteractions.listReactionUsers(
|
||||
channel.id,
|
||||
messageId,
|
||||
parsedEmoji.name,
|
||||
validatedLimit,
|
||||
afterUserId,
|
||||
parsedEmoji.id ? createEmojiID(BigInt(parsedEmoji.id)) : undefined,
|
||||
);
|
||||
|
||||
if (!reactions.length) return [];
|
||||
|
||||
const userIds = reactions.map((reaction: MessageReaction) => reaction.userId);
|
||||
const users = await this.userRepository.listUsers(userIds);
|
||||
|
||||
return users.map(mapUserToPartialResponse);
|
||||
}
|
||||
|
||||
async addReaction({
|
||||
authChannel,
|
||||
messageId,
|
||||
emoji,
|
||||
userId,
|
||||
sessionId,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
emoji: string;
|
||||
userId: UserID;
|
||||
sessionId?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const {channel, guild, hasPermission, checkPermission} = authChannel;
|
||||
this.ensureTextChannel(channel);
|
||||
const isMemberTimedOut = isGuildMemberTimedOut(authChannel.member);
|
||||
|
||||
if (guild && !(await hasPermission(Permissions.READ_MESSAGE_HISTORY))) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
||||
if (!message) throw new UnknownMessageError();
|
||||
|
||||
const parsedEmojiBasic = this.parseEmojiWithoutValidation(emoji);
|
||||
const emojiId = parsedEmojiBasic.id ? createEmojiID(BigInt(parsedEmojiBasic.id)) : undefined;
|
||||
|
||||
const userReactionExists = await this.channelRepository.messageInteractions.checkUserReactionExists(
|
||||
channel.id,
|
||||
messageId,
|
||||
userId,
|
||||
parsedEmojiBasic.name,
|
||||
emojiId,
|
||||
);
|
||||
|
||||
if (userReactionExists) return;
|
||||
|
||||
const reactionCount = await this.channelRepository.messageInteractions.countReactionUsers(
|
||||
channel.id,
|
||||
messageId,
|
||||
parsedEmojiBasic.name,
|
||||
emojiId,
|
||||
);
|
||||
|
||||
if (reactionCount === 0 && guild) {
|
||||
await checkPermission(Permissions.ADD_REACTIONS);
|
||||
}
|
||||
|
||||
let parsedEmoji: ParsedEmoji;
|
||||
|
||||
if (reactionCount > 0) {
|
||||
parsedEmoji = parsedEmojiBasic;
|
||||
} else {
|
||||
parsedEmoji = await this.parseAndValidateEmoji({
|
||||
emoji,
|
||||
guildId: channel.guildId?.toString() || undefined,
|
||||
userId,
|
||||
hasPermission: channel.guildId ? hasPermission : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (reactionCount >= MAX_USERS_PER_MESSAGE_REACTION) {
|
||||
throw new MaxUsersPerMessageReactionError();
|
||||
}
|
||||
|
||||
const uniqueReactionCount = await this.channelRepository.messageInteractions.countUniqueReactions(
|
||||
channel.id,
|
||||
messageId,
|
||||
);
|
||||
|
||||
if (isMemberTimedOut && reactionCount === 0) {
|
||||
throw new CommunicationDisabledError();
|
||||
}
|
||||
|
||||
if (uniqueReactionCount >= MAX_REACTIONS_PER_MESSAGE) {
|
||||
throw new MaxReactionsPerMessageError();
|
||||
}
|
||||
|
||||
await this.channelRepository.messageInteractions.addReaction(
|
||||
channel.id,
|
||||
messageId,
|
||||
userId,
|
||||
parsedEmoji.name,
|
||||
emojiId,
|
||||
parsedEmoji.animated ?? false,
|
||||
);
|
||||
|
||||
await this.dispatchMessageReactionAdd({
|
||||
channel,
|
||||
messageId,
|
||||
emoji: parsedEmoji,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
getMetricsService().counter({name: 'reaction.add'});
|
||||
} catch (error) {
|
||||
getMetricsService().counter({name: 'reaction.add.error'});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeReaction({
|
||||
authChannel,
|
||||
messageId,
|
||||
emoji,
|
||||
targetId,
|
||||
sessionId,
|
||||
actorId,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
emoji: string;
|
||||
targetId: UserID;
|
||||
sessionId?: string;
|
||||
actorId: UserID;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const {channel, guild, hasPermission} = authChannel;
|
||||
this.ensureTextChannel(channel);
|
||||
|
||||
if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
const parsedEmoji = this.parseEmojiWithoutValidation(emoji);
|
||||
|
||||
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
||||
if (!message) return;
|
||||
|
||||
const isRemovingOwnReaction = targetId === actorId;
|
||||
if (!isRemovingOwnReaction) {
|
||||
await this.assertCanModerateMessageReactions({channel, message, actorId, hasPermission});
|
||||
}
|
||||
|
||||
const emojiId = parsedEmoji.id ? createEmojiID(BigInt(parsedEmoji.id)) : undefined;
|
||||
await this.channelRepository.messageInteractions.removeReaction(
|
||||
channel.id,
|
||||
messageId,
|
||||
targetId,
|
||||
parsedEmoji.name,
|
||||
emojiId,
|
||||
);
|
||||
|
||||
await this.dispatchMessageReactionRemove({
|
||||
channel,
|
||||
messageId,
|
||||
emoji: parsedEmoji,
|
||||
userId: targetId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
getMetricsService().counter({name: 'reaction.remove'});
|
||||
} catch (error) {
|
||||
getMetricsService().counter({name: 'reaction.remove.error'});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeAllReactionsForEmoji({
|
||||
authChannel,
|
||||
messageId,
|
||||
emoji,
|
||||
actorId,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
emoji: string;
|
||||
actorId: UserID;
|
||||
}): Promise<void> {
|
||||
const {channel, guild, hasPermission} = authChannel;
|
||||
this.ensureTextChannel(channel);
|
||||
|
||||
if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
const parsedEmoji = this.parseEmojiWithoutValidation(emoji);
|
||||
|
||||
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
||||
if (!message) return;
|
||||
|
||||
await this.assertCanModerateMessageReactions({channel, message, actorId, hasPermission});
|
||||
|
||||
const emojiId = parsedEmoji.id ? createEmojiID(BigInt(parsedEmoji.id)) : undefined;
|
||||
await this.channelRepository.messageInteractions.removeAllReactionsForEmoji(
|
||||
channel.id,
|
||||
messageId,
|
||||
parsedEmoji.name,
|
||||
emojiId,
|
||||
);
|
||||
|
||||
await this.dispatchMessageReactionRemoveAllForEmoji({channel, messageId, emoji: parsedEmoji});
|
||||
}
|
||||
|
||||
async removeAllReactions({
|
||||
authChannel,
|
||||
messageId,
|
||||
actorId,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
actorId: UserID;
|
||||
}): Promise<void> {
|
||||
const {channel, guild, hasPermission} = authChannel;
|
||||
this.ensureTextChannel(channel);
|
||||
|
||||
if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
||||
if (!message) return;
|
||||
|
||||
await this.assertCanModerateMessageReactions({channel, message, actorId, hasPermission});
|
||||
|
||||
await this.channelRepository.messageInteractions.removeAllReactions(channel.id, messageId);
|
||||
|
||||
await this.dispatchMessageReactionRemoveAll({channel, messageId});
|
||||
}
|
||||
|
||||
async getMessageReactions({
|
||||
authChannel,
|
||||
messageId,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
}): Promise<Array<MessageReaction>> {
|
||||
return this.channelRepository.messageInteractions.listMessageReactions(authChannel.channel.id, messageId);
|
||||
}
|
||||
|
||||
async setHasReaction(channelId: ChannelID, messageId: MessageID, hasReaction: boolean): Promise<void> {
|
||||
return this.channelRepository.messageInteractions.setHasReaction(channelId, messageId, hasReaction);
|
||||
}
|
||||
|
||||
private async assertCanModerateMessageReactions({
|
||||
channel,
|
||||
message,
|
||||
actorId,
|
||||
hasPermission,
|
||||
}: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
actorId: UserID;
|
||||
hasPermission: (permission: bigint) => Promise<boolean>;
|
||||
}): Promise<void> {
|
||||
if (message.authorId === actorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!channel.guildId) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const canManageMessages = await hasPermission(Permissions.MANAGE_MESSAGES);
|
||||
if (!canManageMessages) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
|
||||
private parseEmojiWithoutValidation(emoji: string): {name: string; id?: string; animated?: boolean} {
|
||||
const decodedEmoji = decodeURIComponent(emoji);
|
||||
const customEmojiMatch = decodedEmoji.match(REACTION_CUSTOM_EMOJI_REGEX);
|
||||
|
||||
if (customEmojiMatch) {
|
||||
const [, name, id] = customEmojiMatch;
|
||||
return {
|
||||
id,
|
||||
name: name || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
return {name: decodedEmoji};
|
||||
}
|
||||
|
||||
private async parseAndValidateEmoji({
|
||||
emoji,
|
||||
guildId,
|
||||
userId,
|
||||
hasPermission,
|
||||
}: {
|
||||
emoji: string;
|
||||
guildId?: string | undefined;
|
||||
userId?: UserID;
|
||||
hasPermission?: (permission: bigint) => Promise<boolean>;
|
||||
}): Promise<ParsedEmoji> {
|
||||
const decodedEmoji = decodeURIComponent(emoji);
|
||||
const customEmojiMatch = decodedEmoji.match(REACTION_CUSTOM_EMOJI_REGEX);
|
||||
|
||||
if (customEmojiMatch) {
|
||||
const [, , id] = customEmojiMatch;
|
||||
const emojiIdBigInt = createEmojiID(BigInt(id));
|
||||
|
||||
let isPremium = false;
|
||||
if (userId) {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
isPremium = user?.canUseGlobalExpressions() ?? false;
|
||||
}
|
||||
|
||||
const emoji = await this.guildRepository.getEmojiById(emojiIdBigInt);
|
||||
if (!emoji) {
|
||||
throw InputValidationError.create('emoji', 'Custom emoji not found');
|
||||
}
|
||||
|
||||
if (!isPremium && emoji.guildId.toString() !== guildId) {
|
||||
throw InputValidationError.create('emoji', 'Cannot use custom emojis outside of source guilds without premium');
|
||||
}
|
||||
|
||||
if (hasPermission) {
|
||||
const canUseExternalEmojis = await hasPermission(Permissions.USE_EXTERNAL_EMOJIS);
|
||||
if (!canUseExternalEmojis) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: emoji.name,
|
||||
animated: emoji.isAnimated,
|
||||
};
|
||||
}
|
||||
|
||||
const isValidUnicodeEmoji = emojiRegex().test(decodedEmoji);
|
||||
if (!isValidUnicodeEmoji) {
|
||||
throw InputValidationError.create('emoji', 'Not a valid Unicode emoji');
|
||||
}
|
||||
|
||||
return {name: decodedEmoji};
|
||||
}
|
||||
|
||||
private async dispatchMessageReactionAdd(params: {
|
||||
channel: Channel;
|
||||
messageId: MessageID;
|
||||
emoji: ParsedEmoji;
|
||||
userId: UserID;
|
||||
sessionId?: string;
|
||||
}): Promise<void> {
|
||||
await this.dispatchEvent({
|
||||
channel: params.channel,
|
||||
event: 'MESSAGE_REACTION_ADD',
|
||||
data: {
|
||||
channel_id: params.channel.id.toString(),
|
||||
message_id: params.messageId.toString(),
|
||||
emoji: params.emoji,
|
||||
user_id: params.userId.toString(),
|
||||
session_id: params.sessionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchMessageReactionRemove(params: {
|
||||
channel: Channel;
|
||||
messageId: MessageID;
|
||||
emoji: ParsedEmoji;
|
||||
userId: UserID;
|
||||
sessionId?: string;
|
||||
}): Promise<void> {
|
||||
await this.dispatchEvent({
|
||||
channel: params.channel,
|
||||
event: 'MESSAGE_REACTION_REMOVE',
|
||||
data: {
|
||||
channel_id: params.channel.id.toString(),
|
||||
message_id: params.messageId.toString(),
|
||||
emoji: params.emoji,
|
||||
user_id: params.userId.toString(),
|
||||
session_id: params.sessionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchMessageReactionRemoveAllForEmoji(params: {
|
||||
channel: Channel;
|
||||
messageId: MessageID;
|
||||
emoji: ParsedEmoji;
|
||||
}): Promise<void> {
|
||||
const event = params.channel.guildId ? 'MESSAGE_REACTION_REMOVE_EMOJI' : 'MESSAGE_REACTION_REMOVE_ALL';
|
||||
await this.dispatchEvent({
|
||||
channel: params.channel,
|
||||
event,
|
||||
data: {
|
||||
channel_id: params.channel.id.toString(),
|
||||
message_id: params.messageId.toString(),
|
||||
emoji: params.emoji,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchMessageReactionRemoveAll(params: {channel: Channel; messageId: MessageID}): Promise<void> {
|
||||
await this.dispatchEvent({
|
||||
channel: params.channel,
|
||||
event: 'MESSAGE_REACTION_REMOVE_ALL',
|
||||
data: {
|
||||
channel_id: params.channel.id.toString(),
|
||||
message_id: params.messageId.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {GuildOperations} from '~/Constants';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {Channel} from '~/Models';
|
||||
import type {ReadStateService} from '~/read_state/ReadStateService';
|
||||
import type {AuthenticatedChannel} from '../AuthenticatedChannel';
|
||||
import {MessageInteractionBase} from './MessageInteractionBase';
|
||||
|
||||
export class MessageReadStateService extends MessageInteractionBase {
|
||||
constructor(
|
||||
gatewayService: IGatewayService,
|
||||
private readStateService: ReadStateService,
|
||||
) {
|
||||
super(gatewayService);
|
||||
}
|
||||
|
||||
async startTyping({authChannel, userId}: {authChannel: AuthenticatedChannel; userId: UserID}): Promise<void> {
|
||||
const {channel, guild} = authChannel;
|
||||
this.ensureTextChannel(channel);
|
||||
|
||||
if (this.isOperationDisabled(guild, GuildOperations.TYPING_EVENTS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dispatchTypingStart({channel, userId});
|
||||
}
|
||||
|
||||
async ackMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
mentionCount,
|
||||
manual,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
mentionCount: number;
|
||||
manual?: boolean;
|
||||
}): Promise<void> {
|
||||
await this.readStateService.ackMessage({userId, channelId, messageId, mentionCount, manual});
|
||||
}
|
||||
|
||||
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
await this.readStateService.deleteReadState({userId, channelId});
|
||||
}
|
||||
|
||||
async ackPins({
|
||||
userId,
|
||||
channelId,
|
||||
timestamp,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
timestamp: Date;
|
||||
}): Promise<void> {
|
||||
await this.readStateService.ackPins({userId, channelId, timestamp});
|
||||
}
|
||||
|
||||
private async dispatchTypingStart({channel, userId}: {channel: Channel; userId: UserID}): Promise<void> {
|
||||
await this.dispatchEvent({
|
||||
channel,
|
||||
event: 'TYPING_START',
|
||||
data: {
|
||||
channel_id: channel.id.toString(),
|
||||
user_id: userId.toString(),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createAttachmentID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {MessageAttachmentFlags} from '~/Constants';
|
||||
import type {AttachmentToProcess} from '~/channel/AttachmentDTOs';
|
||||
import type {MessageAttachment} from '~/database/CassandraTypes';
|
||||
import {InputValidationError} from '~/Errors';
|
||||
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {IVirusScanService} from '~/infrastructure/IVirusScanService';
|
||||
import {getMetricsService} from '~/infrastructure/MetricsService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {Channel, Message} from '~/Models';
|
||||
import {getContentType, isMediaFile, makeAttachmentCdnKey, validateAttachmentIds} from './MessageHelpers';
|
||||
|
||||
interface ProcessAttachmentParams {
|
||||
message: Message;
|
||||
attachment: AttachmentToProcess;
|
||||
index: number;
|
||||
channel?: Channel;
|
||||
guild?: GuildResponse | null;
|
||||
member?: GuildMemberResponse | null;
|
||||
isNSFWAllowed: boolean;
|
||||
}
|
||||
|
||||
interface AttachmentCopyOperation {
|
||||
sourceBucket: string;
|
||||
sourceKey: string;
|
||||
destinationBucket: string;
|
||||
destinationKey: string;
|
||||
newContentType: string;
|
||||
}
|
||||
|
||||
interface ProcessedAttachment {
|
||||
attachment: MessageAttachment;
|
||||
copyOperation: AttachmentCopyOperation;
|
||||
hasVirusDetected: boolean;
|
||||
}
|
||||
|
||||
export class AttachmentProcessingService {
|
||||
constructor(
|
||||
private storageService: IStorageService,
|
||||
private mediaService: IMediaService,
|
||||
private virusScanService: IVirusScanService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
) {}
|
||||
|
||||
async computeAttachments(params: {
|
||||
message: Message;
|
||||
attachments: Array<AttachmentToProcess>;
|
||||
channel?: Channel;
|
||||
guild?: GuildResponse | null;
|
||||
member?: GuildMemberResponse | null;
|
||||
isNSFWAllowed: boolean;
|
||||
}): Promise<{attachments: Array<MessageAttachment>; hasVirusDetected: boolean}> {
|
||||
validateAttachmentIds(params.attachments.map((a) => ({id: BigInt(a.id)})));
|
||||
|
||||
const results = await Promise.all(
|
||||
params.attachments.map((attachment, index) =>
|
||||
this.processAttachment({
|
||||
message: params.message,
|
||||
attachment,
|
||||
index,
|
||||
channel: params.channel,
|
||||
guild: params.guild,
|
||||
member: params.member,
|
||||
isNSFWAllowed: params.isNSFWAllowed,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const hasVirusDetected = results.some((result) => result.hasVirusDetected);
|
||||
if (hasVirusDetected) {
|
||||
return {attachments: [], hasVirusDetected: true};
|
||||
}
|
||||
|
||||
const copyResults = await Promise.all(
|
||||
results.map((result) =>
|
||||
this.storageService.copyObjectWithJpegProcessing({
|
||||
sourceBucket: result.copyOperation.sourceBucket,
|
||||
sourceKey: result.copyOperation.sourceKey,
|
||||
destinationBucket: result.copyOperation.destinationBucket,
|
||||
destinationKey: result.copyOperation.destinationKey,
|
||||
contentType: result.copyOperation.newContentType,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
void this.deleteUploadObject(result.copyOperation.sourceBucket, result.copyOperation.sourceKey);
|
||||
}
|
||||
|
||||
const processedAttachments: Array<MessageAttachment> = results.map((result, index) => {
|
||||
const maybeDimensions = copyResults[index];
|
||||
if (maybeDimensions) {
|
||||
return {
|
||||
...result.attachment,
|
||||
width: maybeDimensions.width,
|
||||
height: maybeDimensions.height,
|
||||
};
|
||||
}
|
||||
return result.attachment;
|
||||
});
|
||||
|
||||
const metrics = getMetricsService();
|
||||
for (let index = 0; index < processedAttachments.length; index++) {
|
||||
const result = results[index];
|
||||
if (result.hasVirusDetected) continue;
|
||||
|
||||
const attachment = processedAttachments[index];
|
||||
const contentType = attachment.content_type ?? 'unknown';
|
||||
const filename = attachment.filename;
|
||||
const extension =
|
||||
(filename?.includes('.') ?? false) ? (filename.split('.').pop()?.toLowerCase() ?? 'unknown') : 'unknown';
|
||||
|
||||
const channelType = params.channel?.type ?? 'unknown';
|
||||
|
||||
metrics.counter({
|
||||
name: 'attachment.created',
|
||||
dimensions: {
|
||||
content_type: contentType,
|
||||
attachment_extension: extension,
|
||||
channel_type: channelType.toString(),
|
||||
},
|
||||
});
|
||||
metrics.counter({
|
||||
name: 'attachment.storage.bytes',
|
||||
dimensions: {
|
||||
content_type: contentType,
|
||||
channel_type: channelType.toString(),
|
||||
action: 'create',
|
||||
},
|
||||
value: Number(attachment.size),
|
||||
});
|
||||
}
|
||||
|
||||
return {attachments: processedAttachments, hasVirusDetected: false};
|
||||
}
|
||||
|
||||
private async processAttachment(params: ProcessAttachmentParams): Promise<ProcessedAttachment> {
|
||||
const {message, attachment, index, isNSFWAllowed} = params;
|
||||
|
||||
const uploadedFile = await this.storageService.getObjectMetadata(
|
||||
Config.s3.buckets.uploads,
|
||||
attachment.upload_filename,
|
||||
);
|
||||
|
||||
if (!uploadedFile) {
|
||||
throw InputValidationError.create(`attachments.${index}.upload_filename`, 'File not found');
|
||||
}
|
||||
|
||||
const attachmentId = createAttachmentID(this.snowflakeService.generate());
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachmentId, attachment.filename);
|
||||
|
||||
let contentType = getContentType(attachment.filename);
|
||||
let size = BigInt(uploadedFile.contentLength);
|
||||
const clientFlags =
|
||||
(attachment.flags ?? 0) & (MessageAttachmentFlags.IS_SPOILER | MessageAttachmentFlags.CONTAINS_EXPLICIT_MEDIA);
|
||||
|
||||
let flags = clientFlags;
|
||||
let width: number | null = null;
|
||||
let height: number | null = null;
|
||||
let placeholder: string | null = null;
|
||||
let duration: number | null = null;
|
||||
let hasVirusDetected = false;
|
||||
let nsfw: boolean | null = null;
|
||||
let contentHash: string | null = null;
|
||||
|
||||
const isMedia = isMediaFile(contentType);
|
||||
|
||||
const scanResult = await this.scanMalware(attachment);
|
||||
if (scanResult.isVirusDetected) {
|
||||
hasVirusDetected = true;
|
||||
await this.storageService.deleteObject(Config.s3.buckets.uploads, attachment.upload_filename);
|
||||
|
||||
return {
|
||||
attachment: {
|
||||
attachment_id: attachmentId,
|
||||
filename: attachment.filename,
|
||||
size,
|
||||
title: attachment.title ?? null,
|
||||
description: attachment.description ?? null,
|
||||
height,
|
||||
width,
|
||||
content_type: contentType,
|
||||
content_hash: contentHash,
|
||||
placeholder,
|
||||
flags,
|
||||
duration,
|
||||
nsfw,
|
||||
},
|
||||
copyOperation: {
|
||||
sourceBucket: Config.s3.buckets.uploads,
|
||||
sourceKey: attachment.upload_filename,
|
||||
destinationBucket: Config.s3.buckets.cdn,
|
||||
destinationKey: cdnKey,
|
||||
newContentType: contentType,
|
||||
},
|
||||
hasVirusDetected,
|
||||
};
|
||||
}
|
||||
|
||||
if (isMedia) {
|
||||
const metadata = await this.mediaService.getMetadata({
|
||||
type: 'upload',
|
||||
upload_filename: attachment.upload_filename,
|
||||
isNSFWAllowed,
|
||||
});
|
||||
|
||||
if (metadata) {
|
||||
contentType = metadata.content_type;
|
||||
contentHash = metadata.content_hash;
|
||||
size = BigInt(metadata.size);
|
||||
placeholder = metadata.placeholder ?? null;
|
||||
duration = metadata.duration && metadata.duration > 0 ? metadata.duration : null;
|
||||
width = metadata.width ?? null;
|
||||
height = metadata.height ?? null;
|
||||
|
||||
if (metadata.animated) {
|
||||
flags |= MessageAttachmentFlags.IS_ANIMATED;
|
||||
}
|
||||
if (metadata.nsfw) {
|
||||
flags |= MessageAttachmentFlags.CONTAINS_EXPLICIT_MEDIA;
|
||||
}
|
||||
nsfw = metadata.nsfw;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attachment: {
|
||||
attachment_id: attachmentId,
|
||||
filename: attachment.filename,
|
||||
size,
|
||||
title: attachment.title ?? null,
|
||||
description: attachment.description ?? null,
|
||||
height,
|
||||
width,
|
||||
content_type: contentType,
|
||||
content_hash: contentHash,
|
||||
placeholder,
|
||||
flags,
|
||||
duration,
|
||||
nsfw,
|
||||
},
|
||||
copyOperation: {
|
||||
sourceBucket: Config.s3.buckets.uploads,
|
||||
sourceKey: attachment.upload_filename,
|
||||
destinationBucket: Config.s3.buckets.cdn,
|
||||
destinationKey: cdnKey,
|
||||
newContentType: contentType,
|
||||
},
|
||||
hasVirusDetected,
|
||||
};
|
||||
}
|
||||
|
||||
private async scanMalware(attachment: AttachmentToProcess): Promise<{isVirusDetected: boolean}> {
|
||||
const fileData = await this.storageService.readObject(Config.s3.buckets.uploads, attachment.upload_filename);
|
||||
|
||||
if (!fileData) {
|
||||
throw InputValidationError.create('attachment', 'File not found for scanning');
|
||||
}
|
||||
|
||||
const fileBuffer = Buffer.from(fileData);
|
||||
const scanResult = await this.virusScanService.scanBuffer(fileBuffer, attachment.filename);
|
||||
|
||||
return {isVirusDetected: !scanResult.isClean};
|
||||
}
|
||||
|
||||
private deleteUploadObject(bucket: string, key: string): void {
|
||||
void this.storageService.deleteObject(bucket, key).catch((error) => {
|
||||
Logger.warn({bucket, key, error}, 'Failed to delete temporary upload object');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
|
||||
export class MessageAnonymizationService {
|
||||
constructor(private channelRepository: IChannelRepositoryAggregate) {}
|
||||
|
||||
async anonymizeMessagesByAuthor(originalAuthorId: UserID, newAuthorId: UserID): Promise<void> {
|
||||
const CHUNK_SIZE = 100;
|
||||
let lastChannelId: ChannelID | undefined;
|
||||
let lastMessageId: MessageID | undefined;
|
||||
let processedCount = 0;
|
||||
|
||||
while (true) {
|
||||
const messagesToAnonymize = await this.channelRepository.messages.listMessagesByAuthor(
|
||||
originalAuthorId,
|
||||
CHUNK_SIZE,
|
||||
lastChannelId,
|
||||
lastMessageId,
|
||||
);
|
||||
|
||||
if (messagesToAnonymize.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const {channelId, messageId} of messagesToAnonymize) {
|
||||
await this.channelRepository.messages.anonymizeMessage(channelId, messageId, newAuthorId);
|
||||
}
|
||||
|
||||
processedCount += messagesToAnonymize.length;
|
||||
lastChannelId = messagesToAnonymize[messagesToAnonymize.length - 1].channelId;
|
||||
lastMessageId = messagesToAnonymize[messagesToAnonymize.length - 1].messageId;
|
||||
|
||||
Logger.debug(
|
||||
{originalAuthorId, processedCount, chunkSize: messagesToAnonymize.length},
|
||||
'Anonymized message chunk',
|
||||
);
|
||||
|
||||
if (messagesToAnonymize.length < CHUNK_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Logger.debug({originalAuthorId, newAuthorId, totalProcessed: processedCount}, 'Completed message anonymization');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
|
||||
import type {User} from '~/Models';
|
||||
import {checkGuildVerificationWithResponse} from '~/utils/GuildVerificationUtils';
|
||||
import {BaseChannelAuthService, type ChannelAuthOptions} from '../BaseChannelAuthService';
|
||||
|
||||
export class MessageChannelAuthService extends BaseChannelAuthService {
|
||||
protected readonly options: ChannelAuthOptions = {
|
||||
errorOnMissingGuild: 'unknown_channel',
|
||||
validateNsfw: true,
|
||||
useVirtualPersonalNotes: true,
|
||||
};
|
||||
|
||||
async checkGuildVerification({
|
||||
user,
|
||||
guild,
|
||||
member,
|
||||
}: {
|
||||
user: User;
|
||||
guild: GuildResponse;
|
||||
member: GuildMemberResponse;
|
||||
}): Promise<void> {
|
||||
checkGuildVerificationWithResponse({user, guild, member});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID, WebhookID} from '~/BrandedTypes';
|
||||
import {ChannelTypes, GuildExplicitContentFilterTypes} from '~/Constants';
|
||||
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {Channel} from '~/Models';
|
||||
import type {PackService} from '~/pack/PackService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import * as EmojiUtils from '~/utils/EmojiUtils';
|
||||
|
||||
export class MessageContentService {
|
||||
constructor(
|
||||
private userRepository: IUserRepository,
|
||||
private guildRepository: IGuildRepository,
|
||||
private packService: PackService,
|
||||
) {}
|
||||
|
||||
async sanitizeCustomEmojis(params: {
|
||||
content: string;
|
||||
userId: UserID | null;
|
||||
webhookId: WebhookID | null;
|
||||
guildId: GuildID | null;
|
||||
hasPermission?: (permission: bigint) => Promise<boolean>;
|
||||
}): Promise<string> {
|
||||
const packResolver = await this.packService.createPackExpressionAccessResolver({
|
||||
userId: params.userId,
|
||||
type: 'emoji',
|
||||
});
|
||||
|
||||
return await EmojiUtils.sanitizeCustomEmojis({
|
||||
...params,
|
||||
userRepository: this.userRepository,
|
||||
guildRepository: this.guildRepository,
|
||||
packResolver,
|
||||
});
|
||||
}
|
||||
|
||||
isNSFWContentAllowed(params: {
|
||||
channel?: Channel;
|
||||
guild?: GuildResponse | null;
|
||||
member?: GuildMemberResponse | null;
|
||||
}): boolean {
|
||||
const {channel, guild, member} = params;
|
||||
|
||||
if (channel?.type === ChannelTypes.GUILD_TEXT && channel.isNsfw) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!guild) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const explicitContentFilter = guild.explicit_content_filter;
|
||||
|
||||
if (explicitContentFilter === GuildExplicitContentFilterTypes.DISABLED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (explicitContentFilter === GuildExplicitContentFilterTypes.MEMBERS_WITHOUT_ROLES) {
|
||||
const hasRoles = member && member.roles.length > 0;
|
||||
return !!hasRoles;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
245
fluxer_api/src/channel/services/message/MessageDeleteService.ts
Normal file
245
fluxer_api/src/channel/services/message/MessageDeleteService.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, createMessageID, createUserID, type GuildID, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {GuildOperations, Permissions} from '~/Constants';
|
||||
import {AuditLogActionType} from '~/constants/AuditLogActionType';
|
||||
import {
|
||||
CannotExecuteOnDmError,
|
||||
FeatureTemporarilyDisabledError,
|
||||
InputValidationError,
|
||||
MissingPermissionsError,
|
||||
UnknownMessageError,
|
||||
} from '~/Errors';
|
||||
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
|
||||
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import {getMetricsService} from '~/infrastructure/MetricsService';
|
||||
import type {Channel, Message} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {getSnowflake} from '~/utils/SnowflakeUtils';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import type {MessageChannelAuthService} from './MessageChannelAuthService';
|
||||
import type {MessageDispatchService} from './MessageDispatchService';
|
||||
import {isOperationDisabled, purgeMessageAttachments} from './MessageHelpers';
|
||||
import type {MessageSearchService} from './MessageSearchService';
|
||||
import type {MessageValidationService} from './MessageValidationService';
|
||||
|
||||
interface MessageDeleteServiceDeps {
|
||||
channelRepository: IChannelRepositoryAggregate;
|
||||
storageService: IStorageService;
|
||||
cloudflarePurgeQueue: ICloudflarePurgeQueue;
|
||||
validationService: MessageValidationService;
|
||||
channelAuthService: MessageChannelAuthService;
|
||||
dispatchService: MessageDispatchService;
|
||||
searchService: MessageSearchService;
|
||||
guildAuditLogService: GuildAuditLogService;
|
||||
}
|
||||
|
||||
export class MessageDeleteService {
|
||||
private readonly guildAuditLogService: GuildAuditLogService;
|
||||
|
||||
constructor(private readonly deps: MessageDeleteServiceDeps) {
|
||||
this.guildAuditLogService = deps.guildAuditLogService;
|
||||
}
|
||||
|
||||
async deleteMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const {channel, guild, hasPermission} = await this.deps.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
|
||||
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
const message = await this.deps.channelRepository.messages.getMessage(channelId, messageId);
|
||||
if (!message) throw new UnknownMessageError();
|
||||
|
||||
const canDelete = await this.deps.validationService.canDeleteMessage({message, userId, guild, hasPermission});
|
||||
if (!canDelete) throw new MissingPermissionsError();
|
||||
|
||||
if (message.pinnedTimestamp) {
|
||||
await this.deps.channelRepository.messageInteractions.removeChannelPin(channelId, messageId);
|
||||
}
|
||||
|
||||
await purgeMessageAttachments(message, this.deps.storageService, this.deps.cloudflarePurgeQueue);
|
||||
await this.deps.channelRepository.messages.deleteMessage(
|
||||
channelId,
|
||||
messageId,
|
||||
message.authorId || createUserID(0n),
|
||||
message.pinnedTimestamp || undefined,
|
||||
);
|
||||
|
||||
await this.deps.dispatchService.dispatchMessageDelete({channel, messageId, message});
|
||||
|
||||
if (message.pinnedTimestamp) {
|
||||
await this.deps.dispatchService.dispatchEvent({
|
||||
channel,
|
||||
event: 'CHANNEL_PINS_UPDATE',
|
||||
data: {
|
||||
channel_id: channel.id.toString(),
|
||||
last_pin_timestamp: channel.lastPinTimestamp?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.guildId) {
|
||||
await this.guildAuditLogService
|
||||
.createBuilder(channel.guildId, userId)
|
||||
.withAction(AuditLogActionType.MESSAGE_DELETE, message.id.toString())
|
||||
.withMetadata({channel_id: channel.id.toString()})
|
||||
.withReason(null)
|
||||
.commit();
|
||||
}
|
||||
|
||||
if (channel.indexedAt) {
|
||||
void this.deps.searchService.deleteMessageIndex(messageId);
|
||||
}
|
||||
|
||||
getMetricsService().counter({name: 'message.delete'});
|
||||
} catch (error) {
|
||||
getMetricsService().counter({name: 'message.delete.error'});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDeleteMessages({
|
||||
userId,
|
||||
channelId,
|
||||
messageIds,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageIds: Array<MessageID>;
|
||||
}): Promise<void> {
|
||||
if (messageIds.length === 0) {
|
||||
throw InputValidationError.create('message_ids', 'message_ids cannot be empty');
|
||||
}
|
||||
|
||||
if (messageIds.length > 100) {
|
||||
throw InputValidationError.create('message_ids', 'Cannot delete more than 100 messages at once');
|
||||
}
|
||||
|
||||
const {channel, guild, checkPermission} = await this.deps.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
|
||||
if (!guild) throw new CannotExecuteOnDmError();
|
||||
await checkPermission(Permissions.MANAGE_MESSAGES);
|
||||
|
||||
const messages = await Promise.all(
|
||||
messageIds.map((id) => this.deps.channelRepository.messages.getMessage(channelId, id)),
|
||||
);
|
||||
const existingMessages = messages.filter((m: Message | null) => m && m.channelId === channelId) as Array<Message>;
|
||||
|
||||
if (existingMessages.length === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
existingMessages.map((message) =>
|
||||
purgeMessageAttachments(message, this.deps.storageService, this.deps.cloudflarePurgeQueue),
|
||||
),
|
||||
);
|
||||
await this.deps.channelRepository.messages.bulkDeleteMessages(channelId, messageIds);
|
||||
|
||||
await this.deps.dispatchService.dispatchMessageDeleteBulk({channel, messageIds});
|
||||
|
||||
if (channel.guildId && existingMessages.length > 0) {
|
||||
await this.guildAuditLogService
|
||||
.createBuilder(channel.guildId, userId)
|
||||
.withAction(AuditLogActionType.MESSAGE_BULK_DELETE, null)
|
||||
.withMetadata({
|
||||
channel_id: channel.id.toString(),
|
||||
count: existingMessages.length.toString(),
|
||||
})
|
||||
.withReason(null)
|
||||
.commit();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUserMessagesInGuild({
|
||||
userId,
|
||||
guildId,
|
||||
days,
|
||||
}: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
days: number;
|
||||
}): Promise<void> {
|
||||
const channels = await this.deps.channelRepository.channelData.listGuildChannels(guildId);
|
||||
|
||||
const cutoffTimestamp = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const afterSnowflake = createMessageID(getSnowflake(cutoffTimestamp));
|
||||
|
||||
await Promise.all(
|
||||
channels.map(async (channel: Channel) => {
|
||||
const batchSize = 100;
|
||||
let beforeMessageId: MessageID | undefined;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const messages = await this.deps.channelRepository.messages.listMessages(
|
||||
channel.id,
|
||||
beforeMessageId,
|
||||
batchSize,
|
||||
afterSnowflake,
|
||||
);
|
||||
|
||||
if (messages.length === 0) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const userMessages = messages.filter((msg: Message) => msg.authorId === userId);
|
||||
|
||||
if (userMessages.length > 0) {
|
||||
const messageIds = userMessages.map((msg: Message) => msg.id);
|
||||
|
||||
await Promise.all(
|
||||
userMessages.map((message: Message) =>
|
||||
purgeMessageAttachments(message, this.deps.storageService, this.deps.cloudflarePurgeQueue),
|
||||
),
|
||||
);
|
||||
|
||||
await this.deps.channelRepository.messages.bulkDeleteMessages(channel.id, messageIds);
|
||||
|
||||
await this.deps.dispatchService.dispatchMessageDeleteBulk({channel, messageIds});
|
||||
}
|
||||
|
||||
if (messages.length < batchSize) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
beforeMessageId = messages[messages.length - 1].id;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AttachmentDecayService} from '~/attachment/AttachmentDecayService';
|
||||
import {type ChannelID, channelIdToUserId, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes, type GatewayDispatchEvent} from '~/Constants';
|
||||
import {mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import type {IChannelRepositoryAggregate} from '~/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Channel, Message} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {collectMessageAttachments} from './MessageHelpers';
|
||||
|
||||
export class MessageDispatchService {
|
||||
constructor(
|
||||
private gatewayService: IGatewayService,
|
||||
private userCacheService: UserCacheService,
|
||||
private mediaService: IMediaService,
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private attachmentDecayService: AttachmentDecayService = new AttachmentDecayService(),
|
||||
) {}
|
||||
|
||||
async dispatchEvent(params: {channel: Channel; event: GatewayDispatchEvent; data: unknown}): Promise<void> {
|
||||
const {channel, event, data} = params;
|
||||
|
||||
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
return this.gatewayService.dispatchPresence({
|
||||
userId: channelIdToUserId(channel.id),
|
||||
event,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.guildId) {
|
||||
return this.gatewayService.dispatchGuild({guildId: channel.guildId, event, data});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from(channel.recipientIds).map((recipientId) =>
|
||||
this.gatewayService.dispatchPresence({userId: recipientId, event, data}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async dispatchMessageCreate({
|
||||
channel,
|
||||
message,
|
||||
requestCache,
|
||||
currentUserId,
|
||||
nonce,
|
||||
tts,
|
||||
}: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
requestCache: RequestCache;
|
||||
currentUserId?: UserID;
|
||||
nonce?: string;
|
||||
tts?: boolean;
|
||||
}): Promise<void> {
|
||||
const messageAttachments = collectMessageAttachments(message);
|
||||
const attachmentDecayMap =
|
||||
messageAttachments.length > 0
|
||||
? await this.attachmentDecayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
|
||||
: undefined;
|
||||
|
||||
const messageResponse = await mapMessageToResponse({
|
||||
message,
|
||||
currentUserId,
|
||||
nonce,
|
||||
tts,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
attachmentDecayMap,
|
||||
getReferencedMessage: (channelId: ChannelID, messageId: MessageID) =>
|
||||
this.channelRepository.messages.getMessage(channelId, messageId),
|
||||
});
|
||||
|
||||
await this.dispatchEvent({
|
||||
channel,
|
||||
event: 'MESSAGE_CREATE',
|
||||
data: {...messageResponse, channel_type: channel.type},
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchMessageUpdate({
|
||||
channel,
|
||||
message,
|
||||
requestCache,
|
||||
currentUserId,
|
||||
}: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
requestCache: RequestCache;
|
||||
currentUserId?: UserID;
|
||||
}): Promise<void> {
|
||||
const messageAttachments = collectMessageAttachments(message);
|
||||
const attachmentDecayMap =
|
||||
messageAttachments.length > 0
|
||||
? await this.attachmentDecayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
|
||||
: undefined;
|
||||
|
||||
const messageResponse = await mapMessageToResponse({
|
||||
message,
|
||||
currentUserId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
attachmentDecayMap,
|
||||
getReferencedMessage: (channelId: ChannelID, messageId: MessageID) =>
|
||||
this.channelRepository.messages.getMessage(channelId, messageId),
|
||||
});
|
||||
|
||||
await this.dispatchEvent({
|
||||
channel,
|
||||
event: 'MESSAGE_UPDATE',
|
||||
data: messageResponse,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchMessageDelete({
|
||||
channel,
|
||||
messageId,
|
||||
message,
|
||||
}: {
|
||||
channel: Channel;
|
||||
messageId: MessageID;
|
||||
message: Message;
|
||||
}): Promise<void> {
|
||||
await this.dispatchEvent({
|
||||
channel,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {
|
||||
channel_id: channel.id.toString(),
|
||||
id: messageId.toString(),
|
||||
content: message.content,
|
||||
author_id: message.authorId?.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchMessageDeleteBulk({
|
||||
channel,
|
||||
messageIds,
|
||||
}: {
|
||||
channel: Channel;
|
||||
messageIds: Array<MessageID>;
|
||||
}): Promise<void> {
|
||||
await this.dispatchEvent({
|
||||
channel,
|
||||
event: 'MESSAGE_DELETE_BULK',
|
||||
data: {
|
||||
channel_id: channel.id.toString(),
|
||||
ids: messageIds.map((id) => id.toString()),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
173
fluxer_api/src/channel/services/message/MessageEditService.ts
Normal file
173
fluxer_api/src/channel/services/message/MessageEditService.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {GuildOperations, Permissions} from '~/Constants';
|
||||
import type {MessageUpdateRequest} from '~/channel/ChannelModel';
|
||||
import {FeatureTemporarilyDisabledError, MissingPermissionsError, UnknownMessageError} from '~/Errors';
|
||||
import {getMetricsService} from '~/infrastructure/MetricsService';
|
||||
import type {Message} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import type {MessageChannelAuthService} from './MessageChannelAuthService';
|
||||
import type {MessageDispatchService} from './MessageDispatchService';
|
||||
import type {MessageEmbedAttachmentResolver} from './MessageEmbedAttachmentResolver';
|
||||
import {isOperationDisabled} from './MessageHelpers';
|
||||
import type {MessageMentionService} from './MessageMentionService';
|
||||
import type {MessagePersistenceService} from './MessagePersistenceService';
|
||||
import type {MessageProcessingService} from './MessageProcessingService';
|
||||
import type {MessageSearchService} from './MessageSearchService';
|
||||
import type {MessageValidationService} from './MessageValidationService';
|
||||
|
||||
interface MessageEditServiceDeps {
|
||||
channelRepository: IChannelRepositoryAggregate;
|
||||
userRepository: IUserRepository;
|
||||
validationService: MessageValidationService;
|
||||
persistenceService: MessagePersistenceService;
|
||||
channelAuthService: MessageChannelAuthService;
|
||||
processingService: MessageProcessingService;
|
||||
dispatchService: MessageDispatchService;
|
||||
searchService: MessageSearchService;
|
||||
embedAttachmentResolver: MessageEmbedAttachmentResolver;
|
||||
mentionService: MessageMentionService;
|
||||
}
|
||||
|
||||
export class MessageEditService {
|
||||
constructor(private readonly deps: MessageEditServiceDeps) {}
|
||||
|
||||
async editMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
data,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
data: MessageUpdateRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Message> {
|
||||
try {
|
||||
const {channel, guild, hasPermission, member} = await this.deps.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
|
||||
const [canEmbedLinks, canMentionEveryone] = await Promise.all([
|
||||
hasPermission(Permissions.EMBED_LINKS),
|
||||
hasPermission(Permissions.MENTION_EVERYONE),
|
||||
]);
|
||||
|
||||
if (data.embeds && data.embeds.length > 0 && !canEmbedLinks) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
const message = await this.deps.channelRepository.messages.getMessage(channelId, messageId);
|
||||
if (!message) throw new UnknownMessageError();
|
||||
|
||||
const user = await this.deps.userRepository.findUnique(userId);
|
||||
this.deps.validationService.validateMessageEditable(message);
|
||||
this.deps.validationService.validateMessageContent(data, user, true);
|
||||
this.deps.embedAttachmentResolver.validateAttachmentReferences({
|
||||
embeds: data.embeds,
|
||||
attachments: data.attachments,
|
||||
existingAttachments: message.attachments.map((att) => ({filename: att.filename})),
|
||||
});
|
||||
|
||||
const referencedMessage = message.reference
|
||||
? await this.deps.channelRepository.messages.getMessage(channelId, message.reference.messageId)
|
||||
: null;
|
||||
|
||||
const hasMentionContentChanges = data.content !== undefined || data.allowed_mentions !== undefined;
|
||||
if (hasMentionContentChanges) {
|
||||
const mentionContent = data.content ?? message.content ?? '';
|
||||
this.deps.mentionService.extractMentions({
|
||||
content: mentionContent,
|
||||
referencedMessage,
|
||||
message: {
|
||||
id: message.id,
|
||||
channelId: message.channelId,
|
||||
authorId: message.authorId ?? userId,
|
||||
content: mentionContent,
|
||||
flags: data.flags ?? message.flags,
|
||||
} as Message,
|
||||
channelType: channel.type,
|
||||
allowedMentions: data.allowed_mentions ?? null,
|
||||
guild,
|
||||
canMentionEveryone,
|
||||
});
|
||||
}
|
||||
|
||||
if (message.authorId !== userId) {
|
||||
return await this.deps.processingService.handleNonAuthorEdit({
|
||||
message,
|
||||
messageId,
|
||||
data,
|
||||
guild,
|
||||
hasPermission,
|
||||
channel,
|
||||
requestCache,
|
||||
persistenceService: this.deps.persistenceService,
|
||||
dispatchMessageUpdate: this.deps.dispatchService.dispatchMessageUpdate.bind(this.deps.dispatchService),
|
||||
});
|
||||
}
|
||||
|
||||
const updatedMessage = await this.deps.persistenceService.updateMessage({
|
||||
message,
|
||||
messageId,
|
||||
data,
|
||||
channel,
|
||||
guild,
|
||||
member,
|
||||
allowEmbeds: canEmbedLinks,
|
||||
});
|
||||
|
||||
if (data.content !== undefined || data.allowed_mentions !== undefined) {
|
||||
await this.deps.processingService.handleMentions({
|
||||
channel,
|
||||
message: updatedMessage,
|
||||
referencedMessageOnSend: referencedMessage,
|
||||
allowedMentions: data.allowed_mentions ?? null,
|
||||
guild,
|
||||
canMentionEveryone,
|
||||
canMentionRoles: canMentionEveryone,
|
||||
});
|
||||
}
|
||||
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
|
||||
if (channel.indexedAt) {
|
||||
void this.deps.searchService.updateMessageIndex(updatedMessage);
|
||||
}
|
||||
|
||||
getMetricsService().counter({name: 'message.edit'});
|
||||
|
||||
return updatedMessage;
|
||||
} catch (error) {
|
||||
getMetricsService().counter({name: 'message.edit.error'});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AttachmentID, ChannelID} from '~/BrandedTypes';
|
||||
import type {AttachmentRequestData} from '~/channel/AttachmentDTOs';
|
||||
import type {RichEmbedMediaWithMetadata, RichEmbedRequest} from '~/channel/ChannelModel';
|
||||
import {InputValidationError} from '~/Errors';
|
||||
import {makeAttachmentCdnUrl} from './MessageHelpers';
|
||||
|
||||
interface ProcessedAttachment {
|
||||
attachment_id: AttachmentID;
|
||||
filename: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
content_type: string;
|
||||
content_hash: string | null;
|
||||
placeholder: string | null;
|
||||
flags: number;
|
||||
duration: number | null;
|
||||
nsfw: boolean | null;
|
||||
}
|
||||
|
||||
interface RichEmbedRequestWithMetadata extends Omit<RichEmbedRequest, 'image' | 'thumbnail'> {
|
||||
image?: RichEmbedMediaWithMetadata | null;
|
||||
thumbnail?: RichEmbedMediaWithMetadata | null;
|
||||
}
|
||||
|
||||
const SUPPORTED_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif']);
|
||||
|
||||
export class MessageEmbedAttachmentResolver {
|
||||
validateAttachmentReferences(params: {
|
||||
embeds: Array<RichEmbedRequest> | undefined;
|
||||
attachments: Array<AttachmentRequestData> | undefined;
|
||||
existingAttachments?: Array<{filename: string}>;
|
||||
}): void {
|
||||
if (!params.embeds || params.embeds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let availableFilenames: Set<string> | undefined;
|
||||
|
||||
if (params.attachments !== undefined) {
|
||||
const filenames = params.attachments
|
||||
.map((att) => ('filename' in att ? att.filename : undefined))
|
||||
.filter((filename): filename is string => typeof filename === 'string' && filename.length > 0);
|
||||
if (filenames.length > 0) {
|
||||
availableFilenames = new Set(filenames);
|
||||
}
|
||||
} else if (params.existingAttachments) {
|
||||
availableFilenames = new Set(params.existingAttachments.map((att) => att.filename));
|
||||
}
|
||||
|
||||
if (!availableFilenames || availableFilenames.size === 0) {
|
||||
for (const embed of params.embeds) {
|
||||
if (embed.image?.url?.startsWith('attachment://') || embed.thumbnail?.url?.startsWith('attachment://')) {
|
||||
throw InputValidationError.create('embeds', 'Cannot reference attachments when no attachments are provided');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const validateAttachmentReference = (filename: string, field: string, embedIndex: number) => {
|
||||
if (!availableFilenames.has(filename)) {
|
||||
throw InputValidationError.create(
|
||||
`embeds[${embedIndex}].${field}`,
|
||||
`Referenced attachment "${filename}" not found in message attachments`,
|
||||
);
|
||||
}
|
||||
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
if (!extension || !SUPPORTED_IMAGE_EXTENSIONS.has(extension)) {
|
||||
throw InputValidationError.create(
|
||||
`embeds[${embedIndex}].${field}`,
|
||||
`Attachment "${filename}" must be an image file (png, jpg, jpeg, webp, or gif)`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
for (let embedIndex = 0; embedIndex < params.embeds.length; embedIndex++) {
|
||||
const embed = params.embeds[embedIndex];
|
||||
|
||||
if (embed.image?.url?.startsWith('attachment://')) {
|
||||
const filename = embed.image.url.slice(13);
|
||||
validateAttachmentReference(filename, 'image.url', embedIndex);
|
||||
}
|
||||
|
||||
if (embed.thumbnail?.url?.startsWith('attachment://')) {
|
||||
const filename = embed.thumbnail.url.slice(13);
|
||||
validateAttachmentReference(filename, 'thumbnail.url', embedIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolveEmbedAttachmentUrls(params: {
|
||||
embeds: Array<RichEmbedRequest> | undefined;
|
||||
attachments: Array<ProcessedAttachment>;
|
||||
channelId: ChannelID;
|
||||
}): Array<RichEmbedRequestWithMetadata> | undefined {
|
||||
if (!params.embeds || params.embeds.length === 0) {
|
||||
return params.embeds as Array<RichEmbedRequestWithMetadata> | undefined;
|
||||
}
|
||||
|
||||
const attachmentMap = new Map<string, {cdnUrl: string; metadata: ProcessedAttachment}>();
|
||||
for (const attachment of params.attachments) {
|
||||
const cdnUrl = makeAttachmentCdnUrl(params.channelId, attachment.attachment_id, attachment.filename);
|
||||
attachmentMap.set(attachment.filename, {cdnUrl, metadata: attachment});
|
||||
}
|
||||
|
||||
const resolveAttachmentUrl = (filename: string, field: string): {cdnUrl: string; metadata: ProcessedAttachment} => {
|
||||
const attachmentData = attachmentMap.get(filename);
|
||||
if (!attachmentData) {
|
||||
throw InputValidationError.create(
|
||||
field,
|
||||
`Referenced attachment "${filename}" not found in message attachments`,
|
||||
);
|
||||
}
|
||||
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
if (!extension || !SUPPORTED_IMAGE_EXTENSIONS.has(extension)) {
|
||||
throw InputValidationError.create(
|
||||
field,
|
||||
`Attachment "${filename}" must be an image file (png, jpg, jpeg, webp, or gif)`,
|
||||
);
|
||||
}
|
||||
|
||||
return attachmentData;
|
||||
};
|
||||
|
||||
return params.embeds.map((embed) => {
|
||||
const resolvedEmbed: RichEmbedRequestWithMetadata = {...embed};
|
||||
|
||||
if (embed.image?.url?.startsWith('attachment://')) {
|
||||
const filename = embed.image.url.slice(13);
|
||||
const {cdnUrl, metadata} = resolveAttachmentUrl(filename, 'embeds.image.url');
|
||||
resolvedEmbed.image = {
|
||||
...embed.image,
|
||||
url: cdnUrl,
|
||||
_attachmentMetadata: {
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
content_type: metadata.content_type,
|
||||
content_hash: metadata.content_hash,
|
||||
placeholder: metadata.placeholder,
|
||||
flags: metadata.flags,
|
||||
duration: metadata.duration,
|
||||
nsfw: metadata.nsfw,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (embed.thumbnail?.url?.startsWith('attachment://')) {
|
||||
const filename = embed.thumbnail.url.slice(13);
|
||||
const {cdnUrl, metadata} = resolveAttachmentUrl(filename, 'embeds.thumbnail.url');
|
||||
resolvedEmbed.thumbnail = {
|
||||
...embed.thumbnail,
|
||||
url: cdnUrl,
|
||||
_attachmentMetadata: {
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
content_type: metadata.content_type,
|
||||
content_hash: metadata.content_hash,
|
||||
placeholder: metadata.placeholder,
|
||||
flags: metadata.flags,
|
||||
duration: metadata.duration,
|
||||
nsfw: metadata.nsfw,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return resolvedEmbed;
|
||||
});
|
||||
}
|
||||
}
|
||||
257
fluxer_api/src/channel/services/message/MessageHelpers.ts
Normal file
257
fluxer_api/src/channel/services/message/MessageHelpers.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import mime from 'mime';
|
||||
import {type AttachmentID, type ChannelID, createAttachmentID, type UserID, userIdToChannelId} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import type {MessageAttachment, MessageSnapshot} from '~/database/CassandraTypes';
|
||||
import {FileSizeTooLargeError, InputValidationError} from '~/Errors';
|
||||
import type {GuildResponse} from '~/guild/GuildModel';
|
||||
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import {Attachment, type Message, type User} from '~/Models';
|
||||
import {getAttachmentMaxSize} from '~/utils/AttachmentUtils';
|
||||
import {extractTimestamp} from '~/utils/SnowflakeUtils';
|
||||
|
||||
export const MESSAGE_NONCE_TTL = 60 * 5;
|
||||
|
||||
export const VIRUS_MESSAGE_PREFIX =
|
||||
"Hm, it looks like that file might've been a virus. Instead of cooking up trouble, try cooking up a ";
|
||||
|
||||
export const VIRUS_RECIPE_SUGGESTIONS = [
|
||||
'Chocolate Avocado Protein Smoothie: <https://spinach4breakfast.com/chocolate-avocado-protein-smoothie>',
|
||||
'Spicy Italian Meatball: <https://www.foodnetwork.com/recipes/ree-drummond/spicy-italian-meatballs.html>',
|
||||
'Hawaiian Banana Nut Bread: <https://www.food.com/recipe/hawaiian-banana-nut-bread-113608>',
|
||||
'Panang Red Seafood Curry: <https://www.deliaonline.com/recipes/international/asian/chinese/panang-red-seafood-curry>',
|
||||
'Veggie Tofu Stir Fry: <https://minimalistbaker.com/tofu-that-tastes-good-stir-fry>',
|
||||
'Chicken Tikka Masala: <https://www.epicurious.com/recipes/food/views/chicken-tikka-masala-51171400>',
|
||||
'Slow-Cooked Pulled Pork Sliders: <https://www.foodnetwork.com/recipes/food-network-kitchens/slow-cooker-pulled-pork-sandwiches-recipe.html>',
|
||||
'Beet-Pickled Deviled Eggs: <https://www.thekitchn.com/recipe-beet-pickled-deviled-eggs-151550>',
|
||||
];
|
||||
|
||||
export function isMediaFile(contentType: string): boolean {
|
||||
return contentType.startsWith('image/') || contentType.startsWith('video/') || contentType.startsWith('audio/');
|
||||
}
|
||||
|
||||
export function cleanTextForMentions(content: string): string {
|
||||
return content
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`]*`/g, '')
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1')
|
||||
.replace(/https?:\/\/[^\s]+/g, '');
|
||||
}
|
||||
|
||||
export function isPersonalNotesChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): boolean {
|
||||
return userIdToChannelId(userId) === channelId;
|
||||
}
|
||||
|
||||
export function getContentType(filename: string): string {
|
||||
return mime.getType(filename) || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function validateAttachmentIds(attachments: Array<{id: bigint}>): void {
|
||||
const ids = new Set(attachments.map((a) => a.id));
|
||||
if (ids.size !== attachments.length) {
|
||||
throw InputValidationError.create('attachments', 'Duplicate attachment ids are not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
export function validateAttachmentSizes(attachments: Array<{size: number | bigint}>, user: User): void {
|
||||
const maxAttachmentSize = getAttachmentMaxSize(user.isPremium());
|
||||
|
||||
for (const {size} of attachments) {
|
||||
if (Number(size) > maxAttachmentSize) {
|
||||
throw new FileSizeTooLargeError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function makeAttachmentCdnKey(
|
||||
channelId: ChannelID,
|
||||
attachmentId: AttachmentID | bigint,
|
||||
filename: string,
|
||||
): string {
|
||||
return `attachments/${channelId}/${attachmentId}/${filename}`;
|
||||
}
|
||||
|
||||
export function makeAttachmentCdnUrl(
|
||||
channelId: ChannelID,
|
||||
attachmentId: AttachmentID | bigint,
|
||||
filename: string,
|
||||
): string {
|
||||
return `${Config.endpoints.media}/${makeAttachmentCdnKey(channelId, attachmentId, filename)}`;
|
||||
}
|
||||
|
||||
async function cloneAttachments(
|
||||
attachments: Array<Attachment>,
|
||||
sourceChannelId: ChannelID,
|
||||
destinationChannelId: ChannelID,
|
||||
storageService: IStorageService,
|
||||
snowflakeService: SnowflakeService,
|
||||
): Promise<Array<MessageAttachment>> {
|
||||
const clonedAttachments: Array<MessageAttachment> = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const newAttachmentId = createAttachmentID(snowflakeService.generate());
|
||||
|
||||
const sourceKey = makeAttachmentCdnKey(sourceChannelId, attachment.id, attachment.filename);
|
||||
const destinationKey = makeAttachmentCdnKey(destinationChannelId, newAttachmentId, attachment.filename);
|
||||
|
||||
await storageService.copyObject({
|
||||
sourceBucket: Config.s3.buckets.cdn,
|
||||
sourceKey,
|
||||
destinationBucket: Config.s3.buckets.cdn,
|
||||
destinationKey,
|
||||
newContentType: attachment.contentType,
|
||||
});
|
||||
|
||||
clonedAttachments.push({
|
||||
attachment_id: newAttachmentId,
|
||||
filename: attachment.filename,
|
||||
size: BigInt(attachment.size),
|
||||
title: attachment.title,
|
||||
description: attachment.description,
|
||||
width: attachment.width,
|
||||
height: attachment.height,
|
||||
content_type: attachment.contentType,
|
||||
content_hash: attachment.contentHash,
|
||||
placeholder: attachment.placeholder,
|
||||
flags: attachment.flags ?? 0,
|
||||
duration: attachment.duration,
|
||||
nsfw: attachment.nsfw,
|
||||
});
|
||||
}
|
||||
|
||||
return clonedAttachments;
|
||||
}
|
||||
|
||||
export async function createMessageSnapshotsForForward(
|
||||
referencedMessage: Message,
|
||||
user: User,
|
||||
destinationChannelId: ChannelID,
|
||||
storageService: IStorageService,
|
||||
snowflakeService: SnowflakeService,
|
||||
validateAttachmentSizesOverride?: (attachments: Array<{size: number | bigint}>, user: User) => void,
|
||||
): Promise<Array<MessageSnapshot>> {
|
||||
const validate = validateAttachmentSizesOverride ?? validateAttachmentSizes;
|
||||
|
||||
if (referencedMessage.messageSnapshots && referencedMessage.messageSnapshots.length > 0) {
|
||||
const snapshot = referencedMessage.messageSnapshots[0];
|
||||
const snapshotAttachments = snapshot.attachments ?? [];
|
||||
|
||||
validate(snapshotAttachments, user);
|
||||
|
||||
const attachmentsForClone = snapshotAttachments.map((att) =>
|
||||
att instanceof Attachment ? att : new Attachment(att),
|
||||
);
|
||||
|
||||
const clonedAttachments = await cloneAttachments(
|
||||
attachmentsForClone,
|
||||
referencedMessage.channelId,
|
||||
destinationChannelId,
|
||||
storageService,
|
||||
snowflakeService,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
content: snapshot.content,
|
||||
timestamp: snapshot.timestamp,
|
||||
edited_timestamp: snapshot.editedTimestamp,
|
||||
mention_users: snapshot.mentionedUserIds,
|
||||
mention_roles: snapshot.mentionedRoleIds,
|
||||
mention_channels: snapshot.mentionedChannelIds,
|
||||
attachments: clonedAttachments.length > 0 ? clonedAttachments : null,
|
||||
embeds: snapshot.embeds.map((embed) => embed.toMessageEmbed()),
|
||||
sticker_items: snapshot.stickers.map((sticker) => sticker.toMessageStickerItem()),
|
||||
type: snapshot.type,
|
||||
flags: snapshot.flags,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
validate(referencedMessage.attachments, user);
|
||||
|
||||
const clonedAttachments = await cloneAttachments(
|
||||
referencedMessage.attachments,
|
||||
referencedMessage.channelId,
|
||||
destinationChannelId,
|
||||
storageService,
|
||||
snowflakeService,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
content: referencedMessage.content,
|
||||
timestamp: new Date(extractTimestamp(referencedMessage.id)),
|
||||
edited_timestamp: referencedMessage.editedTimestamp,
|
||||
mention_users: referencedMessage.mentionedUserIds.size > 0 ? referencedMessage.mentionedUserIds : null,
|
||||
mention_roles: referencedMessage.mentionedRoleIds.size > 0 ? referencedMessage.mentionedRoleIds : null,
|
||||
mention_channels: referencedMessage.mentionedChannelIds.size > 0 ? referencedMessage.mentionedChannelIds : null,
|
||||
attachments: clonedAttachments.length > 0 ? clonedAttachments : null,
|
||||
embeds: referencedMessage.embeds.length > 0 ? referencedMessage.embeds.map((e) => e.toMessageEmbed()) : null,
|
||||
sticker_items:
|
||||
referencedMessage.stickers.length > 0 ? referencedMessage.stickers.map((s) => s.toMessageStickerItem()) : null,
|
||||
type: referencedMessage.type,
|
||||
flags: referencedMessage.flags,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function purgeMessageAttachments(
|
||||
message: Message,
|
||||
storageService: IStorageService,
|
||||
cloudflarePurgeQueue: ICloudflarePurgeQueue,
|
||||
): Promise<void> {
|
||||
const cdnUrls: Array<string> = [];
|
||||
|
||||
await Promise.all(
|
||||
message.attachments.map(async (attachment) => {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
await storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
|
||||
|
||||
if (Config.cloudflare.purgeEnabled) {
|
||||
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
|
||||
cdnUrls.push(cdnUrl);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (Config.cloudflare.purgeEnabled && cdnUrls.length > 0) {
|
||||
await cloudflarePurgeQueue.addUrls(cdnUrls);
|
||||
}
|
||||
}
|
||||
|
||||
export function isOperationDisabled(guild: GuildResponse | null, operation: number): boolean {
|
||||
if (!guild) return false;
|
||||
return (guild.disabled_operations & operation) !== 0;
|
||||
}
|
||||
|
||||
export function isMessageEmpty(message: Message, excludingAttachments = false): boolean {
|
||||
const hasContent = !!message.content;
|
||||
const hasEmbeds = message.embeds.length > 0;
|
||||
const hasStickers = message.stickers.length > 0;
|
||||
const hasAttachments = !excludingAttachments && message.attachments.length > 0;
|
||||
return !hasContent && !hasEmbeds && !hasStickers && !hasAttachments;
|
||||
}
|
||||
|
||||
export function collectMessageAttachments(message: Message): Array<Attachment> {
|
||||
return [...message.attachments, ...message.messageSnapshots.flatMap((snapshot) => snapshot.attachments)];
|
||||
}
|
||||
272
fluxer_api/src/channel/services/message/MessageMentionService.ts
Normal file
272
fluxer_api/src/channel/services/message/MessageMentionService.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createRoleID, createUserID, type GuildID, type RoleID, type UserID} from '~/BrandedTypes';
|
||||
import {
|
||||
ChannelTypes,
|
||||
GuildOperations,
|
||||
MessageTypes,
|
||||
ROLE_MENTION_REGEX,
|
||||
SENDABLE_MESSAGE_FLAGS,
|
||||
USER_MENTION_REGEX,
|
||||
} from '~/Constants';
|
||||
import {ALLOWED_MENTIONS_PARSE, type AllowedMentionsRequest} from '~/channel/ChannelModel';
|
||||
import {InputValidationError} from '~/Errors';
|
||||
import type {GuildResponse} from '~/guild/GuildModel';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {Channel, Message} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {IWorkerService} from '~/worker/IWorkerService';
|
||||
import {cleanTextForMentions, isOperationDisabled, isPersonalNotesChannel} from './MessageHelpers';
|
||||
|
||||
interface MentionData {
|
||||
userMentions: Set<UserID>;
|
||||
roleMentions: Set<RoleID>;
|
||||
flags: number;
|
||||
mentionsEveryone: boolean;
|
||||
mentionsHere: boolean;
|
||||
}
|
||||
|
||||
export class MessageMentionService {
|
||||
constructor(
|
||||
private userRepository: IUserRepository,
|
||||
private guildRepository: IGuildRepository,
|
||||
private workerService: IWorkerService,
|
||||
) {}
|
||||
|
||||
extractMentions({
|
||||
content,
|
||||
referencedMessage,
|
||||
message,
|
||||
channelType,
|
||||
allowedMentions,
|
||||
guild,
|
||||
canMentionEveryone = true,
|
||||
}: {
|
||||
content: string;
|
||||
referencedMessage: Message | null;
|
||||
message: Message;
|
||||
channelType: number;
|
||||
allowedMentions: AllowedMentionsRequest | null;
|
||||
guild?: GuildResponse | null;
|
||||
canMentionEveryone?: boolean;
|
||||
}): MentionData {
|
||||
const cleanText = cleanTextForMentions(content);
|
||||
let mentionsEveryone = cleanText.includes('@everyone');
|
||||
let mentionsHere = cleanText.includes('@here');
|
||||
|
||||
if (guild && isOperationDisabled(guild, GuildOperations.EVERYONE_MENTIONS)) {
|
||||
mentionsEveryone = false;
|
||||
mentionsHere = false;
|
||||
}
|
||||
|
||||
const userMentions = new Set(
|
||||
[...content.matchAll(USER_MENTION_REGEX)]
|
||||
.map((m) => createUserID(BigInt(m.groups?.userId ?? '0')))
|
||||
.filter((id) => id !== 0n),
|
||||
);
|
||||
|
||||
const roleMentions = new Set(
|
||||
[...content.matchAll(ROLE_MENTION_REGEX)]
|
||||
.map((m) => createRoleID(BigInt(m.groups?.roleId ?? '0')))
|
||||
.filter((id) => id !== createRoleID(0n)),
|
||||
);
|
||||
|
||||
const isDMChannel = channelType === ChannelTypes.DM || channelType === ChannelTypes.DM_PERSONAL_NOTES;
|
||||
const shouldAddReferencedUser =
|
||||
referencedMessage?.authorId &&
|
||||
referencedMessage.authorId !== message.authorId &&
|
||||
!isDMChannel &&
|
||||
(!allowedMentions || allowedMentions.replied_user !== false);
|
||||
|
||||
if (shouldAddReferencedUser) {
|
||||
userMentions.add(referencedMessage!.authorId!);
|
||||
}
|
||||
|
||||
const sendableFlags = message.flags & SENDABLE_MESSAGE_FLAGS;
|
||||
let flags = message.flags & ~SENDABLE_MESSAGE_FLAGS;
|
||||
|
||||
if (allowedMentions) {
|
||||
const result = this.applyAllowedMentions({
|
||||
allowedMentions,
|
||||
userMentions,
|
||||
roleMentions,
|
||||
mentionsEveryone,
|
||||
mentionsHere,
|
||||
flags,
|
||||
referencedMessage,
|
||||
});
|
||||
flags = result.flags;
|
||||
mentionsEveryone = result.mentionsEveryone;
|
||||
mentionsHere = result.mentionsHere;
|
||||
}
|
||||
|
||||
if (!canMentionEveryone && (mentionsEveryone || mentionsHere)) {
|
||||
mentionsEveryone = false;
|
||||
mentionsHere = false;
|
||||
}
|
||||
|
||||
return {userMentions, roleMentions, flags: flags | sendableFlags, mentionsEveryone, mentionsHere};
|
||||
}
|
||||
|
||||
private applyAllowedMentions({
|
||||
allowedMentions,
|
||||
userMentions,
|
||||
roleMentions,
|
||||
mentionsEveryone,
|
||||
mentionsHere,
|
||||
flags,
|
||||
referencedMessage,
|
||||
}: {
|
||||
allowedMentions: AllowedMentionsRequest;
|
||||
userMentions: Set<UserID>;
|
||||
roleMentions: Set<RoleID>;
|
||||
mentionsEveryone: boolean;
|
||||
mentionsHere: boolean;
|
||||
flags: number;
|
||||
referencedMessage?: Message | null;
|
||||
}): {flags: number; mentionsEveryone: boolean; mentionsHere: boolean} {
|
||||
const parse = allowedMentions.parse ?? ALLOWED_MENTIONS_PARSE;
|
||||
const users = allowedMentions.users ?? [];
|
||||
const roles = allowedMentions.roles ?? [];
|
||||
const hasExplicitParse = allowedMentions.parse !== undefined;
|
||||
|
||||
if (hasExplicitParse && parse.length > 0 && (users.length > 0 || roles.length > 0)) {
|
||||
throw InputValidationError.create(
|
||||
'allowed_mentions',
|
||||
'"parse" and ("users" or "roles") cannot be used together.',
|
||||
);
|
||||
}
|
||||
|
||||
const repliedUserId = referencedMessage?.authorId;
|
||||
const shouldPreserveRepliedUser = repliedUserId && allowedMentions.replied_user !== false;
|
||||
let preservedRepliedUser = null;
|
||||
|
||||
if (shouldPreserveRepliedUser && userMentions.has(repliedUserId)) {
|
||||
preservedRepliedUser = repliedUserId;
|
||||
userMentions.delete(repliedUserId);
|
||||
}
|
||||
|
||||
this.filterMentions({
|
||||
mentions: userMentions,
|
||||
allowedList: users.map(createUserID),
|
||||
shouldParse: parse.includes('users'),
|
||||
});
|
||||
|
||||
this.filterMentions({
|
||||
mentions: roleMentions,
|
||||
allowedList: roles.map(createRoleID),
|
||||
shouldParse: parse.includes('roles'),
|
||||
});
|
||||
|
||||
if (preservedRepliedUser) {
|
||||
userMentions.add(preservedRepliedUser);
|
||||
}
|
||||
|
||||
const preserveEveryone = parse.includes('everyone');
|
||||
return {
|
||||
flags,
|
||||
mentionsEveryone: preserveEveryone && mentionsEveryone,
|
||||
mentionsHere: preserveEveryone && mentionsHere,
|
||||
};
|
||||
}
|
||||
|
||||
private filterMentions<T extends UserID | RoleID>({
|
||||
mentions,
|
||||
allowedList,
|
||||
shouldParse,
|
||||
}: {
|
||||
mentions: Set<T>;
|
||||
allowedList: Array<T>;
|
||||
shouldParse: boolean;
|
||||
}): void {
|
||||
const shouldClear = shouldParse ? allowedList.length === 0 : allowedList.length > 0;
|
||||
|
||||
if (shouldClear) {
|
||||
if (allowedList.length === 0) {
|
||||
mentions.clear();
|
||||
} else {
|
||||
for (const id of Array.from(mentions)) {
|
||||
if (!allowedList.includes(id)) {
|
||||
mentions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async validateMentions({
|
||||
userMentions,
|
||||
roleMentions,
|
||||
channel,
|
||||
canMentionRoles = true,
|
||||
}: {
|
||||
userMentions: Set<UserID>;
|
||||
roleMentions: Set<RoleID>;
|
||||
channel: Channel;
|
||||
canMentionRoles?: boolean;
|
||||
}): Promise<{validUserIds: Array<UserID>; validRoleIds: Array<RoleID>}> {
|
||||
if (channel.guildId) {
|
||||
const [users, roles] = await Promise.all([
|
||||
this.userRepository.listUsers(Array.from(userMentions)),
|
||||
this.guildRepository.listRolesByIds(Array.from(roleMentions), channel.guildId),
|
||||
]);
|
||||
|
||||
const filteredRoles = canMentionRoles ? roles : roles.filter((role) => role.isMentionable);
|
||||
return {
|
||||
validUserIds: users.map((u) => u.id),
|
||||
validRoleIds: filteredRoles.map((r) => r.id),
|
||||
};
|
||||
}
|
||||
|
||||
const recipients = Array.from(channel.recipientIds || []);
|
||||
const validUserIds = recipients.filter((r) => userMentions.has(r));
|
||||
|
||||
return {validUserIds, validRoleIds: []};
|
||||
}
|
||||
|
||||
async handleMentionTasks(params: {
|
||||
guildId: GuildID | null;
|
||||
message: Message;
|
||||
authorId: UserID;
|
||||
mentionHere?: boolean;
|
||||
}): Promise<void> {
|
||||
const {guildId, message, authorId, mentionHere = false} = params;
|
||||
|
||||
if (isPersonalNotesChannel({userId: authorId, channelId: message.channelId})) return;
|
||||
|
||||
const taskData = {
|
||||
guildId: guildId?.toString(),
|
||||
channelId: message.channelId.toString(),
|
||||
messageId: message.id.toString(),
|
||||
authorId: authorId.toString(),
|
||||
mentionHere,
|
||||
};
|
||||
|
||||
const hasMentions =
|
||||
message.mentionedUserIds?.size > 0 ||
|
||||
message.mentionedRoleIds?.size > 0 ||
|
||||
message.mentionEveryone ||
|
||||
(message.reference && message.type === MessageTypes.REPLY);
|
||||
|
||||
if (hasMentions) {
|
||||
await this.workerService.addJob('handleMentions', taskData);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ChannelID,
|
||||
createAttachmentID,
|
||||
createChannelID,
|
||||
createMemeID,
|
||||
createMessageID,
|
||||
type UserID,
|
||||
} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import type {MessageAttachment} from '~/database/CassandraTypes';
|
||||
import {InputValidationError, UnknownMessageError} from '~/Errors';
|
||||
import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {Message, User} from '~/Models';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import {makeAttachmentCdnKey} from './MessageHelpers';
|
||||
|
||||
interface MessageOperationsHelpersDeps {
|
||||
channelRepository: IChannelRepositoryAggregate;
|
||||
cacheService: ICacheService;
|
||||
storageService: IStorageService;
|
||||
snowflakeService: SnowflakeService;
|
||||
favoriteMemeRepository: IFavoriteMemeRepository;
|
||||
}
|
||||
|
||||
export class MessageOperationsHelpers {
|
||||
constructor(private readonly deps: MessageOperationsHelpersDeps) {}
|
||||
|
||||
async findExistingMessage({
|
||||
userId,
|
||||
nonce,
|
||||
expectedChannelId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
nonce?: string;
|
||||
expectedChannelId: ChannelID;
|
||||
}): Promise<Message | null> {
|
||||
if (!nonce) return null;
|
||||
|
||||
const existingNonce = await this.deps.cacheService.get<{channel_id: string; message_id: string}>(
|
||||
`message-nonce:${userId}:${nonce}`,
|
||||
);
|
||||
|
||||
if (!existingNonce) return null;
|
||||
|
||||
const cachedChannelId = createChannelID(BigInt(existingNonce.channel_id));
|
||||
if (cachedChannelId !== expectedChannelId) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
return this.deps.channelRepository.messages.getMessage(
|
||||
cachedChannelId,
|
||||
createMessageID(BigInt(existingNonce.message_id)),
|
||||
);
|
||||
}
|
||||
|
||||
async processFavoriteMeme({
|
||||
user,
|
||||
channelId,
|
||||
favoriteMemeId,
|
||||
}: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
favoriteMemeId: bigint;
|
||||
}): Promise<MessageAttachment> {
|
||||
const memeId = createMemeID(favoriteMemeId);
|
||||
const favoriteMeme = await this.deps.favoriteMemeRepository.findById(user.id, memeId);
|
||||
|
||||
if (!favoriteMeme) {
|
||||
throw InputValidationError.create('favorite_meme_id', 'Favorite meme not found');
|
||||
}
|
||||
|
||||
const memeAttachmentId = createAttachmentID(this.deps.snowflakeService.generate());
|
||||
|
||||
const sourceKey = favoriteMeme.storageKey;
|
||||
const destKey = makeAttachmentCdnKey(channelId, memeAttachmentId, favoriteMeme.filename);
|
||||
|
||||
await this.deps.storageService.copyObject({
|
||||
sourceBucket: Config.s3.buckets.cdn,
|
||||
sourceKey,
|
||||
destinationBucket: Config.s3.buckets.cdn,
|
||||
destinationKey: destKey,
|
||||
newContentType: favoriteMeme.contentType,
|
||||
});
|
||||
|
||||
let flags = 0;
|
||||
if (favoriteMeme.isGifv || (favoriteMeme.duration != null && favoriteMeme.duration > 0)) {
|
||||
flags |= 1;
|
||||
}
|
||||
|
||||
return {
|
||||
attachment_id: memeAttachmentId,
|
||||
filename: favoriteMeme.filename,
|
||||
size: favoriteMeme.size,
|
||||
title: null,
|
||||
description: favoriteMeme.altText,
|
||||
width: favoriteMeme.width,
|
||||
height: favoriteMeme.height,
|
||||
content_type: favoriteMeme.contentType,
|
||||
content_hash: favoriteMeme.contentHash,
|
||||
placeholder: null,
|
||||
flags,
|
||||
duration: favoriteMeme.duration,
|
||||
nsfw: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '~/channel/ChannelModel';
|
||||
import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository';
|
||||
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
|
||||
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {Message, User, Webhook} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import type {MessageChannelAuthService} from './MessageChannelAuthService';
|
||||
import {MessageDeleteService} from './MessageDeleteService';
|
||||
import type {MessageDispatchService} from './MessageDispatchService';
|
||||
import {MessageEditService} from './MessageEditService';
|
||||
import type {MessageMentionService} from './MessageMentionService';
|
||||
import {MessageOperationsHelpers} from './MessageOperationsHelpers';
|
||||
import type {MessagePersistenceService} from './MessagePersistenceService';
|
||||
import type {MessageProcessingService} from './MessageProcessingService';
|
||||
import type {MessageSearchService} from './MessageSearchService';
|
||||
import {MessageSendService} from './MessageSendService';
|
||||
import type {MessageValidationService} from './MessageValidationService';
|
||||
|
||||
export class MessageOperationsService {
|
||||
private readonly sendService: MessageSendService;
|
||||
private readonly editService: MessageEditService;
|
||||
private readonly deleteService: MessageDeleteService;
|
||||
private readonly operationsHelpers: MessageOperationsHelpers;
|
||||
|
||||
constructor(
|
||||
channelRepository: IChannelRepositoryAggregate,
|
||||
userRepository: IUserRepository,
|
||||
cacheService: ICacheService,
|
||||
storageService: IStorageService,
|
||||
gatewayService: IGatewayService,
|
||||
snowflakeService: SnowflakeService,
|
||||
rateLimitService: IRateLimitService,
|
||||
cloudflarePurgeQueue: ICloudflarePurgeQueue,
|
||||
favoriteMemeRepository: IFavoriteMemeRepository,
|
||||
validationService: MessageValidationService,
|
||||
mentionService: MessageMentionService,
|
||||
searchService: MessageSearchService,
|
||||
persistenceService: MessagePersistenceService,
|
||||
channelAuthService: MessageChannelAuthService,
|
||||
processingService: MessageProcessingService,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
dispatchService: MessageDispatchService,
|
||||
) {
|
||||
this.operationsHelpers = new MessageOperationsHelpers({
|
||||
channelRepository,
|
||||
cacheService,
|
||||
storageService,
|
||||
snowflakeService,
|
||||
favoriteMemeRepository,
|
||||
});
|
||||
|
||||
this.sendService = new MessageSendService({
|
||||
channelRepository,
|
||||
storageService,
|
||||
gatewayService,
|
||||
snowflakeService,
|
||||
rateLimitService,
|
||||
favoriteMemeRepository,
|
||||
validationService,
|
||||
mentionService,
|
||||
searchService,
|
||||
persistenceService,
|
||||
channelAuthService,
|
||||
processingService,
|
||||
dispatchService,
|
||||
embedAttachmentResolver: persistenceService.getEmbedAttachmentResolver(),
|
||||
operationsHelpers: this.operationsHelpers,
|
||||
});
|
||||
|
||||
this.editService = new MessageEditService({
|
||||
channelRepository,
|
||||
userRepository,
|
||||
validationService,
|
||||
persistenceService,
|
||||
channelAuthService,
|
||||
processingService,
|
||||
dispatchService,
|
||||
searchService,
|
||||
embedAttachmentResolver: persistenceService.getEmbedAttachmentResolver(),
|
||||
mentionService,
|
||||
});
|
||||
|
||||
this.deleteService = new MessageDeleteService({
|
||||
channelRepository,
|
||||
storageService,
|
||||
cloudflarePurgeQueue,
|
||||
validationService,
|
||||
channelAuthService,
|
||||
dispatchService,
|
||||
searchService,
|
||||
guildAuditLogService,
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
requestCache,
|
||||
}: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
data: MessageRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Message> {
|
||||
return this.sendService.sendMessage({user, channelId, data, requestCache});
|
||||
}
|
||||
|
||||
async sendWebhookMessage({
|
||||
webhook,
|
||||
data,
|
||||
username,
|
||||
avatar,
|
||||
requestCache,
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
data: MessageRequest;
|
||||
username?: string | null;
|
||||
avatar?: string | null;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Message> {
|
||||
return this.sendService.sendWebhookMessage({webhook, data, username, avatar, requestCache});
|
||||
}
|
||||
|
||||
async validateMessageCanBeSent({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
}: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
data: MessageRequest;
|
||||
}): Promise<void> {
|
||||
return this.sendService.validateMessageCanBeSent({user, channelId, data});
|
||||
}
|
||||
|
||||
async editMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
data,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
data: MessageUpdateRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Message> {
|
||||
return this.editService.editMessage({userId, channelId, messageId, data, requestCache});
|
||||
}
|
||||
|
||||
async deleteMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
return this.deleteService.deleteMessage({userId, channelId, messageId, requestCache});
|
||||
}
|
||||
|
||||
async bulkDeleteMessages({
|
||||
userId,
|
||||
channelId,
|
||||
messageIds,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageIds: Array<MessageID>;
|
||||
}): Promise<void> {
|
||||
return this.deleteService.bulkDeleteMessages({userId, channelId, messageIds});
|
||||
}
|
||||
|
||||
async deleteUserMessagesInGuild({
|
||||
userId,
|
||||
guildId,
|
||||
days,
|
||||
}: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
days: number;
|
||||
}): Promise<void> {
|
||||
return this.deleteService.deleteUserMessagesInGuild({userId, guildId, days});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AttachmentDecayService} from '~/attachment/AttachmentDecayService';
|
||||
import {
|
||||
type AttachmentID,
|
||||
type ChannelID,
|
||||
createGuildID,
|
||||
type GuildID,
|
||||
type MessageID,
|
||||
type RoleID,
|
||||
type StickerID,
|
||||
type UserID,
|
||||
type WebhookID,
|
||||
} from '~/BrandedTypes';
|
||||
import {MessageFlags, Permissions, SENDABLE_MESSAGE_FLAGS} from '~/Constants';
|
||||
import type {AttachmentToProcess} from '~/channel/AttachmentDTOs';
|
||||
import type {AllowedMentionsRequest, MessageUpdateRequest, RichEmbedRequest} from '~/channel/ChannelModel';
|
||||
import type {IChannelRepositoryAggregate} from '~/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {
|
||||
MessageAttachment,
|
||||
MessageEmbed,
|
||||
MessageReference,
|
||||
MessageSnapshot,
|
||||
MessageStickerItem,
|
||||
} from '~/database/CassandraTypes';
|
||||
import {InputValidationError} from '~/Errors';
|
||||
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {EmbedService} from '~/infrastructure/EmbedService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {IVirusScanService} from '~/infrastructure/IVirusScanService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {Channel, Message, User} from '~/Models';
|
||||
import type {PackService} from '~/pack/PackService';
|
||||
import type {ReadStateService} from '~/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import * as BucketUtils from '~/utils/BucketUtils';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
import {AttachmentProcessingService} from './AttachmentProcessingService';
|
||||
import {MessageContentService} from './MessageContentService';
|
||||
import {MessageEmbedAttachmentResolver} from './MessageEmbedAttachmentResolver';
|
||||
import {VIRUS_MESSAGE_PREFIX, VIRUS_RECIPE_SUGGESTIONS} from './MessageHelpers';
|
||||
import {MessageStickerService} from './MessageStickerService';
|
||||
|
||||
interface CreateMessageParams {
|
||||
messageId: MessageID;
|
||||
channelId: ChannelID;
|
||||
user?: User;
|
||||
userId?: UserID;
|
||||
webhookId?: WebhookID;
|
||||
webhookName?: string;
|
||||
webhookAvatar?: string | null;
|
||||
type: number;
|
||||
content: string | null | undefined;
|
||||
flags: number;
|
||||
embeds?: Array<RichEmbedRequest>;
|
||||
attachments?: Array<AttachmentToProcess>;
|
||||
processedAttachments?: Array<MessageAttachment>;
|
||||
attachmentDecayExcludedIds?: Array<AttachmentID>;
|
||||
stickerIds?: Array<StickerID>;
|
||||
messageReference?: MessageReference;
|
||||
messageSnapshots?: Array<MessageSnapshot>;
|
||||
guildId: GuildID | null;
|
||||
channel?: Channel;
|
||||
referencedMessage?: Message | null;
|
||||
allowedMentions?: AllowedMentionsRequest | null;
|
||||
guild?: GuildResponse | null;
|
||||
member?: GuildMemberResponse | null;
|
||||
hasPermission?: (permission: bigint) => Promise<boolean>;
|
||||
mentionData?: {
|
||||
flags: number;
|
||||
mentionUserIds: Array<UserID>;
|
||||
mentionRoleIds: Array<RoleID>;
|
||||
mentionEveryone: boolean;
|
||||
};
|
||||
allowEmbeds?: boolean;
|
||||
}
|
||||
|
||||
export class MessagePersistenceService {
|
||||
private readonly attachmentService: AttachmentProcessingService;
|
||||
private readonly contentService: MessageContentService;
|
||||
private readonly stickerService: MessageStickerService;
|
||||
private readonly embedAttachmentResolver: MessageEmbedAttachmentResolver;
|
||||
private readonly attachmentDecayService: AttachmentDecayService;
|
||||
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userRepository: IUserRepository,
|
||||
guildRepository: IGuildRepository,
|
||||
private packService: PackService,
|
||||
private embedService: EmbedService,
|
||||
storageService: IStorageService,
|
||||
mediaService: IMediaService,
|
||||
virusScanService: IVirusScanService,
|
||||
snowflakeService: SnowflakeService,
|
||||
private readStateService: ReadStateService,
|
||||
) {
|
||||
this.attachmentService = new AttachmentProcessingService(
|
||||
storageService,
|
||||
mediaService,
|
||||
virusScanService,
|
||||
snowflakeService,
|
||||
);
|
||||
this.contentService = new MessageContentService(this.userRepository, guildRepository, this.packService);
|
||||
this.stickerService = new MessageStickerService(this.userRepository, guildRepository, this.packService);
|
||||
this.embedAttachmentResolver = new MessageEmbedAttachmentResolver();
|
||||
this.attachmentDecayService = new AttachmentDecayService();
|
||||
}
|
||||
|
||||
getEmbedAttachmentResolver(): MessageEmbedAttachmentResolver {
|
||||
return this.embedAttachmentResolver;
|
||||
}
|
||||
|
||||
async createMessage(params: CreateMessageParams): Promise<Message> {
|
||||
const authorId = params.user?.id ?? params.userId ?? null;
|
||||
|
||||
const mentionData =
|
||||
params.mentionData ??
|
||||
({
|
||||
flags: params.flags,
|
||||
mentionUserIds: [],
|
||||
mentionRoleIds: [],
|
||||
mentionEveryone: false,
|
||||
} as const);
|
||||
|
||||
const isNSFWAllowed = this.contentService.isNSFWContentAllowed({
|
||||
channel: params.channel,
|
||||
guild: params.guild,
|
||||
member: params.member,
|
||||
});
|
||||
|
||||
const [sanitizedContent, attachmentResult, processedStickers] = await Promise.all([
|
||||
this.sanitizeContentIfNeeded(params, authorId),
|
||||
this.processAttachments(params, isNSFWAllowed),
|
||||
this.processStickers(params, authorId),
|
||||
]);
|
||||
|
||||
let messageContent = sanitizedContent;
|
||||
let processedAttachments: Array<MessageAttachment> = params.processedAttachments
|
||||
? [...params.processedAttachments]
|
||||
: [];
|
||||
|
||||
if (attachmentResult) {
|
||||
if (attachmentResult.hasVirusDetected) {
|
||||
const randomIndex = Math.floor(Math.random() * VIRUS_RECIPE_SUGGESTIONS.length);
|
||||
messageContent = VIRUS_MESSAGE_PREFIX + VIRUS_RECIPE_SUGGESTIONS[randomIndex];
|
||||
processedAttachments = [];
|
||||
} else {
|
||||
processedAttachments = [...processedAttachments, ...attachmentResult.attachments];
|
||||
}
|
||||
}
|
||||
|
||||
const allowEmbeds = params.allowEmbeds ?? true;
|
||||
let initialEmbeds: Array<MessageEmbed> | null = null;
|
||||
let hasUncachedUrls = false;
|
||||
|
||||
if (allowEmbeds) {
|
||||
const resolvedEmbeds = this.embedAttachmentResolver.resolveEmbedAttachmentUrls({
|
||||
embeds: params.embeds,
|
||||
attachments: processedAttachments.map((att) => ({
|
||||
attachment_id: att.attachment_id,
|
||||
filename: att.filename,
|
||||
width: att.width ?? null,
|
||||
height: att.height ?? null,
|
||||
content_type: att.content_type,
|
||||
content_hash: att.content_hash ?? null,
|
||||
placeholder: att.placeholder ?? null,
|
||||
flags: att.flags ?? 0,
|
||||
duration: att.duration ?? null,
|
||||
nsfw: att.nsfw ?? null,
|
||||
})),
|
||||
channelId: params.channelId,
|
||||
});
|
||||
|
||||
const embedResult = await this.embedService.getInitialEmbeds({
|
||||
content: messageContent,
|
||||
customEmbeds: resolvedEmbeds,
|
||||
isNSFWAllowed,
|
||||
});
|
||||
|
||||
initialEmbeds = embedResult.embeds;
|
||||
hasUncachedUrls = embedResult.hasUncachedUrls;
|
||||
}
|
||||
|
||||
const messageRowData = {
|
||||
channel_id: params.channelId,
|
||||
bucket: BucketUtils.makeBucket(params.messageId),
|
||||
message_id: params.messageId,
|
||||
author_id: authorId,
|
||||
type: params.type,
|
||||
webhook_id: params.webhookId || null,
|
||||
webhook_name: params.webhookName || null,
|
||||
webhook_avatar_hash: params.webhookAvatar || null,
|
||||
content: messageContent,
|
||||
edited_timestamp: null,
|
||||
pinned_timestamp: null,
|
||||
flags: mentionData.flags,
|
||||
mention_everyone: mentionData.mentionEveryone,
|
||||
mention_users: mentionData.mentionUserIds.length > 0 ? new Set(mentionData.mentionUserIds) : null,
|
||||
mention_roles: mentionData.mentionRoleIds.length > 0 ? new Set(mentionData.mentionRoleIds) : null,
|
||||
mention_channels: null,
|
||||
attachments: processedAttachments.length > 0 ? processedAttachments : null,
|
||||
embeds: allowEmbeds ? initialEmbeds : null,
|
||||
sticker_items: processedStickers.length > 0 ? processedStickers : null,
|
||||
message_reference: params.messageReference || null,
|
||||
message_snapshots: params.messageSnapshots || null,
|
||||
call: null,
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const message = await this.channelRepository.messages.upsertMessage(messageRowData);
|
||||
|
||||
await this.runPostPersistenceOperations({
|
||||
params,
|
||||
authorId,
|
||||
processedAttachments,
|
||||
allowEmbeds,
|
||||
hasUncachedUrls,
|
||||
isNSFWAllowed,
|
||||
});
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private async sanitizeContentIfNeeded(params: CreateMessageParams, authorId: UserID | null): Promise<string | null> {
|
||||
const messageContent = params.content ?? null;
|
||||
if (!messageContent || !params.channel) {
|
||||
return messageContent;
|
||||
}
|
||||
|
||||
return this.contentService.sanitizeCustomEmojis({
|
||||
content: messageContent,
|
||||
userId: authorId,
|
||||
webhookId: params.webhookId ?? null,
|
||||
guildId: params.guildId,
|
||||
hasPermission: params.guildId ? params.hasPermission : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private async processAttachments(
|
||||
params: CreateMessageParams,
|
||||
isNSFWAllowed: boolean,
|
||||
): Promise<{attachments: Array<MessageAttachment>; hasVirusDetected: boolean} | null> {
|
||||
if (!params.attachments || params.attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.attachmentService.computeAttachments({
|
||||
message: {
|
||||
id: params.messageId,
|
||||
channelId: params.channelId,
|
||||
} as Message,
|
||||
attachments: params.attachments,
|
||||
channel: params.channel,
|
||||
guild: params.guild,
|
||||
member: params.member,
|
||||
isNSFWAllowed,
|
||||
});
|
||||
}
|
||||
|
||||
private async processStickers(
|
||||
params: CreateMessageParams,
|
||||
authorId: UserID | null,
|
||||
): Promise<Array<MessageStickerItem>> {
|
||||
if (!params.stickerIds || params.stickerIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.stickerService.computeStickerIds({
|
||||
stickerIds: params.stickerIds,
|
||||
userId: authorId,
|
||||
guildId: params.guildId,
|
||||
hasPermission: params.hasPermission,
|
||||
});
|
||||
}
|
||||
|
||||
private async runPostPersistenceOperations(context: {
|
||||
params: CreateMessageParams;
|
||||
authorId: UserID | null;
|
||||
processedAttachments: Array<MessageAttachment>;
|
||||
allowEmbeds: boolean;
|
||||
hasUncachedUrls: boolean;
|
||||
isNSFWAllowed: boolean;
|
||||
}): Promise<void> {
|
||||
const {params, authorId, processedAttachments, allowEmbeds, hasUncachedUrls, isNSFWAllowed} = context;
|
||||
|
||||
const operations: Array<Promise<void>> = [];
|
||||
|
||||
const excludedAttachmentIds = new Set(params.attachmentDecayExcludedIds ?? []);
|
||||
if (processedAttachments.length > 0) {
|
||||
const attachmentsForDecay = processedAttachments.filter((att) => !excludedAttachmentIds.has(att.attachment_id));
|
||||
if (attachmentsForDecay.length > 0) {
|
||||
const uploadedAt = new Date(SnowflakeUtils.extractTimestamp(params.messageId));
|
||||
const decayPayloads = attachmentsForDecay.map((att) => ({
|
||||
attachmentId: att.attachment_id,
|
||||
channelId: params.channelId,
|
||||
messageId: params.messageId,
|
||||
filename: att.filename,
|
||||
sizeBytes: att.size ?? 0n,
|
||||
uploadedAt,
|
||||
}));
|
||||
operations.push(this.attachmentDecayService.upsertMany(decayPayloads));
|
||||
}
|
||||
}
|
||||
|
||||
if (allowEmbeds && hasUncachedUrls) {
|
||||
operations.push(
|
||||
this.embedService.enqueueUrlEmbedExtraction(params.channelId, params.messageId, params.guildId, isNSFWAllowed),
|
||||
);
|
||||
}
|
||||
|
||||
if (authorId) {
|
||||
const isBot = params.user?.isBot ?? false;
|
||||
if (!isBot) {
|
||||
operations.push(
|
||||
this.readStateService.ackMessage({
|
||||
userId: authorId,
|
||||
channelId: params.channelId,
|
||||
messageId: params.messageId,
|
||||
mentionCount: 0,
|
||||
silent: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (operations.length > 0) {
|
||||
await Promise.all(operations);
|
||||
}
|
||||
}
|
||||
|
||||
async updateMessage(params: {
|
||||
message: Message;
|
||||
messageId: MessageID;
|
||||
data: MessageUpdateRequest;
|
||||
channel: Channel;
|
||||
guild: GuildResponse | null;
|
||||
member?: GuildMemberResponse | null;
|
||||
allowEmbeds?: boolean;
|
||||
}): Promise<Message> {
|
||||
const {message, messageId, data, channel, guild, member} = params;
|
||||
|
||||
if (message.messageSnapshots && message.messageSnapshots.length > 0) {
|
||||
throw InputValidationError.create('message', 'Messages with snapshots cannot be edited');
|
||||
}
|
||||
|
||||
const isNSFWAllowed = this.contentService.isNSFWContentAllowed({
|
||||
channel,
|
||||
guild,
|
||||
member,
|
||||
});
|
||||
|
||||
const updatedRowData = {...message.toRow()};
|
||||
let hasChanges = false;
|
||||
const allowEmbeds = params.allowEmbeds ?? true;
|
||||
|
||||
if (data.content !== undefined && data.content !== message.content) {
|
||||
let sanitizedContent = data.content;
|
||||
if (sanitizedContent) {
|
||||
sanitizedContent = await this.contentService.sanitizeCustomEmojis({
|
||||
content: sanitizedContent,
|
||||
userId: message.authorId ?? null,
|
||||
webhookId: message.webhookId ?? null,
|
||||
guildId: channel.guildId,
|
||||
});
|
||||
}
|
||||
updatedRowData.content = sanitizedContent;
|
||||
updatedRowData.edited_timestamp = new Date();
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (data.flags !== undefined) {
|
||||
const preservedFlags = message.flags & ~SENDABLE_MESSAGE_FLAGS;
|
||||
const newFlags = data.flags & SENDABLE_MESSAGE_FLAGS;
|
||||
updatedRowData.flags = preservedFlags | newFlags;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (data.attachments !== undefined) {
|
||||
if (data.attachments.length > 0) {
|
||||
type EditAttachment =
|
||||
| (AttachmentToProcess & {upload_filename: string})
|
||||
| {id: number; title?: string | null; description?: string | null};
|
||||
|
||||
const newAttachments: Array<AttachmentToProcess> = [];
|
||||
const existingAttachments: Array<MessageAttachment> = [];
|
||||
|
||||
for (const att of data.attachments as Array<EditAttachment>) {
|
||||
if ('upload_filename' in att && att.upload_filename) {
|
||||
newAttachments.push(att as AttachmentToProcess);
|
||||
} else {
|
||||
const refId = BigInt(att.id);
|
||||
let found = message.attachments.find((existing) => existing.id === refId);
|
||||
if (!found && refId < BigInt(message.attachments.length)) {
|
||||
found = message.attachments[Number(refId)];
|
||||
}
|
||||
if (found) {
|
||||
const updated = found.toMessageAttachment();
|
||||
if ('title' in att && att.title !== undefined) {
|
||||
updated.title = att.title;
|
||||
}
|
||||
if ('description' in att && att.description !== undefined) {
|
||||
updated.description = att.description;
|
||||
}
|
||||
existingAttachments.push(updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let processedNewAttachments: Array<MessageAttachment> = [];
|
||||
if (newAttachments.length > 0) {
|
||||
const attachmentResult = await this.attachmentService.computeAttachments({
|
||||
message,
|
||||
attachments: newAttachments,
|
||||
channel,
|
||||
guild,
|
||||
member,
|
||||
isNSFWAllowed,
|
||||
});
|
||||
processedNewAttachments = attachmentResult.attachments;
|
||||
|
||||
if (attachmentResult.hasVirusDetected) {
|
||||
const randomIndex = Math.floor(Math.random() * VIRUS_RECIPE_SUGGESTIONS.length);
|
||||
updatedRowData.content = VIRUS_MESSAGE_PREFIX + VIRUS_RECIPE_SUGGESTIONS[randomIndex];
|
||||
}
|
||||
}
|
||||
|
||||
const allAttachments = [...existingAttachments, ...processedNewAttachments];
|
||||
updatedRowData.attachments = allAttachments.length > 0 ? allAttachments : null;
|
||||
} else {
|
||||
updatedRowData.attachments = null;
|
||||
}
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (allowEmbeds && (data.content !== undefined || data.embeds !== undefined)) {
|
||||
const attachmentsForResolution = updatedRowData.attachments || [];
|
||||
const resolvedEmbeds = this.embedAttachmentResolver.resolveEmbedAttachmentUrls({
|
||||
embeds: data.embeds,
|
||||
attachments: attachmentsForResolution.map((att) => ({
|
||||
attachment_id: att.attachment_id,
|
||||
filename: att.filename,
|
||||
width: att.width ?? null,
|
||||
height: att.height ?? null,
|
||||
content_type: att.content_type,
|
||||
content_hash: att.content_hash ?? null,
|
||||
placeholder: att.placeholder ?? null,
|
||||
flags: att.flags ?? 0,
|
||||
duration: att.duration ?? null,
|
||||
nsfw: att.nsfw ?? null,
|
||||
})),
|
||||
channelId: channel.id,
|
||||
});
|
||||
|
||||
const {embeds: initialEmbeds, hasUncachedUrls: embedUrls} = await this.embedService.getInitialEmbeds({
|
||||
content: updatedRowData.content ?? null,
|
||||
customEmbeds: resolvedEmbeds,
|
||||
isNSFWAllowed,
|
||||
});
|
||||
updatedRowData.embeds = initialEmbeds;
|
||||
hasChanges = true;
|
||||
|
||||
if (embedUrls) {
|
||||
await this.embedService.enqueueUrlEmbedExtraction(
|
||||
channel.id,
|
||||
messageId,
|
||||
guild?.id ? createGuildID(BigInt(guild.id)) : null,
|
||||
isNSFWAllowed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let updatedMessage = message;
|
||||
if (hasChanges) {
|
||||
updatedMessage = await this.channelRepository.messages.upsertMessage(updatedRowData, message.toRow());
|
||||
}
|
||||
|
||||
return updatedMessage;
|
||||
}
|
||||
|
||||
async handleNonAuthorEdit(params: {
|
||||
message: Message;
|
||||
data: MessageUpdateRequest;
|
||||
guild: GuildResponse | null;
|
||||
hasPermission: (permission: bigint) => Promise<boolean>;
|
||||
}): Promise<{canEdit: boolean; updatedFlags?: number}> {
|
||||
const {message, data, guild, hasPermission} = params;
|
||||
|
||||
if (guild && data.flags != null) {
|
||||
const canManage = await hasPermission(Permissions.MANAGE_MESSAGES);
|
||||
if (canManage) {
|
||||
let updatedFlags: number;
|
||||
if (data.flags & MessageFlags.SUPPRESS_EMBEDS) {
|
||||
updatedFlags = message.flags | MessageFlags.SUPPRESS_EMBEDS;
|
||||
} else {
|
||||
updatedFlags = message.flags & ~MessageFlags.SUPPRESS_EMBEDS;
|
||||
}
|
||||
|
||||
return {canEdit: true, updatedFlags};
|
||||
}
|
||||
}
|
||||
|
||||
return {canEdit: false};
|
||||
}
|
||||
|
||||
async createSystemMessage(params: {
|
||||
messageId: MessageID;
|
||||
channelId: ChannelID;
|
||||
userId: UserID;
|
||||
type: number;
|
||||
content?: string | null;
|
||||
guildId?: GuildID | null;
|
||||
mentionUserIds?: Array<UserID>;
|
||||
messageReference?: MessageReference;
|
||||
}): Promise<Message> {
|
||||
return this.createMessage({
|
||||
messageId: params.messageId,
|
||||
channelId: params.channelId,
|
||||
userId: params.userId,
|
||||
type: params.type,
|
||||
content: params.content ?? null,
|
||||
flags: 0,
|
||||
guildId: params.guildId ?? null,
|
||||
messageReference: params.messageReference,
|
||||
mentionData: {
|
||||
flags: 0,
|
||||
mentionUserIds: params.mentionUserIds ?? [],
|
||||
mentionRoleIds: [],
|
||||
mentionEveryone: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, createGuildID, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes} from '~/Constants';
|
||||
import {mapChannelToResponse} from '~/channel/ChannelModel';
|
||||
import type {AllowedMentionsRequest, MessageRequest, MessageUpdateRequest} from '~/channel/MessageTypes';
|
||||
import {CannotEditOtherUserMessageError} from '~/Errors';
|
||||
import type {GuildResponse} from '~/guild/GuildModel';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Channel, Message, User} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {ReadStateService} from '~/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import {isPersonalNotesChannel} from './MessageHelpers';
|
||||
import type {MessageMentionService} from './MessageMentionService';
|
||||
import type {MessagePersistenceService} from './MessagePersistenceService';
|
||||
import {incrementDmMentionCounts} from './ReadStateHelpers';
|
||||
|
||||
interface RecipientOpenState {
|
||||
recipientId: UserID;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export class MessageProcessingService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userRepository: IUserRepository,
|
||||
private userCacheService: UserCacheService,
|
||||
private gatewayService: IGatewayService,
|
||||
private readStateService: ReadStateService,
|
||||
private mentionService: MessageMentionService,
|
||||
) {}
|
||||
|
||||
async processMessageAfterCreation(params: {
|
||||
message: Message;
|
||||
channel: Channel;
|
||||
guild: GuildResponse | null;
|
||||
user: User;
|
||||
data: MessageRequest;
|
||||
referencedMessage: Message | null;
|
||||
mentionHere?: boolean;
|
||||
}): Promise<void> {
|
||||
const {message, guild, user, mentionHere = false} = params;
|
||||
|
||||
await this.mentionService.handleMentionTasks({
|
||||
guildId: guild ? createGuildID(BigInt(guild.id)) : null,
|
||||
message,
|
||||
authorId: user.id,
|
||||
mentionHere,
|
||||
});
|
||||
}
|
||||
|
||||
async updateDMRecipients({
|
||||
channel,
|
||||
channelId,
|
||||
requestCache,
|
||||
}: {
|
||||
channel: Channel;
|
||||
channelId: ChannelID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
if (channel.guildId || channel.type !== ChannelTypes.DM) return;
|
||||
if (!channel.recipientIds || channel.recipientIds.size !== 2) return;
|
||||
|
||||
const recipientIds = Array.from(channel.recipientIds);
|
||||
const isGroupDm = false;
|
||||
|
||||
const openStates = await this.batchCheckDmChannelOpen(recipientIds, channelId);
|
||||
|
||||
const closedRecipients = openStates.filter((state) => !state.isOpen);
|
||||
if (closedRecipients.length === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
closedRecipients.map((state) =>
|
||||
this.openDmAndDispatch({
|
||||
recipientId: state.recipientId,
|
||||
channelId,
|
||||
channel,
|
||||
isGroupDm,
|
||||
requestCache,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async batchCheckDmChannelOpen(
|
||||
recipientIds: Array<UserID>,
|
||||
channelId: ChannelID,
|
||||
): Promise<Array<RecipientOpenState>> {
|
||||
return Promise.all(
|
||||
recipientIds.map(async (recipientId) => ({
|
||||
recipientId,
|
||||
isOpen: await this.userRepository.isDmChannelOpen(recipientId, channelId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private async openDmAndDispatch(params: {
|
||||
recipientId: UserID;
|
||||
channelId: ChannelID;
|
||||
channel: Channel;
|
||||
isGroupDm: boolean;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {recipientId, channelId, channel, isGroupDm, requestCache} = params;
|
||||
|
||||
await this.userRepository.openDmForUser(recipientId, channelId, isGroupDm);
|
||||
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: recipientId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: recipientId,
|
||||
event: 'CHANNEL_CREATE',
|
||||
data: channelResponse,
|
||||
});
|
||||
}
|
||||
|
||||
async updateReadStates({
|
||||
user,
|
||||
guild,
|
||||
channel,
|
||||
channelId,
|
||||
}: {
|
||||
user: User;
|
||||
guild: GuildResponse | null;
|
||||
channel: Channel;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<void> {
|
||||
if (user.isBot) return;
|
||||
|
||||
if (!guild) {
|
||||
const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
|
||||
await incrementDmMentionCounts({
|
||||
readStateService: this.readStateService,
|
||||
user,
|
||||
recipients,
|
||||
channelId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMessagesAround({
|
||||
channelId,
|
||||
limit,
|
||||
around,
|
||||
}: {
|
||||
channelId: ChannelID;
|
||||
limit: number;
|
||||
around: MessageID;
|
||||
}): Promise<Array<Message>> {
|
||||
const initialOlder = Math.floor(limit / 2);
|
||||
const initialNewer = Math.max(0, limit - 1 - initialOlder);
|
||||
|
||||
const targetPromise = this.channelRepository.messages.getMessage(channelId, around);
|
||||
|
||||
let newerMessages = await this.channelRepository.messages.listMessages(channelId, undefined, initialNewer, around, {
|
||||
immediateAfter: true,
|
||||
});
|
||||
|
||||
let olderMessages = await this.channelRepository.messages.listMessages(channelId, around, initialOlder, undefined, {
|
||||
restrictToBeforeBucket: false,
|
||||
});
|
||||
|
||||
const targetMessage = await targetPromise;
|
||||
|
||||
const haveCenter = targetMessage ? 1 : 0;
|
||||
const total = newerMessages.length + haveCenter + olderMessages.length;
|
||||
|
||||
if (total < limit) {
|
||||
const missing = limit - total;
|
||||
|
||||
if (newerMessages.length < initialNewer) {
|
||||
const need = missing;
|
||||
const cursor = olderMessages.length > 0 ? olderMessages[olderMessages.length - 1].id : around;
|
||||
const more = await this.channelRepository.messages.listMessages(channelId, cursor, need, undefined, {
|
||||
restrictToBeforeBucket: false,
|
||||
});
|
||||
olderMessages = [...olderMessages, ...more];
|
||||
} else {
|
||||
const need = missing;
|
||||
const cursor = newerMessages.length > 0 ? newerMessages[0].id : around;
|
||||
const more = await this.channelRepository.messages.listMessages(channelId, undefined, need, cursor, {
|
||||
immediateAfter: true,
|
||||
});
|
||||
newerMessages = [...more, ...newerMessages];
|
||||
}
|
||||
}
|
||||
|
||||
const out: Array<Message> = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (m: Message) => {
|
||||
const id = m.id.toString();
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
out.push(m);
|
||||
};
|
||||
|
||||
for (const m of newerMessages) push(m);
|
||||
if (targetMessage) push(targetMessage);
|
||||
for (const m of olderMessages) push(m);
|
||||
|
||||
return out.slice(0, limit);
|
||||
}
|
||||
|
||||
async handleMentions(params: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
referencedMessageOnSend: Message | null;
|
||||
allowedMentions: AllowedMentionsRequest | null;
|
||||
guild?: GuildResponse | null;
|
||||
canMentionEveryone: boolean;
|
||||
canMentionRoles: boolean;
|
||||
}): Promise<void> {
|
||||
const {channel, message, referencedMessageOnSend, allowedMentions, guild, canMentionEveryone, canMentionRoles} =
|
||||
params;
|
||||
|
||||
if (message.authorId && isPersonalNotesChannel({userId: message.authorId, channelId: channel.id})) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = message.content;
|
||||
if (!content) return;
|
||||
|
||||
const mentions = this.mentionService.extractMentions({
|
||||
content,
|
||||
referencedMessage: referencedMessageOnSend,
|
||||
message,
|
||||
channelType: channel.type,
|
||||
allowedMentions,
|
||||
guild,
|
||||
canMentionEveryone,
|
||||
});
|
||||
|
||||
const {validUserIds, validRoleIds} = await this.mentionService.validateMentions({
|
||||
userMentions: mentions.userMentions,
|
||||
roleMentions: mentions.roleMentions,
|
||||
channel,
|
||||
canMentionRoles,
|
||||
});
|
||||
|
||||
const updatedMessageData = {
|
||||
...message.toRow(),
|
||||
flags: mentions.flags,
|
||||
mention_users: validUserIds.length > 0 ? new Set(validUserIds) : null,
|
||||
mention_roles: validRoleIds.length > 0 ? new Set(validRoleIds) : null,
|
||||
mention_everyone: mentions.mentionsEveryone,
|
||||
};
|
||||
|
||||
await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow());
|
||||
}
|
||||
|
||||
async handleNonAuthorEdit(params: {
|
||||
message: Message;
|
||||
messageId: MessageID;
|
||||
data: MessageUpdateRequest;
|
||||
guild: GuildResponse | null;
|
||||
hasPermission: (permission: bigint) => Promise<boolean>;
|
||||
channel: Channel;
|
||||
requestCache: RequestCache;
|
||||
persistenceService: MessagePersistenceService;
|
||||
dispatchMessageUpdate: (params: {channel: Channel; message: Message; requestCache: RequestCache}) => Promise<void>;
|
||||
}): Promise<Message> {
|
||||
const {message, data, guild, hasPermission, channel, requestCache, persistenceService, dispatchMessageUpdate} =
|
||||
params;
|
||||
|
||||
const editResult = await persistenceService.handleNonAuthorEdit({
|
||||
message,
|
||||
data,
|
||||
guild,
|
||||
hasPermission,
|
||||
});
|
||||
|
||||
if (editResult.canEdit && editResult.updatedFlags !== undefined) {
|
||||
const updatedRowData = {
|
||||
...message.toRow(),
|
||||
flags: editResult.updatedFlags,
|
||||
};
|
||||
|
||||
const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedRowData, message.toRow());
|
||||
await dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
return updatedMessage;
|
||||
}
|
||||
|
||||
throw new CannotEditOtherUserMessageError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AttachmentDecayService} from '~/attachment/AttachmentDecayService';
|
||||
import {
|
||||
type AttachmentID,
|
||||
type ChannelID,
|
||||
createChannelID,
|
||||
createMessageID,
|
||||
type MessageID,
|
||||
type UserID,
|
||||
} from '~/BrandedTypes';
|
||||
import {ChannelTypes, MessageFlags, Permissions} from '~/Constants';
|
||||
import type {MessageSearchRequest, MessageSearchResponse} from '~/channel/ChannelModel';
|
||||
import {mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import {
|
||||
ChannelIndexingError,
|
||||
FeatureTemporarilyDisabledError,
|
||||
MissingPermissionsError,
|
||||
UnknownMessageError,
|
||||
} from '~/Errors';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import {getMessageSearchService} from '~/Meilisearch';
|
||||
import type {Channel, Message} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {buildMessageSearchFilters} from '~/search/buildMessageSearchFilters';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import {getDmChannelIdsForScope} from './dmScopeUtils';
|
||||
import type {MessageChannelAuthService} from './MessageChannelAuthService';
|
||||
import {collectMessageAttachments} from './MessageHelpers';
|
||||
import type {MessageProcessingService} from './MessageProcessingService';
|
||||
import type {MessageSearchService} from './MessageSearchService';
|
||||
|
||||
export class MessageRetrievalService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userCacheService: UserCacheService,
|
||||
private mediaService: IMediaService,
|
||||
private channelAuthService: MessageChannelAuthService,
|
||||
private processingService: MessageProcessingService,
|
||||
private searchService: MessageSearchService,
|
||||
private userRepository: IUserRepository,
|
||||
private attachmentDecayService: AttachmentDecayService = new AttachmentDecayService(),
|
||||
) {}
|
||||
|
||||
async getMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<Message> {
|
||||
const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
|
||||
if (authChannel.guild && !(await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY))) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const message = await this.channelRepository.messages.getMessage(channelId, messageId);
|
||||
if (!message) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
await this.extendAttachments([message]);
|
||||
return message;
|
||||
}
|
||||
|
||||
async getMessages({
|
||||
userId,
|
||||
channelId,
|
||||
limit,
|
||||
before,
|
||||
after,
|
||||
around,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
limit: number;
|
||||
before?: MessageID;
|
||||
after?: MessageID;
|
||||
around?: MessageID;
|
||||
}): Promise<Array<Message>> {
|
||||
const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
|
||||
if (authChannel.guild && !(await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const messages = around
|
||||
? await this.processingService.fetchMessagesAround({channelId, limit, around})
|
||||
: await this.channelRepository.messages.listMessages(channelId, before, limit, after);
|
||||
|
||||
await this.extendAttachments(messages);
|
||||
return messages;
|
||||
}
|
||||
|
||||
async searchMessages({
|
||||
userId,
|
||||
channelId,
|
||||
searchParams,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
searchParams: MessageSearchRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<MessageSearchResponse> {
|
||||
const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
const {channel} = authChannel;
|
||||
|
||||
if (authChannel.guild && !(await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY))) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
const searchService = getMessageSearchService();
|
||||
if (!searchService) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
let channelIndexedAt: Date | null = channel.indexedAt;
|
||||
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
const persistedChannel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
if (persistedChannel?.indexedAt) {
|
||||
channelIndexedAt = persistedChannel.indexedAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (!channelIndexedAt) {
|
||||
await this.searchService.triggerChannelIndexing(channelId);
|
||||
throw new ChannelIndexingError();
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await this.applyDmScopeToSearchParams({
|
||||
userId,
|
||||
channel,
|
||||
channelId,
|
||||
searchParams,
|
||||
});
|
||||
|
||||
const channelIdStrings = resolvedSearchParams.channel_id
|
||||
? resolvedSearchParams.channel_id.map((id) => id.toString())
|
||||
: [channelId.toString()];
|
||||
const filters = buildMessageSearchFilters(resolvedSearchParams, channelIdStrings);
|
||||
|
||||
const hitsPerPage = resolvedSearchParams.hits_per_page ?? 25;
|
||||
const page = resolvedSearchParams.page ?? 1;
|
||||
const result = await searchService.searchMessages('', filters, {
|
||||
hitsPerPage,
|
||||
page,
|
||||
});
|
||||
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const messages = await Promise.all(
|
||||
messageEntries.map(({channelId, messageId}) => this.channelRepository.messages.getMessage(channelId, messageId)),
|
||||
);
|
||||
|
||||
const validMessages = messages.filter((msg: Message | null): msg is Message => msg !== null);
|
||||
|
||||
if (validMessages.length > 0) {
|
||||
await this.extendAttachments(validMessages);
|
||||
}
|
||||
|
||||
const attachmentPayloads = validMessages.flatMap((message) => this.buildAttachmentDecayEntriesForMessage(message));
|
||||
const attachmentDecayMap =
|
||||
attachmentPayloads.length > 0
|
||||
? await this.attachmentDecayService.fetchMetadata(
|
||||
attachmentPayloads.map((payload) => ({attachmentId: payload.attachmentId})),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const messageResponses = await Promise.all(
|
||||
validMessages.map((message: Message) =>
|
||||
mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
attachmentDecayMap,
|
||||
getReactions: (channelId, messageId) =>
|
||||
this.channelRepository.messageInteractions.listMessageReactions(channelId, messageId),
|
||||
setHasReaction: (channelId, messageId, hasReaction) =>
|
||||
this.channelRepository.messageInteractions.setHasReaction(channelId, messageId, hasReaction),
|
||||
getReferencedMessage: (channelId, messageId) =>
|
||||
this.channelRepository.messages.getMessage(channelId, messageId),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
messages: messageResponses,
|
||||
total: result.total,
|
||||
hits_per_page: hitsPerPage,
|
||||
page,
|
||||
};
|
||||
}
|
||||
|
||||
private async extendAttachments(messages: Array<Message>): Promise<void> {
|
||||
const payloads = messages.flatMap((message) => {
|
||||
if (message.flags & MessageFlags.COMPACT_ATTACHMENTS) {
|
||||
return [];
|
||||
}
|
||||
return this.buildAttachmentDecayEntriesForMessage(message);
|
||||
});
|
||||
|
||||
if (payloads.length === 0) return;
|
||||
|
||||
await this.attachmentDecayService.extendForAttachments(payloads);
|
||||
}
|
||||
|
||||
private buildAttachmentDecayEntriesForMessage(message: Message): Array<{
|
||||
attachmentId: AttachmentID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
filename: string;
|
||||
sizeBytes: bigint;
|
||||
uploadedAt: Date;
|
||||
}> {
|
||||
const attachments = collectMessageAttachments(message);
|
||||
|
||||
if (attachments.length === 0) return [];
|
||||
|
||||
const uploadedAt = new Date(SnowflakeUtils.extractTimestamp(message.id));
|
||||
return attachments.map((attachment) => ({
|
||||
attachmentId: attachment.id,
|
||||
channelId: message.channelId,
|
||||
messageId: message.id,
|
||||
filename: attachment.filename,
|
||||
sizeBytes: attachment.size,
|
||||
uploadedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
private async applyDmScopeToSearchParams({
|
||||
userId,
|
||||
channel,
|
||||
channelId,
|
||||
searchParams,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channel: Channel;
|
||||
channelId: ChannelID;
|
||||
searchParams: MessageSearchRequest;
|
||||
}): Promise<MessageSearchRequest> {
|
||||
const scope = searchParams.scope;
|
||||
const isDmSearch = channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM;
|
||||
if (!isDmSearch || !scope || scope === 'current') {
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
if (scope !== 'all_dms' && scope !== 'open_dms') {
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
const targetChannelIds = await getDmChannelIdsForScope({
|
||||
scope,
|
||||
userId,
|
||||
userRepository: this.userRepository,
|
||||
includeChannelId: channelId,
|
||||
});
|
||||
if (targetChannelIds.length === 0) {
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
return {...searchParams, channel_id: targetChannelIds.map((id) => BigInt(id))};
|
||||
}
|
||||
}
|
||||
146
fluxer_api/src/channel/services/message/MessageSearchService.ts
Normal file
146
fluxer_api/src/channel/services/message/MessageSearchService.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID} from '~/BrandedTypes';
|
||||
import type {MessageSearchRequest} from '~/channel/ChannelModel';
|
||||
import {getMessageSearchService} from '~/Meilisearch';
|
||||
import type {Message} from '~/Models';
|
||||
import type {MessageSearchFilters} from '~/search/MessageSearchService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {IWorkerService} from '~/worker/IWorkerService';
|
||||
|
||||
export class MessageSearchService {
|
||||
constructor(
|
||||
private userRepository: IUserRepository,
|
||||
private workerService: IWorkerService,
|
||||
) {}
|
||||
|
||||
async indexMessage(message: Message, authorIsBot: boolean): Promise<void> {
|
||||
try {
|
||||
const searchService = getMessageSearchService();
|
||||
if (!searchService) return;
|
||||
|
||||
await searchService.indexMessage(message, authorIsBot);
|
||||
} catch (error) {
|
||||
console.error('Failed to index message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateMessageIndex(message: Message): Promise<void> {
|
||||
try {
|
||||
const searchService = getMessageSearchService();
|
||||
if (!searchService) return;
|
||||
|
||||
let authorIsBot = false;
|
||||
if (message.authorId) {
|
||||
const user = await this.userRepository.findUnique(message.authorId);
|
||||
authorIsBot = user?.isBot ?? false;
|
||||
}
|
||||
|
||||
await searchService.updateMessage(message, authorIsBot);
|
||||
} catch (error) {
|
||||
console.error('Failed to update message in search index:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMessageIndex(messageId: MessageID): Promise<void> {
|
||||
try {
|
||||
const searchService = getMessageSearchService();
|
||||
if (!searchService) return;
|
||||
|
||||
await searchService.deleteMessage(messageId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete message from search index:', error);
|
||||
}
|
||||
}
|
||||
|
||||
buildSearchFilters(channelId: ChannelID, searchParams: MessageSearchRequest): MessageSearchFilters {
|
||||
const filters: MessageSearchFilters = {};
|
||||
|
||||
if (searchParams.max_id) filters.maxId = searchParams.max_id.toString();
|
||||
if (searchParams.min_id) filters.minId = searchParams.min_id.toString();
|
||||
|
||||
if (searchParams.content) filters.content = searchParams.content;
|
||||
if (searchParams.contents) filters.contents = searchParams.contents;
|
||||
|
||||
if (searchParams.channel_id) {
|
||||
filters.channelIds = Array.isArray(searchParams.channel_id)
|
||||
? searchParams.channel_id.map((id: bigint) => id.toString())
|
||||
: [(searchParams.channel_id as bigint).toString()];
|
||||
} else {
|
||||
filters.channelId = channelId.toString();
|
||||
}
|
||||
if (searchParams.exclude_channel_id) {
|
||||
filters.excludeChannelIds = Array.isArray(searchParams.exclude_channel_id)
|
||||
? searchParams.exclude_channel_id.map((id: bigint) => id.toString())
|
||||
: [(searchParams.exclude_channel_id as bigint).toString()];
|
||||
}
|
||||
|
||||
if (searchParams.author_id) {
|
||||
filters.authorId = Array.isArray(searchParams.author_id)
|
||||
? searchParams.author_id.map((id: bigint) => id.toString())
|
||||
: [(searchParams.author_id as bigint).toString()];
|
||||
}
|
||||
if (searchParams.exclude_author_id) {
|
||||
filters.excludeAuthorIds = Array.isArray(searchParams.exclude_author_id)
|
||||
? searchParams.exclude_author_id.map((id: bigint) => id.toString())
|
||||
: [(searchParams.exclude_author_id as bigint).toString()];
|
||||
}
|
||||
if (searchParams.author_type) filters.authorType = searchParams.author_type;
|
||||
if (searchParams.exclude_author_type) filters.excludeAuthorType = searchParams.exclude_author_type;
|
||||
|
||||
if (searchParams.mentions) filters.mentions = searchParams.mentions.map((id: bigint) => id.toString());
|
||||
if (searchParams.exclude_mentions)
|
||||
filters.excludeMentions = searchParams.exclude_mentions.map((id: bigint) => id.toString());
|
||||
if (searchParams.mention_everyone !== undefined) filters.mentionEveryone = searchParams.mention_everyone;
|
||||
|
||||
if (searchParams.pinned !== undefined) filters.pinned = searchParams.pinned;
|
||||
|
||||
if (searchParams.has) filters.has = searchParams.has;
|
||||
if (searchParams.exclude_has) filters.excludeHas = searchParams.exclude_has;
|
||||
|
||||
if (searchParams.embed_type) filters.embedType = searchParams.embed_type;
|
||||
if (searchParams.exclude_embed_type) filters.excludeEmbedTypes = searchParams.exclude_embed_type;
|
||||
if (searchParams.embed_provider) filters.embedProvider = searchParams.embed_provider;
|
||||
if (searchParams.exclude_embed_provider) filters.excludeEmbedProviders = searchParams.exclude_embed_provider;
|
||||
|
||||
if (searchParams.link_hostname) filters.linkHostname = searchParams.link_hostname;
|
||||
if (searchParams.exclude_link_hostname) filters.excludeLinkHostnames = searchParams.exclude_link_hostname;
|
||||
|
||||
if (searchParams.attachment_filename) filters.attachmentFilename = searchParams.attachment_filename;
|
||||
if (searchParams.exclude_attachment_filename)
|
||||
filters.excludeAttachmentFilenames = searchParams.exclude_attachment_filename;
|
||||
if (searchParams.attachment_extension) filters.attachmentExtension = searchParams.attachment_extension;
|
||||
if (searchParams.exclude_attachment_extension)
|
||||
filters.excludeAttachmentExtensions = searchParams.exclude_attachment_extension;
|
||||
|
||||
if (searchParams.sort_by) filters.sortBy = searchParams.sort_by;
|
||||
if (searchParams.sort_order) filters.sortOrder = searchParams.sort_order;
|
||||
|
||||
if (searchParams.include_nsfw !== undefined) filters.includeNsfw = searchParams.include_nsfw;
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
async triggerChannelIndexing(channelId: ChannelID): Promise<void> {
|
||||
await this.workerService.addJob('indexChannelMessages', {
|
||||
channelId: channelId.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
930
fluxer_api/src/channel/services/message/MessageSendService.ts
Normal file
930
fluxer_api/src/channel/services/message/MessageSendService.ts
Normal file
@@ -0,0 +1,930 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ChannelID,
|
||||
createChannelID,
|
||||
createGuildID,
|
||||
createMessageID,
|
||||
createStickerID,
|
||||
createUserID,
|
||||
type GuildID,
|
||||
type RoleID,
|
||||
type UserID,
|
||||
} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {
|
||||
ChannelTypes,
|
||||
GuildOperations,
|
||||
MessageReferenceTypes,
|
||||
MessageTypes,
|
||||
Permissions,
|
||||
SENDABLE_MESSAGE_FLAGS,
|
||||
UserFlags,
|
||||
} from '~/Constants';
|
||||
import type {AttachmentRequestData, AttachmentToProcess} from '~/channel/AttachmentDTOs';
|
||||
import type {MessageRequest} from '~/channel/ChannelModel';
|
||||
import type {MessageAttachment, MessageReference, MessageSnapshot} from '~/database/CassandraTypes';
|
||||
import {
|
||||
CannotExecuteOnDmError,
|
||||
FeatureTemporarilyDisabledError,
|
||||
InputValidationError,
|
||||
MissingPermissionsError,
|
||||
SlowmodeRateLimitError,
|
||||
UnknownChannelError,
|
||||
UnknownMessageError,
|
||||
} from '~/Errors';
|
||||
import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository';
|
||||
import type {GuildResponse} from '~/guild/GuildModel';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import {getMetricsService} from '~/infrastructure/MetricsService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {Message, User, Webhook} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import type {MessageChannelAuthService} from './MessageChannelAuthService';
|
||||
import type {MessageDispatchService} from './MessageDispatchService';
|
||||
import type {MessageEmbedAttachmentResolver} from './MessageEmbedAttachmentResolver';
|
||||
import {createMessageSnapshotsForForward, isOperationDisabled, isPersonalNotesChannel} from './MessageHelpers';
|
||||
import type {MessageMentionService} from './MessageMentionService';
|
||||
import type {MessageOperationsHelpers} from './MessageOperationsHelpers';
|
||||
import type {MessagePersistenceService} from './MessagePersistenceService';
|
||||
import type {MessageProcessingService} from './MessageProcessingService';
|
||||
import type {MessageSearchService} from './MessageSearchService';
|
||||
import type {MessageValidationService} from './MessageValidationService';
|
||||
|
||||
interface MessageSendServiceDeps {
|
||||
channelRepository: IChannelRepositoryAggregate;
|
||||
storageService: IStorageService;
|
||||
gatewayService: IGatewayService;
|
||||
snowflakeService: SnowflakeService;
|
||||
rateLimitService: IRateLimitService;
|
||||
favoriteMemeRepository: IFavoriteMemeRepository;
|
||||
validationService: MessageValidationService;
|
||||
mentionService: MessageMentionService;
|
||||
searchService: MessageSearchService;
|
||||
persistenceService: MessagePersistenceService;
|
||||
channelAuthService: MessageChannelAuthService;
|
||||
processingService: MessageProcessingService;
|
||||
dispatchService: MessageDispatchService;
|
||||
operationsHelpers: MessageOperationsHelpers;
|
||||
embedAttachmentResolver: MessageEmbedAttachmentResolver;
|
||||
}
|
||||
|
||||
export class MessageSendService {
|
||||
constructor(private readonly deps: MessageSendServiceDeps) {}
|
||||
|
||||
private attachmentsToProcess(attachments?: Array<AttachmentRequestData>): Array<AttachmentToProcess> | undefined {
|
||||
if (!attachments) return undefined;
|
||||
const processed = attachments.filter(
|
||||
(att): att is AttachmentToProcess =>
|
||||
'upload_filename' in att && typeof att.upload_filename === 'string' && att.upload_filename.length > 0,
|
||||
);
|
||||
return processed.length > 0 ? processed : undefined;
|
||||
}
|
||||
|
||||
async validateMessageCanBeSent({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
}: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
data: MessageRequest;
|
||||
}): Promise<void> {
|
||||
const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({
|
||||
userId: user.id,
|
||||
channelId,
|
||||
});
|
||||
|
||||
if (!(user.flags & UserFlags.HAS_SESSION_STARTED)) {
|
||||
throw InputValidationError.create('content', 'You must start a session before sending messages');
|
||||
}
|
||||
|
||||
if (isPersonalNotesChannel({userId: user.id, channelId})) {
|
||||
await this.validatePersonalNoteMessage({user, channelId, data});
|
||||
return;
|
||||
}
|
||||
|
||||
const {channel, guild, checkPermission, hasPermission, member} = authChannel;
|
||||
|
||||
const [canEmbedLinks, canMentionEveryone] = await Promise.all([
|
||||
hasPermission(Permissions.EMBED_LINKS),
|
||||
hasPermission(Permissions.MENTION_EVERYONE),
|
||||
]);
|
||||
|
||||
if (data.embeds && data.embeds.length > 0 && !canEmbedLinks) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
if (guild) {
|
||||
if (!member) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
|
||||
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
await checkPermission(Permissions.SEND_MESSAGES);
|
||||
|
||||
if (data.tts) {
|
||||
await checkPermission(Permissions.SEND_TTS_MESSAGES);
|
||||
}
|
||||
|
||||
await this.deps.channelAuthService.checkGuildVerification({user, guild, member});
|
||||
} else if (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM) {
|
||||
await this.deps.channelAuthService.validateDMSendPermissions({channelId, userId: user.id});
|
||||
}
|
||||
|
||||
this.deps.validationService.ensureTextChannel(channel);
|
||||
|
||||
const isForwardMessage = this.ensureMessageRequestIsValid({user, data});
|
||||
|
||||
this.deps.embedAttachmentResolver.validateAttachmentReferences({
|
||||
embeds: data.embeds,
|
||||
attachments: data.attachments,
|
||||
});
|
||||
|
||||
const {referencedMessage, referencedChannelGuildId} = await this.fetchReferencedMessageForValidation({
|
||||
data,
|
||||
channelId,
|
||||
isForwardMessage,
|
||||
});
|
||||
|
||||
if (data.message_reference && referencedMessage && !isForwardMessage) {
|
||||
const replyableTypes: ReadonlySet<Message['type']> = new Set([MessageTypes.DEFAULT, MessageTypes.REPLY]);
|
||||
if (!replyableTypes.has(referencedMessage.type)) {
|
||||
throw InputValidationError.create('message_reference', 'Cannot reply to system message');
|
||||
}
|
||||
}
|
||||
|
||||
this.ensureForwardGuildMatches({data, referencedChannelGuildId});
|
||||
|
||||
if (data.message_reference && guild) {
|
||||
await checkPermission(Permissions.READ_MESSAGE_HISTORY);
|
||||
}
|
||||
|
||||
if (data.content && channel && !isForwardMessage) {
|
||||
const mentions = this.deps.mentionService.extractMentions({
|
||||
content: data.content,
|
||||
referencedMessage: referencedMessage || null,
|
||||
message: {
|
||||
id: createMessageID(this.deps.snowflakeService.generate()),
|
||||
channelId,
|
||||
authorId: user.id,
|
||||
content: data.content,
|
||||
flags: this.deps.validationService.calculateMessageFlags(data),
|
||||
} as Message,
|
||||
channelType: channel.type,
|
||||
allowedMentions: data.allowed_mentions || null,
|
||||
guild,
|
||||
canMentionEveryone,
|
||||
});
|
||||
|
||||
await this.deps.mentionService.validateMentions({
|
||||
userMentions: mentions.userMentions,
|
||||
roleMentions: mentions.roleMentions,
|
||||
channel,
|
||||
canMentionRoles: canMentionEveryone,
|
||||
});
|
||||
}
|
||||
|
||||
await this.ensureAttachmentsExist(data.attachments);
|
||||
}
|
||||
|
||||
private async validatePersonalNoteMessage({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
}: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
data: MessageRequest;
|
||||
}): Promise<void> {
|
||||
const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({
|
||||
userId: user.id,
|
||||
channelId,
|
||||
});
|
||||
const {channel} = authChannel;
|
||||
|
||||
this.deps.validationService.ensureTextChannel(channel);
|
||||
|
||||
const isForwardMessage = this.ensureMessageRequestIsValid({user, data});
|
||||
|
||||
this.deps.embedAttachmentResolver.validateAttachmentReferences({
|
||||
embeds: data.embeds,
|
||||
attachments: data.attachments,
|
||||
});
|
||||
|
||||
const {referencedMessage, referencedChannelGuildId} = await this.fetchReferencedMessageForValidation({
|
||||
data,
|
||||
channelId,
|
||||
isForwardMessage,
|
||||
});
|
||||
|
||||
if (data.message_reference && referencedMessage && !isForwardMessage) {
|
||||
const replyableTypes: ReadonlySet<Message['type']> = new Set([MessageTypes.DEFAULT, MessageTypes.REPLY]);
|
||||
if (!replyableTypes.has(referencedMessage.type)) {
|
||||
throw InputValidationError.create('message_reference', 'Cannot reply to system message');
|
||||
}
|
||||
}
|
||||
|
||||
this.ensureForwardGuildMatches({data, referencedChannelGuildId});
|
||||
|
||||
if (data.content && channel && !isForwardMessage) {
|
||||
const mentions = this.deps.mentionService.extractMentions({
|
||||
content: data.content,
|
||||
referencedMessage: referencedMessage || null,
|
||||
message: {
|
||||
id: createMessageID(this.deps.snowflakeService.generate()),
|
||||
channelId,
|
||||
authorId: user.id,
|
||||
content: data.content,
|
||||
flags: this.deps.validationService.calculateMessageFlags(data),
|
||||
} as Message,
|
||||
channelType: channel.type,
|
||||
allowedMentions: data.allowed_mentions || null,
|
||||
guild: null,
|
||||
canMentionEveryone: true,
|
||||
});
|
||||
|
||||
await this.deps.mentionService.validateMentions({
|
||||
userMentions: mentions.userMentions,
|
||||
roleMentions: mentions.roleMentions,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
|
||||
await this.ensureAttachmentsExist(data.attachments);
|
||||
}
|
||||
|
||||
private async fetchReferencedMessageForValidation({
|
||||
data,
|
||||
channelId,
|
||||
isForwardMessage,
|
||||
}: {
|
||||
data: MessageRequest;
|
||||
channelId: ChannelID;
|
||||
isForwardMessage: boolean;
|
||||
}): Promise<{referencedMessage: Message | null; referencedChannelGuildId?: GuildID | null}> {
|
||||
if (!data.message_reference) {
|
||||
return {referencedMessage: null};
|
||||
}
|
||||
|
||||
const referenceChannelId = isForwardMessage ? createChannelID(data.message_reference.channel_id!) : channelId;
|
||||
|
||||
const referencedMessage = await this.deps.channelRepository.messages.getMessage(
|
||||
referenceChannelId,
|
||||
createMessageID(data.message_reference.message_id),
|
||||
);
|
||||
|
||||
if (!referencedMessage) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
let referencedChannelGuildId: GuildID | null | undefined;
|
||||
if (isForwardMessage) {
|
||||
const referencedChannel = await this.deps.channelRepository.channelData.findUnique(referencedMessage.channelId);
|
||||
if (!referencedChannel) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
referencedChannelGuildId = referencedChannel.guildId ?? null;
|
||||
}
|
||||
|
||||
return {referencedMessage, referencedChannelGuildId};
|
||||
}
|
||||
|
||||
private async ensureAttachmentsExist(attachments?: Array<AttachmentRequestData>): Promise<void> {
|
||||
if (!attachments || attachments.length === 0) return;
|
||||
|
||||
for (let index = 0; index < attachments.length; index++) {
|
||||
const attachment = attachments[index];
|
||||
if (!('upload_filename' in attachment) || !attachment.upload_filename) continue;
|
||||
const metadata = await this.deps.storageService.getObjectMetadata(
|
||||
Config.s3.buckets.uploads,
|
||||
attachment.upload_filename,
|
||||
);
|
||||
if (!metadata) {
|
||||
throw InputValidationError.create(
|
||||
`attachments.${index}.upload_filename`,
|
||||
`Uploaded attachment ${attachment.filename} was not found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureMessageRequestIsValid({user, data}: {user: User; data: MessageRequest}): boolean {
|
||||
const isForwardMessage = data.message_reference?.type === MessageReferenceTypes.FORWARD;
|
||||
|
||||
if (isForwardMessage) {
|
||||
if (!data.message_reference?.channel_id || !data.message_reference?.message_id) {
|
||||
throw InputValidationError.create(
|
||||
'message_reference',
|
||||
'Forward message reference must include channel_id and message_id',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
data.content ||
|
||||
(data.embeds && data.embeds.length > 0) ||
|
||||
(data.attachments && data.attachments.length > 0) ||
|
||||
(data.sticker_ids && data.sticker_ids.length > 0)
|
||||
) {
|
||||
throw InputValidationError.create(
|
||||
'message_reference',
|
||||
'Forward messages cannot contain content, embeds, attachments, or stickers',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.deps.validationService.validateMessageContent(data, user);
|
||||
}
|
||||
|
||||
return isForwardMessage;
|
||||
}
|
||||
|
||||
private async resolveReferenceContext({
|
||||
data,
|
||||
channelId,
|
||||
isForwardMessage,
|
||||
user,
|
||||
}: {
|
||||
data: MessageRequest;
|
||||
channelId: ChannelID;
|
||||
isForwardMessage: boolean;
|
||||
user: User;
|
||||
}): Promise<{
|
||||
referencedMessage: Message | null;
|
||||
referencedChannelGuildId?: GuildID | null;
|
||||
messageSnapshots?: Array<MessageSnapshot>;
|
||||
}> {
|
||||
const referenceChannelId = isForwardMessage ? createChannelID(data.message_reference!.channel_id!) : channelId;
|
||||
|
||||
const referencedMessage = data.message_reference
|
||||
? await this.deps.channelRepository.messages.getMessage(
|
||||
referenceChannelId,
|
||||
createMessageID(data.message_reference.message_id),
|
||||
)
|
||||
: null;
|
||||
|
||||
if (data.message_reference && !referencedMessage) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
let referencedChannelGuildId: GuildID | null | undefined;
|
||||
if (isForwardMessage && referencedMessage) {
|
||||
const referencedChannel = await this.deps.channelRepository.channelData.findUnique(referencedMessage.channelId);
|
||||
if (!referencedChannel) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
referencedChannelGuildId = referencedChannel.guildId;
|
||||
}
|
||||
|
||||
let messageSnapshots: Array<MessageSnapshot> | undefined;
|
||||
if (isForwardMessage && referencedMessage) {
|
||||
messageSnapshots = await createMessageSnapshotsForForward(
|
||||
referencedMessage,
|
||||
user,
|
||||
channelId,
|
||||
this.deps.storageService,
|
||||
this.deps.snowflakeService,
|
||||
this.deps.validationService.validateAttachmentSizes.bind(this.deps.validationService),
|
||||
);
|
||||
}
|
||||
|
||||
return {referencedMessage, referencedChannelGuildId, messageSnapshots};
|
||||
}
|
||||
|
||||
private ensureForwardGuildMatches({
|
||||
data,
|
||||
referencedChannelGuildId,
|
||||
}: {
|
||||
data: MessageRequest;
|
||||
referencedChannelGuildId?: GuildID | null;
|
||||
}): void {
|
||||
if (data.message_reference?.type !== MessageReferenceTypes.FORWARD) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (referencedChannelGuildId === undefined || data.message_reference?.guild_id === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providedGuildId = createGuildID(data.message_reference.guild_id);
|
||||
if (providedGuildId !== referencedChannelGuildId) {
|
||||
throw InputValidationError.create(
|
||||
'message_reference.guild_id',
|
||||
'Guild id must match the channel the referenced message was fetched from',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareMessageAttachments({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
}: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
data: MessageRequest;
|
||||
}): Promise<{
|
||||
attachmentsToProcess?: Array<AttachmentToProcess>;
|
||||
favoriteMemeAttachment?: MessageAttachment;
|
||||
}> {
|
||||
const attachmentsToProcess = this.attachmentsToProcess(data.attachments);
|
||||
let favoriteMemeAttachment: MessageAttachment | undefined;
|
||||
if (data.favorite_meme_id) {
|
||||
favoriteMemeAttachment = await this.deps.operationsHelpers.processFavoriteMeme({
|
||||
user,
|
||||
channelId,
|
||||
favoriteMemeId: data.favorite_meme_id,
|
||||
});
|
||||
}
|
||||
|
||||
return {attachmentsToProcess, favoriteMemeAttachment};
|
||||
}
|
||||
|
||||
private buildMessageReferencePayload({
|
||||
data,
|
||||
referencedMessage,
|
||||
guild,
|
||||
isForwardMessage,
|
||||
referencedChannelGuildId,
|
||||
}: {
|
||||
data: MessageRequest;
|
||||
referencedMessage: Message | null;
|
||||
guild: GuildResponse | null;
|
||||
isForwardMessage: boolean;
|
||||
referencedChannelGuildId?: GuildID | null;
|
||||
}): MessageReference | undefined {
|
||||
if (!data.message_reference) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const channel_id = referencedMessage
|
||||
? referencedMessage.channelId
|
||||
: createChannelID(data.message_reference.channel_id!);
|
||||
const guild_id = isForwardMessage
|
||||
? (referencedChannelGuildId ?? null)
|
||||
: guild?.id
|
||||
? createGuildID(BigInt(guild.id))
|
||||
: null;
|
||||
|
||||
return {
|
||||
message_id: createMessageID(data.message_reference.message_id),
|
||||
channel_id,
|
||||
guild_id,
|
||||
type: data.message_reference.type ?? MessageReferenceTypes.DEFAULT,
|
||||
};
|
||||
}
|
||||
|
||||
async sendMessage({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
requestCache,
|
||||
}: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
data: MessageRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Message> {
|
||||
try {
|
||||
const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({
|
||||
userId: user.id,
|
||||
channelId,
|
||||
});
|
||||
|
||||
if (!(user.flags & UserFlags.HAS_SESSION_STARTED)) {
|
||||
throw InputValidationError.create('content', 'You must start a session before sending messages');
|
||||
}
|
||||
|
||||
if (isPersonalNotesChannel({userId: user.id, channelId})) {
|
||||
return this.sendPersonalNoteMessage({user, channelId, data, requestCache});
|
||||
}
|
||||
|
||||
const {channel, guild, checkPermission, hasPermission, member} = authChannel;
|
||||
|
||||
const [canEmbedLinks, canMentionEveryone] = await Promise.all([
|
||||
hasPermission(Permissions.EMBED_LINKS),
|
||||
hasPermission(Permissions.MENTION_EVERYONE),
|
||||
]);
|
||||
|
||||
if (data.embeds && data.embeds.length > 0 && !canEmbedLinks) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
if (guild) {
|
||||
if (!member) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
await checkPermission(Permissions.SEND_MESSAGES);
|
||||
|
||||
if (data.tts) {
|
||||
await checkPermission(Permissions.SEND_TTS_MESSAGES);
|
||||
}
|
||||
|
||||
await this.deps.channelAuthService.checkGuildVerification({user, guild, member});
|
||||
|
||||
if (channel.rateLimitPerUser && channel.rateLimitPerUser > 0 && !user.isBot) {
|
||||
const hasBypassSlowmode = await hasPermission(Permissions.BYPASS_SLOWMODE);
|
||||
|
||||
if (!hasBypassSlowmode) {
|
||||
const rateLimitKey = `slowmode:${channelId}:${user.id}`;
|
||||
const result = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: rateLimitKey,
|
||||
maxAttempts: 1,
|
||||
windowMs: channel.rateLimitPerUser * 1000,
|
||||
});
|
||||
|
||||
if (!result.allowed) {
|
||||
throw new SlowmodeRateLimitError({
|
||||
message: 'You are sending messages too quickly. Please slow down.',
|
||||
retryAfter: result.retryAfter!,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM) {
|
||||
await this.deps.channelAuthService.validateDMSendPermissions({channelId, userId: user.id});
|
||||
}
|
||||
|
||||
this.deps.validationService.ensureTextChannel(channel);
|
||||
|
||||
const isForwardMessage = this.ensureMessageRequestIsValid({user, data});
|
||||
|
||||
this.deps.embedAttachmentResolver.validateAttachmentReferences({
|
||||
embeds: data.embeds,
|
||||
attachments: data.attachments,
|
||||
});
|
||||
|
||||
const existingMessage = await this.deps.operationsHelpers.findExistingMessage({
|
||||
userId: user.id,
|
||||
nonce: data.nonce,
|
||||
expectedChannelId: channelId,
|
||||
});
|
||||
if (existingMessage) {
|
||||
return existingMessage;
|
||||
}
|
||||
|
||||
if (data.message_reference && guild) {
|
||||
await checkPermission(Permissions.READ_MESSAGE_HISTORY);
|
||||
}
|
||||
|
||||
const referenceContext = await this.resolveReferenceContext({
|
||||
data,
|
||||
channelId,
|
||||
isForwardMessage,
|
||||
user,
|
||||
});
|
||||
|
||||
const {referencedMessage, referencedChannelGuildId, messageSnapshots} = referenceContext;
|
||||
|
||||
if (data.message_reference && referencedMessage && !isForwardMessage) {
|
||||
const replyableTypes: ReadonlySet<Message['type']> = new Set([MessageTypes.DEFAULT, MessageTypes.REPLY]);
|
||||
if (!replyableTypes.has(referencedMessage.type)) {
|
||||
throw InputValidationError.create('message_reference', 'Cannot reply to system message');
|
||||
}
|
||||
}
|
||||
|
||||
this.ensureForwardGuildMatches({data, referencedChannelGuildId});
|
||||
|
||||
const {attachmentsToProcess, favoriteMemeAttachment} = await this.prepareMessageAttachments({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
});
|
||||
|
||||
const messageId = createMessageID(this.deps.snowflakeService.generate());
|
||||
|
||||
let mentionData:
|
||||
| {
|
||||
flags: number;
|
||||
mentionUserIds: Array<UserID>;
|
||||
mentionRoleIds: Array<RoleID>;
|
||||
mentionEveryone: boolean;
|
||||
mentionHere: boolean;
|
||||
}
|
||||
| undefined;
|
||||
if (data.content && channel && !isForwardMessage) {
|
||||
const mentions = this.deps.mentionService.extractMentions({
|
||||
content: data.content,
|
||||
referencedMessage: referencedMessage || null,
|
||||
message: {
|
||||
id: messageId,
|
||||
channelId,
|
||||
authorId: user.id,
|
||||
content: data.content,
|
||||
flags: this.deps.validationService.calculateMessageFlags(data),
|
||||
} as Message,
|
||||
channelType: channel.type,
|
||||
allowedMentions: data.allowed_mentions || null,
|
||||
guild,
|
||||
canMentionEveryone,
|
||||
});
|
||||
|
||||
const {validUserIds, validRoleIds} = await this.deps.mentionService.validateMentions({
|
||||
userMentions: mentions.userMentions,
|
||||
roleMentions: mentions.roleMentions,
|
||||
channel,
|
||||
canMentionRoles: canMentionEveryone,
|
||||
});
|
||||
|
||||
mentionData = {
|
||||
flags: mentions.flags,
|
||||
mentionUserIds: validUserIds,
|
||||
mentionRoleIds: validRoleIds,
|
||||
mentionEveryone: mentions.mentionsEveryone || mentions.mentionsHere,
|
||||
mentionHere: mentions.mentionsHere,
|
||||
};
|
||||
}
|
||||
|
||||
const messageReference = this.buildMessageReferencePayload({
|
||||
data,
|
||||
referencedMessage,
|
||||
guild,
|
||||
isForwardMessage,
|
||||
referencedChannelGuildId,
|
||||
});
|
||||
|
||||
const message = await this.deps.persistenceService.createMessage({
|
||||
messageId,
|
||||
channelId,
|
||||
user,
|
||||
type: referencedMessage ? MessageTypes.REPLY : MessageTypes.DEFAULT,
|
||||
content: data.content,
|
||||
flags: this.deps.validationService.calculateMessageFlags(data),
|
||||
embeds: data.embeds,
|
||||
attachments: attachmentsToProcess,
|
||||
processedAttachments: favoriteMemeAttachment ? [favoriteMemeAttachment] : undefined,
|
||||
attachmentDecayExcludedIds: favoriteMemeAttachment ? [favoriteMemeAttachment.attachment_id] : undefined,
|
||||
stickerIds: data.sticker_ids ? data.sticker_ids.flatMap((stickerId) => createStickerID(stickerId)) : undefined,
|
||||
messageReference,
|
||||
messageSnapshots,
|
||||
guildId: guild?.id ? createGuildID(BigInt(guild.id)) : null,
|
||||
channel,
|
||||
referencedMessage,
|
||||
allowedMentions: data.allowed_mentions,
|
||||
guild,
|
||||
member,
|
||||
hasPermission: guild ? hasPermission : undefined,
|
||||
mentionData,
|
||||
allowEmbeds: canEmbedLinks,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.deps.processingService.updateDMRecipients({channel, channelId, requestCache}),
|
||||
this.deps.processingService.processMessageAfterCreation({
|
||||
message,
|
||||
channel,
|
||||
guild,
|
||||
user,
|
||||
data,
|
||||
referencedMessage,
|
||||
mentionHere: mentionData?.mentionHere ?? false,
|
||||
}),
|
||||
this.deps.processingService.updateReadStates({user, guild, channel, channelId, messageId}),
|
||||
]);
|
||||
|
||||
await this.deps.dispatchService.dispatchMessageCreate({
|
||||
channel,
|
||||
message,
|
||||
requestCache,
|
||||
currentUserId: user.id,
|
||||
nonce: data.nonce,
|
||||
tts: data.tts,
|
||||
});
|
||||
|
||||
if (data.nonce) {
|
||||
await this.deps.validationService.cacheMessageNonce({userId: user.id, nonce: data.nonce, channelId, messageId});
|
||||
}
|
||||
|
||||
if (channel.indexedAt) {
|
||||
void this.deps.searchService.indexMessage(message, user.isBot);
|
||||
}
|
||||
|
||||
getMetricsService().counter({name: 'message.send'});
|
||||
|
||||
return message;
|
||||
} catch (error) {
|
||||
getMetricsService().counter({name: 'message.send.error'});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sendWebhookMessage({
|
||||
webhook,
|
||||
data,
|
||||
username,
|
||||
avatar,
|
||||
requestCache,
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
data: MessageRequest;
|
||||
username?: string | null;
|
||||
avatar?: string | null;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Message> {
|
||||
const channelId = webhook.channelId!;
|
||||
const channel = await this.deps.channelRepository.channelData.findUnique(channelId);
|
||||
|
||||
if (!channel || !channel.guildId) {
|
||||
throw new CannotExecuteOnDmError();
|
||||
}
|
||||
|
||||
const guild = await this.deps.gatewayService.getGuildData({
|
||||
guildId: channel.guildId,
|
||||
userId: createUserID(0n),
|
||||
skipMembershipCheck: true,
|
||||
});
|
||||
|
||||
this.deps.validationService.validateMessageContent(data, null);
|
||||
|
||||
this.deps.embedAttachmentResolver.validateAttachmentReferences({
|
||||
embeds: data.embeds,
|
||||
attachments: data.attachments,
|
||||
});
|
||||
|
||||
const messageId = createMessageID(this.deps.snowflakeService.generate());
|
||||
|
||||
let mentionData:
|
||||
| {
|
||||
flags: number;
|
||||
mentionUserIds: Array<UserID>;
|
||||
mentionRoleIds: Array<RoleID>;
|
||||
mentionEveryone: boolean;
|
||||
mentionHere: boolean;
|
||||
}
|
||||
| undefined;
|
||||
if (data.content && channel) {
|
||||
const mentions = this.deps.mentionService.extractMentions({
|
||||
content: data.content,
|
||||
referencedMessage: null,
|
||||
message: {
|
||||
id: messageId,
|
||||
channelId,
|
||||
webhookId: webhook.id,
|
||||
content: data.content,
|
||||
flags: this.deps.validationService.calculateMessageFlags(data),
|
||||
} as Message,
|
||||
channelType: channel.type,
|
||||
allowedMentions: data.allowed_mentions || null,
|
||||
guild,
|
||||
});
|
||||
|
||||
const {validUserIds, validRoleIds} = await this.deps.mentionService.validateMentions({
|
||||
userMentions: mentions.userMentions,
|
||||
roleMentions: mentions.roleMentions,
|
||||
channel,
|
||||
});
|
||||
|
||||
mentionData = {
|
||||
flags: mentions.flags,
|
||||
mentionUserIds: validUserIds,
|
||||
mentionRoleIds: validRoleIds,
|
||||
mentionEveryone: mentions.mentionsEveryone || mentions.mentionsHere,
|
||||
mentionHere: mentions.mentionsHere,
|
||||
};
|
||||
}
|
||||
|
||||
const message = await this.deps.persistenceService.createMessage({
|
||||
messageId,
|
||||
channelId,
|
||||
webhookId: webhook.id,
|
||||
webhookName: username ?? webhook.name!,
|
||||
webhookAvatar: avatar ?? webhook.avatarHash,
|
||||
type: MessageTypes.DEFAULT,
|
||||
content: data.content,
|
||||
flags: this.deps.validationService.calculateMessageFlags(data),
|
||||
embeds: data.embeds,
|
||||
attachments: this.attachmentsToProcess(data.attachments),
|
||||
guildId: channel.guildId,
|
||||
channel,
|
||||
guild,
|
||||
mentionData,
|
||||
allowEmbeds: true,
|
||||
});
|
||||
|
||||
await this.deps.mentionService.handleMentionTasks({
|
||||
guildId: channel.guildId,
|
||||
message,
|
||||
authorId: createUserID(0n),
|
||||
mentionHere: mentionData?.mentionHere ?? false,
|
||||
});
|
||||
|
||||
await this.deps.dispatchService.dispatchMessageCreate({channel, message, requestCache});
|
||||
|
||||
if (channel.indexedAt) {
|
||||
void this.deps.searchService.indexMessage(message, false);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private async sendPersonalNoteMessage({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
requestCache,
|
||||
}: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
data: MessageRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Message> {
|
||||
const {channel} = await this.deps.channelAuthService.getChannelAuthenticated({userId: user.id, channelId});
|
||||
|
||||
const isForwardMessage = this.ensureMessageRequestIsValid({user, data});
|
||||
|
||||
this.deps.embedAttachmentResolver.validateAttachmentReferences({
|
||||
embeds: data.embeds,
|
||||
attachments: data.attachments,
|
||||
});
|
||||
|
||||
const existingMessage = await this.deps.operationsHelpers.findExistingMessage({
|
||||
userId: user.id,
|
||||
nonce: data.nonce,
|
||||
expectedChannelId: channelId,
|
||||
});
|
||||
|
||||
if (existingMessage) {
|
||||
return existingMessage;
|
||||
}
|
||||
|
||||
const {referencedMessage, referencedChannelGuildId, messageSnapshots} = await this.resolveReferenceContext({
|
||||
data,
|
||||
channelId,
|
||||
isForwardMessage,
|
||||
user,
|
||||
});
|
||||
|
||||
this.ensureForwardGuildMatches({data, referencedChannelGuildId});
|
||||
|
||||
const {attachmentsToProcess, favoriteMemeAttachment} = await this.prepareMessageAttachments({
|
||||
user,
|
||||
channelId,
|
||||
data,
|
||||
});
|
||||
const messageId = createMessageID(this.deps.snowflakeService.generate());
|
||||
const messageReference = this.buildMessageReferencePayload({
|
||||
data,
|
||||
referencedMessage,
|
||||
guild: null,
|
||||
isForwardMessage,
|
||||
referencedChannelGuildId,
|
||||
});
|
||||
|
||||
const message = await this.deps.persistenceService.createMessage({
|
||||
messageId,
|
||||
channelId,
|
||||
user,
|
||||
type: MessageTypes.DEFAULT,
|
||||
content: data.content,
|
||||
flags: data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0,
|
||||
embeds: data.embeds,
|
||||
attachments: attachmentsToProcess,
|
||||
processedAttachments: favoriteMemeAttachment ? [favoriteMemeAttachment] : undefined,
|
||||
attachmentDecayExcludedIds: favoriteMemeAttachment ? [favoriteMemeAttachment.attachment_id] : undefined,
|
||||
messageReference,
|
||||
messageSnapshots,
|
||||
guildId: null,
|
||||
channel,
|
||||
});
|
||||
|
||||
await this.deps.dispatchService.dispatchMessageCreate({
|
||||
channel,
|
||||
message,
|
||||
requestCache,
|
||||
currentUserId: user.id,
|
||||
nonce: data.nonce,
|
||||
tts: data.tts,
|
||||
});
|
||||
|
||||
if (data.nonce) {
|
||||
await this.deps.validationService.cacheMessageNonce({userId: user.id, nonce: data.nonce, channelId, messageId});
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
119
fluxer_api/src/channel/services/message/MessageStickerService.ts
Normal file
119
fluxer_api/src/channel/services/message/MessageStickerService.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, StickerID, UserID} from '~/BrandedTypes';
|
||||
import {Permissions} from '~/Constants';
|
||||
import type {MessageStickerItem} from '~/database/CassandraTypes';
|
||||
import {InputValidationError, MissingPermissionsError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {PackService} from '~/pack/PackService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
|
||||
export class MessageStickerService {
|
||||
constructor(
|
||||
private userRepository: IUserRepository,
|
||||
private guildRepository: IGuildRepository,
|
||||
private packService: PackService,
|
||||
) {}
|
||||
|
||||
async computeStickerIds(params: {
|
||||
stickerIds: Array<StickerID>;
|
||||
userId: UserID | null;
|
||||
guildId: GuildID | null;
|
||||
hasPermission?: (permission: bigint) => Promise<boolean>;
|
||||
}): Promise<Array<MessageStickerItem>> {
|
||||
const {stickerIds, userId, guildId, hasPermission} = params;
|
||||
|
||||
const packResolver = await this.packService.createPackExpressionAccessResolver({
|
||||
userId,
|
||||
type: 'sticker',
|
||||
});
|
||||
|
||||
let isPremium = false;
|
||||
if (userId) {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
isPremium = user?.canUseGlobalExpressions() ?? false;
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
stickerIds.map(async (stickerId) => {
|
||||
if (!guildId) {
|
||||
if (!isPremium) {
|
||||
throw InputValidationError.create('sticker', 'Cannot use custom stickers in DMs without premium');
|
||||
}
|
||||
|
||||
const stickerFromAnyGuild = await this.guildRepository.getStickerById(stickerId);
|
||||
if (!stickerFromAnyGuild) {
|
||||
throw InputValidationError.create('sticker', 'Custom sticker not found');
|
||||
}
|
||||
|
||||
const packAccess = await packResolver.resolve(stickerFromAnyGuild.guildId);
|
||||
if (packAccess === 'not-accessible') {
|
||||
throw InputValidationError.create('sticker', 'Custom sticker not found');
|
||||
}
|
||||
|
||||
return {
|
||||
sticker_id: stickerFromAnyGuild.id,
|
||||
name: stickerFromAnyGuild.name,
|
||||
format_type: stickerFromAnyGuild.formatType,
|
||||
};
|
||||
}
|
||||
|
||||
const guildSticker = await this.guildRepository.getSticker(stickerId, guildId);
|
||||
if (guildSticker) {
|
||||
return {
|
||||
sticker_id: guildSticker.id,
|
||||
name: guildSticker.name,
|
||||
format_type: guildSticker.formatType,
|
||||
};
|
||||
}
|
||||
|
||||
const stickerFromOtherGuild = await this.guildRepository.getStickerById(stickerId);
|
||||
if (!stickerFromOtherGuild) {
|
||||
throw InputValidationError.create('sticker', 'Custom sticker not found');
|
||||
}
|
||||
|
||||
if (!isPremium) {
|
||||
throw InputValidationError.create(
|
||||
'sticker',
|
||||
'Cannot use custom stickers outside of source guilds without premium',
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission) {
|
||||
const canUseExternalStickers = await hasPermission(Permissions.USE_EXTERNAL_STICKERS);
|
||||
if (!canUseExternalStickers) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
|
||||
const packAccess = await packResolver.resolve(stickerFromOtherGuild.guildId);
|
||||
if (packAccess === 'not-accessible') {
|
||||
throw InputValidationError.create('sticker', 'Custom sticker not found');
|
||||
}
|
||||
|
||||
return {
|
||||
sticker_id: stickerFromOtherGuild.id,
|
||||
name: stickerFromOtherGuild.name,
|
||||
format_type: stickerFromOtherGuild.formatType,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createMessageID, type GuildID, type UserID} from '~/BrandedTypes';
|
||||
import {MessageTypes} from '~/Constants';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {Channel, Message} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import type {MessagePersistenceService} from './MessagePersistenceService';
|
||||
|
||||
export class MessageSystemService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private guildRepository: IGuildRepository,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private persistenceService: MessagePersistenceService,
|
||||
) {}
|
||||
|
||||
async sendJoinSystemMessage({
|
||||
guildId,
|
||||
userId,
|
||||
requestCache,
|
||||
dispatchMessageCreate,
|
||||
}: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
requestCache: RequestCache;
|
||||
dispatchMessageCreate: (params: {channel: Channel; message: Message; requestCache: RequestCache}) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild?.systemChannelId) return;
|
||||
|
||||
const systemChannel = await this.channelRepository.channelData.findUnique(guild.systemChannelId);
|
||||
if (!systemChannel) return;
|
||||
|
||||
const messageId = createMessageID(this.snowflakeService.generate());
|
||||
const message = await this.persistenceService.createMessage({
|
||||
messageId,
|
||||
channelId: systemChannel.id,
|
||||
userId,
|
||||
type: MessageTypes.USER_JOIN,
|
||||
content: null,
|
||||
flags: 0,
|
||||
guildId,
|
||||
channel: systemChannel,
|
||||
});
|
||||
|
||||
await dispatchMessageCreate({channel: systemChannel, message, requestCache});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, createChannelID, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {
|
||||
isMessageTypeDeletable,
|
||||
MAX_MESSAGE_LENGTH_NON_PREMIUM,
|
||||
MAX_MESSAGE_LENGTH_PREMIUM,
|
||||
MessageFlags,
|
||||
MessageTypes,
|
||||
Permissions,
|
||||
SENDABLE_MESSAGE_FLAGS,
|
||||
TEXT_BASED_CHANNEL_TYPES,
|
||||
} from '~/Constants';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '~/channel/ChannelModel';
|
||||
import {
|
||||
CannotEditSystemMessageError,
|
||||
CannotSendEmptyMessageError,
|
||||
CannotSendMessageToNonTextChannelError,
|
||||
FileSizeTooLargeError,
|
||||
InputValidationError,
|
||||
UnknownMessageError,
|
||||
} from '~/Errors';
|
||||
import type {GuildResponse} from '~/guild/GuildModel';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {Channel, Message, User} from '~/Models';
|
||||
import {getAttachmentMaxSize} from '~/utils/AttachmentUtils';
|
||||
import {MESSAGE_NONCE_TTL} from './MessageHelpers';
|
||||
|
||||
export class MessageValidationService {
|
||||
constructor(private cacheService: ICacheService) {}
|
||||
|
||||
ensureTextChannel(channel: Channel): void {
|
||||
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
|
||||
throw new CannotSendMessageToNonTextChannelError();
|
||||
}
|
||||
}
|
||||
|
||||
validateMessageContent(data: MessageRequest | MessageUpdateRequest, user: User | null, isUpdate = false): void {
|
||||
const hasContent = data.content != null && data.content.trim().length > 0;
|
||||
const hasEmbeds = data.embeds && data.embeds.length > 0;
|
||||
const hasAttachments = data.attachments && data.attachments.length > 0;
|
||||
const hasFavoriteMeme = 'favorite_meme_id' in data && data.favorite_meme_id != null;
|
||||
const hasStickers = 'sticker_ids' in data && data.sticker_ids != null && data.sticker_ids.length > 0;
|
||||
const hasFlags = data.flags !== undefined && data.flags !== null;
|
||||
|
||||
if (!hasContent && !hasEmbeds && !hasAttachments && !hasFavoriteMeme && !hasStickers && (!isUpdate || !hasFlags)) {
|
||||
throw new CannotSendEmptyMessageError();
|
||||
}
|
||||
|
||||
this.validateContentLength(data.content, user);
|
||||
}
|
||||
|
||||
validateContentLength(content: string | null | undefined, user: User | null): void {
|
||||
if (content == null) return;
|
||||
|
||||
const maxLength = user?.isPremium() ? MAX_MESSAGE_LENGTH_PREMIUM : MAX_MESSAGE_LENGTH_NON_PREMIUM;
|
||||
if (content.length > maxLength) {
|
||||
throw InputValidationError.create('content', `Content must not exceed ${maxLength} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
validateMessageEditable(message: Message): void {
|
||||
const editableTypes: ReadonlySet<Message['type']> = new Set([MessageTypes.DEFAULT, MessageTypes.REPLY]);
|
||||
if (!editableTypes.has(message.type)) {
|
||||
throw new CannotEditSystemMessageError();
|
||||
}
|
||||
}
|
||||
|
||||
calculateMessageFlags(data: {flags?: number; favorite_meme_id?: bigint | null}): number {
|
||||
let flags = data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0;
|
||||
|
||||
if (data.favorite_meme_id) {
|
||||
flags |= MessageFlags.COMPACT_ATTACHMENTS;
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
validateAttachmentSizes(attachments: Array<{size: number | bigint}>, user: User): void {
|
||||
const maxAttachmentSize = getAttachmentMaxSize(user.isPremium());
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (Number(attachment.size) > maxAttachmentSize) {
|
||||
throw new FileSizeTooLargeError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async findExistingMessage({
|
||||
userId,
|
||||
nonce,
|
||||
expectedChannelId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
nonce?: string;
|
||||
expectedChannelId: ChannelID;
|
||||
}): Promise<Message | null> {
|
||||
if (!nonce) return null;
|
||||
|
||||
const existingNonce = await this.cacheService.get<{channel_id: string; message_id: string}>(
|
||||
`message-nonce:${userId}:${nonce}`,
|
||||
);
|
||||
|
||||
if (!existingNonce) return null;
|
||||
|
||||
const cachedChannelId = createChannelID(BigInt(existingNonce.channel_id));
|
||||
if (cachedChannelId !== expectedChannelId) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async cacheMessageNonce({
|
||||
userId,
|
||||
nonce,
|
||||
channelId,
|
||||
messageId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
nonce: string;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<void> {
|
||||
await this.cacheService.set(
|
||||
`message-nonce:${userId}:${nonce}`,
|
||||
{
|
||||
channel_id: channelId.toString(),
|
||||
message_id: messageId.toString(),
|
||||
},
|
||||
MESSAGE_NONCE_TTL,
|
||||
);
|
||||
}
|
||||
|
||||
async canDeleteMessage({
|
||||
message,
|
||||
userId,
|
||||
guild,
|
||||
hasPermission,
|
||||
}: {
|
||||
message: Message;
|
||||
userId: UserID;
|
||||
guild: GuildResponse | null;
|
||||
hasPermission: (permission: bigint) => Promise<boolean>;
|
||||
}): Promise<boolean> {
|
||||
if (!isMessageTypeDeletable(message.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAuthor = message.authorId === userId;
|
||||
if (!guild) return isAuthor;
|
||||
|
||||
const canManageMessages =
|
||||
(await hasPermission(Permissions.SEND_MESSAGES)) && (await hasPermission(Permissions.MANAGE_MESSAGES));
|
||||
return isAuthor || canManageMessages;
|
||||
}
|
||||
}
|
||||
46
fluxer_api/src/channel/services/message/ReadStateHelpers.ts
Normal file
46
fluxer_api/src/channel/services/message/ReadStateHelpers.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID} from '~/BrandedTypes';
|
||||
import type {User} from '~/Models';
|
||||
import type {ReadStateService} from '~/read_state/ReadStateService';
|
||||
|
||||
interface IncrementDmMentionCountsParams {
|
||||
readStateService: ReadStateService;
|
||||
user: User | null;
|
||||
recipients: Array<User>;
|
||||
channelId: ChannelID;
|
||||
}
|
||||
|
||||
export async function incrementDmMentionCounts(params: IncrementDmMentionCountsParams): Promise<void> {
|
||||
const {readStateService, user, recipients, channelId} = params;
|
||||
|
||||
if (!user || user.isBot) return;
|
||||
|
||||
const validRecipients = recipients.filter((recipient) => recipient.id !== user.id && !recipient.isBot);
|
||||
|
||||
if (validRecipients.length === 0) return;
|
||||
|
||||
await readStateService.bulkIncrementMentionCounts(
|
||||
validRecipients.map((recipient) => ({
|
||||
userId: recipient.id,
|
||||
channelId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
62
fluxer_api/src/channel/services/message/dmScopeUtils.ts
Normal file
62
fluxer_api/src/channel/services/message/dmScopeUtils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer 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.
|
||||
*
|
||||
* Fluxer 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 Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes} from '~/Constants';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
|
||||
export type DmSearchScope = 'all_dms' | 'open_dms';
|
||||
|
||||
export interface DmScopeOptions {
|
||||
scope: DmSearchScope;
|
||||
userId: UserID;
|
||||
userRepository: IUserRepository;
|
||||
includeChannelId?: ChannelID | null;
|
||||
}
|
||||
|
||||
export const getDmChannelIdsForScope = async ({
|
||||
scope,
|
||||
userId,
|
||||
userRepository,
|
||||
includeChannelId,
|
||||
}: DmScopeOptions): Promise<Array<string>> => {
|
||||
const summaryResults = await userRepository.listPrivateChannelSummaries(userId);
|
||||
const channelIdStrings = new Set<string>();
|
||||
|
||||
for (const summary of summaryResults) {
|
||||
const isDm =
|
||||
summary.channelType === ChannelTypes.DM || summary.channelType === ChannelTypes.GROUP_DM || summary.isGroupDm;
|
||||
|
||||
if (!isDm) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scope === 'open_dms' && !summary.open) {
|
||||
continue;
|
||||
}
|
||||
|
||||
channelIdStrings.add(summary.channelId.toString());
|
||||
}
|
||||
|
||||
if (includeChannelId) {
|
||||
channelIdStrings.add(includeChannelId.toString());
|
||||
}
|
||||
|
||||
return Array.from(channelIdStrings);
|
||||
};
|
||||
Reference in New Issue
Block a user