refactor progress
This commit is contained in:
64
packages/api/src/channel/AttachmentDTOs.tsx
Normal file
64
packages/api/src/channel/AttachmentDTOs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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 {
|
||||
ClientAttachmentReferenceRequest,
|
||||
ClientAttachmentRequest,
|
||||
} from '@fluxer/schema/src/domains/message/AttachmentSchemas';
|
||||
|
||||
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;
|
||||
duration?: number | null;
|
||||
waveform?: string | null;
|
||||
}
|
||||
|
||||
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,
|
||||
duration: ('duration' in (clientData ?? {}) ? (clientData as ClientAttachmentRequest).duration : null) ?? null,
|
||||
waveform: ('waveform' in (clientData ?? {}) ? (clientData as ClientAttachmentRequest).waveform : null) ?? null,
|
||||
title: clientData?.title ?? null,
|
||||
description: clientData?.description ?? null,
|
||||
flags: 'flags' in (clientData ?? {}) ? (clientData as ClientAttachmentRequest).flags : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export type AttachmentRequestData = AttachmentToProcess | ClientAttachmentRequest | ClientAttachmentReferenceRequest;
|
||||
25
packages/api/src/channel/ChannelController.tsx
Normal file
25
packages/api/src/channel/ChannelController.tsx
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 {registerChannelControllers} from '@fluxer/api/src/channel/controllers';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
|
||||
export function ChannelController(app: HonoApp) {
|
||||
registerChannelControllers(app);
|
||||
}
|
||||
232
packages/api/src/channel/ChannelMappers.tsx
Normal file
232
packages/api/src/channel/ChannelMappers.tsx
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 {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import {getCachedUserPartialResponses} from '@fluxer/api/src/user/UserCacheHelpers';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {
|
||||
ChannelOverwriteResponse,
|
||||
ChannelPartialResponse,
|
||||
ChannelResponse,
|
||||
} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
|
||||
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 === 1 ? 1 : 0,
|
||||
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 function mapChannelToPartialResponse(channel: Channel): ChannelPartialResponse {
|
||||
return {
|
||||
id: channel.id.toString(),
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
};
|
||||
}
|
||||
242
packages/api/src/channel/ChannelRepository.tsx
Normal file
242
packages/api/src/channel/ChannelRepository.tsx
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, EmojiID, GuildID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import {ChannelRepository as NewChannelRepository} from '@fluxer/api/src/channel/repositories/ChannelRepository';
|
||||
import type {ChannelRow} from '@fluxer/api/src/database/types/ChannelTypes';
|
||||
import type {AttachmentLookupRow, MessageRow} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {MessageReaction} from '@fluxer/api/src/models/MessageReaction';
|
||||
|
||||
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,
|
||||
lastMessageId?: MessageID,
|
||||
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>> {
|
||||
return this.repository.messages.listMessagesByAuthor(authorId, limit, lastMessageId);
|
||||
}
|
||||
|
||||
async deleteMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
channelIds?: Array<ChannelID>,
|
||||
messageIds?: Array<MessageID>,
|
||||
): Promise<void> {
|
||||
return this.repository.messages.deleteMessagesByAuthor(authorId, channelIds, messageIds);
|
||||
}
|
||||
|
||||
async backfillMessagesByAuthorIndex(authorId: UserID): Promise<void> {
|
||||
return this.repository.messages.backfillMessagesByAuthorIndex(authorId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async updateEmbeds(message: Message): Promise<void> {
|
||||
return this.repository.messages.updateEmbeds(message);
|
||||
}
|
||||
}
|
||||
33
packages/api/src/channel/EmbedTypes.tsx
Normal file
33
packages/api/src/channel/EmbedTypes.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {RichEmbedMediaRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
119
packages/api/src/channel/IChannelRepository.tsx
Normal file
119
packages/api/src/channel/IChannelRepository.tsx
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 {AttachmentID, ChannelID, EmojiID, GuildID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {ChannelRow} from '@fluxer/api/src/database/types/ChannelTypes';
|
||||
import type {AttachmentLookupRow, MessageRow} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {MessageReaction} from '@fluxer/api/src/models/MessageReaction';
|
||||
|
||||
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,
|
||||
lastMessageId?: MessageID,
|
||||
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>>;
|
||||
abstract deleteMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
channelIds?: Array<ChannelID>,
|
||||
messageIds?: Array<MessageID>,
|
||||
): Promise<void>;
|
||||
abstract backfillMessagesByAuthorIndex(authorId: UserID): Promise<void>;
|
||||
abstract anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void>;
|
||||
abstract deleteAllChannelMessages(channelId: ChannelID): Promise<void>;
|
||||
abstract updateEmbeds(message: Message): Promise<void>;
|
||||
}
|
||||
455
packages/api/src/channel/MessageMappers.tsx
Normal file
455
packages/api/src/channel/MessageMappers.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {makeAttachmentCdnUrl} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Attachment} from '@fluxer/api/src/models/Attachment';
|
||||
import type {Embed} from '@fluxer/api/src/models/Embed';
|
||||
import type {EmbedField} from '@fluxer/api/src/models/EmbedField';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {MessageReaction} from '@fluxer/api/src/models/MessageReaction';
|
||||
import type {MessageRef} from '@fluxer/api/src/models/MessageRef';
|
||||
import type {StickerItem} from '@fluxer/api/src/models/StickerItem';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {AttachmentDecayRow} from '@fluxer/api/src/types/AttachmentDecayTypes';
|
||||
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '@fluxer/api/src/user/UserCacheHelpers';
|
||||
import {MessageFlags, MessageReferenceTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {getContentTypeFromFilename} from '@fluxer/mime_utils/src/ContentTypeUtils';
|
||||
import type {
|
||||
EmbedFieldResponse,
|
||||
MessageEmbedChildResponse,
|
||||
MessageEmbedResponse,
|
||||
} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
import type {
|
||||
MessageAttachmentResponse,
|
||||
MessageReactionResponse,
|
||||
MessageReferenceResponse,
|
||||
MessageResponse,
|
||||
MessageStickerResponse,
|
||||
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
|
||||
interface MapMessageToResponseParams {
|
||||
message: Message;
|
||||
currentUserId?: UserID;
|
||||
nonce?: string;
|
||||
tts?: boolean;
|
||||
withMessageReference?: boolean;
|
||||
messageHistoryCutoff?: string | null;
|
||||
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;
|
||||
}
|
||||
|
||||
function mapEmbedFieldToResponse({field}: MapEmbedFieldToResponseParams): EmbedFieldResponse {
|
||||
return {
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
inline: field.inline ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMessageEmbedChildToResponse({
|
||||
embed,
|
||||
mediaService,
|
||||
}: MapMessageEmbedToResponseParams): MessageEmbedChildResponse {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMessageEmbedToResponse({embed, mediaService}: MapMessageEmbedToResponseParams): MessageEmbedResponse {
|
||||
return {
|
||||
...mapMessageEmbedChildToResponse({embed, mediaService}),
|
||||
children:
|
||||
embed.children && embed.children.length > 0
|
||||
? embed.children.map((child) => mapMessageEmbedChildToResponse({embed: child, mediaService}))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function 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;
|
||||
const contentType = getContentTypeFromFilename(attachment.filename);
|
||||
return {
|
||||
id: attachment.id.toString(),
|
||||
filename: attachment.filename,
|
||||
title: attachment.title ?? null,
|
||||
description: attachment.description ?? null,
|
||||
content_type: contentType,
|
||||
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 ?? undefined,
|
||||
waveform: attachment.waveform ?? undefined,
|
||||
expires_at: decay?.expires_at?.toISOString?.() ?? null,
|
||||
expired: expired || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapStickerItemToResponse(sticker: StickerItem): MessageStickerResponse {
|
||||
return {
|
||||
id: sticker.id.toString(),
|
||||
name: sticker.name,
|
||||
animated: sticker.animated,
|
||||
};
|
||||
}
|
||||
|
||||
function 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,
|
||||
}));
|
||||
}
|
||||
|
||||
function mapMessageReference({reference}: MapMessageReferenceParams): MessageReferenceResponse {
|
||||
return {
|
||||
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,
|
||||
messageHistoryCutoff,
|
||||
getAuthor,
|
||||
getReactions,
|
||||
getReferencedMessage,
|
||||
setHasReaction,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
mediaService,
|
||||
attachmentDecayMap,
|
||||
}: MapMessageToResponseParams): Promise<MessageResponse> {
|
||||
let author: UserPartialResponse;
|
||||
|
||||
if (message.authorId != null) {
|
||||
author = await getCachedUserPartialResponse({
|
||||
userId: message.authorId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
} else if (message.webhookId && message.webhookName) {
|
||||
author = {
|
||||
id: message.webhookId.toString(),
|
||||
username: message.webhookName,
|
||||
discriminator: '0000',
|
||||
global_name: null,
|
||||
avatar: message.webhookAvatarHash,
|
||||
avatar_color: null,
|
||||
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: snowflakeToDate(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 && message.reference.type === MessageReferenceTypes.DEFAULT) {
|
||||
const referencedMessageId = message.reference.messageId;
|
||||
const isReferencedMessageAccessible =
|
||||
!messageHistoryCutoff ||
|
||||
snowflakeToDate(referencedMessageId).getTime() >= new Date(messageHistoryCutoff).getTime();
|
||||
|
||||
if (isReferencedMessageAccessible) {
|
||||
const referencedMessage = await getReferencedMessage(message.reference.channelId, message.reference.messageId);
|
||||
if (referencedMessage) {
|
||||
response.referenced_message = await mapMessageToResponse({
|
||||
message: referencedMessage,
|
||||
withMessageReference: false,
|
||||
messageHistoryCutoff,
|
||||
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.flags & MessageFlags.SUPPRESS_EMBEDS) === 0
|
||||
? (snapshot.embeds?.map((embed) => mapMessageEmbedToResponse({embed, mediaService})) ?? undefined)
|
||||
: 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;
|
||||
}
|
||||
34
packages/api/src/channel/MessageTypes.tsx
Normal file
34
packages/api/src/channel/MessageTypes.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 {AttachmentRequestData} from '@fluxer/api/src/channel/AttachmentDTOs';
|
||||
import type {
|
||||
MessageRequestSchemaType,
|
||||
MessageUpdateRequestSchemaType,
|
||||
} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
|
||||
interface BaseMessageRequestType extends Omit<MessageRequestSchemaType, 'attachments'> {}
|
||||
export interface MessageRequest extends BaseMessageRequestType {
|
||||
attachments?: Array<AttachmentRequestData>;
|
||||
}
|
||||
|
||||
interface BaseMessageUpdateRequestType extends Omit<MessageUpdateRequestSchemaType, 'attachments'> {}
|
||||
export interface MessageUpdateRequest extends BaseMessageUpdateRequestType {
|
||||
attachments?: Array<AttachmentRequestData>;
|
||||
}
|
||||
181
packages/api/src/channel/controllers/CallController.tsx
Normal file
181
packages/api/src/channel/controllers/CallController.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* 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 {createChannelID, createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {CallRingBodySchema, CallUpdateBodySchema} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import {CallEligibilityResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
export function CallController(app: HonoApp) {
|
||||
app.get(
|
||||
'/channels/:channel_id/call',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CALL_GET),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_call_eligibility',
|
||||
summary: 'Get call eligibility status',
|
||||
description:
|
||||
'Checks whether a call can be initiated in the channel and if there is an active call. Returns ringable status and silent mode flag.',
|
||||
responseSchema: CallEligibilityResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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: !!silent});
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/channels/:channel_id/call',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CALL_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ChannelIdParam),
|
||||
Validator('json', CallUpdateBodySchema),
|
||||
OpenAPI({
|
||||
operationId: 'update_call_region',
|
||||
summary: 'Update call region',
|
||||
description: 'Changes the voice server region for an active call to optimise latency and connection quality.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdParam),
|
||||
Validator('json', CallRingBodySchema),
|
||||
OpenAPI({
|
||||
operationId: 'ring_call_recipients',
|
||||
summary: 'Ring call recipients',
|
||||
description:
|
||||
'Sends ringing notifications to specified users in a call. If no recipients are specified, rings all channel members.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdParam),
|
||||
Validator('json', CallRingBodySchema),
|
||||
OpenAPI({
|
||||
operationId: 'stop_ringing_call_recipients',
|
||||
summary: 'Stop ringing call recipients',
|
||||
description:
|
||||
'Stops ringing notifications for specified users in a call. Allows callers to stop notifying users who have declined or not responded.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/call/end',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CALL_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'end_call',
|
||||
summary: 'End call session',
|
||||
description: 'Terminates an active voice call in the channel. Records the call end state for all participants.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
|
||||
ctx.get('channelService').recordCallEnded({channelId});
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
305
packages/api/src/channel/controllers/ChannelController.tsx
Normal file
305
packages/api/src/channel/controllers/ChannelController.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* 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 {createChannelID, createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {
|
||||
ChannelUpdateRequest,
|
||||
DeleteChannelQuery,
|
||||
PermissionOverwriteCreateRequest,
|
||||
} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import {ChannelResponse, RtcRegionResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {
|
||||
ChannelIdOverwriteIdParam,
|
||||
ChannelIdParam,
|
||||
ChannelIdUserIdParam,
|
||||
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import type {Context} from 'hono';
|
||||
import {z} from 'zod';
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function ChannelController(app: HonoApp) {
|
||||
app.get(
|
||||
'/channels/:channel_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_GET),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_channel',
|
||||
summary: 'Fetch a channel',
|
||||
description:
|
||||
'Retrieves the channel object including metadata, member list, and settings. Requires the user to be a member of the channel with view permissions.',
|
||||
responseSchema: ChannelResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const channelRequestService = ctx.get('channelRequestService');
|
||||
return ctx.json(
|
||||
await channelRequestService.getChannelResponse({
|
||||
userId,
|
||||
channelId,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/channels/:channel_id/rtc-regions',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_GET),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_rtc_regions',
|
||||
summary: 'List RTC regions',
|
||||
description:
|
||||
'Returns available voice and video calling regions for the channel, used to optimise connection quality. Requires membership with call permissions.',
|
||||
responseSchema: z.array(RtcRegionResponse),
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const channelRequestService = ctx.get('channelRequestService');
|
||||
return ctx.json(await channelRequestService.listRtcRegions({userId, channelId}));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/channels/:channel_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdParam, {
|
||||
post: async (result, ctx: Context<HonoEnv>) => {
|
||||
if (!result.success) {
|
||||
return undefined;
|
||||
}
|
||||
const channelId = createChannelID(result.data.channel_id);
|
||||
const existing = await ctx.get('channelService').getChannel({
|
||||
userId: ctx.get('user').id,
|
||||
channelId,
|
||||
});
|
||||
ctx.set('channelUpdateType', existing.type);
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
Validator('json', ChannelUpdateRequest, {
|
||||
pre: async (raw: unknown, ctx: Context<HonoEnv>) => {
|
||||
const channelType = ctx.get('channelUpdateType');
|
||||
if (channelType === undefined) {
|
||||
throw new Error('Missing channel type for update validation');
|
||||
}
|
||||
const body = isPlainObject(raw) ? raw : {};
|
||||
return {...body, type: channelType};
|
||||
},
|
||||
}),
|
||||
OpenAPI({
|
||||
operationId: 'update_channel',
|
||||
summary: 'Update channel settings',
|
||||
description:
|
||||
'Modifies channel properties such as name, description, topic, nsfw flag, and slowmode. Requires management permissions in the channel.',
|
||||
responseSchema: ChannelResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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 channelRequestService = ctx.get('channelRequestService');
|
||||
return ctx.json(
|
||||
await channelRequestService.updateChannel({
|
||||
userId,
|
||||
channelId,
|
||||
data,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdParam),
|
||||
Validator('query', DeleteChannelQuery),
|
||||
OpenAPI({
|
||||
operationId: 'delete_channel',
|
||||
summary: 'Delete a channel',
|
||||
description:
|
||||
'Permanently removes a channel and all its content. Only server administrators or the channel owner can delete channels.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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 channelRequestService = ctx.get('channelRequestService');
|
||||
await channelRequestService.deleteChannel({userId, channelId, requestCache, silent});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/channels/:channel_id/recipients/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdUserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'add_group_dm_recipient',
|
||||
summary: 'Add recipient to group DM',
|
||||
description:
|
||||
'Adds a user to a group direct message channel. The requesting user must be a member of the group DM.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdUserIdParam),
|
||||
Validator('query', DeleteChannelQuery),
|
||||
OpenAPI({
|
||||
operationId: 'remove_group_dm_recipient',
|
||||
summary: 'Remove recipient from group DM',
|
||||
description:
|
||||
'Removes a user from a group direct message channel. The requesting user must be a member with appropriate permissions.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdOverwriteIdParam),
|
||||
Validator('json', PermissionOverwriteCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_channel_permission_overwrite',
|
||||
summary: 'Set permission overwrite for channel',
|
||||
description:
|
||||
'Creates or updates permission overrides for a role or user in the channel. Allows fine-grained control over who can view, send messages, or manage the channel.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdOverwriteIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_channel_permission_overwrite',
|
||||
summary: 'Delete permission overwrite',
|
||||
description:
|
||||
'Removes a permission override from a role or user in the channel, restoring default permissions. Requires channel management rights.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
386
packages/api/src/channel/controllers/MessageController.tsx
Normal file
386
packages/api/src/channel/controllers/MessageController.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
/*
|
||||
* 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, createChannelID, createMessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import {parseMultipartMessageData} from '@fluxer/api/src/channel/services/message/MessageRequestParser';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {parseJsonPreservingLargeIntegers} from '@fluxer/api/src/utils/LosslessJsonParser';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {
|
||||
ChannelIdMessageIdAttachmentIdParam,
|
||||
ChannelIdMessageIdParam,
|
||||
ChannelIdParam,
|
||||
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
BulkDeleteMessagesRequest,
|
||||
MessageAckRequest,
|
||||
MessageRequestSchema,
|
||||
MessagesQuery,
|
||||
MessageUpdateRequestSchema,
|
||||
} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import {MessageResponseSchema} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
export function MessageController(app: HonoApp) {
|
||||
app.get(
|
||||
'/channels/:channel_id/messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGES_GET),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdParam),
|
||||
Validator('query', MessagesQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_messages',
|
||||
summary: 'List messages in a channel',
|
||||
responseSchema: z.array(MessageResponseSchema),
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Retrieves a paginated list of messages from a channel. User must have permission to view the channel. Supports pagination via limit, before, after, and around parameters. Returns messages in reverse chronological order (newest first).',
|
||||
}),
|
||||
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 messageRequestService = ctx.get('messageRequestService');
|
||||
return ctx.json(
|
||||
await messageRequestService.listMessages({
|
||||
userId,
|
||||
channelId,
|
||||
query: {
|
||||
limit,
|
||||
before: before ? createMessageID(before) : undefined,
|
||||
after: after ? createMessageID(after) : undefined,
|
||||
around: around ? createMessageID(around) : undefined,
|
||||
},
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/channels/:channel_id/messages/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_GET),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdMessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_message',
|
||||
summary: 'Fetch a message',
|
||||
responseSchema: MessageResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Retrieves a specific message by ID. User must have permission to view the channel and the message must exist. Returns full message details including content, author, reactions, and attachments.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const messageRequestService = ctx.get('messageRequestService');
|
||||
return ctx.json(
|
||||
await messageRequestService.getMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_CREATE),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'send_message',
|
||||
summary: 'Send a message',
|
||||
responseSchema: MessageResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Sends a new message to a channel. Requires permission to send messages in the target channel. Supports text content, embeds, attachments (multipart), and mentions. Returns the created message object with full details.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const messageRequestService = ctx.get('messageRequestService');
|
||||
|
||||
const contentType = ctx.req.header('content-type');
|
||||
const validatedData = contentType?.includes('multipart/form-data')
|
||||
? ((await parseMultipartMessageData(ctx, user, channelId, MessageRequestSchema)) as MessageRequest)
|
||||
: await (async () => {
|
||||
let data: unknown;
|
||||
try {
|
||||
const raw = await ctx.req.text();
|
||||
data = raw.trim().length === 0 ? {} : parseJsonPreservingLargeIntegers(raw);
|
||||
} catch {
|
||||
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
|
||||
}
|
||||
const validationResult = MessageRequestSchema.safeParse(data);
|
||||
if (!validationResult.success) {
|
||||
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
|
||||
}
|
||||
return validationResult.data;
|
||||
})();
|
||||
return ctx.json(
|
||||
await messageRequestService.sendMessage({
|
||||
user,
|
||||
channelId,
|
||||
data: validatedData as MessageRequest,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/channels/:channel_id/messages/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdMessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'edit_message',
|
||||
summary: 'Edit a message',
|
||||
responseSchema: MessageResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Updates an existing message. Only the message author can edit messages (or admins with proper permissions). Supports updating content, embeds, and attachments. Returns the updated message object. Maintains original message ID and timestamps.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id;
|
||||
const channelId = createChannelID(channel_id);
|
||||
const messageId = createMessageID(message_id);
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const messageRequestService = ctx.get('messageRequestService');
|
||||
|
||||
const contentType = ctx.req.header('content-type');
|
||||
const validatedData = contentType?.includes('multipart/form-data')
|
||||
? ((await parseMultipartMessageData(ctx, user, channelId, MessageUpdateRequestSchema)) as MessageUpdateRequest)
|
||||
: await (async () => {
|
||||
let data: unknown;
|
||||
try {
|
||||
const raw = await ctx.req.text();
|
||||
data = raw.trim().length === 0 ? {} : parseJsonPreservingLargeIntegers(raw);
|
||||
} catch {
|
||||
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
|
||||
}
|
||||
const validationResult = MessageUpdateRequestSchema.safeParse(data);
|
||||
if (!validationResult.success) {
|
||||
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
|
||||
}
|
||||
return validationResult.data;
|
||||
})();
|
||||
return ctx.json(
|
||||
await messageRequestService.editMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
data: validatedData as MessageUpdateRequest,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/channels/:channel_id/messages/ack',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_READ_STATE_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'clear_channel_read_state',
|
||||
summary: 'Clear channel read state',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Clears all read state and acknowledgement records for a channel, marking all messages as unread. Only available for regular user accounts. Returns 204 No Content on success.',
|
||||
}),
|
||||
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', ChannelIdMessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_message',
|
||||
summary: 'Delete a message',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Deletes a message permanently. Only the message author can delete messages (or admins/moderators with proper permissions). Cannot be undone. Returns 204 No Content on success.',
|
||||
}),
|
||||
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', ChannelIdMessageIdAttachmentIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_message_attachment',
|
||||
summary: 'Delete a message attachment',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Removes a specific attachment from a message while keeping the message intact. Only the message author can remove attachments (or admins/moderators). Returns 204 No Content on success.',
|
||||
}),
|
||||
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', ChannelIdParam),
|
||||
Validator('json', BulkDeleteMessagesRequest),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_delete_messages',
|
||||
summary: 'Bulk delete messages',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Deletes multiple messages at once. Requires moderation or admin permissions. Commonly used for message cleanup. Messages from different authors can be deleted together. Returns 204 No Content on success.',
|
||||
}),
|
||||
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', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'indicate_typing',
|
||||
summary: 'Indicate typing activity',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Notifies other users in the channel that you are actively typing. Typing indicators typically expire after a short period (usually 10 seconds). Returns 204 No Content. Commonly called repeatedly while the user is composing a message.',
|
||||
}),
|
||||
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', ChannelIdMessageIdParam),
|
||||
Validator('json', MessageAckRequest),
|
||||
OpenAPI({
|
||||
operationId: 'acknowledge_message',
|
||||
summary: 'Acknowledge a message',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Marks a message as read and records acknowledgement state. Only available for regular user accounts. Updates mention count if provided. Returns 204 No Content on success.',
|
||||
}),
|
||||
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,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 {createChannelID, createMessageID, createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {isPersonalNotesChannel} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import {LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {UnclaimedAccountCannotAddReactionsError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotAddReactionsError';
|
||||
import {
|
||||
ChannelIdMessageIdEmojiParam,
|
||||
ChannelIdMessageIdEmojiTargetIdParam,
|
||||
ChannelIdMessageIdParam,
|
||||
ChannelIdParam,
|
||||
SessionIdQuerySchema,
|
||||
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
ChannelPinsQuerySchema,
|
||||
ReactionUsersQuerySchema,
|
||||
} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import {
|
||||
ChannelPinsResponse,
|
||||
ReactionUsersListResponse,
|
||||
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
|
||||
export function MessageInteractionController(app: HonoApp) {
|
||||
app.get(
|
||||
'/channels/:channel_id/messages/pins',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdParam),
|
||||
Validator('query', ChannelPinsQuerySchema),
|
||||
OpenAPI({
|
||||
operationId: 'list_pinned_messages',
|
||||
summary: 'List pinned messages',
|
||||
description:
|
||||
'Retrieves a paginated list of messages pinned in a channel. User must have permission to view the channel. Supports pagination via limit and before parameters. Returns pinned messages with their pin timestamps.',
|
||||
responseSchema: ChannelPinsResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'acknowledge_pins',
|
||||
summary: 'Acknowledge new pin notifications',
|
||||
description:
|
||||
'Marks all new pin notifications in a channel as acknowledged. Clears the notification badge for pinned messages. Returns 204 No Content on success.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdMessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'pin_message',
|
||||
summary: 'Pin a message',
|
||||
description:
|
||||
'Pins a message to the channel. Requires permission to manage pins (typically moderator or higher). Pinned messages are highlighted and searchable. Returns 204 No Content on success.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdMessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'unpin_message',
|
||||
summary: 'Unpin a message',
|
||||
description:
|
||||
'Unpins a message from the channel. Requires permission to manage pins. The message remains in the channel but is no longer highlighted. Returns 204 No Content on success.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdMessageIdEmojiParam),
|
||||
Validator('query', ReactionUsersQuerySchema),
|
||||
OpenAPI({
|
||||
operationId: 'list_reaction_users',
|
||||
summary: 'List users who reacted with emoji',
|
||||
description:
|
||||
'Retrieves a paginated list of users who reacted to a message with a specific emoji. Supports pagination via limit and after parameters. Returns user objects for each reaction.',
|
||||
responseSchema: ReactionUsersListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdMessageIdEmojiParam),
|
||||
Validator('query', SessionIdQuerySchema),
|
||||
OpenAPI({
|
||||
operationId: 'add_reaction',
|
||||
summary: 'Add reaction to message',
|
||||
description:
|
||||
'Adds an emoji reaction to a message. Each user can react once with each emoji. Cannot be used from unclaimed accounts outside personal notes. Returns 204 No Content on success.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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.isUnclaimedAccount() && !isPersonalNotesChannel({userId: user.id, channelId})) {
|
||||
throw new UnclaimedAccountCannotAddReactionsError();
|
||||
}
|
||||
|
||||
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', ChannelIdMessageIdEmojiParam),
|
||||
Validator('query', SessionIdQuerySchema),
|
||||
OpenAPI({
|
||||
operationId: 'remove_own_reaction',
|
||||
summary: 'Remove own reaction from message',
|
||||
description:
|
||||
"Removes your own emoji reaction from a message. Returns 204 No Content on success. Has no effect if you haven't reacted with that emoji.",
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdMessageIdEmojiTargetIdParam),
|
||||
Validator('query', SessionIdQuerySchema),
|
||||
OpenAPI({
|
||||
operationId: 'remove_reaction',
|
||||
summary: 'Remove reaction from message',
|
||||
description:
|
||||
"Removes a specific user's emoji reaction from a message. Requires moderator or higher permissions to remove reactions from other users. Returns 204 No Content on success.",
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdMessageIdEmojiParam),
|
||||
OpenAPI({
|
||||
operationId: 'remove_all_reactions_for_emoji',
|
||||
summary: 'Remove all reactions with emoji',
|
||||
description:
|
||||
"Removes all emoji reactions of a specific type from a message. All users' reactions with that emoji are deleted. Requires moderator or higher permissions. Returns 204 No Content on success.",
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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', ChannelIdMessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'remove_all_reactions',
|
||||
summary: 'Remove all reactions from message',
|
||||
description:
|
||||
'Removes all emoji reactions from a message, regardless of emoji type or user. All reactions are permanently deleted. Requires moderator or higher permissions. Returns 204 No Content on success.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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,70 @@
|
||||
/*
|
||||
* 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 {createChannelID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {parseScheduledMessageInput} from '@fluxer/api/src/channel/controllers/ScheduledMessageParsing';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ScheduledMessageResponseSchema} from '@fluxer/schema/src/domains/message/ScheduledMessageSchemas';
|
||||
|
||||
export function ScheduledMessageController(app: HonoApp) {
|
||||
app.post(
|
||||
'/channels/:channel_id/messages/schedule',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'schedule_message',
|
||||
summary: 'Schedule a message to send later',
|
||||
description:
|
||||
'Schedules a message to be sent at a specified time. Only available for regular user accounts. Requires permission to send messages in the target channel. Message is sent automatically at the scheduled time. Returns the scheduled message object with delivery time.',
|
||||
responseSchema: ScheduledMessageResponseSchema,
|
||||
statusCode: 201,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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,
|
||||
user,
|
||||
channelId,
|
||||
});
|
||||
|
||||
const scheduledMessage = await scheduledMessageService.createScheduledMessage({
|
||||
user,
|
||||
channelId,
|
||||
data: message,
|
||||
scheduledLocalAt,
|
||||
timezone,
|
||||
});
|
||||
|
||||
return ctx.json(scheduledMessage.toResponse(), 201);
|
||||
},
|
||||
);
|
||||
}
|
||||
102
packages/api/src/channel/controllers/ScheduledMessageParsing.tsx
Normal file
102
packages/api/src/channel/controllers/ScheduledMessageParsing.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {MessageRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import {parseMultipartMessageData} from '@fluxer/api/src/channel/services/message/MessageRequestParser';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {HonoEnv} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {parseJsonPreservingLargeIntegers} from '@fluxer/api/src/utils/LosslessJsonParser';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MessageRequestSchema} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import {createStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import type {Context} from 'hono';
|
||||
import {ms} from 'itty-time';
|
||||
import type {z} from 'zod';
|
||||
|
||||
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,
|
||||
user,
|
||||
channelId,
|
||||
}: {
|
||||
ctx: Context<HonoEnv>;
|
||||
user: User;
|
||||
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, user, channelId, MessageRequestSchema, {
|
||||
uploadExpiresAt: new Date(Date.now() + ms('32 days')),
|
||||
onPayloadParsed(payload) {
|
||||
parsedPayload = payload;
|
||||
},
|
||||
})) as MessageRequest;
|
||||
|
||||
if (!parsedPayload) {
|
||||
throw InputValidationError.fromCode('scheduled_message', ValidationErrorCodes.FAILED_TO_PARSE_MULTIPART_PAYLOAD);
|
||||
}
|
||||
|
||||
const validation = ScheduledMessageSchema.safeParse(parsedPayload);
|
||||
if (!validation.success) {
|
||||
throw InputValidationError.fromCode('scheduled_message', ValidationErrorCodes.INVALID_SCHEDULED_MESSAGE_PAYLOAD);
|
||||
}
|
||||
|
||||
const {scheduled_local_at, timezone} = extractScheduleFields(validation.data);
|
||||
return {message, scheduledLocalAt: scheduled_local_at, timezone};
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
const raw = await ctx.req.text();
|
||||
body = raw.trim().length === 0 ? {} : parseJsonPreservingLargeIntegers(raw);
|
||||
} catch {
|
||||
throw InputValidationError.fromCode('scheduled_message', ValidationErrorCodes.INVALID_SCHEDULED_MESSAGE_PAYLOAD);
|
||||
}
|
||||
const validation = ScheduledMessageSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
throw InputValidationError.fromCode('scheduled_message', ValidationErrorCodes.INVALID_SCHEDULED_MESSAGE_PAYLOAD);
|
||||
}
|
||||
|
||||
const {scheduled_local_at, timezone, message} = extractScheduleFields(validation.data);
|
||||
return {message, scheduledLocalAt: scheduled_local_at, timezone};
|
||||
}
|
||||
123
packages/api/src/channel/controllers/StreamController.tsx
Normal file
123
packages/api/src/channel/controllers/StreamController.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 {createChannelID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {
|
||||
StreamPreviewUploadBodySchema,
|
||||
StreamUpdateBodySchema,
|
||||
} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import {StreamKeyParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
export function StreamController(app: HonoApp) {
|
||||
app.patch(
|
||||
'/streams/:stream_key/stream',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_STREAM_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', StreamUpdateBodySchema),
|
||||
Validator('param', StreamKeyParam),
|
||||
OpenAPI({
|
||||
operationId: 'update_stream_region',
|
||||
summary: 'Update stream region',
|
||||
description:
|
||||
'Changes the media server region for an active stream. Used to optimise bandwidth and latency for streaming.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {region} = ctx.req.valid('json');
|
||||
const streamKey = ctx.req.valid('param').stream_key;
|
||||
await ctx.get('streamService').updateStreamRegion({streamKey, region});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/streams/:stream_key/preview',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_STREAM_PREVIEW_GET),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', StreamKeyParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_stream_preview',
|
||||
summary: 'Get stream preview image',
|
||||
description:
|
||||
'Retrieves the current preview thumbnail for a stream. Returns the image with no-store cache headers to ensure freshness.',
|
||||
responseSchema: null,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const streamKey = ctx.req.valid('param').stream_key;
|
||||
const preview = await ctx.get('streamService').getPreview({streamKey, userId: user.id});
|
||||
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', StreamPreviewUploadBodySchema),
|
||||
Validator('param', StreamKeyParam),
|
||||
OpenAPI({
|
||||
operationId: 'upload_stream_preview',
|
||||
summary: 'Upload stream preview image',
|
||||
description:
|
||||
'Uploads a custom thumbnail image for the stream. The image is scanned for content policy violations and stored securely.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
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;
|
||||
await ctx.get('streamService').uploadPreview({
|
||||
streamKey,
|
||||
channelId: createChannelID(channel_id),
|
||||
userId: user.id,
|
||||
thumbnail,
|
||||
contentType: content_type,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
35
packages/api/src/channel/controllers/index.tsx
Normal file
35
packages/api/src/channel/controllers/index.tsx
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 {CallController} from '@fluxer/api/src/channel/controllers/CallController';
|
||||
import {ChannelController} from '@fluxer/api/src/channel/controllers/ChannelController';
|
||||
import {MessageController} from '@fluxer/api/src/channel/controllers/MessageController';
|
||||
import {MessageInteractionController} from '@fluxer/api/src/channel/controllers/MessageInteractionController';
|
||||
import {ScheduledMessageController} from '@fluxer/api/src/channel/controllers/ScheduledMessageController';
|
||||
import {StreamController} from '@fluxer/api/src/channel/controllers/StreamController';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
|
||||
export function registerChannelControllers(app: HonoApp) {
|
||||
ChannelController(app);
|
||||
MessageInteractionController(app);
|
||||
MessageController(app);
|
||||
ScheduledMessageController(app);
|
||||
CallController(app);
|
||||
StreamController(app);
|
||||
}
|
||||
162
packages/api/src/channel/repositories/ChannelDataRepository.tsx
Normal file
162
packages/api/src/channel/repositories/ChannelDataRepository.tsx
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 '@fluxer/api/src/BrandedTypes';
|
||||
import {IChannelDataRepository} from '@fluxer/api/src/channel/repositories/IChannelDataRepository';
|
||||
import {
|
||||
BatchBuilder,
|
||||
buildPatchFromData,
|
||||
Db,
|
||||
executeConditional,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchManyInChunks,
|
||||
fetchOne,
|
||||
} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {ChannelRow} from '@fluxer/api/src/database/types/ChannelTypes';
|
||||
import {CHANNEL_COLUMNS} from '@fluxer/api/src/database/types/ChannelTypes';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import {Channels, ChannelsByGuild} from '@fluxer/api/src/Tables';
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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
packages/api/src/channel/repositories/ChannelRepository.tsx
Normal file
36
packages/api/src/channel/repositories/ChannelRepository.tsx
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 '@fluxer/api/src/channel/repositories/ChannelDataRepository';
|
||||
import {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import {MessageInteractionRepository} from '@fluxer/api/src/channel/repositories/MessageInteractionRepository';
|
||||
import {MessageRepository} from '@fluxer/api/src/channel/repositories/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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {ChannelRow} from '@fluxer/api/src/database/types/ChannelTypes';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
|
||||
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 '@fluxer/api/src/channel/repositories/IChannelDataRepository';
|
||||
import type {IMessageInteractionRepository} from '@fluxer/api/src/channel/repositories/IMessageInteractionRepository';
|
||||
import type {IMessageRepository} from '@fluxer/api/src/channel/repositories/IMessageRepository';
|
||||
|
||||
export abstract class IChannelRepositoryAggregate {
|
||||
abstract readonly channelData: IChannelDataRepository;
|
||||
abstract readonly messages: IMessageRepository;
|
||||
abstract readonly messageInteractions: IMessageInteractionRepository;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {MessageReaction} from '@fluxer/api/src/models/MessageReaction';
|
||||
|
||||
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>;
|
||||
}
|
||||
67
packages/api/src/channel/repositories/IMessageRepository.tsx
Normal file
67
packages/api/src/channel/repositories/IMessageRepository.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {AttachmentLookupRow, MessageRow} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
|
||||
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 updateEmbeds(message: Message): Promise<void>;
|
||||
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,
|
||||
lastMessageId?: MessageID,
|
||||
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>>;
|
||||
abstract deleteMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
channelIds?: Array<ChannelID>,
|
||||
messageIds?: Array<MessageID>,
|
||||
): Promise<void>;
|
||||
abstract backfillMessagesByAuthorIndex(authorId: UserID): 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,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 type {ChannelID, EmojiID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createEmojiID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {IMessageInteractionRepository} from '@fluxer/api/src/channel/repositories/IMessageInteractionRepository';
|
||||
import type {MessageRepository} from '@fluxer/api/src/channel/repositories/MessageRepository';
|
||||
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {ChannelPinRow, MessageReactionRow} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {MessageReaction} from '@fluxer/api/src/models/MessageReaction';
|
||||
import {ChannelPins, MessageReactions, Messages} from '@fluxer/api/src/Tables';
|
||||
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
|
||||
|
||||
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(limit, true), {
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
emoji_id: normalizedEmojiId,
|
||||
emoji_name: emojiName,
|
||||
after_user_id: after!,
|
||||
})
|
||||
: await fetchMany<MessageReactionRow>(createFetchReactionUsersByEmojiQuery(limit, 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);
|
||||
}
|
||||
}
|
||||
128
packages/api/src/channel/repositories/MessageRepository.tsx
Normal file
128
packages/api/src/channel/repositories/MessageRepository.tsx
Normal file
@@ -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 {AttachmentID, ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {ChannelDataRepository} from '@fluxer/api/src/channel/repositories/ChannelDataRepository';
|
||||
import {IMessageRepository, type ListMessagesOptions} from '@fluxer/api/src/channel/repositories/IMessageRepository';
|
||||
import {MessageAttachmentRepository} from '@fluxer/api/src/channel/repositories/message/MessageAttachmentRepository';
|
||||
import {MessageAuthorRepository} from '@fluxer/api/src/channel/repositories/message/MessageAuthorRepository';
|
||||
import {MessageDataRepository} from '@fluxer/api/src/channel/repositories/message/MessageDataRepository';
|
||||
import {MessageDeletionRepository} from '@fluxer/api/src/channel/repositories/message/MessageDeletionRepository';
|
||||
import type {AttachmentLookupRow, MessageRow} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
|
||||
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,
|
||||
lastMessageId?: MessageID,
|
||||
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>> {
|
||||
return this.authorRepo.listMessagesByAuthor(authorId, limit, lastMessageId);
|
||||
}
|
||||
|
||||
async deleteMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
channelIds?: Array<ChannelID>,
|
||||
messageIds?: Array<MessageID>,
|
||||
): Promise<void> {
|
||||
return this.authorRepo.deleteMessagesByAuthor(authorId, channelIds, messageIds);
|
||||
}
|
||||
|
||||
async backfillMessagesByAuthorIndex(authorId: UserID): Promise<void> {
|
||||
return this.authorRepo.backfillMessagesByAuthorIndex(authorId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async updateEmbeds(message: Message): Promise<void> {
|
||||
return this.dataRepo.updateEmbeds(message);
|
||||
}
|
||||
}
|
||||
@@ -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 '@fluxer/api/src/BrandedTypes';
|
||||
import {fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {AttachmentLookupRow} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import {AttachmentLookup} from '@fluxer/api/src/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,179 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {MessageDataRepository} from '@fluxer/api/src/channel/repositories/message/MessageDataRepository';
|
||||
import type {MessageDeletionRepository} from '@fluxer/api/src/channel/repositories/message/MessageDeletionRepository';
|
||||
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import {Messages, MessagesByAuthor, MessagesByAuthorV2} from '@fluxer/api/src/Tables';
|
||||
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
|
||||
|
||||
const SELECT_FROM_OLD_TABLE = MessagesByAuthor.select({
|
||||
where: [MessagesByAuthor.where.eq('author_id')],
|
||||
columns: ['channel_id', 'message_id'],
|
||||
});
|
||||
|
||||
const SELECT_MESSAGE_BY_AUTHOR = MessagesByAuthorV2.select({
|
||||
where: [MessagesByAuthorV2.where.eq('author_id'), MessagesByAuthorV2.where.eq('message_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
function listMessagesByAuthorQuery(limit: number, usePagination: boolean) {
|
||||
return MessagesByAuthorV2.select({
|
||||
columns: ['channel_id', 'message_id'],
|
||||
where: usePagination
|
||||
? [MessagesByAuthorV2.where.eq('author_id'), MessagesByAuthorV2.where.lt('message_id', 'last_message_id')]
|
||||
: [MessagesByAuthorV2.where.eq('author_id')],
|
||||
orderBy: {col: 'message_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export class MessageAuthorRepository {
|
||||
constructor(
|
||||
private messageDataRepo: MessageDataRepository,
|
||||
private messageDeletionRepo: MessageDeletionRepository,
|
||||
) {}
|
||||
|
||||
async listMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
limit: number = 1000,
|
||||
lastMessageId?: MessageID,
|
||||
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>> {
|
||||
const usePagination = Boolean(lastMessageId);
|
||||
|
||||
const q = listMessagesByAuthorQuery(limit, usePagination);
|
||||
|
||||
const results = await fetchMany<{channel_id: bigint; message_id: bigint}>(
|
||||
usePagination
|
||||
? q.bind({
|
||||
author_id: authorId,
|
||||
last_message_id: lastMessageId!,
|
||||
})
|
||||
: q.bind({
|
||||
author_id: authorId,
|
||||
}),
|
||||
);
|
||||
|
||||
return results.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,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async backfillMessagesByAuthorIndex(authorId: UserID): Promise<void> {
|
||||
const oldMessages = await fetchMany<{channel_id: bigint; message_id: bigint}>(
|
||||
SELECT_FROM_OLD_TABLE.bind({author_id: authorId}),
|
||||
);
|
||||
|
||||
if (oldMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
for (let i = 0; i < oldMessages.length; i += BATCH_SIZE) {
|
||||
const batch = new BatchBuilder();
|
||||
const chunk = oldMessages.slice(i, i + BATCH_SIZE);
|
||||
|
||||
for (const row of chunk) {
|
||||
batch.addPrepared(
|
||||
MessagesByAuthorV2.upsertAll({
|
||||
author_id: authorId,
|
||||
channel_id: row.channel_id as ChannelID,
|
||||
message_id: row.message_id as MessageID,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
|
||||
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 != null) {
|
||||
await deleteOneOrMany(
|
||||
MessagesByAuthorV2.deleteByPk({
|
||||
author_id: message.authorId,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await upsertOne(
|
||||
MessagesByAuthorV2.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,933 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {ListMessagesOptions} from '@fluxer/api/src/channel/repositories/IMessageRepository';
|
||||
import {BucketScanDirection, scanBucketsWithIndex} from '@fluxer/api/src/channel/repositories/message/BucketScanEngine';
|
||||
import {
|
||||
BatchBuilder,
|
||||
buildPatchFromData,
|
||||
Db,
|
||||
deleteOneOrMany,
|
||||
executeConditional,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {ChannelMessageBucketRow, ChannelStateRow, MessageRow} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import {MESSAGE_COLUMNS} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {Message} from '@fluxer/api/src/models/Message';
|
||||
import {
|
||||
AttachmentLookup,
|
||||
ChannelEmptyBuckets,
|
||||
ChannelMessageBuckets,
|
||||
ChannelPins,
|
||||
ChannelState,
|
||||
Messages,
|
||||
MessagesByAuthorV2,
|
||||
} from '@fluxer/api/src/Tables';
|
||||
import {generateSnowflake} from '@fluxer/snowflake/src/Snowflake';
|
||||
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
|
||||
|
||||
function getLogger() {
|
||||
return 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 [];
|
||||
|
||||
getLogger().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 = generateSnowflake();
|
||||
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);
|
||||
|
||||
getLogger().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(generateSnowflake());
|
||||
const maxBucket = Math.max(nowBucket, minBucket);
|
||||
|
||||
getLogger().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;
|
||||
|
||||
getLogger().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;
|
||||
|
||||
getLogger().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}> {
|
||||
getLogger().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}> {
|
||||
getLogger().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);
|
||||
|
||||
getLogger().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,
|
||||
);
|
||||
|
||||
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 != null) {
|
||||
batch.addPrepared(
|
||||
MessagesByAuthorV2.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});
|
||||
}
|
||||
|
||||
async updateEmbeds(message: Message): Promise<void> {
|
||||
await upsertOne(
|
||||
Messages.patchByPk(
|
||||
{
|
||||
channel_id: message.channelId,
|
||||
bucket: message.bucket,
|
||||
message_id: message.id,
|
||||
},
|
||||
{
|
||||
embeds: Db.set(message.embeds.length > 0 ? message.embeds.map((e) => e.toMessageEmbed()) : null),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,340 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {MessageDataRepository} from '@fluxer/api/src/channel/repositories/message/MessageDataRepository';
|
||||
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {ChannelMessageBucketRow, ChannelStateRow} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {
|
||||
AttachmentLookup,
|
||||
ChannelEmptyBuckets,
|
||||
ChannelMessageBuckets,
|
||||
ChannelPins,
|
||||
ChannelState,
|
||||
MessageReactions,
|
||||
Messages,
|
||||
MessagesByAuthorV2,
|
||||
} from '@fluxer/api/src/Tables';
|
||||
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
|
||||
|
||||
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')],
|
||||
orderBy: {col: 'message_id', direction: 'DESC'},
|
||||
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(
|
||||
MessagesByAuthorV2.deleteByPk({
|
||||
author_id: effectiveAuthorId,
|
||||
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.delete({
|
||||
where: [
|
||||
MessageReactions.where.eq('channel_id'),
|
||||
MessageReactions.where.eq('bucket'),
|
||||
MessageReactions.where.eq('message_id'),
|
||||
],
|
||||
}).bind({
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
307
packages/api/src/channel/services/AttachmentUploadService.tsx
Normal file
307
packages/api/src/channel/services/AttachmentUploadService.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {UploadedAttachment} from '@fluxer/api/src/channel/AttachmentDTOs';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from '@fluxer/api/src/channel/services/AuthenticatedChannel';
|
||||
import {
|
||||
getContentType,
|
||||
isMessageEmpty,
|
||||
isOperationDisabled,
|
||||
makeAttachmentCdnKey,
|
||||
makeAttachmentCdnUrl,
|
||||
purgeMessageAttachments as purgeMessageAttachmentsHelper,
|
||||
} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {IPurgeQueue} from '@fluxer/api/src/infrastructure/CloudflarePurgeQueue';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Attachment} from '@fluxer/api/src/models/Attachment';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import {recordAttachmentOperation, recordAttachmentUploadDuration} from '@fluxer/api/src/telemetry/MessageTelemetry';
|
||||
import {withSpan} from '@fluxer/api/src/telemetry/Tracing';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ATTACHMENT_MAX_SIZE_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {FileSizeTooLargeError} from '@fluxer/errors/src/domains/core/FileSizeTooLargeError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
|
||||
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 purgeQueue: IPurgeQueue,
|
||||
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>,
|
||||
private limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
async uploadFormDataAttachments({
|
||||
userId,
|
||||
channelId,
|
||||
files,
|
||||
attachmentMetadata,
|
||||
expiresAt,
|
||||
}: UploadFormDataAttachmentsParams): Promise<Array<UploadedAttachment>> {
|
||||
return await withSpan(
|
||||
{
|
||||
name: 'fluxer.media.upload',
|
||||
attributes: {channel_id: channelId.toString()},
|
||||
},
|
||||
async () => this.performUploadFormDataAttachments({userId, channelId, files, attachmentMetadata, expiresAt}),
|
||||
);
|
||||
}
|
||||
|
||||
private async performUploadFormDataAttachments({
|
||||
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();
|
||||
}
|
||||
|
||||
const fallbackMaxSize = ATTACHMENT_MAX_SIZE_NON_PREMIUM;
|
||||
const ctx = createLimitMatchContext({user, guildFeatures: guild?.features ?? null});
|
||||
const maxFileSize = resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'max_attachment_file_size',
|
||||
fallbackMaxSize,
|
||||
);
|
||||
|
||||
const hasFileExceedingLimit = files.some((entry) => entry.file.size > maxFileSize);
|
||||
if (hasFileExceedingLimit) {
|
||||
throw new FileSizeTooLargeError(maxFileSize);
|
||||
}
|
||||
|
||||
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);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
await this.storageService.uploadObject({
|
||||
bucket: Config.s3.buckets.uploads,
|
||||
key: uploadKey,
|
||||
body,
|
||||
contentType,
|
||||
expiresAt: expiresAt ?? undefined,
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
recordAttachmentOperation({
|
||||
operation: 'upload',
|
||||
contentType: file.type || 'unknown',
|
||||
status: 'success',
|
||||
});
|
||||
recordAttachmentUploadDuration({
|
||||
contentType: file.type || 'unknown',
|
||||
durationMs,
|
||||
});
|
||||
|
||||
const uploaded: UploadedAttachment = {
|
||||
id: index,
|
||||
upload_filename: uploadKey,
|
||||
filename: filename,
|
||||
file_size: file.size,
|
||||
content_type: contentType,
|
||||
};
|
||||
|
||||
return uploaded;
|
||||
} catch (error) {
|
||||
recordAttachmentOperation({
|
||||
operation: 'upload',
|
||||
contentType: file.type || 'unknown',
|
||||
status: 'error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return uploadedAttachments;
|
||||
}
|
||||
|
||||
async deleteAttachment({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
attachmentId,
|
||||
requestCache,
|
||||
}: DeleteAttachmentParams): Promise<void> {
|
||||
return await withBusinessSpan(
|
||||
'fluxer.attachment.delete',
|
||||
'fluxer.attachments.deleted',
|
||||
{
|
||||
channel_id: channelId.toString(),
|
||||
attachment_id: attachmentId.toString(),
|
||||
},
|
||||
async () => {
|
||||
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.purgeQueue.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.purgeQueue),
|
||||
),
|
||||
);
|
||||
|
||||
if (messages.length < batchSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
beforeMessageId = messages[messages.length - 1].id;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveContentType(_file: File, normalizedFilename: string): string {
|
||||
return getContentType(normalizedFilename);
|
||||
}
|
||||
}
|
||||
30
packages/api/src/channel/services/AuthenticatedChannel.tsx
Normal file
30
packages/api/src/channel/services/AuthenticatedChannel.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
export interface AuthenticatedChannel {
|
||||
channel: Channel;
|
||||
guild: GuildResponse | null;
|
||||
member: GuildMemberResponse | null;
|
||||
hasPermission: (permission: bigint) => Promise<boolean>;
|
||||
checkPermission: (permission: bigint) => Promise<void>;
|
||||
}
|
||||
239
packages/api/src/channel/services/BaseChannelAuthService.tsx
Normal file
239
packages/api/src/channel/services/BaseChannelAuthService.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* 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, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {userIdToChannelId} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from '@fluxer/api/src/channel/services/AuthenticatedChannel';
|
||||
import {DMPermissionValidator} from '@fluxer/api/src/channel/services/DMPermissionValidator';
|
||||
import {SYSTEM_USER_ID} from '@fluxer/api/src/constants/Core';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {isUserAdult} from '@fluxer/api/src/utils/AgeUtils';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
export interface ChannelAuthOptions {
|
||||
errorOnMissingGuild: 'unknown_channel' | 'missing_permissions';
|
||||
validateNsfw: boolean;
|
||||
}
|
||||
|
||||
export abstract class BaseChannelAuthService {
|
||||
protected abstract readonly options: ChannelAuthOptions;
|
||||
protected dmPermissionValidator: DMPermissionValidator;
|
||||
|
||||
constructor(
|
||||
protected channelRepository: IChannelRepositoryAggregate,
|
||||
protected userRepository: IUserRepository,
|
||||
protected guildRepository: IGuildRepositoryAggregate,
|
||||
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})) {
|
||||
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 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> {
|
||||
if (userId === SYSTEM_USER_ID) {
|
||||
return {
|
||||
channel,
|
||||
guild: null,
|
||||
member: null,
|
||||
hasPermission: async () => true,
|
||||
checkPermission: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 = await this.fetchGuildDataOrThrow({guildId, userId});
|
||||
const guildMemberResult = await 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);
|
||||
|
||||
const isGuildAgeRestricted = guildDataResult!.nsfw_level === GuildNSFWLevel.AGE_RESTRICTED;
|
||||
const requiresAgeVerification = channel.isNsfw || isGuildAgeRestricted;
|
||||
|
||||
if (this.options.validateNsfw && channel.type === ChannelTypes.GUILD_TEXT && requiresAgeVerification) {
|
||||
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();
|
||||
}
|
||||
|
||||
private async fetchGuildDataOrThrow(params: {guildId: GuildID; userId: UserID}): Promise<GuildResponse | null> {
|
||||
const {guildId, userId} = params;
|
||||
try {
|
||||
return await this.gatewayService.getGuildData({guildId, userId});
|
||||
} catch (error) {
|
||||
await this.handleGuildAccessError(error, guildId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGuildAccessError(error: unknown, guildId: GuildID): Promise<void> {
|
||||
if (error instanceof UnknownGuildError) {
|
||||
if (await this.guildExists(guildId)) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private async guildExists(guildId: GuildID): Promise<boolean> {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
return guild !== null;
|
||||
}
|
||||
}
|
||||
560
packages/api/src/channel/services/CallService.tsx
Normal file
560
packages/api/src/channel/services/CallService.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import {DMPermissionValidator} from '@fluxer/api/src/channel/services/DMPermissionValidator';
|
||||
import {incrementDmMentionCounts} from '@fluxer/api/src/channel/services/message/ReadStateHelpers';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {CallData, IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {ReadStateService} from '@fluxer/api/src/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {VoiceAccessContext, VoiceAvailabilityService} from '@fluxer/api/src/voice/VoiceAvailabilityService';
|
||||
import {resolveVoiceRegionPreference, selectVoiceRegionId} from '@fluxer/api/src/voice/VoiceRegionSelection';
|
||||
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {IncomingCallFlags, RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {CallAlreadyExistsError} from '@fluxer/errors/src/domains/channel/CallAlreadyExistsError';
|
||||
import {InvalidChannelTypeForCallError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeForCallError';
|
||||
import {NoActiveCallError} from '@fluxer/errors/src/domains/channel/NoActiveCallError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
|
||||
|
||||
const FALLBACK_VOICE_REGION = 'us-east';
|
||||
|
||||
export class CallService {
|
||||
private dmPermissionValidator: DMPermissionValidator;
|
||||
|
||||
constructor(
|
||||
private channelRepository: IChannelRepository,
|
||||
private userRepository: IUserRepository,
|
||||
private guildRepository: IGuildRepositoryAggregate,
|
||||
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 caller = await this.userRepository.findUnique(userId);
|
||||
const isUnclaimedCaller = caller?.isUnclaimedAccount() ?? false;
|
||||
if (isUnclaimedCaller && channel.type === ChannelTypes.DM) {
|
||||
return {ringable: false};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (region) {
|
||||
this.ensureCallRegionAccessible(userId, region);
|
||||
}
|
||||
|
||||
const selectedRegion = region || this.selectOptimalRegionForCall(userId, latitude, longitude);
|
||||
const allRecipients = Array.from(new Set([userId, ...Array.from(channel.recipientIds)]));
|
||||
const messageId = createMessageID(await 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({
|
||||
userId,
|
||||
channelId,
|
||||
region,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
region?: string | null;
|
||||
}): 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 !== undefined) {
|
||||
if (region !== null) {
|
||||
this.ensureCallRegionAccessible(userId, 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 callerRequestedNoRing = recipients !== undefined && recipients.length === 0;
|
||||
const recipientsToNotify = (
|
||||
callerRequestedNoRing ? Array.from(channel.recipientIds) : recipients || Array.from(channel.recipientIds)
|
||||
).filter((id) => id !== userId);
|
||||
|
||||
const recipientsToRing: Array<UserID> = [];
|
||||
|
||||
if (channel.type === ChannelTypes.DM) {
|
||||
if (!callerRequestedNoRing) {
|
||||
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 {
|
||||
if (!callerRequestedNoRing) {
|
||||
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 || channel.type === ChannelTypes.GROUP_DM) {
|
||||
const isGroupDm = channel.type === ChannelTypes.GROUP_DM;
|
||||
|
||||
const callerHasChannelOpen = await this.userRepository.isDmChannelOpen(userId, channelId);
|
||||
if (!callerHasChannelOpen) {
|
||||
await this.userRepository.openDmForUser(userId, channelId, isGroupDm);
|
||||
|
||||
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, isGroupDm);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const preference = resolveVoiceRegionPreference({
|
||||
preferredRegionId: null,
|
||||
accessibleRegions,
|
||||
availableRegions,
|
||||
defaultRegionId: null,
|
||||
});
|
||||
|
||||
const selectedRegion = selectVoiceRegionId({
|
||||
preferredRegionId: preference.regionId,
|
||||
mode: preference.mode,
|
||||
accessibleRegions,
|
||||
availableRegions,
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
|
||||
if (!selectedRegion) {
|
||||
throw new Error('No accessible voice regions available');
|
||||
}
|
||||
|
||||
return selectedRegion;
|
||||
}
|
||||
|
||||
private ensureCallRegionAccessible(userId: UserID, region: string): void {
|
||||
if (!this.voiceAvailabilityService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: userId,
|
||||
};
|
||||
|
||||
const availableRegions = this.voiceAvailabilityService.getAvailableRegions(context);
|
||||
const isAllowed = availableRegions.some(
|
||||
(availableRegion) => availableRegion.id === region && availableRegion.isAccessible,
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
throw InputValidationError.fromCode('region', ValidationErrorCodes.INVALID_OR_RESTRICTED_RTC_REGION, {
|
||||
region,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
302
packages/api/src/channel/services/ChannelDataService.tsx
Normal file
302
packages/api/src/channel/services/ChannelDataService.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapChannelToPartialResponse} from '@fluxer/api/src/channel/ChannelMappers';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from '@fluxer/api/src/channel/services/AuthenticatedChannel';
|
||||
import {ChannelAuthService} from '@fluxer/api/src/channel/services/channel_data/ChannelAuthService';
|
||||
import type {ChannelUpdateData} from '@fluxer/api/src/channel/services/channel_data/ChannelOperationsService';
|
||||
import {ChannelOperationsService} from '@fluxer/api/src/channel/services/channel_data/ChannelOperationsService';
|
||||
import {ChannelUtilsService} from '@fluxer/api/src/channel/services/channel_data/ChannelUtilsService';
|
||||
import {GroupDmUpdateService} from '@fluxer/api/src/channel/services/channel_data/GroupDmUpdateService';
|
||||
import type {MessagePersistenceService} from '@fluxer/api/src/channel/services/message/MessagePersistenceService';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {AvatarService} from '@fluxer/api/src/infrastructure/AvatarService';
|
||||
import type {IPurgeQueue} from '@fluxer/api/src/infrastructure/CloudflarePurgeQueue';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {ILiveKitService} from '@fluxer/api/src/infrastructure/ILiveKitService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {IVoiceRoomStore} from '@fluxer/api/src/infrastructure/IVoiceRoomStore';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {IInviteRepository} from '@fluxer/api/src/invite/IInviteRepository';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {VoiceAvailabilityService} from '@fluxer/api/src/voice/VoiceAvailabilityService';
|
||||
import type {VoiceRegionAvailability} from '@fluxer/api/src/voice/VoiceModel';
|
||||
import type {IWebhookRepository} from '@fluxer/api/src/webhook/IWebhookRepository';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelUpdateRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import type {ChannelPartialResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
|
||||
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: IGuildRepositoryAggregate,
|
||||
userCacheService: UserCacheService,
|
||||
storageService: IStorageService,
|
||||
gatewayService: IGatewayService,
|
||||
mediaService: IMediaService,
|
||||
avatarService: AvatarService,
|
||||
snowflakeService: SnowflakeService,
|
||||
purgeQueue: IPurgeQueue,
|
||||
voiceRoomStore: IVoiceRoomStore,
|
||||
liveKitService: ILiveKitService,
|
||||
voiceAvailabilityService: VoiceAvailabilityService | undefined,
|
||||
messagePersistenceService: MessagePersistenceService,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
inviteRepository: IInviteRepository,
|
||||
webhookRepository: IWebhookRepository,
|
||||
limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.channelUtilsService = new ChannelUtilsService(
|
||||
channelRepository,
|
||||
userCacheService,
|
||||
storageService,
|
||||
gatewayService,
|
||||
purgeQueue,
|
||||
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,
|
||||
guildRepository,
|
||||
limitConfigService,
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
114
packages/api/src/channel/services/ChannelRequestService.tsx
Normal file
114
packages/api/src/channel/services/ChannelRequestService.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelUpdateRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
|
||||
export class ChannelRequestService {
|
||||
constructor(
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
) {}
|
||||
|
||||
async getChannelResponse(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<ChannelResponse> {
|
||||
const channel = await this.channelService.getChannel({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
return mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: params.userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async listRtcRegions(params: {userId: UserID; channelId: ChannelID}) {
|
||||
const regions = await this.channelService.getAvailableRtcRegions({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
|
||||
return regions.map((region) => ({
|
||||
id: region.id,
|
||||
name: region.name,
|
||||
emoji: region.emoji,
|
||||
}));
|
||||
}
|
||||
|
||||
async updateChannel(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
data: ChannelUpdateRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<ChannelResponse> {
|
||||
const channel = await this.channelService.editChannel({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
data: params.data,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
|
||||
return mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: params.userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteChannel(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
requestCache: RequestCache;
|
||||
silent?: boolean;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelService.getChannel({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
|
||||
if (channel.type === ChannelTypes.GROUP_DM) {
|
||||
await this.channelService.removeRecipientFromChannel({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
recipientId: params.userId,
|
||||
requestCache: params.requestCache,
|
||||
silent: params.silent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.channelService.deleteChannel({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
}
|
||||
}
|
||||
435
packages/api/src/channel/services/ChannelService.tsx
Normal file
435
packages/api/src/channel/services/ChannelService.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {MessageRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import {AttachmentUploadService} from '@fluxer/api/src/channel/services/AttachmentUploadService';
|
||||
import {CallService} from '@fluxer/api/src/channel/services/CallService';
|
||||
import {ChannelDataService} from '@fluxer/api/src/channel/services/ChannelDataService';
|
||||
import {GroupDmService} from '@fluxer/api/src/channel/services/GroupDmService';
|
||||
import {MessageInteractionService} from '@fluxer/api/src/channel/services/MessageInteractionService';
|
||||
import {MessageService} from '@fluxer/api/src/channel/services/MessageService';
|
||||
import {MessagePersistenceService} from '@fluxer/api/src/channel/services/message/MessagePersistenceService';
|
||||
import type {IFavoriteMemeRepository} from '@fluxer/api/src/favorite_meme/IFavoriteMemeRepository';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {AvatarService} from '@fluxer/api/src/infrastructure/AvatarService';
|
||||
import type {IPurgeQueue} from '@fluxer/api/src/infrastructure/CloudflarePurgeQueue';
|
||||
import type {EmbedService} from '@fluxer/api/src/infrastructure/EmbedService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {ILiveKitService} from '@fluxer/api/src/infrastructure/ILiveKitService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {IVoiceRoomStore} from '@fluxer/api/src/infrastructure/IVoiceRoomStore';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {IInviteRepository} from '@fluxer/api/src/invite/IInviteRepository';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {PackService} from '@fluxer/api/src/pack/PackService';
|
||||
import type {ReadStateService} from '@fluxer/api/src/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {VoiceAvailabilityService} from '@fluxer/api/src/voice/VoiceAvailabilityService';
|
||||
import type {IWebhookRepository} from '@fluxer/api/src/webhook/IWebhookRepository';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {CannotSendMessageToNonTextChannelError} from '@fluxer/errors/src/domains/channel/CannotSendMessageToNonTextChannelError';
|
||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import type {IVirusScanService} from '@fluxer/virus_scan/src/IVirusScanService';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
|
||||
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: IGuildRepositoryAggregate,
|
||||
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,
|
||||
purgeQueue: IPurgeQueue,
|
||||
favoriteMemeRepository: IFavoriteMemeRepository,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
voiceRoomStore: IVoiceRoomStore,
|
||||
liveKitService: ILiveKitService,
|
||||
inviteRepository: IInviteRepository,
|
||||
webhookRepository: IWebhookRepository,
|
||||
limitConfigService: LimitConfigService,
|
||||
voiceAvailabilityService?: VoiceAvailabilityService,
|
||||
) {
|
||||
const messagePersistenceService = new MessagePersistenceService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
packService,
|
||||
embedService,
|
||||
storageService,
|
||||
mediaService,
|
||||
virusScanService,
|
||||
snowflakeService,
|
||||
readStateService,
|
||||
limitConfigService,
|
||||
);
|
||||
|
||||
this.channelData = new ChannelDataService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
storageService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
avatarService,
|
||||
snowflakeService,
|
||||
purgeQueue,
|
||||
voiceRoomStore,
|
||||
liveKitService,
|
||||
voiceAvailabilityService,
|
||||
messagePersistenceService,
|
||||
guildAuditLogService,
|
||||
inviteRepository,
|
||||
webhookRepository,
|
||||
limitConfigService,
|
||||
);
|
||||
|
||||
this.messages = new MessageService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
readStateService,
|
||||
cacheService,
|
||||
storageService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
workerService,
|
||||
snowflakeService,
|
||||
rateLimitService,
|
||||
purgeQueue,
|
||||
favoriteMemeRepository,
|
||||
guildAuditLogService,
|
||||
messagePersistenceService,
|
||||
limitConfigService,
|
||||
);
|
||||
|
||||
this.interactions = new MessageInteractionService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
readStateService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
snowflakeService,
|
||||
messagePersistenceService,
|
||||
guildAuditLogService,
|
||||
limitConfigService,
|
||||
);
|
||||
|
||||
this.attachments = new AttachmentUploadService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
storageService,
|
||||
purgeQueue,
|
||||
this.interactions.getChannelAuthenticated.bind(this.interactions),
|
||||
(channel) => {
|
||||
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
|
||||
throw new CannotSendMessageToNonTextChannelError();
|
||||
}
|
||||
},
|
||||
this.interactions.dispatchMessageUpdate.bind(this.interactions),
|
||||
this.messages.deleteMessage.bind(this.messages),
|
||||
limitConfigService,
|
||||
);
|
||||
|
||||
this.groupDms = new GroupDmService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
snowflakeService,
|
||||
this.channelData.groupDmUpdateService,
|
||||
this.messages.getMessagePersistenceService(),
|
||||
limitConfigService,
|
||||
);
|
||||
|
||||
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']>) {
|
||||
const [{channelId}] = args;
|
||||
const result = await this.calls.checkCallEligibility(...args);
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.voice.started',
|
||||
dimensions: {
|
||||
channel_id: channelId.toString(),
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
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']>) {
|
||||
const [{channelId}] = args;
|
||||
const response = await this.calls.ringCallRecipients(...args);
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.voice.joined',
|
||||
dimensions: {
|
||||
channel_id: channelId.toString(),
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async stopRingingCallRecipients(...args: Parameters<CallService['stopRingingCallRecipients']>) {
|
||||
const [{channelId}] = args;
|
||||
const response = await this.calls.stopRingingCallRecipients(...args);
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.voice.left',
|
||||
dimensions: {
|
||||
channel_id: channelId.toString(),
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
recordCallEnded({channelId}: {channelId: ChannelID}): void {
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.voice.ended',
|
||||
dimensions: {
|
||||
channel_id: channelId.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async setChannelPermissionOverwrite(params: Parameters<ChannelDataService['setChannelPermissionOverwrite']>[0]) {
|
||||
return this.channelData.setChannelPermissionOverwrite(params);
|
||||
}
|
||||
|
||||
async deleteChannelPermissionOverwrite(
|
||||
params: Parameters<ChannelDataService['deleteChannelPermissionOverwrite']>[0],
|
||||
) {
|
||||
return this.channelData.deleteChannelPermissionOverwrite(params);
|
||||
}
|
||||
}
|
||||
129
packages/api/src/channel/services/DMPermissionValidator.tsx
Normal file
129
packages/api/src/channel/services/DMPermissionValidator.tsx
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 type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {SYSTEM_USER_ID} from '@fluxer/api/src/constants/Core';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {checkGuildVerificationWithGuildModel} from '@fluxer/api/src/utils/GuildVerificationUtils';
|
||||
import {RelationshipTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {CannotSendMessagesToUserError} from '@fluxer/errors/src/domains/channel/CannotSendMessagesToUserError';
|
||||
import {UnclaimedAccountCannotSendDirectMessagesError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotSendDirectMessagesError';
|
||||
|
||||
interface DMPermissionValidatorDeps {
|
||||
userRepository: IUserRepository;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
}
|
||||
|
||||
export class DMPermissionValidator {
|
||||
constructor(private deps: DMPermissionValidatorDeps) {}
|
||||
|
||||
async validate({recipients, userId}: {recipients: Array<User>; userId: UserID}): Promise<void> {
|
||||
if (userId === SYSTEM_USER_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const senderUser = await this.deps.userRepository.findUnique(userId);
|
||||
if (senderUser?.isUnclaimedAccount()) {
|
||||
throw new UnclaimedAccountCannotSendDirectMessagesError();
|
||||
}
|
||||
|
||||
const targetUser = recipients.find((recipient) => recipient.id !== userId);
|
||||
if (!targetUser) return;
|
||||
|
||||
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 usesBotRestrictions = targetUser.isBot;
|
||||
const dmRestrictionsEnabled =
|
||||
(usesBotRestrictions ? targetSettings.botDefaultGuildsRestricted : targetSettings.defaultGuildsRestricted) ||
|
||||
(usesBotRestrictions ? targetSettings.botRestrictedGuilds.size : 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 = usesBotRestrictions
|
||||
? targetSettings.botRestrictedGuilds
|
||||
: 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
103
packages/api/src/channel/services/GroupDmService.tsx
Normal file
103
packages/api/src/channel/services/GroupDmService.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {GroupDmUpdateService} from '@fluxer/api/src/channel/services/channel_data/GroupDmUpdateService';
|
||||
import {GroupDmOperationsService} from '@fluxer/api/src/channel/services/group_dm/GroupDmOperationsService';
|
||||
import type {MessagePersistenceService} from '@fluxer/api/src/channel/services/message/MessagePersistenceService';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
|
||||
export class GroupDmService {
|
||||
private operationsService: GroupDmOperationsService;
|
||||
|
||||
constructor(
|
||||
channelRepository: IChannelRepository,
|
||||
userRepository: IUserRepository,
|
||||
guildRepository: IGuildRepositoryAggregate,
|
||||
userCacheService: UserCacheService,
|
||||
gatewayService: IGatewayService,
|
||||
mediaService: IMediaService,
|
||||
snowflakeService: SnowflakeService,
|
||||
groupDmUpdateService: GroupDmUpdateService,
|
||||
messagePersistenceService: MessagePersistenceService,
|
||||
limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.operationsService = new GroupDmOperationsService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
snowflakeService,
|
||||
groupDmUpdateService,
|
||||
messagePersistenceService,
|
||||
limitConfigService,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
356
packages/api/src/channel/services/MessageInteractionService.tsx
Normal file
356
packages/api/src/channel/services/MessageInteractionService.tsx
Normal file
@@ -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 type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {channelIdToUserId} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import {MessageInteractionAuthService} from '@fluxer/api/src/channel/services/interaction/MessageInteractionAuthService';
|
||||
import {MessagePinService} from '@fluxer/api/src/channel/services/interaction/MessagePinService';
|
||||
import {MessageReactionService} from '@fluxer/api/src/channel/services/interaction/MessageReactionService';
|
||||
import {MessageReadStateService} from '@fluxer/api/src/channel/services/interaction/MessageReadStateService';
|
||||
import type {MessagePersistenceService} from '@fluxer/api/src/channel/services/message/MessagePersistenceService';
|
||||
import type {GatewayDispatchEvent} from '@fluxer/api/src/constants/Gateway';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {MessageReaction} from '@fluxer/api/src/models/MessageReaction';
|
||||
import type {ReadStateService} from '@fluxer/api/src/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelPinResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
export class MessageInteractionService {
|
||||
private authService: MessageInteractionAuthService;
|
||||
private readStateService: MessageReadStateService;
|
||||
private pinService: MessagePinService;
|
||||
private reactionService: MessageReactionService;
|
||||
|
||||
constructor(
|
||||
channelRepository: IChannelRepository,
|
||||
userRepository: IUserRepository,
|
||||
guildRepository: IGuildRepositoryAggregate,
|
||||
private userCacheService: UserCacheService,
|
||||
readStateService: ReadStateService,
|
||||
private gatewayService: IGatewayService,
|
||||
private mediaService: IMediaService,
|
||||
snowflakeService: SnowflakeService,
|
||||
messagePersistenceService: MessagePersistenceService,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
limitConfigService: LimitConfigService,
|
||||
) {
|
||||
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,
|
||||
limitConfigService,
|
||||
);
|
||||
}
|
||||
|
||||
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, userId});
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
314
packages/api/src/channel/services/MessageService.tsx
Normal file
314
packages/api/src/channel/services/MessageService.tsx
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 type {ChannelID, GuildID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import {MessageAnonymizationService} from '@fluxer/api/src/channel/services/message/MessageAnonymizationService';
|
||||
import {MessageChannelAuthService} from '@fluxer/api/src/channel/services/message/MessageChannelAuthService';
|
||||
import {MessageDispatchService} from '@fluxer/api/src/channel/services/message/MessageDispatchService';
|
||||
import {MessageMentionService} from '@fluxer/api/src/channel/services/message/MessageMentionService';
|
||||
import {MessageOperationsService} from '@fluxer/api/src/channel/services/message/MessageOperationsService';
|
||||
import type {MessagePersistenceService} from '@fluxer/api/src/channel/services/message/MessagePersistenceService';
|
||||
import {MessageProcessingService} from '@fluxer/api/src/channel/services/message/MessageProcessingService';
|
||||
import {MessageRetrievalService} from '@fluxer/api/src/channel/services/message/MessageRetrievalService';
|
||||
import {MessageSearchService} from '@fluxer/api/src/channel/services/message/MessageSearchService';
|
||||
import {MessageSystemService} from '@fluxer/api/src/channel/services/message/MessageSystemService';
|
||||
import {MessageValidationService} from '@fluxer/api/src/channel/services/message/MessageValidationService';
|
||||
import type {IFavoriteMemeRepository} from '@fluxer/api/src/favorite_meme/IFavoriteMemeRepository';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IPurgeQueue} from '@fluxer/api/src/infrastructure/CloudflarePurgeQueue';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {Webhook} from '@fluxer/api/src/models/Webhook';
|
||||
import type {ReadStateService} from '@fluxer/api/src/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import type {MessageSearchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import type {MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
|
||||
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: IGuildRepositoryAggregate,
|
||||
userCacheService: UserCacheService,
|
||||
readStateService: ReadStateService,
|
||||
cacheService: ICacheService,
|
||||
storageService: IStorageService,
|
||||
gatewayService: IGatewayService,
|
||||
mediaService: IMediaService,
|
||||
workerService: IWorkerService,
|
||||
snowflakeService: SnowflakeService,
|
||||
rateLimitService: IRateLimitService,
|
||||
purgeQueue: IPurgeQueue,
|
||||
favoriteMemeRepository: IFavoriteMemeRepository,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
persistenceService: MessagePersistenceService,
|
||||
limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.validationService = new MessageValidationService(cacheService, limitConfigService);
|
||||
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,
|
||||
purgeQueue,
|
||||
favoriteMemeRepository,
|
||||
this.validationService,
|
||||
this.mentionService,
|
||||
this.searchService,
|
||||
this.persistenceService,
|
||||
this.channelAuthService,
|
||||
this.processingService,
|
||||
guildAuditLogService,
|
||||
this.dispatchService,
|
||||
limitConfigService,
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
242
packages/api/src/channel/services/ScheduledMessageService.tsx
Normal file
242
packages/api/src/channel/services/ScheduledMessageService.tsx
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 {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createMessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {MessageRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {ScheduledMessagePayload} from '@fluxer/api/src/models/ScheduledMessage';
|
||||
import {ScheduledMessage} from '@fluxer/api/src/models/ScheduledMessage';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import type {ScheduledMessageRepository} from '@fluxer/api/src/user/repositories/ScheduledMessageRepository';
|
||||
import {ManagedTraits} from '@fluxer/constants/src/ManagedTraits';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {recordCounter} from '@fluxer/telemetry/src/Metrics';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
import type {WorkerJobPayload} from '@fluxer/worker/src/contracts/WorkerTypes';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import {DateTime, IANAZone} from 'luxon';
|
||||
|
||||
export const SCHEDULED_MESSAGE_TTL_SECONDS = seconds('31 days');
|
||||
|
||||
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 extends WorkerJobPayload {
|
||||
userId: string;
|
||||
scheduledMessageId: string;
|
||||
expectedScheduledAt: string;
|
||||
}
|
||||
|
||||
export class ScheduledMessageService {
|
||||
constructor(
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly scheduledMessageRepository: ScheduledMessageRepository,
|
||||
private readonly workerService: IWorkerService,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
) {}
|
||||
|
||||
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 withBusinessSpan(
|
||||
'fluxer.message.schedule',
|
||||
'fluxer.messages.scheduled',
|
||||
{
|
||||
channel_id: params.channelId.toString(),
|
||||
scheduled_for: params.scheduledLocalAt,
|
||||
has_attachments: String(!!params.data.attachments && params.data.attachments.length > 0),
|
||||
},
|
||||
async () =>
|
||||
this.upsertScheduledMessage({
|
||||
...params,
|
||||
scheduledMessageId: await createSnowflake(this.snowflakeService),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async updateScheduledMessage(params: UpdateScheduleParams): Promise<ScheduledMessage> {
|
||||
return await withBusinessSpan(
|
||||
'fluxer.message.schedule.update',
|
||||
'fluxer.messages.scheduled_updated',
|
||||
{
|
||||
channel_id: params.channelId.toString(),
|
||||
scheduled_message_id: params.scheduledMessageId.toString(),
|
||||
},
|
||||
async () =>
|
||||
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);
|
||||
recordCounter({
|
||||
name: 'fluxer.messages.scheduled_deleted',
|
||||
dimensions: {scheduled_message_id: scheduledMessageId.toString()},
|
||||
value: 1,
|
||||
});
|
||||
}
|
||||
|
||||
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 (!user.traits.has(ManagedTraits.MESSAGE_SCHEDULING)) {
|
||||
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.fromCode('timezone', ValidationErrorCodes.INVALID_TIMEZONE_IDENTIFIER);
|
||||
}
|
||||
|
||||
const dt = DateTime.fromISO(local, {zone});
|
||||
if (!dt.isValid) {
|
||||
throw InputValidationError.fromCode(
|
||||
'scheduled_local_at',
|
||||
ValidationErrorCodes.INVALID_DATETIME_FOR_SCHEDULED_SEND,
|
||||
);
|
||||
}
|
||||
|
||||
const scheduledAt = dt.toJSDate();
|
||||
const now = Date.now();
|
||||
|
||||
const diffMs = scheduledAt.getTime() - now;
|
||||
if (diffMs <= 0) {
|
||||
throw InputValidationError.fromCode('scheduled_local_at', ValidationErrorCodes.SCHEDULED_TIME_MUST_BE_FUTURE);
|
||||
}
|
||||
|
||||
if (diffMs > ms('30 days')) {
|
||||
throw InputValidationError.fromCode('scheduled_local_at', ValidationErrorCodes.SCHEDULED_MESSAGES_MAX_30_DAYS);
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
}
|
||||
|
||||
async function createSnowflake(snowflakeService: SnowflakeService): Promise<MessageID> {
|
||||
return createMessageID(await 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,
|
||||
};
|
||||
}
|
||||
114
packages/api/src/channel/services/StreamPreviewService.tsx
Normal file
114
packages/api/src/channel/services/StreamPreviewService.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {STREAM_PREVIEW_CONTENT_TYPE_JPEG, STREAM_PREVIEW_MAX_BYTES} from '@fluxer/constants/src/StreamConstants';
|
||||
import {FileSizeTooLargeError} from '@fluxer/errors/src/domains/core/FileSizeTooLargeError';
|
||||
import {PreviewMustBeJpegError} from '@fluxer/errors/src/domains/core/PreviewMustBeJpegError';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
|
||||
const PREVIEW_TTL_SECONDS = seconds('1 day');
|
||||
|
||||
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 PreviewMustBeJpegError();
|
||||
}
|
||||
if (buffer.byteLength > STREAM_PREVIEW_MAX_BYTES) {
|
||||
throw new FileSizeTooLargeError();
|
||||
}
|
||||
}
|
||||
|
||||
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() + ms('1 day'));
|
||||
|
||||
await this.storageService.uploadObject({
|
||||
bucket,
|
||||
key,
|
||||
body: params.body,
|
||||
contentType: params.contentType ?? STREAM_PREVIEW_CONTENT_TYPE_JPEG,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const meta: StreamPreviewMeta = {
|
||||
bucket,
|
||||
key,
|
||||
updatedAt: Date.now(),
|
||||
ownerId: params.userId.toString(),
|
||||
channelId: params.channelId.toString(),
|
||||
contentType: params.contentType ?? STREAM_PREVIEW_CONTENT_TYPE_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 || STREAM_PREVIEW_CONTENT_TYPE_JPEG};
|
||||
}
|
||||
}
|
||||
168
packages/api/src/channel/services/StreamService.tsx
Normal file
168
packages/api/src/channel/services/StreamService.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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 UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import type {StreamPreviewService} from '@fluxer/api/src/channel/services/StreamPreviewService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {EmptyStreamThumbnailPayloadError} from '@fluxer/errors/src/domains/channel/EmptyStreamThumbnailPayloadError';
|
||||
import {InvalidStreamKeyFormatError} from '@fluxer/errors/src/domains/channel/InvalidStreamKeyFormatError';
|
||||
import {InvalidStreamThumbnailPayloadError} from '@fluxer/errors/src/domains/channel/InvalidStreamThumbnailPayloadError';
|
||||
import {StreamKeyChannelMismatchError} from '@fluxer/errors/src/domains/channel/StreamKeyChannelMismatchError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {StreamKeyScopeMismatchError} from '@fluxer/errors/src/domains/oauth/StreamKeyScopeMismatchError';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
type ParsedStreamKey = {scope: 'guild' | 'dm'; guildId?: string; channelId: string; connectionId: string};
|
||||
|
||||
export class StreamService {
|
||||
constructor(
|
||||
private readonly cacheService: ICacheService,
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly streamPreviewService: StreamPreviewService,
|
||||
) {}
|
||||
|
||||
private parseStreamKey(streamKey: string): ParsedStreamKey | null {
|
||||
const parts = streamKey.split(':');
|
||||
if (parts.length !== 3) return null;
|
||||
const [scopeRaw, channelId, connectionId] = parts;
|
||||
if (!channelId || !connectionId) return null;
|
||||
if (!/^[0-9]+$/.test(channelId)) return null;
|
||||
if (scopeRaw === 'dm') {
|
||||
return {scope: 'dm', channelId, connectionId};
|
||||
}
|
||||
if (!/^[0-9]+$/.test(scopeRaw)) return null;
|
||||
return {scope: 'guild', guildId: scopeRaw, channelId, connectionId};
|
||||
}
|
||||
|
||||
private getParsedStreamKeyOrThrow(streamKey: string): ParsedStreamKey {
|
||||
const parsedKey = this.parseStreamKey(streamKey);
|
||||
if (!parsedKey) {
|
||||
throw new InvalidStreamKeyFormatError();
|
||||
}
|
||||
return parsedKey;
|
||||
}
|
||||
|
||||
private getChannelIdFromParsedKeyOrThrow(parsedKey: ParsedStreamKey): ChannelID {
|
||||
try {
|
||||
return createChannelID(BigInt(parsedKey.channelId));
|
||||
} catch {
|
||||
throw new InvalidStreamKeyFormatError();
|
||||
}
|
||||
}
|
||||
|
||||
private async assertStreamChannelAccess(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
parsedKey: ParsedStreamKey;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelService.getChannel({userId: params.userId, channelId: params.channelId});
|
||||
|
||||
if (channel.guildId) {
|
||||
if (params.parsedKey.scope !== 'guild') {
|
||||
throw new StreamKeyScopeMismatchError();
|
||||
}
|
||||
if (params.parsedKey.guildId !== channel.guildId.toString()) {
|
||||
throw new StreamKeyScopeMismatchError();
|
||||
}
|
||||
const hasConnect = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
channelId: params.channelId,
|
||||
userId: params.userId,
|
||||
permission: Permissions.CONNECT,
|
||||
});
|
||||
if (!hasConnect) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
} else if (params.parsedKey.scope !== 'dm') {
|
||||
throw new StreamKeyScopeMismatchError();
|
||||
}
|
||||
|
||||
if (params.parsedKey.channelId !== params.channelId.toString()) {
|
||||
throw new StreamKeyChannelMismatchError();
|
||||
}
|
||||
}
|
||||
|
||||
async updateStreamRegion(params: {streamKey: string; region?: string}): Promise<void> {
|
||||
await this.cacheService.set(
|
||||
`stream_region:${params.streamKey}`,
|
||||
{region: params.region, updatedAt: Date.now()},
|
||||
seconds('1 day'),
|
||||
);
|
||||
}
|
||||
|
||||
async getPreview(params: {
|
||||
userId: UserID;
|
||||
streamKey: string;
|
||||
}): Promise<{buffer: Uint8Array; contentType: string} | null> {
|
||||
const parsedKey = this.getParsedStreamKeyOrThrow(params.streamKey);
|
||||
const channelId = this.getChannelIdFromParsedKeyOrThrow(parsedKey);
|
||||
|
||||
await this.assertStreamChannelAccess({
|
||||
userId: params.userId,
|
||||
channelId,
|
||||
parsedKey,
|
||||
});
|
||||
|
||||
const preview = await this.streamPreviewService.getPreview(params.streamKey);
|
||||
if (preview) {
|
||||
getMetricsService().counter({name: 'fluxer.stream.preview.fetched', value: 1});
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
async uploadPreview(params: {
|
||||
userId: UserID;
|
||||
streamKey: string;
|
||||
channelId: ChannelID;
|
||||
thumbnail: string;
|
||||
contentType?: string;
|
||||
}): Promise<void> {
|
||||
const parsedKey = this.getParsedStreamKeyOrThrow(params.streamKey);
|
||||
await this.assertStreamChannelAccess({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
parsedKey,
|
||||
});
|
||||
|
||||
let body: Uint8Array;
|
||||
try {
|
||||
body = Uint8Array.from(Buffer.from(params.thumbnail, 'base64'));
|
||||
} catch {
|
||||
throw new InvalidStreamThumbnailPayloadError();
|
||||
}
|
||||
if (body.byteLength === 0) {
|
||||
throw new EmptyStreamThumbnailPayloadError();
|
||||
}
|
||||
|
||||
await this.streamPreviewService.uploadPreview({
|
||||
streamKey: params.streamKey,
|
||||
channelId: params.channelId,
|
||||
userId: params.userId,
|
||||
body,
|
||||
contentType: params.contentType,
|
||||
});
|
||||
|
||||
getMetricsService().counter({name: 'fluxer.stream.preview.uploaded', value: 1});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/channel/services/BaseChannelAuthService';
|
||||
|
||||
export class ChannelAuthService extends BaseChannelAuthService {
|
||||
protected readonly options: ChannelAuthOptions = {
|
||||
errorOnMissingGuild: 'missing_permissions',
|
||||
validateNsfw: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
/*
|
||||
* 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, RoleID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createChannelID, createGuildID, createRoleID, createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {ChannelAuthService} from '@fluxer/api/src/channel/services/channel_data/ChannelAuthService';
|
||||
import type {ChannelUtilsService} from '@fluxer/api/src/channel/services/channel_data/ChannelUtilsService';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {ChannelHelpers} from '@fluxer/api/src/guild/services/channel/ChannelHelpers';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {ILiveKitService} from '@fluxer/api/src/infrastructure/ILiveKitService';
|
||||
import type {IVoiceRoomStore} from '@fluxer/api/src/infrastructure/IVoiceRoomStore';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {IInviteRepository} from '@fluxer/api/src/invite/IInviteRepository';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import {ChannelPermissionOverwrite} from '@fluxer/api/src/models/ChannelPermissionOverwrite';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {serializeChannelForAudit} from '@fluxer/api/src/utils/AuditSerializationUtils';
|
||||
import type {VoiceAvailabilityService} from '@fluxer/api/src/voice/VoiceAvailabilityService';
|
||||
import type {VoiceRegionAvailability} from '@fluxer/api/src/voice/VoiceModel';
|
||||
import type {IWebhookRepository} from '@fluxer/api/src/webhook/IWebhookRepository';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {ALL_PERMISSIONS, ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MAX_CHANNELS_PER_CATEGORY} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
||||
import {MaxCategoryChannelsError} from '@fluxer/errors/src/domains/channel/MaxCategoryChannelsError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {CannotExecuteOnDmError} from '@fluxer/errors/src/domains/core/CannotExecuteOnDmError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
|
||||
import {ChannelNameType} from '@fluxer/schema/src/primitives/ChannelValidators';
|
||||
|
||||
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,
|
||||
private guildRepository: IGuildRepositoryAggregate,
|
||||
private limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
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 InputValidationError.fromCode('rtc_region', ValidationErrorCodes.INVALID_OR_RESTRICTED_RTC_REGION, {
|
||||
region: data.rtc_region ?? 'unknown',
|
||||
});
|
||||
}
|
||||
} else if (this.liveKitService) {
|
||||
const availableRegions = this.liveKitService.getRegionMetadata().map((region) => region.id);
|
||||
if (availableRegions.length > 0 && !availableRegions.includes(data.rtc_region)) {
|
||||
throw InputValidationError.fromCode('rtc_region', ValidationErrorCodes.INVALID_RTC_REGION, {
|
||||
region: data.rtc_region ?? 'unknown',
|
||||
availableRegions: 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',
|
||||
);
|
||||
}
|
||||
|
||||
if (data.name !== undefined && channel.name !== updatedChannel.name) {
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.channels.renamed',
|
||||
dimensions: {
|
||||
guild_id: guildIdValue.toString(),
|
||||
channel_type: updatedChannel.type.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const updatedChild = await this.channelRepository.channelData.upsert({
|
||||
...childChannel.toRow(),
|
||||
parent_id: null,
|
||||
});
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.channels.deleted',
|
||||
dimensions: {
|
||||
guild_id: guildIdValue.toString(),
|
||||
channel_type: channel.type.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
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) {
|
||||
Logger.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);
|
||||
|
||||
let maxChannels = MAX_CHANNELS_PER_CATEGORY;
|
||||
const guild = await this.guildRepository.findUnique(params.guildId);
|
||||
const ctx = createLimitMatchContext({user: null, guildFeatures: guild?.features ?? null});
|
||||
const resolved = resolveLimit(this.limitConfigService.getConfigSnapshot(), ctx, 'max_channels_per_category', {
|
||||
evaluationContext: 'guild',
|
||||
});
|
||||
if (Number.isFinite(resolved) && resolved >= 0) {
|
||||
maxChannels = Math.floor(resolved);
|
||||
}
|
||||
|
||||
if (count >= maxChannels) {
|
||||
throw new MaxCategoryChannelsError(maxChannels);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
|
||||
if (!hasAdministrator && (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.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.toPermissionOverwrite()]),
|
||||
),
|
||||
});
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {GatewayDispatchEvent} from '@fluxer/api/src/constants/Gateway';
|
||||
import type {IPurgeQueue} from '@fluxer/api/src/infrastructure/CloudflarePurgeQueue';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
|
||||
export class ChannelUtilsService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userCacheService: UserCacheService,
|
||||
private storageService: IStorageService,
|
||||
private gatewayService: IGatewayService,
|
||||
private purgeQueue: IPurgeQueue,
|
||||
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.purgeQueue.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,181 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {createMessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {ChannelUtilsService} from '@fluxer/api/src/channel/services/channel_data/ChannelUtilsService';
|
||||
import type {MessagePersistenceService} from '@fluxer/api/src/channel/services/message/MessagePersistenceService';
|
||||
import type {AvatarService} from '@fluxer/api/src/infrastructure/AvatarService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
|
||||
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(await 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(await 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;
|
||||
}
|
||||
}
|
||||
130
packages/api/src/channel/services/group_dm/GroupDmHelpers.tsx
Normal file
130
packages/api/src/channel/services/group_dm/GroupDmHelpers.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/attachment/AttachmentDecayService';
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {channelIdToUserId} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import {collectMessageAttachments} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {GatewayDispatchEvent} from '@fluxer/api/src/constants/Gateway';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
|
||||
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 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,373 @@
|
||||
/*
|
||||
* 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, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createMessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {GroupDmUpdateService} from '@fluxer/api/src/channel/services/channel_data/GroupDmUpdateService';
|
||||
import {dispatchChannelDelete} from '@fluxer/api/src/channel/services/group_dm/GroupDmHelpers';
|
||||
import type {MessagePersistenceService} from '@fluxer/api/src/channel/services/message/MessagePersistenceService';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {UserPermissionUtils} from '@fluxer/api/src/utils/UserPermissionUtils';
|
||||
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import {MAX_GROUP_DM_RECIPIENTS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
||||
import {MaxGroupDmRecipientsError} from '@fluxer/errors/src/domains/channel/MaxGroupDmRecipientsError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {CannotRemoveOtherRecipientsError} from '@fluxer/errors/src/domains/core/CannotRemoveOtherRecipientsError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import {NotFriendsWithUserError} from '@fluxer/errors/src/domains/user/NotFriendsWithUserError';
|
||||
|
||||
export class GroupDmOperationsService {
|
||||
private readonly userPermissionUtils: UserPermissionUtils;
|
||||
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userRepository: IUserRepository,
|
||||
guildRepository: IGuildRepositoryAggregate,
|
||||
private userCacheService: UserCacheService,
|
||||
private gatewayService: IGatewayService,
|
||||
private mediaService: IMediaService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private groupDmUpdateService: GroupDmUpdateService,
|
||||
private messagePersistenceService: MessagePersistenceService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
const inviterUser = inviterId ? await this.userRepository.findUnique(inviterId) : null;
|
||||
const fallbackLimit = MAX_GROUP_DM_RECIPIENTS;
|
||||
const recipientLimit = this.resolveLimitForUser(inviterUser ?? null, 'max_group_dm_recipients', fallbackLimit);
|
||||
if (channel.recipientIds.size >= recipientLimit) {
|
||||
throw new MaxGroupDmRecipientsError(recipientLimit);
|
||||
}
|
||||
|
||||
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(await 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.fromCode('user_id', ValidationErrorCodes.USER_NOT_IN_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(await 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,
|
||||
});
|
||||
}
|
||||
|
||||
private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number {
|
||||
const ctx = createLimitMatchContext({user});
|
||||
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/channel/services/BaseChannelAuthService';
|
||||
|
||||
export class MessageInteractionAuthService extends BaseChannelAuthService {
|
||||
protected readonly options: ChannelAuthOptions = {
|
||||
errorOnMissingGuild: 'unknown_channel',
|
||||
validateNsfw: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {GatewayDispatchEvent} from '@fluxer/api/src/constants/Gateway';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import {ChannelTypes, TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {CannotSendMessageToNonTextChannelError} from '@fluxer/errors/src/domains/channel/CannotSendMessageToNonTextChannelError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
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});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
* 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 {MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createMessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from '@fluxer/api/src/channel/services/AuthenticatedChannel';
|
||||
import {MessageInteractionBase} from '@fluxer/api/src/channel/services/interaction/MessageInteractionBase';
|
||||
import type {MessagePersistenceService} from '@fluxer/api/src/channel/services/message/MessagePersistenceService';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {MessageTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {CannotEditSystemMessageError} from '@fluxer/errors/src/domains/channel/CannotEditSystemMessageError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import type {ChannelPinResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private async assertMessageHistoryAccess({
|
||||
authChannel,
|
||||
messageId,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
}): Promise<void> {
|
||||
if (!authChannel.guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cutoff = authChannel.guild.message_history_cutoff;
|
||||
if (!cutoff || snowflakeToDate(messageId).getTime() < new Date(cutoff).getTime()) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const hasReadHistory = !authChannel.guild || (await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY));
|
||||
|
||||
if (!hasReadHistory) {
|
||||
const cutoff = authChannel.guild?.message_history_cutoff;
|
||||
if (!cutoff) {
|
||||
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));
|
||||
|
||||
let filtered = sorted;
|
||||
if (!hasReadHistory) {
|
||||
const cutoff = authChannel.guild!.message_history_cutoff!;
|
||||
const cutoffTimestamp = new Date(cutoff).getTime();
|
||||
filtered = sorted.filter((message) => snowflakeToDate(message.id).getTime() >= cutoffTimestamp);
|
||||
}
|
||||
|
||||
const hasMore = filtered.length > pageSize;
|
||||
const trimmed = hasMore ? filtered.slice(0, pageSize) : filtered;
|
||||
|
||||
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> {
|
||||
return await withBusinessSpan(
|
||||
'fluxer.message.pin',
|
||||
'fluxer.messages.pinned',
|
||||
{
|
||||
channel_id: authChannel.channel.id.toString(),
|
||||
message_id: messageId.toString(),
|
||||
channel_type: authChannel.channel.type.toString(),
|
||||
},
|
||||
() => this.performPin({authChannel, messageId, userId, requestCache}),
|
||||
);
|
||||
}
|
||||
|
||||
private async performPin({
|
||||
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);
|
||||
await this.assertMessageHistoryAccess({authChannel, messageId});
|
||||
|
||||
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};
|
||||
const updatedMessage = 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});
|
||||
|
||||
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> {
|
||||
return await withBusinessSpan(
|
||||
'fluxer.message.unpin',
|
||||
'fluxer.messages.unpinned',
|
||||
{
|
||||
channel_id: authChannel.channel.id.toString(),
|
||||
message_id: messageId.toString(),
|
||||
channel_type: authChannel.channel.type.toString(),
|
||||
},
|
||||
() => this.performUnpin({authChannel, messageId, userId, requestCache}),
|
||||
);
|
||||
}
|
||||
|
||||
private async performUnpin({
|
||||
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);
|
||||
await this.assertMessageHistoryAccess({authChannel, messageId});
|
||||
|
||||
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};
|
||||
const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow());
|
||||
|
||||
await this.channelRepository.messageInteractions.removeChannelPin(channel.id, messageId);
|
||||
|
||||
await this.dispatchChannelPinsUpdate(channel);
|
||||
|
||||
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(await 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,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, createEmojiID, type MessageID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from '@fluxer/api/src/channel/services/AuthenticatedChannel';
|
||||
import {
|
||||
MessageInteractionBase,
|
||||
type ParsedEmoji,
|
||||
} from '@fluxer/api/src/channel/services/interaction/MessageInteractionBase';
|
||||
import {isGuildMemberTimedOut} from '@fluxer/api/src/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {MessageReaction} from '@fluxer/api/src/models/MessageReaction';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {mapUserToPartialResponse} from '@fluxer/api/src/user/UserMappers';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import {MAX_REACTIONS_PER_MESSAGE, MAX_USERS_PER_MESSAGE_REACTION} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {MaxReactionsPerMessageError} from '@fluxer/errors/src/domains/channel/MaxReactionsPerMessageError';
|
||||
import {MaxUsersPerMessageReactionError} from '@fluxer/errors/src/domains/channel/MaxUsersPerMessageReactionError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {CommunicationDisabledError} from '@fluxer/errors/src/domains/moderation/CommunicationDisabledError';
|
||||
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {isValidSingleUnicodeEmoji} from '@fluxer/schema/src/primitives/EmojiValidators';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
|
||||
const REACTION_CUSTOM_EMOJI_REGEX = /^(.+):(\d+)$/;
|
||||
|
||||
export class MessageReactionService extends MessageInteractionBase {
|
||||
constructor(
|
||||
gatewayService: IGatewayService,
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userRepository: IUserRepository,
|
||||
private guildRepository: IGuildRepositoryAggregate,
|
||||
private limitConfigService: LimitConfigService,
|
||||
) {
|
||||
super(gatewayService);
|
||||
}
|
||||
|
||||
private resolveLimitForUser(params: {
|
||||
user: User | null;
|
||||
guildFeatures?: Iterable<string> | null;
|
||||
key: LimitKey;
|
||||
fallback: number;
|
||||
}): number {
|
||||
const ctx = createLimitMatchContext({user: params.user, guildFeatures: params.guildFeatures});
|
||||
const limitValue = resolveLimit(this.limitConfigService.getConfigSnapshot(), ctx, params.key);
|
||||
if (!Number.isFinite(limitValue) || limitValue < 0) {
|
||||
return Math.max(0, Math.floor(params.fallback));
|
||||
}
|
||||
return Math.floor(limitValue);
|
||||
}
|
||||
|
||||
private async assertMessageHistoryAccess({
|
||||
authChannel,
|
||||
messageId,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
}): Promise<void> {
|
||||
const {guild, hasPermission} = authChannel;
|
||||
if (!guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await hasPermission(Permissions.READ_MESSAGE_HISTORY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cutoff = guild.message_history_cutoff;
|
||||
if (!cutoff || snowflakeToDate(messageId).getTime() < new Date(cutoff).getTime()) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
}
|
||||
|
||||
async getUsersForReaction({
|
||||
authChannel,
|
||||
messageId,
|
||||
emoji,
|
||||
limit,
|
||||
after,
|
||||
userId,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
emoji: string;
|
||||
limit?: number;
|
||||
after?: UserID;
|
||||
userId: UserID;
|
||||
}): Promise<Array<UserPartialResponse>> {
|
||||
const {channel, guild} = authChannel;
|
||||
this.ensureTextChannel(channel);
|
||||
await this.assertMessageHistoryAccess({authChannel, messageId});
|
||||
|
||||
const requestingUser = await this.userRepository.findUnique(userId);
|
||||
const guildFeatures = guild?.features ?? null;
|
||||
const runtimeMaxUsers = this.resolveLimitForUser({
|
||||
user: requestingUser ?? null,
|
||||
guildFeatures,
|
||||
key: 'max_users_per_message_reaction',
|
||||
fallback: MAX_USERS_PER_MESSAGE_REACTION,
|
||||
});
|
||||
const limitCap = runtimeMaxUsers > 0 ? runtimeMaxUsers : Number.MAX_SAFE_INTEGER;
|
||||
const defaultLimit = Math.min(limitCap, 25);
|
||||
const requestedLimit = limit !== undefined && Number.isFinite(limit) ? Math.floor(limit) : defaultLimit;
|
||||
const validatedLimit = Math.min(Math.max(requestedLimit, 1), limitCap);
|
||||
|
||||
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);
|
||||
|
||||
getMetricsService().counter({name: 'fluxer.reactions.users_fetched'});
|
||||
|
||||
return users.map((user) => mapUserToPartialResponse(user));
|
||||
}
|
||||
|
||||
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);
|
||||
await this.assertMessageHistoryAccess({authChannel, messageId});
|
||||
|
||||
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 requestingUser = await this.userRepository.findUnique(userId);
|
||||
const guildFeatures = guild?.features ?? null;
|
||||
const maxUsersPerReaction = this.resolveLimitForUser({
|
||||
user: requestingUser ?? null,
|
||||
guildFeatures,
|
||||
key: 'max_users_per_message_reaction',
|
||||
fallback: MAX_USERS_PER_MESSAGE_REACTION,
|
||||
});
|
||||
const maxReactionsPerMessage = this.resolveLimitForUser({
|
||||
user: requestingUser ?? null,
|
||||
guildFeatures,
|
||||
key: 'max_reactions_per_message',
|
||||
fallback: MAX_REACTIONS_PER_MESSAGE,
|
||||
});
|
||||
|
||||
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 >= maxUsersPerReaction) {
|
||||
throw new MaxUsersPerMessageReactionError(maxUsersPerReaction);
|
||||
}
|
||||
|
||||
const uniqueReactionCount = await this.channelRepository.messageInteractions.countUniqueReactions(
|
||||
channel.id,
|
||||
messageId,
|
||||
);
|
||||
|
||||
if (isMemberTimedOut && reactionCount === 0) {
|
||||
throw new CommunicationDisabledError();
|
||||
}
|
||||
|
||||
if (uniqueReactionCount >= maxReactionsPerMessage) {
|
||||
throw new MaxReactionsPerMessageError(maxReactionsPerMessage);
|
||||
}
|
||||
|
||||
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);
|
||||
await this.assertMessageHistoryAccess({authChannel, messageId});
|
||||
|
||||
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);
|
||||
await this.assertMessageHistoryAccess({authChannel, messageId});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
getMetricsService().counter({name: 'fluxer.reactions.emoji_cleared'});
|
||||
}
|
||||
|
||||
async removeAllReactions({
|
||||
authChannel,
|
||||
messageId,
|
||||
actorId,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
actorId: UserID;
|
||||
}): Promise<void> {
|
||||
const {channel, guild, hasPermission} = authChannel;
|
||||
this.ensureTextChannel(channel);
|
||||
await this.assertMessageHistoryAccess({authChannel, messageId});
|
||||
|
||||
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});
|
||||
|
||||
getMetricsService().counter({name: 'fluxer.reactions.all_cleared'});
|
||||
}
|
||||
|
||||
async getMessageReactions({
|
||||
authChannel,
|
||||
messageId,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
}): Promise<Array<MessageReaction>> {
|
||||
await this.assertMessageHistoryAccess({authChannel, messageId});
|
||||
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 hasGlobalExpressions = 0;
|
||||
if (userId) {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
const ctx = createLimitMatchContext({user});
|
||||
hasGlobalExpressions = resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'feature_global_expressions',
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
const emoji = await this.guildRepository.getEmojiById(emojiIdBigInt);
|
||||
if (!emoji) {
|
||||
throw InputValidationError.fromCode('emoji', ValidationErrorCodes.CUSTOM_EMOJI_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (hasGlobalExpressions === 0 && emoji.guildId.toString() !== guildId) {
|
||||
throw InputValidationError.fromCode('emoji', ValidationErrorCodes.CUSTOM_EMOJIS_REQUIRE_PREMIUM_OUTSIDE_SOURCE);
|
||||
}
|
||||
|
||||
if (hasPermission) {
|
||||
const canUseExternalEmojis = await hasPermission(Permissions.USE_EXTERNAL_EMOJIS);
|
||||
if (!canUseExternalEmojis) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: emoji.name,
|
||||
animated: emoji.isAnimated,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isValidSingleUnicodeEmoji(decodedEmoji)) {
|
||||
throw InputValidationError.fromCode('emoji', ValidationErrorCodes.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> {
|
||||
await this.dispatchEvent({
|
||||
channel: params.channel,
|
||||
event: 'MESSAGE_REACTION_REMOVE_EMOJI',
|
||||
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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {AuthenticatedChannel} from '@fluxer/api/src/channel/services/AuthenticatedChannel';
|
||||
import {MessageInteractionBase} from '@fluxer/api/src/channel/services/interaction/MessageInteractionBase';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {ReadStateService} from '@fluxer/api/src/read_state/ReadStateService';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
|
||||
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,417 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {AttachmentToProcess} from '@fluxer/api/src/channel/AttachmentDTOs';
|
||||
import {
|
||||
getContentType,
|
||||
isMediaFile,
|
||||
makeAttachmentCdnKey,
|
||||
validateAttachmentIds,
|
||||
} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {ICsamReportSnapshotService} from '@fluxer/api/src/csam/ICsamReportSnapshotService';
|
||||
import type {ISynchronousCsamScanner} from '@fluxer/api/src/csam/ISynchronousCsamScanner';
|
||||
import type {SynchronousCsamScanResult} from '@fluxer/api/src/csam/SynchronousCsamScanner';
|
||||
import type {MessageAttachment} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {ISnowflakeService} from '@fluxer/api/src/infrastructure/ISnowflakeService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {recordAttachmentOperation} from '@fluxer/api/src/telemetry/MessageTelemetry';
|
||||
import {MessageAttachmentFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {recordCounter} from '@fluxer/telemetry/src/Metrics';
|
||||
import type {IVirusScanService} from '@fluxer/virus_scan/src/IVirusScanService';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface HandleCsamMatchParams {
|
||||
scanResult: SynchronousCsamScanResult;
|
||||
result: ProcessedAttachment;
|
||||
results: Array<ProcessedAttachment>;
|
||||
message: Message;
|
||||
guild?: GuildResponse | null;
|
||||
csamReportSnapshotService: ICsamReportSnapshotService;
|
||||
}
|
||||
|
||||
export class AttachmentProcessingService {
|
||||
constructor(
|
||||
private storageService: IStorageService,
|
||||
private mediaService: IMediaService,
|
||||
private virusScanService: IVirusScanService,
|
||||
private snowflakeService: ISnowflakeService,
|
||||
private readonly synchronousCsamScanner?: ISynchronousCsamScanner,
|
||||
private readonly csamReportSnapshotService?: ICsamReportSnapshotService,
|
||||
) {}
|
||||
|
||||
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};
|
||||
}
|
||||
|
||||
if (this.synchronousCsamScanner && this.csamReportSnapshotService) {
|
||||
for (const result of results) {
|
||||
const scanResult = await this.synchronousCsamScanner.scanMedia({
|
||||
bucket: result.copyOperation.sourceBucket,
|
||||
key: result.copyOperation.sourceKey,
|
||||
contentType: result.attachment.content_type,
|
||||
});
|
||||
|
||||
if (scanResult.isMatch) {
|
||||
await this.handleCsamMatch({
|
||||
scanResult,
|
||||
result,
|
||||
results,
|
||||
message: params.message,
|
||||
guild: params.guild,
|
||||
csamReportSnapshotService: this.csamReportSnapshotService,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
recordCounter({
|
||||
name: 'attachment.created',
|
||||
dimensions: {
|
||||
content_type: contentType,
|
||||
attachment_extension: extension,
|
||||
channel_type: channelType.toString(),
|
||||
},
|
||||
});
|
||||
recordCounter({
|
||||
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 handleCsamMatch(params: HandleCsamMatchParams): Promise<never> {
|
||||
const messageId = params.message.id.toString();
|
||||
const bucket = params.result.copyOperation.sourceBucket;
|
||||
const key = params.result.copyOperation.sourceKey;
|
||||
|
||||
if (!params.scanResult.matchResult) {
|
||||
Logger.error({bucket, key, messageId}, 'CSAM match detected without match details');
|
||||
await this.deleteCsamUploads(params.results);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
|
||||
const mediaData = await this.storageService.readObject(bucket, key);
|
||||
|
||||
if (!mediaData) {
|
||||
Logger.error({bucket, key, messageId}, 'CSAM match detected but media data could not be read');
|
||||
await this.deleteCsamUploads(params.results);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
|
||||
await params.csamReportSnapshotService.createSnapshot({
|
||||
scanResult: params.scanResult.matchResult,
|
||||
resourceType: 'attachment',
|
||||
userId: params.message.authorId?.toString() ?? null,
|
||||
guildId: params.guild?.id ?? null,
|
||||
channelId: params.message.channelId.toString(),
|
||||
messageId,
|
||||
mediaData: Buffer.from(mediaData),
|
||||
filename: params.result.attachment.filename,
|
||||
contentType: params.result.attachment.content_type,
|
||||
});
|
||||
|
||||
await this.deleteCsamUploads(params.results);
|
||||
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
|
||||
private async deleteCsamUploads(results: Array<ProcessedAttachment>): Promise<void> {
|
||||
for (const result of results) {
|
||||
await this.storageService.deleteObject(result.copyOperation.sourceBucket, result.copyOperation.sourceKey);
|
||||
}
|
||||
}
|
||||
|
||||
private async processAttachment(params: ProcessAttachmentParams): Promise<ProcessedAttachment> {
|
||||
try {
|
||||
const {message, attachment, index, isNSFWAllowed} = params;
|
||||
|
||||
const uploadedFile = await this.storageService.getObjectMetadata(
|
||||
Config.s3.buckets.uploads,
|
||||
attachment.upload_filename,
|
||||
);
|
||||
|
||||
if (!uploadedFile) {
|
||||
throw InputValidationError.fromCode(
|
||||
`attachments.${index}.upload_filename`,
|
||||
ValidationErrorCodes.FILE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const attachmentId = createAttachmentID(await 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 clientDuration: number | null = attachment.duration ?? null;
|
||||
const waveform: string | null = attachment.waveform ?? 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);
|
||||
|
||||
recordAttachmentOperation({
|
||||
operation: 'process',
|
||||
contentType: attachment.content_type || 'unknown',
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
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: duration ?? clientDuration,
|
||||
nsfw,
|
||||
waveform,
|
||||
},
|
||||
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,
|
||||
filename: attachment.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;
|
||||
}
|
||||
}
|
||||
|
||||
const isAudio = contentType.startsWith('audio/');
|
||||
if (waveform && !isAudio) {
|
||||
throw InputValidationError.fromCode(
|
||||
`attachments.${index}.upload_filename`,
|
||||
ValidationErrorCodes.VOICE_MESSAGES_ATTACHMENT_MUST_BE_AUDIO,
|
||||
);
|
||||
}
|
||||
|
||||
recordAttachmentOperation({
|
||||
operation: 'process',
|
||||
contentType: attachment.content_type || 'unknown',
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
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: duration ?? clientDuration,
|
||||
nsfw,
|
||||
waveform,
|
||||
},
|
||||
copyOperation: {
|
||||
sourceBucket: Config.s3.buckets.uploads,
|
||||
sourceKey: attachment.upload_filename,
|
||||
destinationBucket: Config.s3.buckets.cdn,
|
||||
destinationKey: cdnKey,
|
||||
newContentType: contentType,
|
||||
},
|
||||
hasVirusDetected,
|
||||
};
|
||||
} catch (error) {
|
||||
recordAttachmentOperation({
|
||||
operation: 'process',
|
||||
contentType: params.attachment.content_type || 'unknown',
|
||||
status: 'error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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.fromCode('attachment', ValidationErrorCodes.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');
|
||||
});
|
||||
}
|
||||
}
|
||||
68
packages/api/src/channel/services/message/DmScopeUtils.tsx
Normal file
68
packages/api/src/channel/services/message/DmScopeUtils.tsx
Normal file
@@ -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 type {ChannelID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
|
||||
export type DmSearchScope = 'all_dms' | 'open_dms';
|
||||
export interface DmScopeOptions {
|
||||
scope: DmSearchScope;
|
||||
userId: UserID;
|
||||
userRepository: IUserRepository;
|
||||
includeChannelId?: ChannelID | null;
|
||||
}
|
||||
|
||||
export async function getDmChannelIdsForScope({
|
||||
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 (scope === 'all_dms') {
|
||||
const historicalIds = await userRepository.listHistoricalDmChannelIds(userId);
|
||||
for (const channelId of historicalIds) {
|
||||
channelIdStrings.add(channelId.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (includeChannelId) {
|
||||
channelIdStrings.add(includeChannelId.toString());
|
||||
}
|
||||
|
||||
return Array.from(channelIdStrings);
|
||||
}
|
||||
@@ -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 {MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
|
||||
export class MessageAnonymizationService {
|
||||
constructor(private channelRepository: IChannelRepositoryAggregate) {}
|
||||
|
||||
async anonymizeMessagesByAuthor(originalAuthorId: UserID, newAuthorId: UserID): Promise<void> {
|
||||
const CHUNK_SIZE = 100;
|
||||
let lastMessageId: MessageID | undefined;
|
||||
let processedCount = 0;
|
||||
|
||||
while (true) {
|
||||
const messagesToAnonymize = await this.channelRepository.messages.listMessagesByAuthor(
|
||||
originalAuthorId,
|
||||
CHUNK_SIZE,
|
||||
lastMessageId,
|
||||
);
|
||||
|
||||
if (messagesToAnonymize.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const {channelId, messageId} of messagesToAnonymize) {
|
||||
await this.channelRepository.messages.anonymizeMessage(channelId, messageId, newAuthorId);
|
||||
}
|
||||
|
||||
processedCount += messagesToAnonymize.length;
|
||||
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 {BaseChannelAuthService, type ChannelAuthOptions} from '@fluxer/api/src/channel/services/BaseChannelAuthService';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {checkGuildVerificationWithResponse} from '@fluxer/api/src/utils/GuildVerificationUtils';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
export class MessageChannelAuthService extends BaseChannelAuthService {
|
||||
protected readonly options: ChannelAuthOptions = {
|
||||
errorOnMissingGuild: 'unknown_channel',
|
||||
validateNsfw: true,
|
||||
};
|
||||
|
||||
async checkGuildVerification({
|
||||
user,
|
||||
guild,
|
||||
member,
|
||||
}: {
|
||||
user: User;
|
||||
guild: GuildResponse;
|
||||
member: GuildMemberResponse;
|
||||
}): Promise<void> {
|
||||
checkGuildVerificationWithResponse({user, guild, member});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {PackService} from '@fluxer/api/src/pack/PackService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import * as EmojiUtils from '@fluxer/api/src/utils/EmojiUtils';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildExplicitContentFilterTypes} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
export class MessageContentService {
|
||||
constructor(
|
||||
private userRepository: IUserRepository,
|
||||
private guildRepository: IGuildRepositoryAggregate,
|
||||
private packService: PackService,
|
||||
private limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
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,
|
||||
limitConfigService: this.limitConfigService,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {createMessageID, createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {MessageChannelAuthService} from '@fluxer/api/src/channel/services/message/MessageChannelAuthService';
|
||||
import type {MessageDispatchService} from '@fluxer/api/src/channel/services/message/MessageDispatchService';
|
||||
import {isOperationDisabled, purgeMessageAttachments} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {MessageSearchService} from '@fluxer/api/src/channel/services/message/MessageSearchService';
|
||||
import type {MessageValidationService} from '@fluxer/api/src/channel/services/message/MessageValidationService';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {IPurgeQueue} from '@fluxer/api/src/infrastructure/CloudflarePurgeQueue';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {CannotExecuteOnDmError} from '@fluxer/errors/src/domains/core/CannotExecuteOnDmError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {createSnowflakeFromTimestamp} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
interface MessageDeleteServiceDeps {
|
||||
channelRepository: IChannelRepositoryAggregate;
|
||||
storageService: IStorageService;
|
||||
purgeQueue: IPurgeQueue;
|
||||
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.purgeQueue);
|
||||
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> {
|
||||
return await withBusinessSpan(
|
||||
'fluxer.message.bulk_delete',
|
||||
'fluxer.messages.bulk_deleted',
|
||||
{
|
||||
channel_id: channelId.toString(),
|
||||
message_count: messageIds.length.toString(),
|
||||
},
|
||||
() => this.performBulkDelete({userId, channelId, messageIds}),
|
||||
);
|
||||
}
|
||||
|
||||
private async performBulkDelete({
|
||||
userId,
|
||||
channelId,
|
||||
messageIds,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageIds: Array<MessageID>;
|
||||
}): Promise<void> {
|
||||
if (messageIds.length === 0) {
|
||||
throw InputValidationError.fromCode('message_ids', ValidationErrorCodes.MESSAGE_IDS_CANNOT_BE_EMPTY);
|
||||
}
|
||||
|
||||
if (messageIds.length > 100) {
|
||||
throw InputValidationError.fromCode('message_ids', ValidationErrorCodes.CANNOT_DELETE_MORE_THAN_100_MESSAGES);
|
||||
}
|
||||
|
||||
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.purgeQueue),
|
||||
),
|
||||
);
|
||||
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 * ms('1 day');
|
||||
const afterSnowflake = createMessageID(createSnowflakeFromTimestamp(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.purgeQueue),
|
||||
),
|
||||
);
|
||||
|
||||
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,179 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/attachment/AttachmentDecayService';
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {channelIdToUserId} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import {collectMessageAttachments} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {GatewayDispatchEvent} from '@fluxer/api/src/constants/Gateway';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
|
||||
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()),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
179
packages/api/src/channel/services/message/MessageEditService.tsx
Normal file
179
packages/api/src/channel/services/message/MessageEditService.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {MessageUpdateRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {MessageChannelAuthService} from '@fluxer/api/src/channel/services/message/MessageChannelAuthService';
|
||||
import type {MessageDispatchService} from '@fluxer/api/src/channel/services/message/MessageDispatchService';
|
||||
import type {MessageEmbedAttachmentResolver} from '@fluxer/api/src/channel/services/message/MessageEmbedAttachmentResolver';
|
||||
import {isOperationDisabled} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {MessageMentionService} from '@fluxer/api/src/channel/services/message/MessageMentionService';
|
||||
import type {MessagePersistenceService} from '@fluxer/api/src/channel/services/message/MessagePersistenceService';
|
||||
import type {MessageProcessingService} from '@fluxer/api/src/channel/services/message/MessageProcessingService';
|
||||
import type {MessageSearchService} from '@fluxer/api/src/channel/services/message/MessageSearchService';
|
||||
import type {MessageValidationService} from '@fluxer/api/src/channel/services/message/MessageValidationService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
|
||||
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, {
|
||||
isUpdate: true,
|
||||
guildFeatures: guild?.features ?? null,
|
||||
});
|
||||
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,190 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {AttachmentRequestData} from '@fluxer/api/src/channel/AttachmentDTOs';
|
||||
import type {RichEmbedMediaWithMetadata} from '@fluxer/api/src/channel/EmbedTypes';
|
||||
import {makeAttachmentCdnUrl} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {RichEmbedRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
|
||||
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.fromCode(
|
||||
'embeds',
|
||||
ValidationErrorCodes.CANNOT_REFERENCE_ATTACHMENTS_WITHOUT_ATTACHMENTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const validateAttachmentReference = (filename: string, field: string, embedIndex: number) => {
|
||||
if (!availableFilenames.has(filename)) {
|
||||
throw InputValidationError.fromCode(
|
||||
`embeds[${embedIndex}].${field}`,
|
||||
ValidationErrorCodes.REFERENCED_ATTACHMENT_NOT_FOUND,
|
||||
{filename},
|
||||
);
|
||||
}
|
||||
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
if (!extension || !SUPPORTED_IMAGE_EXTENSIONS.has(extension)) {
|
||||
throw InputValidationError.fromCode(
|
||||
`embeds[${embedIndex}].${field}`,
|
||||
ValidationErrorCodes.ATTACHMENT_MUST_BE_IMAGE,
|
||||
{filename},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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.fromCode(field, ValidationErrorCodes.REFERENCED_ATTACHMENT_NOT_FOUND, {filename});
|
||||
}
|
||||
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
if (!extension || !SUPPORTED_IMAGE_EXTENSIONS.has(extension)) {
|
||||
throw InputValidationError.fromCode(field, ValidationErrorCodes.ATTACHMENT_MUST_BE_IMAGE, {filename});
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
284
packages/api/src/channel/services/message/MessageHelpers.tsx
Normal file
284
packages/api/src/channel/services/message/MessageHelpers.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
* 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, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createAttachmentID, userIdToChannelId} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {
|
||||
MessageSnapshot as CassandraMessageSnapshot,
|
||||
MessageAttachment,
|
||||
} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {IPurgeQueue} from '@fluxer/api/src/infrastructure/CloudflarePurgeQueue';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import {Attachment} from '@fluxer/api/src/models/Attachment';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {MessageSnapshot as MessageSnapshotModel} from '@fluxer/api/src/models/MessageSnapshot';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {MessageFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ATTACHMENT_MAX_SIZE_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {FileSizeTooLargeError} from '@fluxer/errors/src/domains/core/FileSizeTooLargeError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {getContentTypeFromFilename} from '@fluxer/mime_utils/src/ContentTypeUtils';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
export const MESSAGE_NONCE_TTL = seconds('5 minutes');
|
||||
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 getContentTypeFromFilename(filename);
|
||||
}
|
||||
|
||||
export function validateAttachmentIds(attachments: Array<{id: bigint}>): void {
|
||||
const ids = new Set(attachments.map((a) => a.id));
|
||||
if (ids.size !== attachments.length) {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.DUPLICATE_ATTACHMENT_IDS_NOT_ALLOWED);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateTotalAttachmentSize(
|
||||
attachments: Array<{size: number | bigint}>,
|
||||
user: User,
|
||||
limitConfigService: LimitConfigService,
|
||||
): void {
|
||||
const fallbackMaxSize = ATTACHMENT_MAX_SIZE_NON_PREMIUM;
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const maxFileSize = Math.floor(
|
||||
resolveLimitSafe(limitConfigService.getConfigSnapshot(), ctx, 'max_attachment_file_size', fallbackMaxSize, 'user'),
|
||||
);
|
||||
|
||||
const hasFileExceedingLimit = attachments.some(({size}) => Number(size) > maxFileSize);
|
||||
if (hasFileExceedingLimit) {
|
||||
throw new FileSizeTooLargeError(maxFileSize);
|
||||
}
|
||||
}
|
||||
|
||||
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(await 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,
|
||||
waveform: attachment.waveform ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return clonedAttachments;
|
||||
}
|
||||
|
||||
export async function createMessageSnapshotsForForward(
|
||||
referencedMessage: Message,
|
||||
user: User,
|
||||
destinationChannelId: ChannelID,
|
||||
storageService: IStorageService,
|
||||
snowflakeService: SnowflakeService,
|
||||
limitConfigService: LimitConfigService,
|
||||
): Promise<Array<MessageSnapshotModel>> {
|
||||
if (referencedMessage.messageSnapshots && referencedMessage.messageSnapshots.length > 0) {
|
||||
const snapshot = referencedMessage.messageSnapshots[0];
|
||||
const snapshotAttachments = snapshot.attachments ?? [];
|
||||
const snapshotEmbeds =
|
||||
(snapshot.flags & MessageFlags.SUPPRESS_EMBEDS) === 0
|
||||
? snapshot.embeds.map((embed) => embed.toMessageEmbed())
|
||||
: [];
|
||||
|
||||
validateTotalAttachmentSize(snapshotAttachments, user, limitConfigService);
|
||||
|
||||
const attachmentsForClone = snapshotAttachments.map((att) =>
|
||||
att instanceof Attachment ? att : new Attachment(att),
|
||||
);
|
||||
|
||||
const clonedAttachments = await cloneAttachments(
|
||||
attachmentsForClone,
|
||||
referencedMessage.channelId,
|
||||
destinationChannelId,
|
||||
storageService,
|
||||
snowflakeService,
|
||||
);
|
||||
|
||||
const snapshotData: CassandraMessageSnapshot = {
|
||||
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: snapshotEmbeds.length > 0 ? snapshotEmbeds : null,
|
||||
sticker_items: snapshot.stickers.map((sticker) => sticker.toMessageStickerItem()),
|
||||
type: snapshot.type,
|
||||
flags: snapshot.flags,
|
||||
};
|
||||
|
||||
return [new MessageSnapshotModel(snapshotData)];
|
||||
}
|
||||
|
||||
validateTotalAttachmentSize(referencedMessage.attachments, user, limitConfigService);
|
||||
|
||||
const clonedAttachments = await cloneAttachments(
|
||||
referencedMessage.attachments,
|
||||
referencedMessage.channelId,
|
||||
destinationChannelId,
|
||||
storageService,
|
||||
snowflakeService,
|
||||
);
|
||||
const referencedMessageEmbeds =
|
||||
(referencedMessage.flags & MessageFlags.SUPPRESS_EMBEDS) === 0
|
||||
? referencedMessage.embeds.map((embed) => embed.toMessageEmbed())
|
||||
: [];
|
||||
|
||||
const snapshotData: CassandraMessageSnapshot = {
|
||||
content: referencedMessage.content,
|
||||
timestamp: snowflakeToDate(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: referencedMessageEmbeds.length > 0 ? referencedMessageEmbeds : null,
|
||||
sticker_items:
|
||||
referencedMessage.stickers.length > 0 ? referencedMessage.stickers.map((s) => s.toMessageStickerItem()) : null,
|
||||
type: referencedMessage.type,
|
||||
flags: referencedMessage.flags,
|
||||
};
|
||||
|
||||
return [new MessageSnapshotModel(snapshotData)];
|
||||
}
|
||||
|
||||
export async function purgeMessageAttachments(
|
||||
message: Message,
|
||||
storageService: IStorageService,
|
||||
purgeQueue: IPurgeQueue,
|
||||
): 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 purgeQueue.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)];
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
cleanTextForMentions,
|
||||
isOperationDisabled,
|
||||
isPersonalNotesChannel,
|
||||
} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {ChannelTypes, MessageTypes, SENDABLE_MESSAGE_FLAGS} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ROLE_MENTION_REGEX, USER_MENTION_REGEX} from '@fluxer/constants/src/Core';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {
|
||||
ALLOWED_MENTIONS_PARSE,
|
||||
type AllowedMentionsRequest,
|
||||
} from '@fluxer/schema/src/domains/message/SharedMessageSchemas';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
|
||||
interface MentionData {
|
||||
userMentions: Set<UserID>;
|
||||
roleMentions: Set<RoleID>;
|
||||
flags: number;
|
||||
mentionsEveryone: boolean;
|
||||
mentionsHere: boolean;
|
||||
}
|
||||
|
||||
export class MessageMentionService {
|
||||
constructor(
|
||||
private userRepository: IUserRepository,
|
||||
private guildRepository: IGuildRepositoryAggregate,
|
||||
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 hasExplicitLists = allowedMentions.users !== undefined || allowedMentions.roles !== undefined;
|
||||
// Discord-like semantics:
|
||||
// - If `parse` is omitted but explicit `users`/`roles` lists are present, treat parse as empty (no auto-parsing).
|
||||
// - Otherwise default to parsing all mention types.
|
||||
const parse = allowedMentions.parse ?? (hasExplicitLists ? [] : 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.fromCode(
|
||||
'allowed_mentions',
|
||||
ValidationErrorCodes.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 {
|
||||
if (shouldParse) {
|
||||
// Auto-parse mentions from content: keep what was extracted.
|
||||
return;
|
||||
}
|
||||
|
||||
// No auto-parsing: only allow explicitly whitelisted ids.
|
||||
if (allowedList.length === 0) {
|
||||
mentions.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
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,127 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {createAttachmentID, createChannelID, createMemeID, createMessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import {makeAttachmentCdnKey} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {MessageAttachment} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {IFavoriteMemeRepository} from '@fluxer/api/src/favorite_meme/IFavoriteMemeRepository';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {MessageAttachmentFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
|
||||
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.fromCode('favorite_meme_id', ValidationErrorCodes.FAVORITE_MEME_NOT_FOUND);
|
||||
}
|
||||
|
||||
const memeAttachmentId = createAttachmentID(await 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) {
|
||||
flags |= MessageAttachmentFlags.IS_ANIMATED;
|
||||
}
|
||||
|
||||
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,
|
||||
waveform: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {MessageChannelAuthService} from '@fluxer/api/src/channel/services/message/MessageChannelAuthService';
|
||||
import {MessageDeleteService} from '@fluxer/api/src/channel/services/message/MessageDeleteService';
|
||||
import type {MessageDispatchService} from '@fluxer/api/src/channel/services/message/MessageDispatchService';
|
||||
import {MessageEditService} from '@fluxer/api/src/channel/services/message/MessageEditService';
|
||||
import type {MessageMentionService} from '@fluxer/api/src/channel/services/message/MessageMentionService';
|
||||
import {MessageOperationsHelpers} from '@fluxer/api/src/channel/services/message/MessageOperationsHelpers';
|
||||
import type {MessagePersistenceService} from '@fluxer/api/src/channel/services/message/MessagePersistenceService';
|
||||
import type {MessageProcessingService} from '@fluxer/api/src/channel/services/message/MessageProcessingService';
|
||||
import type {MessageSearchService} from '@fluxer/api/src/channel/services/message/MessageSearchService';
|
||||
import {MessageSendService} from '@fluxer/api/src/channel/services/message/MessageSendService';
|
||||
import type {MessageValidationService} from '@fluxer/api/src/channel/services/message/MessageValidationService';
|
||||
import type {IFavoriteMemeRepository} from '@fluxer/api/src/favorite_meme/IFavoriteMemeRepository';
|
||||
import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService';
|
||||
import type {IPurgeQueue} from '@fluxer/api/src/infrastructure/CloudflarePurgeQueue';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {Webhook} from '@fluxer/api/src/models/Webhook';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
|
||||
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,
|
||||
purgeQueue: IPurgeQueue,
|
||||
favoriteMemeRepository: IFavoriteMemeRepository,
|
||||
validationService: MessageValidationService,
|
||||
mentionService: MessageMentionService,
|
||||
searchService: MessageSearchService,
|
||||
persistenceService: MessagePersistenceService,
|
||||
channelAuthService: MessageChannelAuthService,
|
||||
processingService: MessageProcessingService,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
dispatchService: MessageDispatchService,
|
||||
limitConfigService: LimitConfigService,
|
||||
) {
|
||||
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,
|
||||
limitConfigService,
|
||||
});
|
||||
|
||||
this.editService = new MessageEditService({
|
||||
channelRepository,
|
||||
userRepository,
|
||||
validationService,
|
||||
persistenceService,
|
||||
channelAuthService,
|
||||
processingService,
|
||||
dispatchService,
|
||||
searchService,
|
||||
embedAttachmentResolver: persistenceService.getEmbedAttachmentResolver(),
|
||||
mentionService,
|
||||
});
|
||||
|
||||
this.deleteService = new MessageDeleteService({
|
||||
channelRepository,
|
||||
storageService,
|
||||
purgeQueue,
|
||||
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,628 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/attachment/AttachmentDecayService';
|
||||
import type {
|
||||
AttachmentID,
|
||||
ChannelID,
|
||||
GuildID,
|
||||
MessageID,
|
||||
RoleID,
|
||||
StickerID,
|
||||
UserID,
|
||||
WebhookID,
|
||||
} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createAttachmentID, createGuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {AttachmentToProcess} from '@fluxer/api/src/channel/AttachmentDTOs';
|
||||
import type {MessageUpdateRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import {AttachmentProcessingService} from '@fluxer/api/src/channel/services/message/AttachmentProcessingService';
|
||||
import {MessageContentService} from '@fluxer/api/src/channel/services/message/MessageContentService';
|
||||
import {MessageEmbedAttachmentResolver} from '@fluxer/api/src/channel/services/message/MessageEmbedAttachmentResolver';
|
||||
import {VIRUS_MESSAGE_PREFIX, VIRUS_RECIPE_SUGGESTIONS} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import {MessageStickerService} from '@fluxer/api/src/channel/services/message/MessageStickerService';
|
||||
import type {
|
||||
MessageAttachment,
|
||||
MessageEmbed,
|
||||
MessageReference,
|
||||
MessageStickerItem,
|
||||
} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {EmbedService} from '@fluxer/api/src/infrastructure/EmbedService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {ISnowflakeService} from '@fluxer/api/src/infrastructure/ISnowflakeService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {MessageSnapshot} from '@fluxer/api/src/models/MessageSnapshot';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {PackService} from '@fluxer/api/src/pack/PackService';
|
||||
import type {ReadStateService} from '@fluxer/api/src/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {MessageFlags, Permissions, SENDABLE_MESSAGE_FLAGS} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {RichEmbedRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import type {AllowedMentionsRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
|
||||
import type {IVirusScanService} from '@fluxer/virus_scan/src/IVirusScanService';
|
||||
|
||||
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: IGuildRepositoryAggregate,
|
||||
private packService: PackService,
|
||||
private embedService: EmbedService,
|
||||
storageService: IStorageService,
|
||||
mediaService: IMediaService,
|
||||
virusScanService: IVirusScanService,
|
||||
snowflakeService: ISnowflakeService,
|
||||
private readStateService: ReadStateService,
|
||||
limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.attachmentService = new AttachmentProcessingService(
|
||||
storageService,
|
||||
mediaService,
|
||||
virusScanService,
|
||||
snowflakeService,
|
||||
);
|
||||
this.contentService = new MessageContentService(
|
||||
this.userRepository,
|
||||
guildRepository,
|
||||
this.packService,
|
||||
limitConfigService,
|
||||
);
|
||||
this.stickerService = new MessageStickerService(
|
||||
this.userRepository,
|
||||
guildRepository,
|
||||
this.packService,
|
||||
limitConfigService,
|
||||
);
|
||||
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 && params.messageSnapshots.length > 0
|
||||
? params.messageSnapshots.map((snapshot) => snapshot.toMessageSnapshot())
|
||||
: 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 = snowflakeToDate(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.fromCode('message', ValidationErrorCodes.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 EditNewAttachment = AttachmentToProcess & {upload_filename: string};
|
||||
type EditExistingAttachment = {id: bigint; title?: string | null; description?: string | null};
|
||||
type EditAttachment = EditNewAttachment | EditExistingAttachment;
|
||||
|
||||
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 existingAtt = att as EditExistingAttachment;
|
||||
const refId = createAttachmentID(existingAtt.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 existingAtt && existingAtt.title !== undefined) {
|
||||
updated.title = existingAtt.title;
|
||||
}
|
||||
if ('description' in existingAtt && existingAtt.description !== undefined) {
|
||||
updated.description = existingAtt.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; updatedAttachments?: Array<MessageAttachment>}> {
|
||||
const {message, data, guild, hasPermission} = params;
|
||||
|
||||
if (!guild) {
|
||||
return {canEdit: false};
|
||||
}
|
||||
|
||||
const hasEditableFields = data.flags != null || data.attachments !== undefined;
|
||||
if (!hasEditableFields) {
|
||||
return {canEdit: false};
|
||||
}
|
||||
|
||||
const canManage = await hasPermission(Permissions.MANAGE_MESSAGES);
|
||||
if (!canManage) {
|
||||
return {canEdit: false};
|
||||
}
|
||||
|
||||
let updatedFlags: number | undefined;
|
||||
let updatedAttachments: Array<MessageAttachment> | undefined;
|
||||
|
||||
if (data.flags != null) {
|
||||
if (data.flags & MessageFlags.SUPPRESS_EMBEDS) {
|
||||
updatedFlags = message.flags | MessageFlags.SUPPRESS_EMBEDS;
|
||||
} else {
|
||||
updatedFlags = message.flags & ~MessageFlags.SUPPRESS_EMBEDS;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.attachments !== undefined) {
|
||||
type EditExistingAttachment = {id: bigint; title?: string | null; description?: string | null};
|
||||
type EditAttachment = (AttachmentToProcess & {upload_filename: string}) | EditExistingAttachment;
|
||||
|
||||
for (const att of data.attachments as Array<EditAttachment>) {
|
||||
if (!('upload_filename' in att)) {
|
||||
const allowedKeys = new Set(['id', 'title', 'description', 'flags']);
|
||||
const disallowedEditKeys = new Set(['filename', 'duration', 'waveform']);
|
||||
const actualKeys = Object.keys(att);
|
||||
const hasDisallowedKeys =
|
||||
actualKeys.some((key) => !allowedKeys.has(key)) || actualKeys.some((key) => disallowedEditKeys.has(key));
|
||||
if (hasDisallowedKeys) {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.CANNOT_EDIT_ATTACHMENT_METADATA);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processedAttachments: Array<MessageAttachment> = [];
|
||||
|
||||
for (const att of data.attachments as Array<EditAttachment>) {
|
||||
if (!('upload_filename' in att)) {
|
||||
const existingAtt = att as EditExistingAttachment;
|
||||
const refId = createAttachmentID(existingAtt.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 existingAtt && existingAtt.title !== undefined) {
|
||||
updated.title = existingAtt.title;
|
||||
}
|
||||
if ('description' in existingAtt && existingAtt.description !== undefined) {
|
||||
updated.description = existingAtt.description;
|
||||
}
|
||||
processedAttachments.push(updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedAttachments = processedAttachments;
|
||||
}
|
||||
|
||||
return {canEdit: true, updatedFlags, updatedAttachments};
|
||||
}
|
||||
|
||||
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,319 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import {isPersonalNotesChannel} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {MessageMentionService} from '@fluxer/api/src/channel/services/message/MessageMentionService';
|
||||
import type {MessagePersistenceService} from '@fluxer/api/src/channel/services/message/MessagePersistenceService';
|
||||
import {incrementDmMentionCounts} from '@fluxer/api/src/channel/services/message/ReadStateHelpers';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {ReadStateService} from '@fluxer/api/src/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {CannotEditOtherUserMessageError} from '@fluxer/errors/src/domains/channel/CannotEditOtherUserMessageError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {AllowedMentionsRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas';
|
||||
|
||||
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 != null && isPersonalNotesChannel({userId: message.authorId, channelId: channel.id})) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = message.content ?? '';
|
||||
if (content.length === 0 && referencedMessageOnSend == null) {
|
||||
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 || editResult.updatedAttachments !== undefined)) {
|
||||
const updatedRowData = {...message.toRow()};
|
||||
if (editResult.updatedFlags !== undefined) {
|
||||
updatedRowData.flags = editResult.updatedFlags;
|
||||
}
|
||||
if (editResult.updatedAttachments !== undefined) {
|
||||
updatedRowData.attachments = editResult.updatedAttachments;
|
||||
}
|
||||
|
||||
const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedRowData, message.toRow());
|
||||
await dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
return updatedMessage;
|
||||
}
|
||||
|
||||
throw new CannotEditOtherUserMessageError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
type AttachmentRequestData,
|
||||
mergeUploadWithClientData,
|
||||
type UploadedAttachment,
|
||||
} from '@fluxer/api/src/channel/AttachmentDTOs';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {HonoEnv} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {parseJsonPreservingLargeIntegers} from '@fluxer/api/src/utils/LosslessJsonParser';
|
||||
import {MAX_ATTACHMENTS_PER_MESSAGE} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {
|
||||
ClientAttachmentReferenceRequest,
|
||||
ClientAttachmentRequest,
|
||||
} from '@fluxer/schema/src/domains/message/AttachmentSchemas';
|
||||
import type {Context} from 'hono';
|
||||
import type {z} from 'zod';
|
||||
|
||||
const FIELD_NAME_PATTERN = /^files\[(\d+)\]$/;
|
||||
|
||||
export interface ParseMultipartMessageDataOptions {
|
||||
uploadExpiresAt?: Date;
|
||||
onPayloadParsed?: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
export async function parseMultipartMessageData(
|
||||
ctx: Context<HonoEnv>,
|
||||
user: User,
|
||||
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.fromCode('multipart_form', ValidationErrorCodes.FAILED_TO_PARSE_MULTIPART_FORM_DATA);
|
||||
}
|
||||
|
||||
if (!body['payload_json'] || typeof body['payload_json'] !== 'string') {
|
||||
throw InputValidationError.fromCode('payload_json', ValidationErrorCodes.PAYLOAD_JSON_REQUIRED_FOR_MULTIPART);
|
||||
}
|
||||
|
||||
let jsonData: unknown;
|
||||
try {
|
||||
jsonData = parseJsonPreservingLargeIntegers(body['payload_json']);
|
||||
} catch (_error) {
|
||||
throw InputValidationError.fromCode('payload_json', ValidationErrorCodes.INVALID_JSON_IN_PAYLOAD_JSON);
|
||||
}
|
||||
|
||||
options?.onPayloadParsed?.(jsonData);
|
||||
const validationResult = schema.safeParse(jsonData);
|
||||
if (!validationResult.success) {
|
||||
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
|
||||
}
|
||||
|
||||
const data = validationResult.data as Partial<MessageRequest> &
|
||||
Partial<MessageUpdateRequest> & {
|
||||
attachments?: Array<AttachmentRequestData>;
|
||||
};
|
||||
|
||||
const maxAttachments = await resolveMessageAttachmentLimit(ctx, user, channelId);
|
||||
const maxIndexLabel = maxAttachments > 0 ? maxAttachments - 1 : 0;
|
||||
|
||||
const filesWithIndices: Array<{file: File; index: number}> = [];
|
||||
const seenIndices = new Set<number>();
|
||||
|
||||
Object.keys(body).forEach((key) => {
|
||||
if (key.startsWith('files[')) {
|
||||
const match = FIELD_NAME_PATTERN.exec(key);
|
||||
if (!match) {
|
||||
throw InputValidationError.fromCode('files', ValidationErrorCodes.INVALID_FILE_FIELD_NAME, {
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
const index = parseInt(match[1], 10);
|
||||
|
||||
if (Number.isNaN(index) || index < 0 || index > 10000) {
|
||||
throw InputValidationError.fromCode('files', ValidationErrorCodes.FILE_INDEX_EXCEEDS_MAXIMUM, {
|
||||
index,
|
||||
maxIndex: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
if (maxAttachments <= 0) {
|
||||
throw InputValidationError.fromCode('files', ValidationErrorCodes.ATTACHMENTS_NOT_ALLOWED_FOR_MESSAGE);
|
||||
}
|
||||
|
||||
if (index >= maxAttachments) {
|
||||
throw InputValidationError.fromCode('files', ValidationErrorCodes.FILE_INDEX_EXCEEDS_MAXIMUM, {
|
||||
index,
|
||||
maxIndex: maxIndexLabel,
|
||||
});
|
||||
}
|
||||
|
||||
if (seenIndices.has(index)) {
|
||||
throw InputValidationError.fromCode('files', ValidationErrorCodes.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.fromCode('files', ValidationErrorCodes.MULTIPLE_FILES_FOR_INDEX_NOT_ALLOWED, {
|
||||
index,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (filesWithIndices.length > maxAttachments) {
|
||||
throw InputValidationError.fromCode('files', ValidationErrorCodes.TOO_MANY_FILES, {maxFiles: maxAttachments});
|
||||
}
|
||||
|
||||
if (filesWithIndices.length > 0) {
|
||||
if (!data.attachments || !Array.isArray(data.attachments) || data.attachments.length === 0) {
|
||||
throw InputValidationError.fromCode(
|
||||
'attachments',
|
||||
ValidationErrorCodes.ATTACHMENTS_METADATA_REQUIRED_WHEN_UPLOADING,
|
||||
);
|
||||
}
|
||||
|
||||
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) => {
|
||||
const id = a.id;
|
||||
return typeof id === 'string' ? parseInt(id, 10) : id;
|
||||
}),
|
||||
);
|
||||
const fileIds = new Set(filesWithIndices.map((f) => f.index));
|
||||
|
||||
for (const fileId of fileIds) {
|
||||
if (!metadataIds.has(fileId)) {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.NO_METADATA_FOR_FILE, {fileId});
|
||||
}
|
||||
}
|
||||
|
||||
for (const att of newAttachments) {
|
||||
const id = typeof att.id === 'string' ? parseInt(att.id, 10) : att.id;
|
||||
if (!fileIds.has(id)) {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.NO_FILE_FOR_ATTACHMENT_METADATA, {
|
||||
attachmentId: att.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const uploadedAttachments: Array<UploadedAttachment> = await ctx.get('channelService').uploadFormDataAttachments({
|
||||
userId: user.id,
|
||||
channelId,
|
||||
files: filesWithIndices,
|
||||
attachmentMetadata: newAttachments,
|
||||
expiresAt: options?.uploadExpiresAt,
|
||||
});
|
||||
|
||||
const uploadedMap = new Map(uploadedAttachments.map((u) => [u.id, u]));
|
||||
|
||||
const processedNewAttachments = newAttachments.map((clientData) => {
|
||||
const id = typeof clientData.id === 'string' ? parseInt(clientData.id, 10) : clientData.id;
|
||||
const uploaded = uploadedMap.get(id);
|
||||
if (!uploaded) {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.NO_FILE_FOR_ATTACHMENT, {
|
||||
attachmentId: clientData.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (clientData.filename !== uploaded.filename) {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.FILENAME_MISMATCH_FOR_ATTACHMENT, {
|
||||
attachmentId: clientData.id,
|
||||
expectedFilename: clientData.filename,
|
||||
});
|
||||
}
|
||||
|
||||
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.fromCode('attachments', ValidationErrorCodes.ATTACHMENT_METADATA_WITHOUT_FILES);
|
||||
}
|
||||
|
||||
return data as MessageRequest | MessageUpdateRequest;
|
||||
}
|
||||
|
||||
async function resolveMessageAttachmentLimit(ctx: Context<HonoEnv>, user: User, channelId: ChannelID): Promise<number> {
|
||||
const limitConfigService = ctx.get('limitConfigService') as LimitConfigService | undefined;
|
||||
if (!limitConfigService) {
|
||||
return MAX_ATTACHMENTS_PER_MESSAGE;
|
||||
}
|
||||
|
||||
let guildFeatures: Iterable<string> | null = null;
|
||||
const channelRepository = ctx.get('channelRepository') as IChannelRepository | undefined;
|
||||
const guildService = ctx.get('guildService') as GuildService | undefined;
|
||||
|
||||
if (channelRepository) {
|
||||
try {
|
||||
const channel = await channelRepository.findUnique(channelId);
|
||||
if (channel?.guildId && guildService) {
|
||||
const guild = await guildService.getGuildSystem(channel.guildId);
|
||||
guildFeatures = guild.features;
|
||||
}
|
||||
} catch {
|
||||
guildFeatures = null;
|
||||
}
|
||||
}
|
||||
|
||||
const ctxLimits = createLimitMatchContext({
|
||||
user,
|
||||
guildFeatures,
|
||||
});
|
||||
const limitValue = resolveLimitSafe(
|
||||
limitConfigService.getConfigSnapshot(),
|
||||
ctxLimits,
|
||||
'max_attachments_per_message',
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
);
|
||||
|
||||
return Math.floor(limitValue);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/attachment/AttachmentDecayService';
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import {
|
||||
collectMessageAttachments,
|
||||
isPersonalNotesChannel,
|
||||
} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {AttachmentDecayRow} from '@fluxer/api/src/types/AttachmentDecayTypes';
|
||||
import {UnclaimedAccountCannotSendMessagesError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotSendMessagesError';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
|
||||
export class MessageRequestService {
|
||||
private readonly decayService = new AttachmentDecayService();
|
||||
|
||||
constructor(
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly channelRepository: IChannelRepository,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
private readonly mediaService: IMediaService,
|
||||
) {}
|
||||
|
||||
async listMessages(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
query: {limit: number; before?: MessageID; after?: MessageID; around?: MessageID};
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<MessageResponse>> {
|
||||
const messages = await this.channelService.getMessages({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
limit: params.query.limit,
|
||||
before: params.query.before,
|
||||
after: params.query.after,
|
||||
around: params.query.around,
|
||||
});
|
||||
|
||||
const attachmentDecayMap = await this.buildAttachmentDecayMap(messages);
|
||||
return Promise.all(
|
||||
messages.map((message) =>
|
||||
this.mapMessage({
|
||||
message,
|
||||
currentUserId: params.userId,
|
||||
requestCache: params.requestCache,
|
||||
attachmentDecayMap,
|
||||
includeReactions: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getMessage(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<MessageResponse> {
|
||||
const message = await this.channelService.getMessage({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
messageId: params.messageId,
|
||||
});
|
||||
|
||||
const attachmentDecayMap = await this.buildAttachmentDecayMap([message]);
|
||||
return this.mapMessage({
|
||||
message,
|
||||
currentUserId: params.userId,
|
||||
requestCache: params.requestCache,
|
||||
attachmentDecayMap,
|
||||
includeReactions: true,
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(params: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
data: MessageRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<MessageResponse> {
|
||||
if (
|
||||
params.user.isUnclaimedAccount() &&
|
||||
!isPersonalNotesChannel({userId: params.user.id, channelId: params.channelId})
|
||||
) {
|
||||
throw new UnclaimedAccountCannotSendMessagesError();
|
||||
}
|
||||
|
||||
const message = await this.channelService.sendMessage({
|
||||
user: params.user,
|
||||
channelId: params.channelId,
|
||||
data: params.data,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
|
||||
const attachmentDecayMap = await this.buildAttachmentDecayMap([message]);
|
||||
return this.mapMessage({
|
||||
message,
|
||||
currentUserId: params.user.id,
|
||||
nonce: params.data.nonce,
|
||||
tts: params.data.tts,
|
||||
requestCache: params.requestCache,
|
||||
attachmentDecayMap,
|
||||
});
|
||||
}
|
||||
|
||||
async editMessage(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
data: MessageUpdateRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<MessageResponse> {
|
||||
const message = await this.channelService.editMessage({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
messageId: params.messageId,
|
||||
data: params.data,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
|
||||
const attachmentDecayMap = await this.buildAttachmentDecayMap([message]);
|
||||
return this.mapMessage({
|
||||
message,
|
||||
currentUserId: params.userId,
|
||||
requestCache: params.requestCache,
|
||||
attachmentDecayMap,
|
||||
});
|
||||
}
|
||||
|
||||
private async mapMessage(params: {
|
||||
message: Message;
|
||||
currentUserId: UserID;
|
||||
requestCache: RequestCache;
|
||||
attachmentDecayMap?: Map<string, AttachmentDecayRow>;
|
||||
nonce?: string;
|
||||
tts?: boolean;
|
||||
includeReactions?: boolean;
|
||||
}): Promise<MessageResponse> {
|
||||
const baseParams = {
|
||||
message: params.message,
|
||||
currentUserId: params.currentUserId,
|
||||
nonce: params.nonce,
|
||||
tts: params.tts,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
mediaService: this.mediaService,
|
||||
attachmentDecayMap: params.attachmentDecayMap,
|
||||
getReferencedMessage: (channelId: ChannelID, messageId: MessageID) =>
|
||||
this.channelRepository.getMessage(channelId, messageId),
|
||||
};
|
||||
|
||||
if (!params.includeReactions) {
|
||||
return mapMessageToResponse(baseParams);
|
||||
}
|
||||
|
||||
return mapMessageToResponse({
|
||||
...baseParams,
|
||||
getReactions: (channelId: ChannelID, messageId: MessageID) =>
|
||||
this.channelService.getMessageReactions({
|
||||
userId: params.currentUserId,
|
||||
channelId,
|
||||
messageId,
|
||||
}),
|
||||
setHasReaction: (channelId: ChannelID, messageId: MessageID, hasReaction: boolean) =>
|
||||
this.channelService.setHasReaction(channelId, messageId, hasReaction),
|
||||
});
|
||||
}
|
||||
|
||||
private async buildAttachmentDecayMap(
|
||||
messages: Array<Message>,
|
||||
): Promise<Map<string, AttachmentDecayRow> | undefined> {
|
||||
const allAttachments = messages.flatMap((message) => collectMessageAttachments(message));
|
||||
if (allAttachments.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.decayService.fetchMetadata(allAttachments.map((att) => ({attachmentId: att.id})));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/attachment/AttachmentDecayService';
|
||||
import type {AttachmentID, ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from '@fluxer/api/src/channel/services/AuthenticatedChannel';
|
||||
import {getDmChannelIdsForScope} from '@fluxer/api/src/channel/services/message/DmScopeUtils';
|
||||
import type {MessageChannelAuthService} from '@fluxer/api/src/channel/services/message/MessageChannelAuthService';
|
||||
import {collectMessageAttachments} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {MessageProcessingService} from '@fluxer/api/src/channel/services/message/MessageProcessingService';
|
||||
import type {MessageSearchService} from '@fluxer/api/src/channel/services/message/MessageSearchService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {getMessageSearchService} from '@fluxer/api/src/SearchFactory';
|
||||
import {buildMessageSearchFilters} from '@fluxer/api/src/search/BuildMessageSearchFilters';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import {recordMessageRetrievalDuration, recordMessageRetrieved} from '@fluxer/api/src/telemetry/MessageTelemetry';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {ChannelTypes, MessageFlags, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import type {MessageSearchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import type {MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
|
||||
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(),
|
||||
) {}
|
||||
|
||||
private isMessageAfterCutoff(messageId: MessageID, cutoffIso: string): boolean {
|
||||
const messageTimestamp = snowflakeToDate(messageId).getTime();
|
||||
const cutoffTimestamp = new Date(cutoffIso).getTime();
|
||||
return messageTimestamp >= cutoffTimestamp;
|
||||
}
|
||||
|
||||
private async canAccessMessage(authChannel: AuthenticatedChannel, messageId: MessageID): Promise<boolean> {
|
||||
if (!authChannel.guild) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cutoff = authChannel.guild.message_history_cutoff;
|
||||
if (!cutoff) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.isMessageAfterCutoff(messageId, cutoff);
|
||||
}
|
||||
|
||||
async getMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<Message> {
|
||||
return await withBusinessSpan(
|
||||
'fluxer.message.get',
|
||||
'fluxer.messages.retrieved',
|
||||
{channel_id: channelId.toString()},
|
||||
async () => {
|
||||
const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
|
||||
if (!(await this.canAccessMessage(authChannel, messageId))) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
const message = await this.channelRepository.messages.getMessage(channelId, messageId);
|
||||
if (!message) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
await this.extendAttachments([message]);
|
||||
recordMessageRetrieved({channelType: authChannel.channel.type, count: 1});
|
||||
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 startTime = Date.now();
|
||||
return await withBusinessSpan(
|
||||
'fluxer.messages.list',
|
||||
'fluxer.messages.listed',
|
||||
{channel_id: channelId.toString()},
|
||||
async () => {
|
||||
const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
|
||||
const hasReadHistory =
|
||||
!authChannel.guild || (await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY));
|
||||
|
||||
if (!hasReadHistory) {
|
||||
const cutoff = authChannel.guild?.message_history_cutoff;
|
||||
if (!cutoff) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
let messages = around
|
||||
? await this.processingService.fetchMessagesAround({channelId, limit, around})
|
||||
: await this.channelRepository.messages.listMessages(channelId, before, limit, after);
|
||||
|
||||
if (!hasReadHistory) {
|
||||
const cutoff = authChannel.guild!.message_history_cutoff!;
|
||||
messages = messages.filter((message) => this.isMessageAfterCutoff(message.id, cutoff));
|
||||
}
|
||||
|
||||
await this.extendAttachments(messages);
|
||||
const durationMs = Date.now() - startTime;
|
||||
recordMessageRetrievalDuration({channelType: authChannel.channel.type, durationMs});
|
||||
recordMessageRetrieved({channelType: authChannel.channel.type, count: messages.length});
|
||||
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;
|
||||
|
||||
const hasReadHistory = !authChannel.guild || (await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY));
|
||||
|
||||
if (!hasReadHistory) {
|
||||
const cutoff = authChannel.guild?.message_history_cutoff;
|
||||
if (!cutoff) {
|
||||
return {messages: [], total: 0, hits_per_page: searchParams.hits_per_page ?? 25, page: searchParams.page ?? 1};
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return {indexing: true as const};
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await this.applyDmScopeToSearchParams({
|
||||
userId,
|
||||
channel,
|
||||
channelId,
|
||||
searchParams,
|
||||
});
|
||||
const scope = resolvedSearchParams.scope ?? 'current';
|
||||
const channelIdStrings =
|
||||
scope === 'current'
|
||||
? [channelId.toString()]
|
||||
: resolvedSearchParams.channel_id
|
||||
? resolvedSearchParams.channel_id.map((id) => id.toString())
|
||||
: [channelId.toString()];
|
||||
const normalizedSearchParams =
|
||||
scope === 'current' ? {...resolvedSearchParams, channel_id: undefined} : resolvedSearchParams;
|
||||
const filters = buildMessageSearchFilters(normalizedSearchParams, channelIdStrings);
|
||||
|
||||
const hitsPerPage = resolvedSearchParams.hits_per_page ?? 25;
|
||||
const page = resolvedSearchParams.page ?? 1;
|
||||
const result = await searchService.searchMessages(resolvedSearchParams.content ?? '', 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)),
|
||||
);
|
||||
|
||||
let validMessages = messages.filter((msg: Message | null): msg is Message => msg !== null);
|
||||
|
||||
if (!hasReadHistory) {
|
||||
const cutoff = authChannel.guild!.message_history_cutoff!;
|
||||
validMessages = validMessages.filter((message) => this.isMessageAfterCutoff(message.id, cutoff));
|
||||
}
|
||||
|
||||
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 effectiveCutoff = !hasReadHistory ? authChannel.guild?.message_history_cutoff : undefined;
|
||||
|
||||
const messageResponses = await Promise.all(
|
||||
validMessages.map((message: Message) =>
|
||||
mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
attachmentDecayMap,
|
||||
messageHistoryCutoff: effectiveCutoff,
|
||||
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: hasReadHistory ? result.total : messageResponses.length,
|
||||
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 = snowflakeToDate(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))};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {getMessageSearchService} from '@fluxer/api/src/SearchFactory';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {MessageSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {MessageSearchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/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) {
|
||||
Logger.error(
|
||||
{
|
||||
messageId: message.id,
|
||||
channelId: message.channelId,
|
||||
authorId: message.authorId,
|
||||
authorIsBot,
|
||||
error,
|
||||
},
|
||||
'Failed to index message in search',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async updateMessageIndex(message: Message): Promise<void> {
|
||||
try {
|
||||
const searchService = getMessageSearchService();
|
||||
if (!searchService) {
|
||||
return;
|
||||
}
|
||||
|
||||
let authorIsBot = false;
|
||||
if (message.authorId != null) {
|
||||
const user = await this.userRepository.findUnique(message.authorId);
|
||||
authorIsBot = user?.isBot ?? false;
|
||||
}
|
||||
|
||||
await searchService.updateMessage(message, authorIsBot);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
messageId: message.id,
|
||||
channelId: message.channelId,
|
||||
authorId: message.authorId,
|
||||
error,
|
||||
},
|
||||
'Failed to update message in search index',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMessageIndex(messageId: MessageID): Promise<void> {
|
||||
try {
|
||||
const searchService = getMessageSearchService();
|
||||
if (!searchService) {
|
||||
return;
|
||||
}
|
||||
|
||||
await searchService.deleteMessage(messageId);
|
||||
} catch (error) {
|
||||
Logger.error({messageId, error}, 'Failed to delete message from search index');
|
||||
}
|
||||
}
|
||||
|
||||
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 != null) {
|
||||
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 != null) {
|
||||
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 as Array<'image' | 'video' | 'sound' | 'article'>;
|
||||
if (searchParams.exclude_embed_type)
|
||||
filters.excludeEmbedTypes = searchParams.exclude_embed_type as Array<'image' | 'video' | 'sound' | 'article'>;
|
||||
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 as 'timestamp' | 'relevance';
|
||||
if (searchParams.sort_order) filters.sortOrder = searchParams.sort_order as 'asc' | 'desc';
|
||||
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
1119
packages/api/src/channel/services/message/MessageSendService.tsx
Normal file
1119
packages/api/src/channel/services/message/MessageSendService.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {MessageStickerItem} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {PackService} from '@fluxer/api/src/pack/PackService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
|
||||
export class MessageStickerService {
|
||||
constructor(
|
||||
private userRepository: IUserRepository,
|
||||
private guildRepository: IGuildRepositoryAggregate,
|
||||
private packService: PackService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
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 hasGlobalExpressions = 0;
|
||||
if (userId) {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
const ctx = createLimitMatchContext({user});
|
||||
hasGlobalExpressions = resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'feature_global_expressions',
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
stickerIds.map(async (stickerId) => {
|
||||
if (!guildId) {
|
||||
if (hasGlobalExpressions === 0) {
|
||||
throw InputValidationError.fromCode('sticker', ValidationErrorCodes.CUSTOM_STICKERS_IN_DMS_REQUIRE_PREMIUM);
|
||||
}
|
||||
|
||||
const stickerFromAnyGuild = await this.guildRepository.getStickerById(stickerId);
|
||||
if (!stickerFromAnyGuild) {
|
||||
throw InputValidationError.fromCode('sticker', ValidationErrorCodes.CUSTOM_STICKER_NOT_FOUND);
|
||||
}
|
||||
|
||||
const packAccess = await packResolver.resolve(stickerFromAnyGuild.guildId);
|
||||
if (packAccess === 'not-accessible') {
|
||||
throw InputValidationError.fromCode('sticker', ValidationErrorCodes.CUSTOM_STICKER_NOT_FOUND);
|
||||
}
|
||||
|
||||
return {
|
||||
sticker_id: stickerFromAnyGuild.id,
|
||||
name: stickerFromAnyGuild.name,
|
||||
animated: stickerFromAnyGuild.animated,
|
||||
};
|
||||
}
|
||||
|
||||
const guildSticker = await this.guildRepository.getSticker(stickerId, guildId);
|
||||
if (guildSticker) {
|
||||
return {
|
||||
sticker_id: guildSticker.id,
|
||||
name: guildSticker.name,
|
||||
animated: guildSticker.animated,
|
||||
};
|
||||
}
|
||||
|
||||
const stickerFromOtherGuild = await this.guildRepository.getStickerById(stickerId);
|
||||
if (!stickerFromOtherGuild) {
|
||||
throw InputValidationError.fromCode('sticker', ValidationErrorCodes.CUSTOM_STICKER_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (hasGlobalExpressions === 0) {
|
||||
throw InputValidationError.fromCode(
|
||||
'sticker',
|
||||
ValidationErrorCodes.CUSTOM_STICKERS_REQUIRE_PREMIUM_OUTSIDE_SOURCE,
|
||||
);
|
||||
}
|
||||
|
||||
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.fromCode('sticker', ValidationErrorCodes.CUSTOM_STICKER_NOT_FOUND);
|
||||
}
|
||||
|
||||
return {
|
||||
sticker_id: stickerFromOtherGuild.id,
|
||||
name: stickerFromOtherGuild.name,
|
||||
animated: stickerFromOtherGuild.animated,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createMessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@fluxer/api/src/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {MessagePersistenceService} from '@fluxer/api/src/channel/services/message/MessagePersistenceService';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
|
||||
export class MessageSystemService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private guildRepository: IGuildRepositoryAggregate,
|
||||
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(await 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,325 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import {MESSAGE_NONCE_TTL} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {
|
||||
isMessageTypeDeletable,
|
||||
MessageFlags,
|
||||
MessageTypes,
|
||||
Permissions,
|
||||
SENDABLE_MESSAGE_FLAGS,
|
||||
TEXT_BASED_CHANNEL_TYPES,
|
||||
} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {
|
||||
ATTACHMENT_MAX_SIZE_NON_PREMIUM,
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
MAX_EMBEDS_PER_MESSAGE,
|
||||
MAX_MESSAGE_LENGTH_NON_PREMIUM,
|
||||
MAX_VOICE_MESSAGE_DURATION,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {CannotEditSystemMessageError} from '@fluxer/errors/src/domains/channel/CannotEditSystemMessageError';
|
||||
import {CannotSendEmptyMessageError} from '@fluxer/errors/src/domains/channel/CannotSendEmptyMessageError';
|
||||
import {CannotSendMessageToNonTextChannelError} from '@fluxer/errors/src/domains/channel/CannotSendMessageToNonTextChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FileSizeTooLargeError} from '@fluxer/errors/src/domains/core/FileSizeTooLargeError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
export class MessageValidationService {
|
||||
constructor(
|
||||
private cacheService: ICacheService,
|
||||
private limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
ensureTextChannel(channel: Channel): void {
|
||||
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
|
||||
throw new CannotSendMessageToNonTextChannelError();
|
||||
}
|
||||
}
|
||||
|
||||
validateMessageContent(
|
||||
data: MessageRequest | MessageUpdateRequest,
|
||||
user: User | null,
|
||||
options?: {isUpdate?: boolean; guildFeatures?: Iterable<string> | null},
|
||||
): void {
|
||||
const isUpdate = options?.isUpdate ?? false;
|
||||
const hasContent = data.content != null && data.content.trim().length > 0;
|
||||
const hasEmbeds = Boolean(data.embeds && data.embeds.length > 0);
|
||||
const hasAttachments = Boolean(data.attachments && data.attachments.length > 0);
|
||||
const hasFavoriteMeme = Boolean('favorite_meme_id' in data && data.favorite_meme_id != null);
|
||||
const hasStickers = Boolean('sticker_ids' in data && data.sticker_ids != null && data.sticker_ids.length > 0);
|
||||
const hasFlags = data.flags !== undefined && data.flags !== null;
|
||||
const guildFeatures = options?.guildFeatures ?? null;
|
||||
|
||||
const hasVoiceMessageFlag = !!(data.flags && data.flags & MessageFlags.VOICE_MESSAGE);
|
||||
if (hasVoiceMessageFlag) {
|
||||
this.validateVoiceMessageConstraints(
|
||||
data,
|
||||
hasContent,
|
||||
hasEmbeds,
|
||||
hasStickers,
|
||||
hasFavoriteMeme,
|
||||
user,
|
||||
guildFeatures,
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasContent && !hasEmbeds && !hasAttachments && !hasFavoriteMeme && !hasStickers && (!isUpdate || !hasFlags)) {
|
||||
throw new CannotSendEmptyMessageError();
|
||||
}
|
||||
|
||||
this.validateContentLength(data.content, user, guildFeatures);
|
||||
|
||||
const ctx = createLimitMatchContext({user, guildFeatures});
|
||||
const evaluationContext = guildFeatures ? 'guild' : 'user';
|
||||
|
||||
const snapshot = this.limitConfigService.getConfigSnapshot();
|
||||
const maxEmbeds = Math.floor(
|
||||
resolveLimitSafe(snapshot, ctx, 'max_embeds_per_message', MAX_EMBEDS_PER_MESSAGE, evaluationContext),
|
||||
);
|
||||
const maxAttachments = Math.floor(
|
||||
resolveLimitSafe(snapshot, ctx, 'max_attachments_per_message', MAX_ATTACHMENTS_PER_MESSAGE, evaluationContext),
|
||||
);
|
||||
|
||||
const totalEmbeds = data.embeds?.length ?? 0;
|
||||
if (totalEmbeds > maxEmbeds) {
|
||||
throw InputValidationError.fromCode('embeds', ValidationErrorCodes.TOO_MANY_EMBEDS, {maxEmbeds});
|
||||
}
|
||||
|
||||
const totalAttachments = data.attachments?.length ?? 0;
|
||||
if (totalAttachments > maxAttachments) {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.TOO_MANY_FILES, {
|
||||
maxFiles: maxAttachments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
validateContentLength(
|
||||
content: string | null | undefined,
|
||||
user: User | null,
|
||||
guildFeatures?: Iterable<string> | null,
|
||||
): void {
|
||||
if (content == null) return;
|
||||
|
||||
const ctx = createLimitMatchContext({user, guildFeatures});
|
||||
const evaluationContext = guildFeatures ? 'guild' : 'user';
|
||||
const maxLength = Math.floor(
|
||||
resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'max_message_length',
|
||||
MAX_MESSAGE_LENGTH_NON_PREMIUM,
|
||||
evaluationContext,
|
||||
),
|
||||
);
|
||||
if (content.length > maxLength) {
|
||||
throw InputValidationError.fromCode('content', ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH, {
|
||||
maxLength,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
validateTotalAttachmentSize(
|
||||
attachments: Array<{size: number | bigint}>,
|
||||
user: User,
|
||||
guildFeatures?: Iterable<string> | null,
|
||||
): void {
|
||||
const ctx = createLimitMatchContext({user, guildFeatures});
|
||||
const evaluationContext = guildFeatures ? 'guild' : 'user';
|
||||
const fallbackMaxSize = ATTACHMENT_MAX_SIZE_NON_PREMIUM;
|
||||
const maxFileSize = Math.floor(
|
||||
resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'max_attachment_file_size',
|
||||
fallbackMaxSize,
|
||||
evaluationContext,
|
||||
),
|
||||
);
|
||||
|
||||
const hasFileExceedingLimit = attachments.some((attachment) => Number(attachment.size) > maxFileSize);
|
||||
if (hasFileExceedingLimit) {
|
||||
throw new FileSizeTooLargeError(maxFileSize);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private validateVoiceMessageConstraints(
|
||||
data: MessageRequest,
|
||||
hasContent: boolean,
|
||||
hasEmbeds: boolean,
|
||||
hasStickers: boolean,
|
||||
hasFavoriteMeme: boolean,
|
||||
user: User | null,
|
||||
guildFeatures: Iterable<string> | null | undefined,
|
||||
): void {
|
||||
if (hasContent) {
|
||||
throw InputValidationError.fromCode('content', ValidationErrorCodes.VOICE_MESSAGES_CANNOT_HAVE_CONTENT);
|
||||
}
|
||||
|
||||
if (hasEmbeds) {
|
||||
throw InputValidationError.fromCode('embeds', ValidationErrorCodes.VOICE_MESSAGES_CANNOT_HAVE_EMBEDS);
|
||||
}
|
||||
|
||||
if (hasFavoriteMeme) {
|
||||
throw InputValidationError.fromCode(
|
||||
'favorite_meme_id',
|
||||
ValidationErrorCodes.VOICE_MESSAGES_CANNOT_HAVE_FAVORITE_MEMES,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasStickers) {
|
||||
throw InputValidationError.fromCode('sticker_ids', ValidationErrorCodes.VOICE_MESSAGES_CANNOT_HAVE_STICKERS);
|
||||
}
|
||||
|
||||
const attachments = data.attachments ?? [];
|
||||
if (attachments.length !== 1) {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.VOICE_MESSAGES_REQUIRE_SINGLE_ATTACHMENT);
|
||||
}
|
||||
|
||||
const attachment = attachments[0];
|
||||
if (!('waveform' in attachment) || !attachment.waveform) {
|
||||
throw InputValidationError.fromCode(
|
||||
'attachments.0.waveform',
|
||||
ValidationErrorCodes.VOICE_MESSAGES_ATTACHMENT_WAVEFORM_REQUIRED,
|
||||
);
|
||||
}
|
||||
if (!('duration' in attachment) || attachment.duration == null) {
|
||||
throw InputValidationError.fromCode(
|
||||
'attachments.0.duration',
|
||||
ValidationErrorCodes.VOICE_MESSAGES_ATTACHMENT_DURATION_REQUIRED,
|
||||
);
|
||||
}
|
||||
|
||||
const duration = attachment.duration;
|
||||
const ctx = createLimitMatchContext({user, guildFeatures});
|
||||
const evaluationContext = guildFeatures ? 'guild' : 'user';
|
||||
const maxDuration = Math.floor(
|
||||
resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'max_voice_message_duration',
|
||||
MAX_VOICE_MESSAGE_DURATION,
|
||||
evaluationContext,
|
||||
),
|
||||
);
|
||||
if (duration > maxDuration) {
|
||||
throw InputValidationError.fromCode(
|
||||
'attachments.0.duration',
|
||||
ValidationErrorCodes.VOICE_MESSAGES_DURATION_EXCEEDS_LIMIT,
|
||||
{maxDuration},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {ReadStateService} from '@fluxer/api/src/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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
320
packages/api/src/channel/tests/AttachmentDecay.test.tsx
Normal file
320
packages/api/src/channel/tests/AttachmentDecay.test.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createChannel,
|
||||
createGuild,
|
||||
loadFixture,
|
||||
sendMessageWithAttachments,
|
||||
} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {getExpiryBucket} from '@fluxer/api/src/utils/AttachmentDecay';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface AttachmentDecayRow {
|
||||
attachment_id: string;
|
||||
channel_id: string;
|
||||
message_id: string;
|
||||
filename: string;
|
||||
size_bytes: string;
|
||||
expires_at: string;
|
||||
expiry_bucket: number;
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
interface AttachmentDecayQueryResponse {
|
||||
rows: Array<AttachmentDecayRow>;
|
||||
has_more: boolean;
|
||||
count: number;
|
||||
}
|
||||
|
||||
describe('Attachment Decay', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should create decay metadata when uploading attachment', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Decay Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
account.token,
|
||||
channelId,
|
||||
{
|
||||
content: 'Attachment with decay tracking',
|
||||
attachments: [{id: 0, filename: 'test.png'}],
|
||||
},
|
||||
[{index: 0, filename: 'test.png', data: fileData}],
|
||||
);
|
||||
|
||||
expect(response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!.length).toBe(1);
|
||||
|
||||
const attachmentId = json.attachments![0].id;
|
||||
|
||||
const decayResponse = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
|
||||
.get(`/test/attachment-decay/${attachmentId}`)
|
||||
.execute();
|
||||
|
||||
expect(decayResponse.row).not.toBeNull();
|
||||
expect(decayResponse.row?.attachment_id).toBe(attachmentId);
|
||||
expect(decayResponse.row?.channel_id).toBe(channelId);
|
||||
expect(decayResponse.row?.message_id).toBe(json.id);
|
||||
});
|
||||
|
||||
test('should track multiple attachments in single message', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Multi Attachment Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
account.token,
|
||||
channelId,
|
||||
{
|
||||
content: 'Multiple attachments',
|
||||
attachments: [
|
||||
{id: 0, filename: 'first.png'},
|
||||
{id: 1, filename: 'second.gif'},
|
||||
],
|
||||
},
|
||||
[
|
||||
{index: 0, filename: 'first.png', data: file1Data},
|
||||
{index: 1, filename: 'second.gif', data: file2Data},
|
||||
],
|
||||
);
|
||||
|
||||
expect(response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!.length).toBe(2);
|
||||
|
||||
for (const attachment of json.attachments!) {
|
||||
const decayResponse = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
|
||||
.get(`/test/attachment-decay/${attachment.id}`)
|
||||
.execute();
|
||||
|
||||
expect(decayResponse.row).not.toBeNull();
|
||||
expect(decayResponse.row?.message_id).toBe(json.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('should seed attachment decay rows via test endpoint', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Seed Test Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
|
||||
const futureExpiryDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
const futureExpiry = futureExpiryDate.toISOString();
|
||||
const bucket = getExpiryBucket(futureExpiryDate);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/test/attachment-decay/rows')
|
||||
.body({
|
||||
rows: [
|
||||
{
|
||||
attachment_id: '123456789012345678',
|
||||
channel_id: channelId,
|
||||
message_id: '234567890123456789',
|
||||
filename: 'seeded.png',
|
||||
size_bytes: '1024',
|
||||
expires_at: futureExpiry,
|
||||
},
|
||||
],
|
||||
})
|
||||
.execute();
|
||||
|
||||
const queryTimeAfterExpiry = new Date(futureExpiryDate.getTime() + 1000).toISOString();
|
||||
const queryResponse = await createBuilderWithoutAuth<AttachmentDecayQueryResponse>(harness)
|
||||
.post('/test/attachment-decay/query')
|
||||
.body({
|
||||
bucket,
|
||||
limit: 100,
|
||||
current_time: queryTimeAfterExpiry,
|
||||
})
|
||||
.execute();
|
||||
|
||||
expect(queryResponse.rows.some((r) => r.attachment_id === '123456789012345678')).toBe(true);
|
||||
});
|
||||
|
||||
test('should clear attachment decay data', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Clear Test Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
|
||||
const expiry = new Date(Date.now() + 1000).toISOString();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/test/attachment-decay/rows')
|
||||
.body({
|
||||
rows: [
|
||||
{
|
||||
attachment_id: '111111111111111111',
|
||||
channel_id: channelId,
|
||||
message_id: '222222222222222222',
|
||||
filename: 'clear-test.png',
|
||||
size_bytes: '512',
|
||||
expires_at: expiry,
|
||||
},
|
||||
],
|
||||
})
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness).post('/test/attachment-decay/clear').body({}).execute();
|
||||
|
||||
const checkResponse = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
|
||||
.get('/test/attachment-decay/111111111111111111')
|
||||
.execute();
|
||||
|
||||
expect(checkResponse.row).toBeNull();
|
||||
});
|
||||
|
||||
test('should include decay metadata in message response', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Metadata Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
account.token,
|
||||
channelId,
|
||||
{
|
||||
content: 'Decay metadata test',
|
||||
attachments: [{id: 0, filename: 'metadata.png'}],
|
||||
},
|
||||
[{index: 0, filename: 'metadata.png', data: fileData}],
|
||||
);
|
||||
|
||||
expect(response.status).toBe(HTTP_STATUS.OK);
|
||||
|
||||
const messageResponse = await createBuilder<{
|
||||
id: string;
|
||||
attachments: Array<{id: string; filename: string; url: string}>;
|
||||
}>(harness, account.token)
|
||||
.get(`/channels/${channelId}/messages/${json.id}`)
|
||||
.execute();
|
||||
|
||||
expect(messageResponse.attachments).toBeDefined();
|
||||
expect(messageResponse.attachments).not.toBeNull();
|
||||
expect(messageResponse.attachments!.length).toBe(1);
|
||||
expect(messageResponse.attachments![0].url).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should handle attachment with different sizes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Size Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const smallFile = loadFixture('yeah.png');
|
||||
const largeFile = loadFixture('thisisfine.gif');
|
||||
|
||||
const smallResult = await sendMessageWithAttachments(
|
||||
harness,
|
||||
account.token,
|
||||
channelId,
|
||||
{
|
||||
content: 'Small file',
|
||||
attachments: [{id: 0, filename: 'small.png'}],
|
||||
},
|
||||
[{index: 0, filename: 'small.png', data: smallFile}],
|
||||
);
|
||||
|
||||
const largeResult = await sendMessageWithAttachments(
|
||||
harness,
|
||||
account.token,
|
||||
channelId,
|
||||
{
|
||||
content: 'Large file',
|
||||
attachments: [{id: 0, filename: 'large.gif'}],
|
||||
},
|
||||
[{index: 0, filename: 'large.gif', data: largeFile}],
|
||||
);
|
||||
|
||||
expect(smallResult.response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(largeResult.response.status).toBe(HTTP_STATUS.OK);
|
||||
|
||||
const smallDecay = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
|
||||
.get(`/test/attachment-decay/${smallResult.json.attachments![0].id}`)
|
||||
.execute();
|
||||
|
||||
const largeDecay = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
|
||||
.get(`/test/attachment-decay/${largeResult.json.attachments![0].id}`)
|
||||
.execute();
|
||||
|
||||
expect(smallDecay.row).not.toBeNull();
|
||||
expect(largeDecay.row).not.toBeNull();
|
||||
|
||||
const smallSize = BigInt(smallDecay.row?.size_bytes ?? '0');
|
||||
const largeSize = BigInt(largeDecay.row?.size_bytes ?? '0');
|
||||
expect(largeSize).toBeGreaterThan(smallSize);
|
||||
});
|
||||
|
||||
test('should track filename in decay metadata', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Filename Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
account.token,
|
||||
channelId,
|
||||
{
|
||||
content: 'Filename tracking test',
|
||||
attachments: [{id: 0, filename: 'unique-filename-test.png'}],
|
||||
},
|
||||
[{index: 0, filename: 'unique-filename-test.png', data: fileData}],
|
||||
);
|
||||
|
||||
expect(response.status).toBe(HTTP_STATUS.OK);
|
||||
|
||||
const decayResponse = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
|
||||
.get(`/test/attachment-decay/${json.attachments![0].id}`)
|
||||
.execute();
|
||||
|
||||
expect(decayResponse.row).not.toBeNull();
|
||||
expect(decayResponse.row?.filename).toBe('unique-filename-test.png');
|
||||
});
|
||||
});
|
||||
165
packages/api/src/channel/tests/AttachmentTestUtils.tsx
Normal file
165
packages/api/src/channel/tests/AttachmentTestUtils.tsx
Normal file
@@ -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 {readFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {createTestAccount, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
|
||||
export interface AttachmentMetadata {
|
||||
id: number;
|
||||
filename: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
flags?: number;
|
||||
}
|
||||
|
||||
export function createMultipartFormData(
|
||||
payload: Record<string, unknown>,
|
||||
files: Array<{index: number; filename: string; data: Buffer}>,
|
||||
): {body: Buffer; contentType: string} {
|
||||
const boundary = `----FormBoundary${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const chunks: Array<Buffer> = [];
|
||||
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
chunks.push(Buffer.from(`--${boundary}\r\n`));
|
||||
chunks.push(Buffer.from(`Content-Disposition: form-data; name="payload_json"\r\n`));
|
||||
chunks.push(Buffer.from(`Content-Type: application/json\r\n\r\n`));
|
||||
chunks.push(Buffer.from(`${payloadJson}\r\n`));
|
||||
|
||||
for (const file of files) {
|
||||
chunks.push(Buffer.from(`--${boundary}\r\n`));
|
||||
chunks.push(
|
||||
Buffer.from(`Content-Disposition: form-data; name="files[${file.index}]"; filename="${file.filename}"\r\n`),
|
||||
);
|
||||
chunks.push(Buffer.from(`Content-Type: application/octet-stream\r\n\r\n`));
|
||||
chunks.push(file.data);
|
||||
chunks.push(Buffer.from(`\r\n`));
|
||||
}
|
||||
|
||||
chunks.push(Buffer.from(`--${boundary}--\r\n`));
|
||||
|
||||
return {
|
||||
body: Buffer.concat(chunks),
|
||||
contentType: `multipart/form-data; boundary=${boundary}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadFixture(filename: string): Buffer {
|
||||
const fixturesPath = join(import.meta.dirname, '..', '..', 'test', 'fixtures', filename);
|
||||
return readFileSync(fixturesPath);
|
||||
}
|
||||
|
||||
export async function createGuild(harness: ApiTestHarness, token: string, name: string): Promise<GuildResponse> {
|
||||
return createBuilder<GuildResponse>(harness, token).post('/guilds').body({name}).execute();
|
||||
}
|
||||
|
||||
export async function createChannel(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
name: string,
|
||||
type = 0,
|
||||
): Promise<ChannelResponse> {
|
||||
return createBuilder<ChannelResponse>(harness, token)
|
||||
.post(`/guilds/${guildId}/channels`)
|
||||
.body({name, type})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function sendMessageWithAttachments(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
payload: Record<string, unknown>,
|
||||
files: Array<{index: number; filename: string; data: Buffer}>,
|
||||
): Promise<{response: Response; text: string; json: MessageResponse}> {
|
||||
await ensureSessionStarted(harness, token);
|
||||
|
||||
const {body, contentType} = createMultipartFormData(payload, files);
|
||||
|
||||
const mergedHeaders = new Headers();
|
||||
mergedHeaders.set('Content-Type', contentType);
|
||||
mergedHeaders.set('Authorization', token);
|
||||
if (!mergedHeaders.has('x-forwarded-for')) {
|
||||
mergedHeaders.set('x-forwarded-for', '127.0.0.1');
|
||||
}
|
||||
|
||||
const response = await harness.app.request(`/channels/${channelId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: mergedHeaders,
|
||||
body,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = text.length > 0 ? (JSON.parse(text) as unknown) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
|
||||
return {response, text, json: json as MessageResponse};
|
||||
}
|
||||
|
||||
export async function getMessage(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
): Promise<{response: Response; json: MessageResponse}> {
|
||||
return createBuilder<MessageResponse>(harness, token)
|
||||
.get(`/channels/${channelId}/messages/${messageId}`)
|
||||
.executeWithResponse();
|
||||
}
|
||||
|
||||
export async function setupTestGuildAndChannel(
|
||||
harness: ApiTestHarness,
|
||||
account?: TestAccount,
|
||||
): Promise<{account: TestAccount; guild: GuildResponse; channel: ChannelResponse}> {
|
||||
const testAccount = account ?? (await createTestAccount(harness));
|
||||
|
||||
const guild = await createGuild(harness, testAccount.token, 'Test Guild');
|
||||
|
||||
const channel = await createChannel(harness, testAccount.token, guild.id, 'test-channel');
|
||||
|
||||
return {account: testAccount, guild, channel};
|
||||
}
|
||||
|
||||
export async function createTestAccountForAttachmentTests(harness: ApiTestHarness): Promise<TestAccount> {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
await createBuilder<{type: 'session'}>(harness, `Bearer ${Config.gateway.rpcSecret}`)
|
||||
.post('/_rpc')
|
||||
.body({
|
||||
type: 'session',
|
||||
token: account.token,
|
||||
version: 1,
|
||||
ip: '127.0.0.1',
|
||||
})
|
||||
.execute();
|
||||
return account;
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createChannel,
|
||||
createGuild,
|
||||
loadFixture,
|
||||
sendMessageWithAttachments,
|
||||
} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Attachment Upload Validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
describe('File Validation', () => {
|
||||
describe('size limits', () => {
|
||||
it('should reject files exceeding size limits', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Too Large File Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const largeFileData = Buffer.alloc(26 * 1024 * 1024);
|
||||
|
||||
const payload = {
|
||||
content: 'Large file test',
|
||||
attachments: [{id: 0, filename: 'large.bin'}],
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'large.bin', data: largeFileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filename validation', () => {
|
||||
it('should reject empty filenames', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Empty Filename Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = Buffer.from('test content');
|
||||
|
||||
const payload = {
|
||||
content: 'Empty filename test',
|
||||
attachments: [
|
||||
{
|
||||
id: 0,
|
||||
filename: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'actualname.txt', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject empty filename in metadata and upload', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Invalid Filename Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = Buffer.from('test content');
|
||||
|
||||
const payload = {
|
||||
content: 'Invalid filename test',
|
||||
attachments: [
|
||||
{
|
||||
id: 0,
|
||||
filename: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: '', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).not.toBe(200);
|
||||
});
|
||||
|
||||
it('should reject excessively long filename', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Invalid Filename Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = Buffer.from('test content');
|
||||
const longFilename = 'a'.repeat(300);
|
||||
|
||||
const payload = {
|
||||
content: 'Invalid filename test',
|
||||
attachments: [
|
||||
{
|
||||
id: 0,
|
||||
filename: longFilename,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: longFilename, data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).not.toBe(200);
|
||||
});
|
||||
|
||||
it('should use metadata filename when different from upload filename', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Filename Mismatch Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = Buffer.from('test content');
|
||||
|
||||
const payload = {
|
||||
content: 'Filename mismatch test',
|
||||
attachments: [{id: 0, filename: 'expected.txt'}],
|
||||
};
|
||||
|
||||
const {response, text, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'different.txt', data: fileData},
|
||||
]);
|
||||
|
||||
if (response.status !== 200) {
|
||||
console.log('Error response:', text);
|
||||
}
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!).toHaveLength(1);
|
||||
expect(json.attachments![0].filename).toBe('expected.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('special characters', () => {
|
||||
let channelId: string;
|
||||
let accountToken: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
accountToken = account.token;
|
||||
const guild = await createGuild(harness, account.token, 'Special Chars Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
channelId = guild.system_channel_id ?? channel.id;
|
||||
});
|
||||
|
||||
const testCases = [
|
||||
{filename: 'file<script>.txt', shouldSanitize: true, description: 'HTML tag in filename'},
|
||||
{filename: 'file>output.txt', shouldSanitize: true, description: 'redirect operator'},
|
||||
{filename: 'file|pipe.txt', shouldSanitize: true, description: 'pipe character'},
|
||||
{filename: 'file:colon.txt', shouldSanitize: true, description: 'colon (Windows reserved)'},
|
||||
{filename: 'file*star.txt', shouldSanitize: true, description: 'asterisk (wildcard)'},
|
||||
{filename: 'file?question.txt', shouldSanitize: true, description: 'question mark'},
|
||||
{filename: 'file"quote.txt', shouldSanitize: true, description: 'double quote'},
|
||||
{filename: 'COM1.txt', shouldSanitize: true, description: 'Windows reserved name'},
|
||||
{filename: 'LPT1.txt', shouldSanitize: true, description: 'Windows reserved name'},
|
||||
{filename: 'file with spaces.txt', shouldSanitize: false, description: 'spaces should be OK'},
|
||||
{
|
||||
filename: 'file-dash_underscore.txt',
|
||||
shouldSanitize: false,
|
||||
description: 'dash and underscore OK',
|
||||
},
|
||||
{filename: 'file.multiple.dots.txt', shouldSanitize: false, description: 'multiple dots OK'},
|
||||
{filename: 'файл.txt', shouldSanitize: false, description: 'unicode characters OK'},
|
||||
{filename: '文件.txt', shouldSanitize: false, description: 'CJK characters OK'},
|
||||
{filename: '😀.txt', shouldSanitize: false, description: 'emoji OK'},
|
||||
];
|
||||
|
||||
for (const tc of testCases) {
|
||||
it(`should handle ${tc.description}`, async () => {
|
||||
const fileData = Buffer.from('test content');
|
||||
|
||||
const payload = {
|
||||
content: `Special char test: ${tc.description}`,
|
||||
attachments: [
|
||||
{
|
||||
id: 0,
|
||||
filename: tc.filename,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, accountToken, channelId, payload, [
|
||||
{index: 0, filename: 'test.txt', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!).toHaveLength(1);
|
||||
|
||||
const sanitized = json.attachments![0].filename;
|
||||
|
||||
if (tc.shouldSanitize) {
|
||||
const dangerousChars = ['<', '>', ':', '"', '|', '?', '*'];
|
||||
for (const char of dangerousChars) {
|
||||
expect(sanitized).not.toContain(char);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('path traversal', () => {
|
||||
const testCases = [
|
||||
{input: '../../../etc/passwd', expectedSuffix: 'passwd'},
|
||||
{input: '..\\\\..\\\\..\\\\windows\\\\system32\\\\config\\\\sam', expectedSuffix: 'sam'},
|
||||
{input: '....//....//....//etc/passwd', expectedSuffix: 'passwd'},
|
||||
{input: '..\\\\..\\\\..\\\\', expectedSuffix: ''},
|
||||
{input: '../../sensitive.txt', expectedSuffix: 'sensitive.txt'},
|
||||
{input: './../../etc/hosts', expectedSuffix: 'hosts'},
|
||||
];
|
||||
|
||||
for (const tc of testCases) {
|
||||
it(`should sanitize path traversal in filename: ${tc.input}`, async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Path Traversal Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = Buffer.from('test content');
|
||||
|
||||
const payload = {
|
||||
content: 'Path traversal test',
|
||||
attachments: [{id: 0, filename: tc.input}],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: tc.input, data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!).toHaveLength(1);
|
||||
|
||||
const sanitized = json.attachments![0].filename;
|
||||
expect(sanitized).not.toContain('..');
|
||||
expect(sanitized).not.toContain('/');
|
||||
expect(sanitized).not.toContain('\\');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metadata Validation', () => {
|
||||
describe('id matching', () => {
|
||||
it('should reject negative IDs in metadata', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Negative ID Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = Buffer.from('test content');
|
||||
|
||||
const payload = {
|
||||
content: 'Negative ID test',
|
||||
attachments: [{id: -1, filename: 'test.txt'}],
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'test.txt', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject mismatched IDs between metadata and files', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'ID Mismatch Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = Buffer.from('test content');
|
||||
|
||||
const payload = {
|
||||
content: 'ID mismatch test',
|
||||
attachments: [{id: 5, filename: 'test.txt'}],
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'test.txt', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should correctly match files with metadata when sent in natural order', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'ID Matching Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
|
||||
const payload = {
|
||||
content: 'Ordered files test',
|
||||
attachments: [
|
||||
{id: 0, filename: 'yeah.png', description: 'First file', title: 'First'},
|
||||
{id: 1, filename: 'thisisfine.gif', description: 'Second file', title: 'Second'},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: file1Data},
|
||||
{index: 1, filename: 'thisisfine.gif', data: file2Data},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!).toHaveLength(2);
|
||||
|
||||
expect(json.attachments![0].filename).toBe('yeah.png');
|
||||
expect(json.attachments![0].description).toBe('First file');
|
||||
expect(json.attachments![0].title).toBe('First');
|
||||
|
||||
expect(json.attachments![1].filename).toBe('thisisfine.gif');
|
||||
expect(json.attachments![1].description).toBe('Second file');
|
||||
expect(json.attachments![1].title).toBe('Second');
|
||||
});
|
||||
|
||||
it('should handle non-sequential IDs like 2, 5', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Sparse IDs Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
|
||||
const payload = {
|
||||
content: 'Sparse IDs test',
|
||||
attachments: [
|
||||
{id: 2, filename: 'yeah.png', description: 'ID is 2', title: 'Two'},
|
||||
{id: 5, filename: 'thisisfine.gif', description: 'ID is 5', title: 'Five'},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 2, filename: 'yeah.png', data: file1Data},
|
||||
{index: 5, filename: 'thisisfine.gif', data: file2Data},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!).toHaveLength(2);
|
||||
|
||||
expect(json.attachments![0].description).toBe('ID is 2');
|
||||
expect(json.attachments![0].title).toBe('Two');
|
||||
|
||||
expect(json.attachments![1].description).toBe('ID is 5');
|
||||
expect(json.attachments![1].title).toBe('Five');
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata requirements', () => {
|
||||
it('should reject file upload without attachment metadata', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Without Metadata Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'File without metadata',
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject attachment metadata without corresponding file', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Metadata Without File Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const payload = {
|
||||
content: 'Metadata without file',
|
||||
attachments: [{id: 0, filename: 'missing.png', description: 'This file does not exist'}],
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, []);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('title and description', () => {
|
||||
it('should preserve title and description fields through upload flow', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Title Description Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Testing title and description',
|
||||
attachments: [
|
||||
{
|
||||
id: 0,
|
||||
filename: 'yeah.png',
|
||||
title: 'My Awesome Title',
|
||||
description: 'This is a detailed description of the attachment with special chars: émoji 🎉',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!).toHaveLength(1);
|
||||
|
||||
const att = json.attachments![0];
|
||||
expect(att.title).toBe('My Awesome Title');
|
||||
|
||||
const expectedDesc = 'This is a detailed description of the attachment with special chars: émoji 🎉';
|
||||
expect(att.description).toBe(expectedDesc);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Flag Preservation', () => {
|
||||
it('should preserve attachment flags through upload flow', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Flags Preserved Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Flags preservation test',
|
||||
attachments: [
|
||||
{
|
||||
id: 0,
|
||||
filename: 'spoiler.png',
|
||||
flags: 8,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'spoiler.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!).toHaveLength(1);
|
||||
expect(json.attachments![0].flags).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple File Upload', () => {
|
||||
it('should handle multiple files with mixed metadata quality', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Mixed Metadata Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
|
||||
const payload = {
|
||||
content: 'Mixed metadata test',
|
||||
attachments: [
|
||||
{
|
||||
id: 0,
|
||||
filename: 'yeah.png',
|
||||
title: 'Full Metadata',
|
||||
description: 'Complete description',
|
||||
flags: 0,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
filename: 'thisisfine.gif',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: file1Data},
|
||||
{index: 1, filename: 'thisisfine.gif', data: file2Data},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!).toHaveLength(2);
|
||||
|
||||
expect(json.attachments![0].title).toBe('Full Metadata');
|
||||
expect(json.attachments![0].description).toBe('Complete description');
|
||||
|
||||
expect(json.attachments![1].title).toBeNull();
|
||||
expect(json.attachments![1].description).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
116
packages/api/src/channel/tests/BulkDeleteMessages.test.tsx
Normal file
116
packages/api/src/channel/tests/BulkDeleteMessages.test.tsx
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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
describe('Bulk Delete Messages', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('rejects empty message_ids array', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Validation Test Guild');
|
||||
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild should have a system channel');
|
||||
}
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/channels/${guild.system_channel_id}/messages/bulk-delete`)
|
||||
.body({message_ids: []})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects more than 100 messages', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Validation Test Guild');
|
||||
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild should have a system channel');
|
||||
}
|
||||
|
||||
const tooManyIds: Array<string> = [];
|
||||
for (let i = 0; i < 101; i++) {
|
||||
tooManyIds.push(`${1000000000000000000n + BigInt(i)}`);
|
||||
}
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/channels/${guild.system_channel_id}/messages/bulk-delete`)
|
||||
.body({message_ids: tooManyIds})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects bulk delete without MANAGE_MESSAGES permission when channel does not exist', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Permissions Test Guild');
|
||||
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild should have a system channel');
|
||||
}
|
||||
|
||||
const messageIds: Array<string> = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
messageIds.push(`${1000000000000000000n + BigInt(i)}`);
|
||||
}
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${guild.system_channel_id}/messages/bulk-delete`)
|
||||
.body({message_ids: messageIds})
|
||||
.expect(403)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('accepts bulk delete request with valid message IDs (does not require messages to exist)', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Bulk Delete Test Guild');
|
||||
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild should have a system channel');
|
||||
}
|
||||
|
||||
const messageIds: Array<string> = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
messageIds.push(`${1000000000000000000n + BigInt(i)}`);
|
||||
}
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/channels/${guild.system_channel_id}/messages/bulk-delete`)
|
||||
.body({message_ids: messageIds})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
188
packages/api/src/channel/tests/CallEndpoints.test.tsx
Normal file
188
packages/api/src/channel/tests/CallEndpoints.test.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createDmChannel, createGuild} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Call Endpoints', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function setupUsersWithMutualGuild() {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, user1.token, 'Test Guild');
|
||||
|
||||
const invite = await createBuilder<GuildInviteMetadataResponse>(harness, user1.token)
|
||||
.post(`/channels/${guild.system_channel_id}/invites`)
|
||||
.body({})
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, user2.token).post(`/invites/${invite.code}`).body(null).execute();
|
||||
|
||||
return {user1, user2};
|
||||
}
|
||||
|
||||
describe('GET /channels/:channel_id/call', () => {
|
||||
it('returns call eligibility for DM channel when voice is disabled', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
const callData = await createBuilder<{ringable: boolean}>(harness, user1.token)
|
||||
.get(`/channels/${dmChannel.id}/call`)
|
||||
.execute();
|
||||
|
||||
expect(callData).toHaveProperty('ringable');
|
||||
expect(typeof callData.ringable).toBe('boolean');
|
||||
});
|
||||
|
||||
it('returns call eligibility for non-existent channel', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, user.token).get('/channels/999999999999999999/call').expect(404).execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /channels/:channel_id/call', () => {
|
||||
it('updates call region for DM channel', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.patch(`/channels/${dmChannel.id}/call`)
|
||||
.body({region: 'us-west'})
|
||||
.expect(404)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns error when updating non-existent call', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token).patch(`/channels/${dmChannel.id}/call`).body({}).expect(404).execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /channels/:channel_id/call/ring', () => {
|
||||
it('rings call recipients for DM channel', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/ring`)
|
||||
.body({recipients: [user2.userId]})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('creates silent call without ringing when recipients is empty array (shift-click)', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/ring`)
|
||||
.body({recipients: []})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rings call without specifying recipients for DM channel', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/ring`)
|
||||
.body({})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects ringing for non-existent channel', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post('/channels/123456789/call/ring')
|
||||
.body({recipients: [user2.userId]})
|
||||
.expect(404)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /channels/:channel_id/call/stop-ringing', () => {
|
||||
it('returns error when stopping ringing for non-existent call', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/stop-ringing`)
|
||||
.body({recipients: [user2.userId]})
|
||||
.expect(404)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns error when stopping ringing all for non-existent call', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/stop-ringing`)
|
||||
.body({})
|
||||
.expect(404)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /channels/:channel_id/call/end', () => {
|
||||
it('ends call for DM channel', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/end`)
|
||||
.body(null)
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannel,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
deleteChannel,
|
||||
getChannel,
|
||||
updateChannel,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Channel Operation Permissions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should allow member to get channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Channel Perms Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
const channel = await getChannel(harness, member.token, systemChannel.id);
|
||||
|
||||
expect(channel.id).toBe(systemChannel.id);
|
||||
});
|
||||
|
||||
it('should reject nonmember from getting channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const nonmember = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Channel Perms Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, nonmember.token)
|
||||
.get(`/channels/${systemChannel.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject member from updating channel without MANAGE_CHANNELS', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Channel Perms Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/channels/${systemChannel.id}`)
|
||||
.body({name: 'hacked'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject member from deleting channel without MANAGE_CHANNELS', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Channel Perms Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/channels/${systemChannel.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should allow owner to update channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Channel Perms Guild');
|
||||
|
||||
const testChannel = await createChannel(harness, owner.token, guild.id, 'test-channel');
|
||||
|
||||
const updated = await updateChannel(harness, owner.token, testChannel.id, {name: 'owner-updated'});
|
||||
|
||||
expect(updated.name).toBe('owner-updated');
|
||||
});
|
||||
|
||||
it('should allow owner to delete channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Channel Perms Guild');
|
||||
|
||||
const testChannel = await createChannel(harness, owner.token, guild.id, 'test-channel');
|
||||
|
||||
await deleteChannel(harness, owner.token, testChannel.id);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/channels/${testChannel.id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createChannel,
|
||||
createGuild,
|
||||
deleteChannel,
|
||||
getChannel,
|
||||
updateChannel,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Channel Operation Validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should reject getting nonexistent channel', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.get('/channels/999999999999999999')
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject updating nonexistent channel', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch('/channels/999999999999999999')
|
||||
.body({name: 'new-name'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject deleting nonexistent channel', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete('/channels/999999999999999999')
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should get channel successfully', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Channel Operation Guild');
|
||||
const channel = await getChannel(harness, account.token, guild.system_channel_id!);
|
||||
|
||||
expect(channel.id).toBe(guild.system_channel_id);
|
||||
});
|
||||
|
||||
it('should update channel name successfully', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Channel Operation Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
|
||||
const updated = await updateChannel(harness, account.token, channelId, {name: 'updated-name'});
|
||||
|
||||
expect(updated.name).toBe('updated-name');
|
||||
});
|
||||
|
||||
it('should update channel topic successfully', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Channel Operation Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
|
||||
const updated = await updateChannel(harness, account.token, channelId, {topic: 'New topic'});
|
||||
|
||||
expect(updated.topic).toBe('New topic');
|
||||
});
|
||||
|
||||
it('should delete channel successfully', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Channel Operation Guild');
|
||||
|
||||
const newChannel = await createChannel(harness, account.token, guild.id, 'to-delete');
|
||||
|
||||
await deleteChannel(harness, account.token, newChannel.id);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.get(`/channels/${newChannel.id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createChannel,
|
||||
createGuild,
|
||||
createPermissionOverwrite,
|
||||
createRole,
|
||||
getChannel,
|
||||
setupTestGuildWithMembers,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Channel Permission Overwrites', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should create permission overwrite for role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const role = await createRole(harness, account.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
const overwrite = await createPermissionOverwrite(harness, account.token, channel.id, role.id, {
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
});
|
||||
|
||||
expect(overwrite.id).toBe(role.id);
|
||||
expect(overwrite.type).toBe(0);
|
||||
});
|
||||
|
||||
test('should create permission overwrite for member', async () => {
|
||||
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const overwrite = await createPermissionOverwrite(harness, owner.token, systemChannel.id, member.userId, {
|
||||
type: 1,
|
||||
allow: Permissions.VIEW_CHANNEL.toString(),
|
||||
deny: Permissions.SEND_MESSAGES.toString(),
|
||||
});
|
||||
|
||||
expect(overwrite.id).toBe(member.userId);
|
||||
expect(overwrite.type).toBe(1);
|
||||
});
|
||||
|
||||
test('should deny permission via overwrite', async () => {
|
||||
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createPermissionOverwrite(harness, owner.token, systemChannel.id, member.userId, {
|
||||
type: 1,
|
||||
allow: '0',
|
||||
deny: Permissions.SEND_MESSAGES.toString(),
|
||||
});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${systemChannel.id}/messages`)
|
||||
.body({content: 'Test message'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow permission via overwrite', async () => {
|
||||
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createPermissionOverwrite(harness, owner.token, systemChannel.id, member.userId, {
|
||||
type: 1,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${systemChannel.id}/messages`)
|
||||
.body({content: 'Test message'})
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should update existing permission overwrite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const role = await createRole(harness, account.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createPermissionOverwrite(harness, account.token, channel.id, role.id, {
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
});
|
||||
|
||||
const updated = await createPermissionOverwrite(harness, account.token, channel.id, role.id, {
|
||||
type: 0,
|
||||
allow: (Permissions.SEND_MESSAGES | Permissions.EMBED_LINKS).toString(),
|
||||
deny: '0',
|
||||
});
|
||||
|
||||
expect(BigInt(updated.allow)).toBe(Permissions.SEND_MESSAGES | Permissions.EMBED_LINKS);
|
||||
});
|
||||
|
||||
test('should delete permission overwrite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const role = await createRole(harness, account.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createPermissionOverwrite(harness, account.token, channel.id, role.id, {
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
});
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channelData = await getChannel(harness, account.token, channel.id);
|
||||
const overwrite = channelData.permission_overwrites?.find((o) => o.id === role.id);
|
||||
expect(overwrite).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should require MANAGE_ROLES to create overwrites', async () => {
|
||||
const {owner, members, guild, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
const role = await createRole(harness, owner.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.put(`/channels/${systemChannel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should show overwrites in channel response', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const role = await createRole(harness, account.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createPermissionOverwrite(harness, account.token, channel.id, role.id, {
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
});
|
||||
|
||||
const channelData = await getChannel(harness, account.token, channel.id);
|
||||
expect(channelData.permission_overwrites).toBeDefined();
|
||||
expect(channelData.permission_overwrites?.some((o) => o.id === role.id)).toBe(true);
|
||||
});
|
||||
|
||||
test('should prioritize member overwrite over role overwrite', async () => {
|
||||
const {owner, members, guild, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
const role = await createRole(harness, owner.token, guild.id, {name: 'Deny Role'});
|
||||
|
||||
await createBuilder<void>(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/members/${member.userId}/roles/${role.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createPermissionOverwrite(harness, owner.token, systemChannel.id, role.id, {
|
||||
type: 0,
|
||||
allow: '0',
|
||||
deny: Permissions.SEND_MESSAGES.toString(),
|
||||
});
|
||||
|
||||
await createPermissionOverwrite(harness, owner.token, systemChannel.id, member.userId, {
|
||||
type: 1,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${systemChannel.id}/messages`)
|
||||
.body({content: 'Test message'})
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject invalid overwrite type', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/123456789`)
|
||||
.body({
|
||||
type: 999,
|
||||
allow: '0',
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should handle multiple overlapping role overwrites', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const role1 = await createRole(harness, account.token, guild.id, {name: 'Role 1'});
|
||||
const role2 = await createRole(harness, account.token, guild.id, {name: 'Role 2'});
|
||||
|
||||
await createPermissionOverwrite(harness, account.token, channel.id, role1.id, {
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
});
|
||||
|
||||
await createPermissionOverwrite(harness, account.token, channel.id, role2.id, {
|
||||
type: 0,
|
||||
allow: Permissions.EMBED_LINKS.toString(),
|
||||
deny: '0',
|
||||
});
|
||||
|
||||
const channelData = await getChannel(harness, account.token, channel.id);
|
||||
expect(channelData.permission_overwrites?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
415
packages/api/src/channel/tests/ChannelTestUtils.tsx
Normal file
415
packages/api/src/channel/tests/ChannelTestUtils.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
/*
|
||||
* 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 {createTestAccount, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import type {ChannelOverwriteResponse, ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
|
||||
import type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
|
||||
export async function createGuild(harness: ApiTestHarness, token: string, name: string): Promise<GuildResponse> {
|
||||
return createBuilder<GuildResponse>(harness, token).post('/guilds').body({name}).execute();
|
||||
}
|
||||
|
||||
export async function createChannel(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
name: string,
|
||||
type = 0,
|
||||
): Promise<ChannelResponse> {
|
||||
return createBuilder<ChannelResponse>(harness, token)
|
||||
.post(`/guilds/${guildId}/channels`)
|
||||
.body({name, type})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function getChannel(harness: ApiTestHarness, token: string, channelId: string): Promise<ChannelResponse> {
|
||||
return createBuilder<ChannelResponse>(harness, token).get(`/channels/${channelId}`).execute();
|
||||
}
|
||||
|
||||
export async function updateChannel(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
updates: Partial<
|
||||
Pick<ChannelResponse, 'name' | 'type' | 'topic' | 'parent_id' | 'position' | 'rate_limit_per_user' | 'nsfw'>
|
||||
>,
|
||||
): Promise<ChannelResponse> {
|
||||
return createBuilder<ChannelResponse>(harness, token).patch(`/channels/${channelId}`).body(updates).execute();
|
||||
}
|
||||
|
||||
export async function deleteChannel(harness: ApiTestHarness, token: string, channelId: string): Promise<void> {
|
||||
return createBuilder<void>(harness, token).delete(`/channels/${channelId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function createChannelInvite(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
): Promise<GuildInviteMetadataResponse> {
|
||||
return createBuilder<GuildInviteMetadataResponse>(harness, token)
|
||||
.post(`/channels/${channelId}/invites`)
|
||||
.body({})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function acceptInvite(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
inviteCode: string,
|
||||
): Promise<{guild: GuildResponse}> {
|
||||
return createBuilder<{guild: GuildResponse}>(harness, token).post(`/invites/${inviteCode}`).body(null).execute();
|
||||
}
|
||||
|
||||
export async function createRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
role: Partial<Omit<GuildRoleResponse, 'id' | 'position'>>,
|
||||
): Promise<GuildRoleResponse> {
|
||||
return createBuilder<GuildRoleResponse>(harness, token).post(`/guilds/${guildId}/roles`).body(role).execute();
|
||||
}
|
||||
|
||||
export async function updateRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
roleId: string,
|
||||
updates: Partial<GuildRoleResponse>,
|
||||
): Promise<GuildRoleResponse> {
|
||||
return createBuilder<GuildRoleResponse>(harness, token)
|
||||
.patch(`/guilds/${guildId}/roles/${roleId}`)
|
||||
.body(updates)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function deleteRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
roleId: string,
|
||||
): Promise<void> {
|
||||
return createBuilder<void>(harness, token).delete(`/guilds/${guildId}/roles/${roleId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function addMemberRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
): Promise<void> {
|
||||
return createBuilder<void>(harness, token)
|
||||
.put(`/guilds/${guildId}/members/${userId}/roles/${roleId}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function removeMemberRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
): Promise<void> {
|
||||
return createBuilder<void>(harness, token)
|
||||
.delete(`/guilds/${guildId}/members/${userId}/roles/${roleId}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function updateMember(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
updates: {roles?: Array<string>; nick?: string},
|
||||
): Promise<GuildMemberResponse> {
|
||||
return createBuilder<GuildMemberResponse>(harness, token)
|
||||
.patch(`/guilds/${guildId}/members/${userId}`)
|
||||
.body(updates)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function getMember(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
): Promise<GuildMemberResponse> {
|
||||
return createBuilder<GuildMemberResponse>(harness, token).get(`/guilds/${guildId}/members/${userId}`).execute();
|
||||
}
|
||||
|
||||
export async function getGuild(harness: ApiTestHarness, token: string, guildId: string): Promise<GuildResponse> {
|
||||
return createBuilder<GuildResponse>(harness, token).get(`/guilds/${guildId}`).execute();
|
||||
}
|
||||
|
||||
export async function updateGuild(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
updates: Partial<GuildResponse>,
|
||||
): Promise<GuildResponse> {
|
||||
return createBuilder<GuildResponse>(harness, token).patch(`/guilds/${guildId}`).body(updates).execute();
|
||||
}
|
||||
|
||||
export async function leaveGuild(harness: ApiTestHarness, token: string, guildId: string): Promise<void> {
|
||||
return createBuilder<void>(harness, token).delete(`/users/@me/guilds/${guildId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function getUserGuilds(harness: ApiTestHarness, token: string): Promise<Array<GuildResponse>> {
|
||||
return createBuilder<Array<GuildResponse>>(harness, token).get('/users/@me/guilds').execute();
|
||||
}
|
||||
|
||||
export async function createPermissionOverwrite(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
overwriteId: string,
|
||||
overwrite: Omit<ChannelOverwriteResponse, 'id'>,
|
||||
): Promise<ChannelOverwriteResponse> {
|
||||
await createBuilder<void>(harness, token)
|
||||
.put(`/channels/${channelId}/permissions/${overwriteId}`)
|
||||
.body({
|
||||
type: overwrite.type,
|
||||
allow: overwrite.allow,
|
||||
deny: overwrite.deny,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
|
||||
return {
|
||||
id: overwriteId,
|
||||
type: overwrite.type,
|
||||
allow: overwrite.allow,
|
||||
deny: overwrite.deny,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setupTestGuildWithChannels(
|
||||
harness: ApiTestHarness,
|
||||
account?: TestAccount,
|
||||
): Promise<{
|
||||
account: TestAccount;
|
||||
guild: GuildResponse;
|
||||
systemChannel: ChannelResponse;
|
||||
}> {
|
||||
const testAccount = account ?? (await createTestAccount(harness));
|
||||
|
||||
const guild = await createGuild(harness, testAccount.token, 'Test Guild');
|
||||
|
||||
const systemChannel = await getChannel(harness, testAccount.token, guild.system_channel_id!);
|
||||
|
||||
return {account: testAccount, guild, systemChannel};
|
||||
}
|
||||
|
||||
export async function setupTestGuildWithMembers(
|
||||
harness: ApiTestHarness,
|
||||
memberCount = 2,
|
||||
): Promise<{
|
||||
owner: TestAccount;
|
||||
members: Array<TestAccount>;
|
||||
guild: GuildResponse;
|
||||
systemChannel: ChannelResponse;
|
||||
}> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const members: Array<TestAccount> = [];
|
||||
|
||||
for (let i = 0; i < memberCount; i++) {
|
||||
members.push(await createTestAccount(harness));
|
||||
}
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
for (const member of members) {
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
}
|
||||
|
||||
return {owner, members, guild, systemChannel};
|
||||
}
|
||||
|
||||
export interface MinimalChannelResponse {
|
||||
id: string;
|
||||
type: number;
|
||||
recipients?: Array<{id: string; username: string}>;
|
||||
}
|
||||
|
||||
export async function createFriendship(harness: ApiTestHarness, user1: TestAccount, user2: TestAccount): Promise<void> {
|
||||
await createBuilder<unknown>(harness, user1.token)
|
||||
.post(`/users/@me/relationships/${user2.userId}`)
|
||||
.body(null)
|
||||
.execute();
|
||||
|
||||
await createBuilder<unknown>(harness, user2.token).put(`/users/@me/relationships/${user1.userId}`).body({}).execute();
|
||||
}
|
||||
|
||||
export async function createDmChannel(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
recipientId: string,
|
||||
): Promise<MinimalChannelResponse> {
|
||||
const channel = await createBuilder<MinimalChannelResponse>(harness, token)
|
||||
.post('/users/@me/channels')
|
||||
.body({
|
||||
recipient_id: recipientId,
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (!channel.id) {
|
||||
throw new Error('DM channel response missing id');
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
export async function sendChannelMessage(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
content: string,
|
||||
): Promise<MessageResponse> {
|
||||
await ensureSessionStarted(harness, token);
|
||||
|
||||
const msg = await createBuilder<MessageResponse>(harness, token)
|
||||
.post(`/channels/${channelId}/messages`)
|
||||
.body({
|
||||
content,
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (!msg.id) {
|
||||
throw new Error('Message response missing id');
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
export async function blockUser(harness: ApiTestHarness, user: TestAccount, targetUserId: string): Promise<void> {
|
||||
await createBuilder<unknown>(harness, user.token)
|
||||
.put(`/users/@me/relationships/${targetUserId}`)
|
||||
.body({
|
||||
type: 2,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function initiateCall(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
recipients: Array<string>,
|
||||
expectedStatus: number = 204,
|
||||
): Promise<{response: Response; json: unknown}> {
|
||||
return createBuilder(harness, token)
|
||||
.post(`/channels/${channelId}/call/ring`)
|
||||
.body({recipients})
|
||||
.expect(expectedStatus)
|
||||
.executeWithResponse();
|
||||
}
|
||||
|
||||
export async function pinMessage(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
expectedStatus: number = 204,
|
||||
): Promise<{response: Response; json: unknown}> {
|
||||
return createBuilder(harness, token)
|
||||
.put(`/channels/${channelId}/pins/${messageId}`)
|
||||
.body(null)
|
||||
.expect(expectedStatus)
|
||||
.executeWithResponse();
|
||||
}
|
||||
|
||||
export interface GroupDmChannelResponse {
|
||||
id: string;
|
||||
type: number;
|
||||
name: string | null;
|
||||
owner_id: string;
|
||||
recipients: Array<{id: string; username: string}>;
|
||||
}
|
||||
|
||||
export async function createGroupDmChannel(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
recipientUserIds: Array<string>,
|
||||
): Promise<GroupDmChannelResponse> {
|
||||
const channel = await createBuilder<GroupDmChannelResponse>(harness, token)
|
||||
.post('/users/@me/channels')
|
||||
.body({
|
||||
recipients: recipientUserIds,
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (!channel.id) {
|
||||
throw new Error('Group DM channel response missing id');
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
export async function addRecipientToGroupDm(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token)
|
||||
.put(`/channels/${channelId}/recipients/${userId}`)
|
||||
.body(null)
|
||||
.expect(204)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function removeRecipientFromGroupDm(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/channels/${channelId}/recipients/${userId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export interface SeedPrivateChannelsResult {
|
||||
group_dms: Array<{channel_id: string; last_message_id: string}>;
|
||||
dms: Array<{channel_id: string; last_message_id: string}>;
|
||||
}
|
||||
|
||||
export async function seedPrivateChannels(
|
||||
harness: ApiTestHarness,
|
||||
_token: string,
|
||||
userId: string,
|
||||
params: {
|
||||
group_dm_count?: number;
|
||||
dm_count?: number;
|
||||
recipients?: Array<string>;
|
||||
clear_existing?: boolean;
|
||||
},
|
||||
): Promise<SeedPrivateChannelsResult> {
|
||||
return createBuilder<SeedPrivateChannelsResult>(harness, '')
|
||||
.post(`/test/users/${userId}/private-channels`)
|
||||
.body(params)
|
||||
.execute();
|
||||
}
|
||||
188
packages/api/src/channel/tests/DMBlockingBehaviors.test.tsx
Normal file
188
packages/api/src/channel/tests/DMBlockingBehaviors.test.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
blockUser,
|
||||
createChannelInvite,
|
||||
createDmChannel,
|
||||
createFriendship,
|
||||
createGuild,
|
||||
getChannel,
|
||||
initiateCall,
|
||||
pinMessage,
|
||||
sendChannelMessage,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('DM Blocking Behaviors', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('DM Creation Blocking', () => {
|
||||
it('prevents DM creation when the other user has blocked you, even with mutual guild', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, user1.token, 'Test Community');
|
||||
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
|
||||
await acceptInvite(harness, user2.token, invite.code);
|
||||
|
||||
await blockUser(harness, user1, user2.userId);
|
||||
|
||||
await createBuilder(harness, user2.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipient_id: user1.userId})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('prevents DM creation with someone you have blocked, even with mutual guild', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, user1.token, 'Test Community');
|
||||
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
|
||||
await acceptInvite(harness, user2.token, invite.code);
|
||||
|
||||
await blockUser(harness, user1, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipient_id: user2.userId})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Voice Call Blocking', () => {
|
||||
it('prevents the user who blocked someone from initiating calls in the DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const channel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await blockUser(harness, user1, user2.userId);
|
||||
|
||||
const {response, json} = await initiateCall(harness, user1.token, channel.id, [user2.userId], 400);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((json as {code: string}).code).toBe('CANNOT_SEND_MESSAGES_TO_USER');
|
||||
});
|
||||
|
||||
it('prevents calls in a DM after one user blocks the other', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const channel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await blockUser(harness, user1, user2.userId);
|
||||
|
||||
const {response, json} = await initiateCall(harness, user2.token, channel.id, [user1.userId], 400);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((json as {code: string}).code).toBe('CANNOT_SEND_MESSAGES_TO_USER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pin Operation Blocking', () => {
|
||||
it('prevents the user who blocked someone from pinning messages in the DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const channel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
const msg = await sendChannelMessage(harness, user2.token, channel.id, 'message to pin');
|
||||
|
||||
await blockUser(harness, user1, user2.userId);
|
||||
|
||||
const {response, json} = await pinMessage(harness, user1.token, channel.id, msg.id, 400);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((json as {code: string}).code).toBe('CANNOT_SEND_MESSAGES_TO_USER');
|
||||
});
|
||||
|
||||
it('prevents pinning messages in a DM after one user blocks the other', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const channel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
const msg = await sendChannelMessage(harness, user1.token, channel.id, 'message to pin');
|
||||
|
||||
await blockUser(harness, user1, user2.userId);
|
||||
|
||||
const {response, json} = await pinMessage(harness, user2.token, channel.id, msg.id, 400);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((json as {code: string}).code).toBe('CANNOT_SEND_MESSAGES_TO_USER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Blocking', () => {
|
||||
it('prevents messages in a DM after one user blocks the other', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const channel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await blockUser(harness, user1, user2.userId);
|
||||
|
||||
await createBuilder(harness, user2.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: 'hello'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
74
packages/api/src/channel/tests/DMChannelManagement.test.tsx
Normal file
74
packages/api/src/channel/tests/DMChannelManagement.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createDmChannel, createFriendship, deleteChannel} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('DM channel management', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('can create DM channel', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const dm = await createDmChannel(harness, user1.token, user2.userId);
|
||||
expect(dm.id).toBeTruthy();
|
||||
expect(dm.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('can get DM channel', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const dm = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token).get(`/channels/${dm.id}`).expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
|
||||
it('can close DM channel', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const dm = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await deleteChannel(harness, user1.token, dm.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createDmChannel, createFriendship} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('DM creation allowed with friendship', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('allows friends to create DMs with each other', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const dm = await createDmChannel(harness, user1.token, user2.userId);
|
||||
expect(dm.id).toBeTruthy();
|
||||
expect(dm.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createDmChannel,
|
||||
createGuild,
|
||||
getChannel,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('DM creation allowed with mutual guild', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('allows users sharing a guild to create DMs without being friends', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, user1.token, 'Test Community');
|
||||
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
|
||||
await acceptInvite(harness, user2.token, invite.code);
|
||||
|
||||
const dm = await createDmChannel(harness, user1.token, user2.userId);
|
||||
expect(dm.id).toBeTruthy();
|
||||
expect(dm.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
describe('DM creation requires friendship or mutual guild', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('prevents DM creation with strangers who you do not share a guild with and are not friends with', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipient_id: user2.userId})
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,845 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createChannel,
|
||||
createGuild,
|
||||
loadFixture,
|
||||
sendMessageWithAttachments,
|
||||
} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {MessageAttachmentFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Embed Attachment URL Resolution', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
describe('Basic URL Resolution', () => {
|
||||
it('should resolve attachment:// URLs in embed image field', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Embed Attachment Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Test message with embed',
|
||||
attachments: [{id: 0, filename: 'yeah.png'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Test Embed',
|
||||
description: 'This embed uses an attached image',
|
||||
image: {url: 'attachment://yeah.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.content).toBe('Test message with embed');
|
||||
expect(json.embeds).toBeDefined();
|
||||
expect(json.embeds).toHaveLength(1);
|
||||
|
||||
const embed = json.embeds![0];
|
||||
expect(embed.title).toBe('Test Embed');
|
||||
expect(embed.image?.url).toBeTruthy();
|
||||
expect(embed.image?.url).not.toContain('attachment://');
|
||||
});
|
||||
|
||||
it('should resolve attachment:// URLs in embed thumbnail field', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Thumbnail Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Test with thumbnail',
|
||||
attachments: [{id: 0, filename: 'yeah.png'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Thumbnail Test',
|
||||
description: 'This embed uses a thumbnail',
|
||||
thumbnail: {url: 'attachment://yeah.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.embeds).toBeDefined();
|
||||
expect(json.embeds).toHaveLength(1);
|
||||
|
||||
const embed = json.embeds![0];
|
||||
expect(embed.title).toBe('Thumbnail Test');
|
||||
expect(embed.thumbnail?.url).toBeTruthy();
|
||||
expect(embed.thumbnail?.url).not.toContain('attachment://');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Image and Thumbnail Fields', () => {
|
||||
it('should handle single embed using attachment:// for both image and thumbnail', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Both Fields Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
|
||||
const payload = {
|
||||
content: 'Both image and thumbnail',
|
||||
attachments: [
|
||||
{id: 0, filename: 'yeah.png'},
|
||||
{id: 1, filename: 'thisisfine.gif'},
|
||||
],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Complete Embed',
|
||||
description: 'This embed uses both image and thumbnail',
|
||||
image: {url: 'attachment://thisisfine.gif'},
|
||||
thumbnail: {url: 'attachment://yeah.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: file1Data},
|
||||
{index: 1, filename: 'thisisfine.gif', data: file2Data},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.embeds).toBeDefined();
|
||||
expect(json.embeds).toHaveLength(1);
|
||||
|
||||
const embed = json.embeds![0];
|
||||
expect(embed.image?.url).toBeTruthy();
|
||||
expect(embed.thumbnail?.url).toBeTruthy();
|
||||
expect(embed.image?.url).not.toContain('attachment://');
|
||||
expect(embed.thumbnail?.url).not.toContain('attachment://');
|
||||
});
|
||||
|
||||
it('should handle image and thumbnail in same embed from different files', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Image And Thumbnail Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Image and thumbnail from different attachments',
|
||||
attachments: [
|
||||
{id: 0, filename: 'main-image.png'},
|
||||
{id: 1, filename: 'thumb-image.png'},
|
||||
],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Dual Image Embed',
|
||||
image: {url: 'attachment://main-image.png'},
|
||||
thumbnail: {url: 'attachment://thumb-image.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'main-image.png', data: fileData},
|
||||
{index: 1, filename: 'thumb-image.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!).toHaveLength(2);
|
||||
expect(json.embeds).toHaveLength(1);
|
||||
|
||||
const embed = json.embeds![0];
|
||||
expect(embed.image?.url).not.toContain('attachment://');
|
||||
expect(embed.thumbnail?.url).not.toContain('attachment://');
|
||||
expect(embed.image?.url).not.toBe(embed.thumbnail?.url);
|
||||
});
|
||||
|
||||
it('should handle mixed attachment:// and https:// URLs in embeds', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Mixed URLs Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Test message with mixed embed URLs',
|
||||
attachments: [{id: 0, filename: 'local-image.png'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Mixed URL Embed',
|
||||
description: 'This embed uses both attachment and external URLs',
|
||||
image: {url: 'attachment://local-image.png'},
|
||||
thumbnail: {url: 'https://example.com/external-image.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'local-image.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.embeds).toBeDefined();
|
||||
expect(json.embeds).toHaveLength(1);
|
||||
|
||||
const embed = json.embeds![0];
|
||||
expect(embed.image?.url).toBeTruthy();
|
||||
expect(embed.image?.url).not.toContain('attachment://');
|
||||
expect(embed.thumbnail?.url).toBe('https://example.com/external-image.png');
|
||||
});
|
||||
|
||||
it('should resolve attachment:// URL while preserving external thumbnail URL', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Preserve External URL Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Test with external thumbnail',
|
||||
attachments: [{id: 0, filename: 'attached.png'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'External Thumbnail Test',
|
||||
image: {url: 'attachment://attached.png'},
|
||||
thumbnail: {url: 'https://cdn.example.com/thumb.jpg'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'attached.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.embeds).toHaveLength(1);
|
||||
|
||||
const embed = json.embeds![0];
|
||||
expect(embed.image?.url).not.toContain('attachment://');
|
||||
expect(embed.thumbnail?.url).toBe('https://cdn.example.com/thumb.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filename Matching', () => {
|
||||
it('should require exact filename matching', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Filename Matching Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload1 = {
|
||||
content: 'Case-sensitive filename test',
|
||||
attachments: [{id: 0, filename: 'yeah.png'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Exact Match Required',
|
||||
image: {url: 'attachment://yeah.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response: response1} = await sendMessageWithAttachments(harness, account.token, channelId, payload1, [
|
||||
{index: 0, filename: 'yeah.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response1.status).toBe(200);
|
||||
|
||||
const payload2 = {
|
||||
content: 'Case mismatch test',
|
||||
attachments: [{id: 0, filename: 'yeah.png'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Wrong Case',
|
||||
image: {url: 'attachment://Yeah.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response: response2} = await sendMessageWithAttachments(harness, account.token, channelId, payload2, [
|
||||
{index: 0, filename: 'yeah.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response2.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should match correct file by filename', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Filename Match Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Testing filename matching',
|
||||
attachments: [
|
||||
{id: 0, filename: 'alpha.png'},
|
||||
{id: 1, filename: 'beta.png'},
|
||||
],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Beta Image',
|
||||
image: {url: 'attachment://beta.png'},
|
||||
},
|
||||
{
|
||||
title: 'Alpha Image',
|
||||
image: {url: 'attachment://alpha.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'alpha.png', data: fileData},
|
||||
{index: 1, filename: 'beta.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toHaveLength(2);
|
||||
expect(json.embeds).toHaveLength(2);
|
||||
|
||||
expect(json.embeds![0].title).toBe('Beta Image');
|
||||
expect(json.embeds![0].image?.url).not.toContain('attachment://');
|
||||
|
||||
expect(json.embeds![1].title).toBe('Alpha Image');
|
||||
expect(json.embeds![1].image?.url).not.toContain('attachment://');
|
||||
});
|
||||
|
||||
it('should resolve spoiler attachment in embed', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Spoiler Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Test message with spoiler attachment in embed',
|
||||
attachments: [{id: 0, filename: 'SPOILER_secret.png', flags: MessageAttachmentFlags.IS_SPOILER}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Spoiler Embed',
|
||||
description: 'This embed uses a spoiler attachment',
|
||||
image: {url: 'attachment://SPOILER_secret.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'SPOILER_secret.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!).toHaveLength(1);
|
||||
expect(json.attachments![0].filename).toBe('SPOILER_secret.png');
|
||||
|
||||
expect(json.embeds).toBeDefined();
|
||||
expect(json.embeds).toHaveLength(1);
|
||||
|
||||
const embed = json.embeds![0];
|
||||
expect(embed.image?.url).toBeTruthy();
|
||||
expect(embed.image?.url).not.toContain('attachment://');
|
||||
});
|
||||
|
||||
it('should preserve spoiler flag on attachment when referenced by embed', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Spoiler Flag Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Spoiler flag preservation test',
|
||||
attachments: [{id: 0, filename: 'SPOILER_hidden.png', flags: MessageAttachmentFlags.IS_SPOILER}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Hidden Content',
|
||||
image: {url: 'attachment://SPOILER_hidden.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'SPOILER_hidden.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!).toHaveLength(1);
|
||||
|
||||
const attachment = json.attachments![0];
|
||||
expect(attachment.flags).toBeDefined();
|
||||
expect(attachment.flags! & MessageAttachmentFlags.IS_SPOILER).toBe(MessageAttachmentFlags.IS_SPOILER);
|
||||
});
|
||||
|
||||
it('should handle spoiler attachment alongside non-spoiler in embed', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Mixed Spoiler Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Mixed spoiler and non-spoiler attachments',
|
||||
attachments: [
|
||||
{id: 0, filename: 'SPOILER_hidden.png', flags: MessageAttachmentFlags.IS_SPOILER},
|
||||
{id: 1, filename: 'visible.png'},
|
||||
],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Spoiler Image',
|
||||
image: {url: 'attachment://SPOILER_hidden.png'},
|
||||
},
|
||||
{
|
||||
title: 'Visible Image',
|
||||
thumbnail: {url: 'attachment://visible.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'SPOILER_hidden.png', data: fileData},
|
||||
{index: 1, filename: 'visible.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments).not.toBeNull();
|
||||
expect(json.attachments!).toHaveLength(2);
|
||||
|
||||
const spoilerAttachment = json.attachments!.find((a) => a.filename === 'SPOILER_hidden.png');
|
||||
const visibleAttachment = json.attachments!.find((a) => a.filename === 'visible.png');
|
||||
|
||||
expect(spoilerAttachment).toBeDefined();
|
||||
expect(spoilerAttachment!.flags! & MessageAttachmentFlags.IS_SPOILER).toBe(MessageAttachmentFlags.IS_SPOILER);
|
||||
|
||||
expect(visibleAttachment).toBeDefined();
|
||||
expect(visibleAttachment!.flags ?? 0).not.toBe(MessageAttachmentFlags.IS_SPOILER);
|
||||
|
||||
expect(json.embeds).toHaveLength(2);
|
||||
expect(json.embeds![0].image?.url).not.toContain('attachment://');
|
||||
expect(json.embeds![1].thumbnail?.url).not.toContain('attachment://');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should reject embed with attachment:// URL when no files are uploaded', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'No Attachments Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const payload = {
|
||||
content: 'Embed references attachment but none provided',
|
||||
embeds: [
|
||||
{
|
||||
title: 'Invalid Reference',
|
||||
description: 'No attachment uploaded',
|
||||
image: {url: 'attachment://image.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channelId}/messages`)
|
||||
.body(payload)
|
||||
.expect(400)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject embed referencing non-existent filename', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Missing Attachment Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Embed references missing file',
|
||||
attachments: [{id: 0, filename: 'yeah.png'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Invalid Reference',
|
||||
description: 'This embed references a non-existent file',
|
||||
image: {url: 'attachment://nonexistent.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation Rules', () => {
|
||||
it('should reject non-image attachment in embed image field', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Non-Image Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const textFileData = Buffer.from('This is a text file, not an image.');
|
||||
|
||||
const payload = {
|
||||
content: 'Test message with non-image in embed',
|
||||
attachments: [{id: 0, filename: 'document.txt'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Invalid Embed',
|
||||
image: {url: 'attachment://document.txt'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'document.txt', data: textFileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject PDF attachment in embed image field', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'PDF Rejection Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const pdfHeader = Buffer.from('%PDF-1.4\n');
|
||||
const pdfData = Buffer.concat([pdfHeader, Buffer.alloc(100)]);
|
||||
|
||||
const payload = {
|
||||
content: 'Test message with PDF in embed',
|
||||
attachments: [{id: 0, filename: 'document.pdf'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'PDF Embed Attempt',
|
||||
image: {url: 'attachment://document.pdf'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'document.pdf', data: pdfData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject JSON attachment in embed thumbnail field', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'JSON Rejection Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const jsonData = Buffer.from(JSON.stringify({key: 'value'}));
|
||||
|
||||
const payload = {
|
||||
content: 'Test message with JSON in embed thumbnail',
|
||||
attachments: [{id: 0, filename: 'data.json'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'JSON Embed Attempt',
|
||||
thumbnail: {url: 'attachment://data.json'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'data.json', data: jsonData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject executable attachment in embed image field', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Executable Rejection Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const exeData = Buffer.from('MZ');
|
||||
|
||||
const payload = {
|
||||
content: 'Test message with executable in embed',
|
||||
attachments: [{id: 0, filename: 'program.exe'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Executable Embed Attempt',
|
||||
image: {url: 'attachment://program.exe'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'program.exe', data: exeData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should accept file with image extension but corrupted content for embed reference', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Content Type Mismatch Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const textData = Buffer.from('Not an image, just text.');
|
||||
|
||||
const payload = {
|
||||
content: 'Test with fake PNG extension',
|
||||
attachments: [{id: 0, filename: 'fake.png'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Fake Image Embed',
|
||||
image: {url: 'attachment://fake.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'fake.png', data: textData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.embeds).toHaveLength(1);
|
||||
expect(json.embeds![0].image?.url).not.toContain('attachment://');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Embeds and Files', () => {
|
||||
it('should handle multiple embeds with different URL types', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Multiple Embeds Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Multiple embeds with different URL types',
|
||||
attachments: [{id: 0, filename: 'image1.png'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'First Embed - Attachment',
|
||||
image: {url: 'attachment://image1.png'},
|
||||
},
|
||||
{
|
||||
title: 'Second Embed - External',
|
||||
image: {url: 'https://example.com/external.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'image1.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.embeds).toHaveLength(2);
|
||||
|
||||
expect(json.embeds![0].image?.url).not.toContain('attachment://');
|
||||
expect(json.embeds![1].image?.url).toBe('https://example.com/external.png');
|
||||
});
|
||||
|
||||
it('should handle multiple embeds each referencing different attachments', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Multiple Embeds Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
|
||||
const payload = {
|
||||
content: 'Multiple embeds with different attachments',
|
||||
attachments: [
|
||||
{id: 0, filename: 'yeah.png'},
|
||||
{id: 1, filename: 'thisisfine.gif'},
|
||||
],
|
||||
embeds: [
|
||||
{
|
||||
title: 'First Embed',
|
||||
description: 'Uses PNG',
|
||||
image: {url: 'attachment://yeah.png'},
|
||||
},
|
||||
{
|
||||
title: 'Second Embed',
|
||||
description: 'Uses GIF',
|
||||
image: {url: 'attachment://thisisfine.gif'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: file1Data},
|
||||
{index: 1, filename: 'thisisfine.gif', data: file2Data},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.embeds).toBeDefined();
|
||||
expect(json.embeds).toHaveLength(2);
|
||||
|
||||
expect(json.embeds![0].image?.url).toContain('yeah.png');
|
||||
expect(json.embeds![1].image?.url).toContain('thisisfine.gif');
|
||||
});
|
||||
|
||||
it('should resolve multiple files referenced by embeds', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Multiple Files Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Test message with multiple attachments in embeds',
|
||||
attachments: [
|
||||
{id: 0, filename: 'image1.png'},
|
||||
{id: 1, filename: 'image2.png'},
|
||||
{id: 2, filename: 'image3.png'},
|
||||
],
|
||||
embeds: [
|
||||
{
|
||||
title: 'First Image',
|
||||
image: {url: 'attachment://image1.png'},
|
||||
},
|
||||
{
|
||||
title: 'Second Image',
|
||||
image: {url: 'attachment://image2.png'},
|
||||
},
|
||||
{
|
||||
title: 'Third Image',
|
||||
thumbnail: {url: 'attachment://image3.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'image1.png', data: fileData},
|
||||
{index: 1, filename: 'image2.png', data: fileData},
|
||||
{index: 2, filename: 'image3.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toHaveLength(3);
|
||||
expect(json.embeds).toHaveLength(3);
|
||||
|
||||
for (const embed of json.embeds!) {
|
||||
if (embed.image) {
|
||||
expect(embed.image.url).not.toContain('attachment://');
|
||||
}
|
||||
if (embed.thumbnail) {
|
||||
expect(embed.thumbnail.url).not.toContain('attachment://');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow same attachment to be used in multiple embeds', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Reused Attachment Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
|
||||
const fileData = loadFixture('yeah.png');
|
||||
|
||||
const payload = {
|
||||
content: 'Same attachment in multiple embeds',
|
||||
attachments: [{id: 0, filename: 'shared.png'}],
|
||||
embeds: [
|
||||
{
|
||||
title: 'First Use',
|
||||
image: {url: 'attachment://shared.png'},
|
||||
},
|
||||
{
|
||||
title: 'Second Use',
|
||||
thumbnail: {url: 'attachment://shared.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'shared.png', data: fileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toHaveLength(1);
|
||||
expect(json.embeds).toHaveLength(2);
|
||||
|
||||
expect(json.embeds![0].image?.url).not.toContain('attachment://');
|
||||
expect(json.embeds![1].thumbnail?.url).not.toContain('attachment://');
|
||||
});
|
||||
});
|
||||
});
|
||||
69
packages/api/src/channel/tests/GroupDMNameUpdate.test.tsx
Normal file
69
packages/api/src/channel/tests/GroupDMNameUpdate.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createFriendship,
|
||||
createGroupDmChannel,
|
||||
type GroupDmChannelResponse,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Group DM name update', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('updates group DM name correctly', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await ensureSessionStarted(harness, user3.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
|
||||
const updated = await createBuilder<GroupDmChannelResponse>(harness, user1.token)
|
||||
.patch(`/channels/${groupDm.id}`)
|
||||
.body({name: 'Cool Group Chat'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.name).toBe('Cool Group Chat');
|
||||
});
|
||||
});
|
||||
154
packages/api/src/channel/tests/GroupDMNicknameUpdate.test.tsx
Normal file
154
packages/api/src/channel/tests/GroupDMNicknameUpdate.test.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createFriendship, createGroupDmChannel, getChannel} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Group DM nickname update', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('user can update their own nickname in a group DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await ensureSessionStarted(harness, user3.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
|
||||
const updatedChannel = await createBuilder<ChannelResponse>(harness, user2.token)
|
||||
.patch(`/channels/${groupDm.id}`)
|
||||
.body({
|
||||
nicks: {
|
||||
[user2.userId]: 'User 2 Nick',
|
||||
},
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updatedChannel.nicks).toBeDefined();
|
||||
expect(updatedChannel.nicks?.[user2.userId]).toBe('User 2 Nick');
|
||||
});
|
||||
|
||||
it('nickname is returned correctly in channel response after update', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await ensureSessionStarted(harness, user3.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
|
||||
await createBuilder<ChannelResponse>(harness, user2.token)
|
||||
.patch(`/channels/${groupDm.id}`)
|
||||
.body({
|
||||
nicks: {
|
||||
[user2.userId]: 'My Custom Nick',
|
||||
},
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const fetchedChannel = await getChannel(harness, user2.token, groupDm.id);
|
||||
|
||||
expect(fetchedChannel.nicks).toBeDefined();
|
||||
expect(fetchedChannel.nicks?.[user2.userId]).toBe('My Custom Nick');
|
||||
});
|
||||
|
||||
it('non-owner cannot update another users nickname', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await ensureSessionStarted(harness, user3.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
|
||||
await createBuilder(harness, user2.token)
|
||||
.patch(`/channels/${groupDm.id}`)
|
||||
.body({
|
||||
nicks: {
|
||||
[user3.userId]: 'User 3 Nick by User 2',
|
||||
},
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('owner can update another users nickname', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await ensureSessionStarted(harness, user3.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
|
||||
const updatedChannel = await createBuilder<ChannelResponse>(harness, user1.token)
|
||||
.patch(`/channels/${groupDm.id}`)
|
||||
.body({
|
||||
nicks: {
|
||||
[user3.userId]: 'User 3 Nick by Owner',
|
||||
},
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updatedChannel.nicks).toBeDefined();
|
||||
expect(updatedChannel.nicks?.[user3.userId]).toBe('User 3 Nick by Owner');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createFriendship, createGroupDmChannel} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
describe('Group DM Security Boundaries', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('non-member cannot read messages from group DM', async () => {
|
||||
const creator = await createTestAccount(harness);
|
||||
const recipient = await createTestAccount(harness);
|
||||
const attacker = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, creator, recipient);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, creator.token, [recipient.userId]);
|
||||
|
||||
await createBuilder(harness, attacker.token)
|
||||
.get(`/channels/${groupDm.id}/messages`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('non-member cannot send messages to group DM', async () => {
|
||||
const creator = await createTestAccount(harness);
|
||||
const recipient = await createTestAccount(harness);
|
||||
const attacker = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, creator, recipient);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, creator.token, [recipient.userId]);
|
||||
|
||||
await createBuilder(harness, attacker.token)
|
||||
.post(`/channels/${groupDm.id}/messages`)
|
||||
.body({content: 'Unauthorized message'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('non-member cannot add themselves to group DM', async () => {
|
||||
const creator = await createTestAccount(harness);
|
||||
const recipient = await createTestAccount(harness);
|
||||
const attacker = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, creator, recipient);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, creator.token, [recipient.userId]);
|
||||
|
||||
await createBuilder(harness, attacker.token)
|
||||
.put(`/channels/${groupDm.id}/recipients/${attacker.userId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACCESS')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('recipient can leave group DM', async () => {
|
||||
const creator = await createTestAccount(harness);
|
||||
const recipient = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, creator, recipient);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, creator.token, [recipient.userId]);
|
||||
|
||||
await createBuilder(harness, recipient.token)
|
||||
.delete(`/channels/${groupDm.id}/recipients/${recipient.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('after leaving, former member cannot read messages', async () => {
|
||||
const creator = await createTestAccount(harness);
|
||||
const recipient = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, creator, recipient);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, creator.token, [recipient.userId]);
|
||||
|
||||
await createBuilder(harness, recipient.token)
|
||||
.delete(`/channels/${groupDm.id}/recipients/${recipient.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, recipient.token)
|
||||
.get(`/channels/${groupDm.id}/messages`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('after leaving, former member cannot send messages', async () => {
|
||||
const creator = await createTestAccount(harness);
|
||||
const recipient = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, creator, recipient);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, creator.token, [recipient.userId]);
|
||||
|
||||
await createBuilder(harness, recipient.token)
|
||||
.delete(`/channels/${groupDm.id}/recipients/${recipient.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, recipient.token)
|
||||
.post(`/channels/${groupDm.id}/messages`)
|
||||
.body({content: 'Message after leaving'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createFriendship, createGroupDmChannel} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
describe('Group DM Add Recipient Permissions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('rejects adding non-friend to group DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
const user4 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
|
||||
const groupDmChannel = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.put(`/channels/${groupDmChannel.id}/recipients/${user4.userId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'NOT_FRIENDS_WITH_USER')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('allows adding friend to group DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
const user4 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
await createFriendship(harness, user1, user4);
|
||||
|
||||
const groupDmChannel = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.put(`/channels/${groupDmChannel.id}/recipients/${user4.userId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('allows member to add their own friend to group DM', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const otherMember = await createTestAccount(harness);
|
||||
const newUser = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, member);
|
||||
await createFriendship(harness, owner, otherMember);
|
||||
await createFriendship(harness, member, newUser);
|
||||
|
||||
const groupDmChannel = await createGroupDmChannel(harness, owner.token, [member.userId, otherMember.userId]);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.put(`/channels/${groupDmChannel.id}/recipients/${newUser.userId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects non-participant from adding recipients to group DM', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const third = await createTestAccount(harness);
|
||||
const outsider = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, member);
|
||||
await createFriendship(harness, owner, third);
|
||||
await createFriendship(harness, member, third);
|
||||
await createFriendship(harness, outsider, third);
|
||||
|
||||
const groupDmChannel = await createGroupDmChannel(harness, owner.token, [member.userId, third.userId]);
|
||||
|
||||
await createBuilder(harness, outsider.token)
|
||||
.put(`/channels/${groupDmChannel.id}/recipients/${third.userId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACCESS')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects member from adding non-friend to group DM', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const otherMember = await createTestAccount(harness);
|
||||
const ownerFriend = await createTestAccount(harness);
|
||||
const outsider = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, member);
|
||||
await createFriendship(harness, owner, otherMember);
|
||||
await createFriendship(harness, owner, ownerFriend);
|
||||
await createFriendship(harness, outsider, otherMember);
|
||||
|
||||
const groupDmChannel = await createGroupDmChannel(harness, owner.token, [member.userId, otherMember.userId]);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${groupDmChannel.id}/recipients/${ownerFriend.userId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.put(`/channels/${groupDmChannel.id}/recipients/${outsider.userId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'NOT_FRIENDS_WITH_USER')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
76
packages/api/src/channel/tests/GroupDmLimit.test.tsx
Normal file
76
packages/api/src/channel/tests/GroupDmLimit.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createFriendship, seedPrivateChannels} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const MAX_GROUP_DM_LIMIT = 150;
|
||||
const MAX_GROUP_DMS_ERROR_CODE = 'MAX_GROUP_DMS';
|
||||
|
||||
describe('Group DM recipient limit', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('rejects creating group DM when user has reached the limit', async () => {
|
||||
const creator = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
const recipient = await createTestAccount(harness);
|
||||
const helper = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, creator.token);
|
||||
await ensureSessionStarted(harness, target.token);
|
||||
await ensureSessionStarted(harness, recipient.token);
|
||||
await ensureSessionStarted(harness, helper.token);
|
||||
|
||||
await createFriendship(harness, creator, target);
|
||||
await createFriendship(harness, creator, recipient);
|
||||
|
||||
const seedResult = await seedPrivateChannels(harness, target.token, target.userId, {
|
||||
group_dm_count: MAX_GROUP_DM_LIMIT,
|
||||
recipients: [helper.userId, recipient.userId],
|
||||
clear_existing: true,
|
||||
});
|
||||
|
||||
expect(seedResult.group_dms.length).toBe(MAX_GROUP_DM_LIMIT);
|
||||
|
||||
await createBuilder(harness, creator.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({
|
||||
recipients: [helper.userId, target.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, MAX_GROUP_DMS_ERROR_CODE)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
156
packages/api/src/channel/tests/GroupDmManagement.test.tsx
Normal file
156
packages/api/src/channel/tests/GroupDmManagement.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
addRecipientToGroupDm,
|
||||
createFriendship,
|
||||
createGroupDmChannel,
|
||||
getChannel,
|
||||
removeRecipientFromGroupDm,
|
||||
updateChannel,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Group DM management', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('can create group DM with multiple recipients', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await ensureSessionStarted(harness, user3.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
|
||||
expect(groupDm.id).toBeTruthy();
|
||||
expect(groupDm.type).toBe(3);
|
||||
expect(groupDm.owner_id).toBe(user1.userId);
|
||||
expect(groupDm.recipients.length).toBe(2);
|
||||
});
|
||||
|
||||
it('can update group DM name', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await ensureSessionStarted(harness, user3.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
|
||||
const updated = await updateChannel(harness, user1.token, groupDm.id, {name: 'Cool Group Chat'});
|
||||
|
||||
expect(updated.name).toBe('Cool Group Chat');
|
||||
});
|
||||
|
||||
it('can add recipient to existing group DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
const user4 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await ensureSessionStarted(harness, user3.token);
|
||||
await ensureSessionStarted(harness, user4.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
await createFriendship(harness, user1, user4);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
expect(groupDm.recipients.length).toBe(2);
|
||||
|
||||
await addRecipientToGroupDm(harness, user1.token, groupDm.id, user4.userId);
|
||||
|
||||
const updatedChannel = await getChannel(harness, user1.token, groupDm.id);
|
||||
expect(updatedChannel.type).toBe(3);
|
||||
});
|
||||
|
||||
it('can remove recipient from group DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await ensureSessionStarted(harness, user3.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
|
||||
await removeRecipientFromGroupDm(harness, user1.token, groupDm.id, user3.userId);
|
||||
|
||||
const updatedChannel = await getChannel(harness, user1.token, groupDm.id);
|
||||
expect(updatedChannel.type).toBe(3);
|
||||
});
|
||||
|
||||
it('rejects non-member adding recipient to group DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
const outsider = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
await ensureSessionStarted(harness, user3.token);
|
||||
await ensureSessionStarted(harness, outsider.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
await createFriendship(harness, outsider, user3);
|
||||
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
|
||||
await createBuilder(harness, outsider.token)
|
||||
.put(`/channels/${groupDm.id}/recipients/${user3.userId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createFriendship, seedPrivateChannels} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const MAX_GROUP_DM_LIMIT = 150;
|
||||
const MAX_GROUP_DM_ERROR_CODE = 'MAX_GROUP_DMS';
|
||||
|
||||
describe('Group DM Recipient Limit', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('rejects creating group DM when user has reached limit', async () => {
|
||||
const creator = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
const recipient = await createTestAccount(harness);
|
||||
const helper = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, creator, target);
|
||||
await createFriendship(harness, creator, recipient);
|
||||
|
||||
const seedResult = await seedPrivateChannels(harness, target.token, target.userId, {
|
||||
group_dm_count: MAX_GROUP_DM_LIMIT,
|
||||
recipients: [helper.userId, recipient.userId],
|
||||
clear_existing: true,
|
||||
});
|
||||
|
||||
expect(seedResult.group_dms).toHaveLength(MAX_GROUP_DM_LIMIT);
|
||||
|
||||
await createBuilder(harness, creator.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({
|
||||
recipients: [helper.userId, target.userId],
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, MAX_GROUP_DM_ERROR_CODE)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user