initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {coerceNumberFromString, createStringType, FilenameType, Int32Type, z} from '~/Schema';
export const ClientAttachmentRequest = z.object({
id: coerceNumberFromString(Int32Type),
filename: FilenameType,
title: createStringType(1, 1024).nullish(),
description: createStringType(1, 1024).nullish(),
flags: coerceNumberFromString(Int32Type).default(0),
});
export type ClientAttachmentRequest = z.infer<typeof ClientAttachmentRequest>;
export interface UploadedAttachment {
id: number;
filename: string;
upload_filename: string;
file_size: number;
content_type: string;
}
export interface AttachmentToProcess {
id: number;
filename: string;
upload_filename: string;
title: string | null;
description: string | null;
flags: number;
file_size: number;
content_type: string;
}
export const ClientAttachmentReferenceRequest = z.object({
id: coerceNumberFromString(Int32Type),
filename: FilenameType.optional(),
title: createStringType(1, 1024).nullish(),
description: createStringType(1, 1024).nullish(),
flags: coerceNumberFromString(Int32Type).default(0),
});
export type ClientAttachmentReferenceRequest = z.infer<typeof ClientAttachmentReferenceRequest>;
export function mergeUploadWithClientData(
uploaded: UploadedAttachment,
clientData?: ClientAttachmentRequest | ClientAttachmentReferenceRequest,
): AttachmentToProcess {
return {
id: uploaded.id,
filename: uploaded.filename,
upload_filename: uploaded.upload_filename,
file_size: uploaded.file_size,
content_type: uploaded.content_type,
title: clientData?.title ?? null,
description: clientData?.description ?? null,
flags: 'flags' in (clientData ?? {}) ? (clientData as ClientAttachmentRequest).flags : 0,
};
}
export type AttachmentRequestData = AttachmentToProcess | ClientAttachmentRequest | ClientAttachmentReferenceRequest;

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {registerChannelControllers} from './controllers';
export const ChannelController = (app: HonoApp) => {
registerChannelControllers(app);
};

View File

@@ -0,0 +1,226 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '~/BrandedTypes';
import {ChannelTypes} from '~/Constants';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Channel} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {getCachedUserPartialResponses} from '~/user/UserCacheHelpers';
import type {ChannelOverwriteResponse, ChannelPartialResponse, ChannelResponse} from './ChannelTypes';
interface MapChannelToResponseParams {
channel: Channel;
currentUserId: UserID | null;
userCacheService: UserCacheService;
requestCache: RequestCache;
}
function serializeBaseChannelFields(channel: Channel) {
return {
id: channel.id.toString(),
type: channel.type,
};
}
function serializeMessageableFields(channel: Channel) {
return {
last_message_id: channel.lastMessageId ? channel.lastMessageId.toString() : null,
last_pin_timestamp: channel.lastPinTimestamp ? channel.lastPinTimestamp.toISOString() : null,
};
}
function serializeGuildChannelFields(channel: Channel) {
return {
guild_id: channel.guildId?.toString(),
name: channel.name ?? undefined,
position: channel.position ?? undefined,
permission_overwrites: serializePermissionOverwrites(channel),
};
}
function serializePositionableGuildChannelFields(channel: Channel) {
return {
...serializeGuildChannelFields(channel),
parent_id: channel.parentId ? channel.parentId.toString() : null,
};
}
function serializePermissionOverwrites(channel: Channel): Array<ChannelOverwriteResponse> {
if (!channel.permissionOverwrites) return [];
return Array.from(channel.permissionOverwrites).map(([targetId, overwrite]) => ({
id: targetId.toString(),
type: overwrite.type,
allow: (overwrite.allow ?? 0n).toString(),
deny: (overwrite.deny ?? 0n).toString(),
}));
}
function serializeGuildTextChannel(channel: Channel): ChannelResponse {
return {
...serializeBaseChannelFields(channel),
...serializeMessageableFields(channel),
...serializePositionableGuildChannelFields(channel),
topic: channel.topic,
nsfw: channel.isNsfw,
rate_limit_per_user: channel.rateLimitPerUser,
};
}
function serializeGuildVoiceChannel(channel: Channel): ChannelResponse {
return {
...serializeBaseChannelFields(channel),
...serializePositionableGuildChannelFields(channel),
bitrate: channel.bitrate,
user_limit: channel.userLimit,
rtc_region: channel.rtcRegion,
};
}
function serializeGuildCategoryChannel(channel: Channel): ChannelResponse {
return {
...serializeBaseChannelFields(channel),
...serializeGuildChannelFields(channel),
};
}
function serializeGuildLinkChannel(channel: Channel): ChannelResponse {
return {
...serializeBaseChannelFields(channel),
...serializePositionableGuildChannelFields(channel),
url: channel.url,
};
}
function serializeDMChannel(channel: Channel): ChannelResponse {
return {
...serializeBaseChannelFields(channel),
...serializeMessageableFields(channel),
};
}
function serializeGroupDMChannel(channel: Channel): ChannelResponse {
const nicknameMap = channel.nicknames ?? new Map<string, string>();
const nicks: Record<string, string> = {};
if (nicknameMap.size > 0) {
for (const [userId, nickname] of nicknameMap) {
const key = String(userId);
nicks[key] = nickname;
}
}
return {
...serializeBaseChannelFields(channel),
...serializeMessageableFields(channel),
name: channel.name ?? undefined,
icon: channel.iconHash ?? null,
owner_id: channel.ownerId ? channel.ownerId.toString() : null,
nicks: nicknameMap.size > 0 ? nicks : undefined,
};
}
function serializeDMPersonalNotesChannel(channel: Channel): ChannelResponse {
return {
...serializeBaseChannelFields(channel),
...serializeMessageableFields(channel),
};
}
async function addDMRecipients(
response: ChannelResponse,
channel: Channel,
currentUserId: UserID | null,
userCacheService: UserCacheService,
requestCache: RequestCache,
): Promise<void> {
if (
channel.guildId == null &&
channel.type !== ChannelTypes.DM_PERSONAL_NOTES &&
currentUserId != null &&
channel.recipientIds &&
channel.recipientIds.size > 0
) {
const recipientIds = Array.from(channel.recipientIds).filter((id) => id !== currentUserId);
if (recipientIds.length > 0) {
const userPartials = await getCachedUserPartialResponses({
userIds: recipientIds,
userCacheService,
requestCache,
});
response.recipients = recipientIds.map((userId) => userPartials.get(userId)!);
}
}
}
export async function mapChannelToResponse({
channel,
currentUserId,
userCacheService,
requestCache,
}: MapChannelToResponseParams): Promise<ChannelResponse> {
let response: ChannelResponse;
switch (channel.type) {
case ChannelTypes.GUILD_TEXT:
response = serializeGuildTextChannel(channel);
break;
case ChannelTypes.GUILD_VOICE:
response = serializeGuildVoiceChannel(channel);
break;
case ChannelTypes.GUILD_CATEGORY:
response = serializeGuildCategoryChannel(channel);
break;
case ChannelTypes.GUILD_LINK:
response = serializeGuildLinkChannel(channel);
break;
case ChannelTypes.DM:
response = serializeDMChannel(channel);
await addDMRecipients(response, channel, currentUserId, userCacheService, requestCache);
break;
case ChannelTypes.GROUP_DM:
response = serializeGroupDMChannel(channel);
await addDMRecipients(response, channel, currentUserId, userCacheService, requestCache);
break;
case ChannelTypes.DM_PERSONAL_NOTES:
response = serializeDMPersonalNotesChannel(channel);
break;
default:
response = {
...serializeBaseChannelFields(channel),
...serializeMessageableFields(channel),
guild_id: channel.guildId?.toString(),
name: channel.name ?? undefined,
topic: channel.topic,
url: channel.url ?? undefined,
icon: channel.iconHash ?? null,
owner_id: channel.ownerId ? channel.ownerId.toString() : null,
position: channel.position ?? undefined,
parent_id: channel.parentId ? channel.parentId.toString() : null,
permission_overwrites: channel.guildId ? serializePermissionOverwrites(channel) : undefined,
};
}
return response;
}
export const mapChannelToPartialResponse = (channel: Channel): ChannelPartialResponse => ({
id: channel.id.toString(),
name: channel.name,
type: channel.type,
});

View File

@@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export * from './ChannelMappers';
export * from './ChannelTypes';
export * from './EmbedTypes';
export * from './MessageMappers';
export * from './MessageTypes';

View File

@@ -0,0 +1,232 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AttachmentID, ChannelID, EmojiID, GuildID, MessageID, UserID} from '~/BrandedTypes';
import type {AttachmentLookupRow, ChannelRow, MessageRow} from '~/database/CassandraTypes';
import type {Channel, Message, MessageReaction} from '~/Models';
import {IChannelRepository} from './IChannelRepository';
import {ChannelRepository as NewChannelRepository} from './repositories/ChannelRepository';
export class ChannelRepository extends IChannelRepository {
private repository: NewChannelRepository;
constructor() {
super();
this.repository = new NewChannelRepository();
}
get channelData() {
return this.repository.channelData;
}
get messages() {
return this.repository.messages;
}
get messageInteractions() {
return this.repository.messageInteractions;
}
async findUnique(channelId: ChannelID): Promise<Channel | null> {
return this.repository.channelData.findUnique(channelId);
}
async upsert(data: ChannelRow): Promise<Channel> {
return this.repository.channelData.upsert(data);
}
async updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void> {
return this.repository.channelData.updateLastMessageId(channelId, messageId);
}
async delete(channelId: ChannelID, guildId?: GuildID): Promise<void> {
return this.repository.channelData.delete(channelId, guildId);
}
async listMessages(
channelId: ChannelID,
beforeMessageId?: MessageID,
limit?: number,
afterMessageId?: MessageID,
): Promise<Array<Message>> {
return this.repository.messages.listMessages(channelId, beforeMessageId, limit, afterMessageId);
}
async getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null> {
return this.repository.messages.getMessage(channelId, messageId);
}
async upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message> {
return this.repository.messages.upsertMessage(data, oldData);
}
async deleteMessage(
channelId: ChannelID,
messageId: MessageID,
authorId: UserID,
pinnedTimestamp?: Date,
): Promise<void> {
return this.repository.messages.deleteMessage(channelId, messageId, authorId, pinnedTimestamp);
}
async bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void> {
return this.repository.messages.bulkDeleteMessages(channelId, messageIds);
}
async listChannelPins(channelId: ChannelID, beforePinnedTimestamp: Date, limit?: number): Promise<Array<Message>> {
return this.repository.messageInteractions.listChannelPins(channelId, beforePinnedTimestamp, limit);
}
async listMessageReactions(channelId: ChannelID, messageId: MessageID): Promise<Array<MessageReaction>> {
return this.repository.messageInteractions.listMessageReactions(channelId, messageId);
}
async listReactionUsers(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
limit?: number,
after?: UserID,
emojiId?: EmojiID,
): Promise<Array<MessageReaction>> {
return this.repository.messageInteractions.listReactionUsers(
channelId,
messageId,
emojiName,
limit,
after,
emojiId,
);
}
async addReaction(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
emojiAnimated?: boolean,
): Promise<MessageReaction> {
return this.repository.messageInteractions.addReaction(
channelId,
messageId,
userId,
emojiName,
emojiId,
emojiAnimated,
);
}
async removeReaction(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
): Promise<void> {
return this.repository.messageInteractions.removeReaction(channelId, messageId, userId, emojiName, emojiId);
}
async removeAllReactions(channelId: ChannelID, messageId: MessageID): Promise<void> {
return this.repository.messageInteractions.removeAllReactions(channelId, messageId);
}
async removeAllReactionsForEmoji(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
emojiId?: EmojiID,
): Promise<void> {
return this.repository.messageInteractions.removeAllReactionsForEmoji(channelId, messageId, emojiName, emojiId);
}
async countReactionUsers(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
emojiId?: EmojiID,
): Promise<number> {
return this.repository.messageInteractions.countReactionUsers(channelId, messageId, emojiName, emojiId);
}
async countUniqueReactions(channelId: ChannelID, messageId: MessageID): Promise<number> {
return this.repository.messageInteractions.countUniqueReactions(channelId, messageId);
}
async checkUserReactionExists(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
): Promise<boolean> {
return this.repository.messageInteractions.checkUserReactionExists(
channelId,
messageId,
userId,
emojiName,
emojiId,
);
}
async listGuildChannels(guildId: GuildID): Promise<Array<Channel>> {
return this.repository.channelData.listGuildChannels(guildId);
}
async countGuildChannels(guildId: GuildID): Promise<number> {
return this.repository.channelData.countGuildChannels(guildId);
}
async lookupAttachmentByChannelAndFilename(
channelId: ChannelID,
attachmentId: AttachmentID,
filename: string,
): Promise<MessageID | null> {
return this.repository.messages.lookupAttachmentByChannelAndFilename(channelId, attachmentId, filename);
}
async listChannelAttachments(channelId: ChannelID): Promise<Array<AttachmentLookupRow>> {
return this.repository.messages.listChannelAttachments(channelId);
}
async listMessagesByAuthor(
authorId: UserID,
limit?: number,
lastChannelId?: ChannelID,
lastMessageId?: MessageID,
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>> {
return this.repository.messages.listMessagesByAuthor(authorId, limit, lastChannelId, lastMessageId);
}
async deleteMessagesByAuthor(
authorId: UserID,
channelIds?: Array<ChannelID>,
messageIds?: Array<MessageID>,
): Promise<void> {
return this.repository.messages.deleteMessagesByAuthor(authorId, channelIds, messageIds);
}
async anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void> {
return this.repository.messages.anonymizeMessage(channelId, messageId, newAuthorId);
}
async deleteAllChannelMessages(channelId: ChannelID): Promise<void> {
return this.repository.messages.deleteAllChannelMessages(channelId);
}
}

View File

@@ -0,0 +1,160 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AVATAR_MAX_SIZE, ChannelTypes} from '~/Constants';
import {createBase64StringType, createStringType, GeneralChannelNameType, Int64Type, URLType, z} from '~/Schema';
import {UserPartialResponse} from '~/user/UserModel';
const ChannelOverwriteResponse = z.object({
id: z.string(),
type: z.number().int(),
allow: z.string(),
deny: z.string(),
});
export type ChannelOverwriteResponse = z.infer<typeof ChannelOverwriteResponse>;
export const ChannelResponse = z.object({
id: z.string(),
guild_id: z.string().optional(),
name: z.string().optional(),
topic: z.string().nullish(),
url: z.url().nullish(),
icon: z.string().nullish(),
owner_id: z.string().nullish(),
type: z.number().int(),
position: z.number().int().optional(),
parent_id: z.string().nullish(),
bitrate: z.number().int().nullish(),
user_limit: z.number().int().nullish(),
rtc_region: z.string().nullish(),
last_message_id: z.string().nullish(),
last_pin_timestamp: z.iso.datetime().nullish(),
permission_overwrites: z.array(ChannelOverwriteResponse).optional(),
recipients: z.array(z.lazy(() => UserPartialResponse)).optional(),
nsfw: z.boolean().optional(),
rate_limit_per_user: z.number().int().optional(),
nicks: z.record(z.string(), createStringType(1, 32)).optional(),
});
export type ChannelResponse = z.infer<typeof ChannelResponse>;
export const ChannelPartialResponse = z.object({
id: z.string(),
name: z.string().nullish(),
type: z.number().int(),
recipients: z
.array(
z.object({
username: z.string(),
}),
)
.optional(),
});
export type ChannelPartialResponse = z.infer<typeof ChannelPartialResponse>;
const ChannelOverwriteRequest = z.object({
id: Int64Type,
type: z.union([z.literal(0), z.literal(1)]),
allow: Int64Type.optional(),
deny: Int64Type.optional(),
});
const CreateCommon = z.object({
topic: createStringType(1, 1024).nullish(),
url: URLType.nullish(),
parent_id: Int64Type.nullish(),
bitrate: z.number().int().min(8000).max(320000).nullish(),
user_limit: z.number().int().min(0).max(99).nullish(),
nsfw: z.boolean().default(false),
permission_overwrites: z.array(ChannelOverwriteRequest).optional(),
});
const UpdateCommon = z.object({
topic: createStringType(1, 1024).nullish(),
url: URLType.nullish(),
parent_id: Int64Type.nullish(),
bitrate: z.number().int().min(8000).max(320000).nullish(),
user_limit: z.number().int().min(0).max(99).nullish(),
nsfw: z.boolean().nullish(),
rate_limit_per_user: z.number().int().min(0).max(21600).nullish(),
icon: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
owner_id: Int64Type.nullish(),
permission_overwrites: z.array(ChannelOverwriteRequest).optional(),
nicks: z.record(createStringType(0, 32), z.union([createStringType(0, 32), z.null()])).optional(),
rtc_region: createStringType(1, 64).nullish(),
});
const CreateText = CreateCommon.extend({
type: z.literal(ChannelTypes.GUILD_TEXT),
name: GeneralChannelNameType,
});
const CreateVoice = CreateCommon.extend({
type: z.literal(ChannelTypes.GUILD_VOICE),
name: GeneralChannelNameType,
});
const CreateCat = CreateCommon.extend({
type: z.literal(ChannelTypes.GUILD_CATEGORY),
name: GeneralChannelNameType,
});
const CreateLink = CreateCommon.extend({
type: z.literal(ChannelTypes.GUILD_LINK),
name: GeneralChannelNameType,
});
export const ChannelCreateRequest = z.discriminatedUnion('type', [CreateText, CreateVoice, CreateCat, CreateLink]);
export type ChannelCreateRequest = z.infer<typeof ChannelCreateRequest>;
const UpdateText = UpdateCommon.extend({
type: z.literal(ChannelTypes.GUILD_TEXT),
name: GeneralChannelNameType.nullish(),
});
const UpdateVoice = UpdateCommon.extend({
type: z.literal(ChannelTypes.GUILD_VOICE),
name: GeneralChannelNameType.nullish(),
});
const UpdateCat = UpdateCommon.extend({
type: z.literal(ChannelTypes.GUILD_CATEGORY),
name: GeneralChannelNameType.nullish(),
});
const UpdateLink = UpdateCommon.extend({
type: z.literal(ChannelTypes.GUILD_LINK),
name: GeneralChannelNameType.nullish(),
});
const UpdateGroupDm = z.object({
type: z.literal(ChannelTypes.GROUP_DM),
name: GeneralChannelNameType.nullish(),
icon: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
owner_id: Int64Type.nullish(),
nicks: z.record(createStringType(0, 32), z.union([createStringType(0, 32), z.null()])).nullish(),
});
export const ChannelUpdateRequest = z.discriminatedUnion('type', [
UpdateText,
UpdateVoice,
UpdateCat,
UpdateLink,
UpdateGroupDm,
]);
export type ChannelUpdateRequest = z.infer<typeof ChannelUpdateRequest>;

View File

@@ -0,0 +1,124 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AttachmentURLType, ColorType, createStringType, DateTimeType, URLType, z} from '~/Schema';
export const RichEmbedAuthorRequest = z.object({
name: createStringType(),
url: URLType.nullish(),
icon_url: URLType.nullish(),
});
export type RichEmbedAuthorRequest = z.infer<typeof RichEmbedAuthorRequest>;
export const RichEmbedMediaRequest = z.object({
url: AttachmentURLType,
description: createStringType(1, 4096).nullish(),
});
export type RichEmbedMediaRequest = z.infer<typeof RichEmbedMediaRequest>;
export interface RichEmbedMediaWithMetadata extends RichEmbedMediaRequest {
_attachmentMetadata?: {
width: number | null;
height: number | null;
content_type: string;
content_hash: string | null;
placeholder: string | null;
flags: number;
duration: number | null;
nsfw: boolean | null;
};
}
export const RichEmbedFooterRequest = z.object({
text: createStringType(1, 2048),
icon_url: URLType.nullish(),
});
export type RichEmbedFooterRequest = z.infer<typeof RichEmbedFooterRequest>;
const RichEmbedFieldRequest = z.object({
name: createStringType(),
value: createStringType(1, 1024),
inline: z.boolean().default(false),
});
export const RichEmbedRequest = z.object({
url: URLType.nullish(),
title: createStringType().nullish(),
color: ColorType.nullish(),
timestamp: DateTimeType.nullish(),
description: createStringType(1, 4096).nullish(),
author: RichEmbedAuthorRequest.nullish(),
image: RichEmbedMediaRequest.nullish(),
thumbnail: RichEmbedMediaRequest.nullish(),
footer: RichEmbedFooterRequest.nullish(),
fields: z.array(RichEmbedFieldRequest).max(25).nullish(),
});
export type RichEmbedRequest = z.infer<typeof RichEmbedRequest>;
const EmbedAuthorResponse = z.object({
name: z.string(),
url: z.url().nullish(),
icon_url: z.url().nullish(),
proxy_icon_url: z.url().nullish(),
});
const EmbedFooterResponse = z.object({
text: z.string(),
icon_url: z.url().nullish(),
proxy_icon_url: z.url().nullish(),
});
const EmbedMediaResponse = z.object({
url: z.string(),
proxy_url: z.url().nullish(),
content_type: z.string().nullish(),
content_hash: z.string().nullish(),
width: z.number().int().nullish(),
height: z.number().int().nullish(),
description: z.string().nullish(),
placeholder: z.string().nullish(),
duration: z.number().int().nullish(),
flags: z.number().int(),
});
const EmbedFieldResponse = z.object({
name: z.string(),
value: z.string(),
inline: z.boolean(),
});
export type EmbedFieldResponse = z.infer<typeof EmbedFieldResponse>;
export const MessageEmbedResponse = z.object({
type: z.string(),
url: z.url().nullish(),
title: z.string().nullish(),
color: z.number().int().nullish(),
timestamp: z.iso.datetime().nullish(),
description: z.string().nullish(),
author: EmbedAuthorResponse.nullish(),
image: EmbedMediaResponse.nullish(),
thumbnail: EmbedMediaResponse.nullish(),
footer: EmbedFooterResponse.nullish(),
fields: z.array(EmbedFieldResponse).nullish(),
provider: EmbedAuthorResponse.nullish(),
video: EmbedMediaResponse.nullish(),
audio: EmbedMediaResponse.nullish(),
nsfw: z.boolean().nullish(),
});
export type MessageEmbedResponse = z.infer<typeof MessageEmbedResponse>;

View File

@@ -0,0 +1,115 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AttachmentID, ChannelID, EmojiID, GuildID, MessageID, UserID} from '~/BrandedTypes';
import type {AttachmentLookupRow, ChannelRow, MessageRow} from '~/database/CassandraTypes';
import type {Channel, Message, MessageReaction} from '~/Models';
import {IChannelRepositoryAggregate} from './repositories/IChannelRepositoryAggregate';
export abstract class IChannelRepository extends IChannelRepositoryAggregate {
abstract findUnique(channelId: ChannelID): Promise<Channel | null>;
abstract upsert(data: ChannelRow): Promise<Channel>;
abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void>;
abstract delete(channelId: ChannelID, guildId?: GuildID): Promise<void>;
abstract listGuildChannels(guildId: GuildID): Promise<Array<Channel>>;
abstract countGuildChannels(guildId: GuildID): Promise<number>;
abstract listMessages(
channelId: ChannelID,
beforeMessageId?: MessageID,
limit?: number,
afterMessageId?: MessageID,
): Promise<Array<Message>>;
abstract getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null>;
abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message>;
abstract deleteMessage(
channelId: ChannelID,
messageId: MessageID,
authorId: UserID,
pinnedTimestamp?: Date,
): Promise<void>;
abstract bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void>;
abstract listChannelPins(channelId: ChannelID, beforePinnedTimestamp: Date, limit?: number): Promise<Array<Message>>;
abstract listMessageReactions(channelId: ChannelID, messageId: MessageID): Promise<Array<MessageReaction>>;
abstract listReactionUsers(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
limit?: number,
after?: UserID,
emojiId?: EmojiID,
): Promise<Array<MessageReaction>>;
abstract addReaction(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
emojiAnimated?: boolean,
): Promise<MessageReaction>;
abstract removeReaction(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
): Promise<void>;
abstract removeAllReactions(channelId: ChannelID, messageId: MessageID): Promise<void>;
abstract removeAllReactionsForEmoji(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
emojiId?: EmojiID,
): Promise<void>;
abstract countReactionUsers(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
emojiId?: EmojiID,
): Promise<number>;
abstract countUniqueReactions(channelId: ChannelID, messageId: MessageID): Promise<number>;
abstract checkUserReactionExists(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
): Promise<boolean>;
abstract lookupAttachmentByChannelAndFilename(
channelId: ChannelID,
attachmentId: AttachmentID,
filename: string,
): Promise<MessageID | null>;
abstract listChannelAttachments(channelId: ChannelID): Promise<Array<AttachmentLookupRow>>;
abstract listMessagesByAuthor(
authorId: UserID,
limit?: number,
lastChannelId?: ChannelID,
lastMessageId?: MessageID,
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>>;
abstract deleteMessagesByAuthor(
authorId: UserID,
channelIds?: Array<ChannelID>,
messageIds?: Array<MessageID>,
): Promise<void>;
abstract anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void>;
abstract deleteAllChannelMessages(channelId: ChannelID): Promise<void>;
}

View File

@@ -0,0 +1,394 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
import {makeAttachmentCdnUrl} from '~/channel/services/message/MessageHelpers';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Attachment, Embed, EmbedField, Message, MessageReaction, MessageRef, StickerItem, User} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {AttachmentDecayRow} from '~/types/AttachmentDecayTypes';
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '~/user/UserCacheHelpers';
import type {UserPartialResponse} from '~/user/UserModel';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
import type {EmbedFieldResponse, MessageEmbedResponse} from './EmbedTypes';
import type {
MessageAttachmentResponse,
MessageReactionResponse,
MessageReferenceResponse,
MessageResponse,
MessageStickerResponse,
} from './MessageTypes';
interface MapMessageToResponseParams {
message: Message;
currentUserId?: UserID;
nonce?: string;
tts?: boolean;
withMessageReference?: boolean;
getAuthor?: (userId: UserID) => Promise<User | null>;
getReactions?: (channelId: ChannelID, messageId: MessageID) => Promise<Array<MessageReaction>>;
getReferencedMessage?: (channelId: ChannelID, messageId: MessageID) => Promise<Message | null>;
setHasReaction?: (channelId: ChannelID, messageId: MessageID, hasReaction: boolean) => Promise<void>;
userCacheService: UserCacheService;
requestCache: RequestCache;
mediaService: IMediaService;
attachmentDecayMap?: Map<string, AttachmentDecayRow>;
}
interface MapEmbedFieldToResponseParams {
field: EmbedField;
}
interface MapMessageEmbedToResponseParams {
embed: Embed;
mediaService: IMediaService;
}
interface MapMessageAttachmentToResponseParams {
channelId: ChannelID;
attachment: Attachment;
mediaService: IMediaService;
}
interface MapReactionsParams {
reactions: Array<MessageReaction>;
currentUserId: UserID;
}
interface MapMessageReferenceParams {
reference: MessageRef;
}
const mapEmbedFieldToResponse = ({field}: MapEmbedFieldToResponseParams): EmbedFieldResponse => ({
name: field.name,
value: field.value,
inline: field.inline ?? false,
});
const mapMessageEmbedToResponse = ({embed, mediaService}: MapMessageEmbedToResponseParams): MessageEmbedResponse => ({
type: embed.type ?? 'rich',
url: embed.url ?? undefined,
title: embed.title ?? undefined,
color: embed.color ?? undefined,
timestamp: embed.timestamp?.toISOString() ?? undefined,
description: embed.description ?? undefined,
author: embed.author
? {
name: embed.author.name!,
url: embed.author.url ?? undefined,
icon_url: embed.author.iconUrl ?? undefined,
proxy_icon_url: embed.author.iconUrl ? mediaService.getExternalMediaProxyURL(embed.author.iconUrl) : undefined,
}
: undefined,
image: embed.image
? {
url: embed.image.url!,
proxy_url: mediaService.getExternalMediaProxyURL(embed.image.url!),
content_type: embed.image.contentType ?? undefined,
content_hash: embed.image.contentHash ?? undefined,
width: embed.image.width ?? undefined,
height: embed.image.height ?? undefined,
description: embed.image.description ?? undefined,
placeholder: embed.image.placeholder ?? undefined,
flags: embed.image.flags,
}
: undefined,
thumbnail: embed.thumbnail
? {
url: embed.thumbnail.url!,
proxy_url: mediaService.getExternalMediaProxyURL(embed.thumbnail.url!),
content_type: embed.thumbnail.contentType ?? undefined,
content_hash: embed.thumbnail.contentHash ?? undefined,
width: embed.thumbnail.width ?? undefined,
height: embed.thumbnail.height ?? undefined,
description: embed.thumbnail.description ?? undefined,
placeholder: embed.thumbnail.placeholder ?? undefined,
flags: embed.thumbnail.flags,
}
: undefined,
footer: embed.footer
? {
text: embed.footer.text!,
icon_url: embed.footer.iconUrl ?? undefined,
proxy_icon_url: embed.footer.iconUrl ? mediaService.getExternalMediaProxyURL(embed.footer.iconUrl) : undefined,
}
: undefined,
fields:
embed.fields && embed.fields.length > 0 ? embed.fields.map((field) => mapEmbedFieldToResponse({field})) : undefined,
provider: embed.provider
? {
name: embed.provider.name!,
url: embed.provider.url ?? undefined,
icon_url: undefined,
proxy_icon_url: undefined,
}
: undefined,
video: embed.video
? {
url: embed.video.url!,
proxy_url: mediaService.getExternalMediaProxyURL(embed.video.url!),
content_type: embed.video.contentType ?? undefined,
content_hash: embed.video.contentHash ?? undefined,
width: embed.video.width ?? undefined,
height: embed.video.height ?? undefined,
description: embed.video.description ?? undefined,
placeholder: embed.video.placeholder ?? undefined,
flags: embed.video.flags,
duration: embed.video.duration,
}
: undefined,
audio: undefined,
nsfw: embed.nsfw ?? undefined,
});
const mapMessageAttachmentToResponse = ({
channelId,
attachment,
mediaService,
attachmentDecayMap,
}: MapMessageAttachmentToResponseParams & {
attachmentDecayMap?: Map<string, AttachmentDecayRow>;
}): MessageAttachmentResponse => {
const url = makeAttachmentCdnUrl(channelId, attachment.id, attachment.filename);
const decay = attachmentDecayMap?.get(attachment.id.toString());
const expired = decay ? decay.expires_at.getTime() <= Date.now() : false;
return {
id: attachment.id.toString(),
filename: attachment.filename,
title: attachment.title ?? undefined,
description: attachment.description ?? undefined,
content_type: attachment.contentType ?? undefined,
content_hash: attachment.contentHash ?? undefined,
size: Number(attachment.size),
url: expired ? null : url,
proxy_url: expired ? null : mediaService.getExternalMediaProxyURL(url),
width: attachment.width ?? undefined,
height: attachment.height ?? undefined,
placeholder: attachment.placeholder ?? undefined,
flags: attachment.flags,
nsfw: attachment.nsfw ?? undefined,
duration: attachment.duration,
expires_at: decay?.expires_at?.toISOString?.() ?? null,
expired: expired || undefined,
};
};
const mapStickerItemToResponse = (sticker: StickerItem): MessageStickerResponse => ({
id: sticker.id.toString(),
name: sticker.name,
format_type: sticker.formatType,
});
const mapReactions = ({reactions, currentUserId}: MapReactionsParams): Array<MessageReactionResponse> => {
if (!reactions?.length) return [];
const reactionMap = new Map<
string,
{emoji: {id?: string; name: string; animated?: boolean}; count: number; me: boolean}
>();
for (const reaction of reactions) {
const {emojiId, emojiName, isEmojiAnimated, userId} = reaction;
const isCustomEmoji = emojiId !== 0n;
const emojiKey = isCustomEmoji ? `custom_${emojiId}` : `unicode_${emojiName}`;
const existing = reactionMap.get(emojiKey);
if (existing) {
existing.count++;
if (userId === currentUserId) {
existing.me = true;
}
} else {
reactionMap.set(emojiKey, {
emoji: {
id: isCustomEmoji ? emojiId.toString() : undefined,
name: emojiName,
animated: isEmojiAnimated || undefined,
},
count: 1,
me: userId === currentUserId,
});
}
}
return Array.from(reactionMap.values()).map(({emoji, count, me}) => ({
emoji,
count,
me: me || undefined,
}));
};
const mapMessageReference = ({reference}: MapMessageReferenceParams): MessageReferenceResponse => ({
channel_id: reference.channelId.toString(),
message_id: reference.messageId.toString(),
guild_id: reference.guildId?.toString(),
type: reference.type,
});
export async function mapMessageToResponse({
message,
currentUserId,
nonce,
tts,
withMessageReference = true,
getAuthor,
getReactions,
getReferencedMessage,
setHasReaction,
userCacheService,
requestCache,
mediaService,
attachmentDecayMap,
}: MapMessageToResponseParams): Promise<MessageResponse> {
let author: UserPartialResponse;
if (message.authorId) {
author = await getCachedUserPartialResponse({userId: message.authorId, userCacheService, requestCache});
} else if (message.webhookId && message.webhookName) {
author = {
id: message.webhookId.toString(),
username: message.webhookName,
discriminator: '0000',
avatar: message.webhookAvatarHash,
bot: true,
flags: 0,
};
} else {
throw new Error(`Message ${message.id} has neither authorId nor webhookId`);
}
const response: Partial<MessageResponse> = {
id: message.id.toString(),
type: message.type,
content: message.content ?? '',
channel_id: message.channelId.toString(),
author,
attachments:
message.attachments?.map((att) =>
mapMessageAttachmentToResponse({
channelId: message.channelId,
attachment: att,
mediaService,
attachmentDecayMap,
}),
) ?? [],
stickers: message.stickers?.map((sticker) => mapStickerItemToResponse(sticker)) ?? [],
embeds: message.embeds?.map((embed) => mapMessageEmbedToResponse({embed, mediaService})) ?? [],
timestamp: new Date(SnowflakeUtils.extractTimestamp(message.id)).toISOString(),
edited_timestamp: message.editedTimestamp?.toISOString() ?? null,
flags: message.flags ?? 0,
mention_everyone: message.mentionEveryone,
pinned: message.pinnedTimestamp != null,
};
if (message.webhookId) {
response.webhook_id = message.webhookId.toString();
}
if (tts) {
response.tts = true;
}
if (message.mentionedUserIds.size > 0) {
const mentionedUserIds = Array.from(message.mentionedUserIds);
const mentionedUserPartials = await getCachedUserPartialResponses({
userIds: mentionedUserIds,
userCacheService,
requestCache,
});
response.mentions = mentionedUserIds.map((userId) => mentionedUserPartials.get(userId)!);
}
if (message.mentionedRoleIds.size > 0) {
response.mention_roles = Array.from(message.mentionedRoleIds).map(String);
}
if (currentUserId != null && getReactions && message.hasReaction !== false) {
const reactions = await getReactions(message.channelId, message.id);
const hasReactions = reactions && reactions.length > 0;
if (message.hasReaction == null && setHasReaction) {
void setHasReaction(message.channelId, message.id, hasReactions);
}
if (hasReactions) {
const mappedReactions = mapReactions({reactions, currentUserId});
if (mappedReactions.length > 0) {
response.reactions = mappedReactions;
}
}
}
if (message.reference) {
response.message_reference = mapMessageReference({reference: message.reference});
if (withMessageReference && getReferencedMessage) {
const referencedMessage = await getReferencedMessage(message.reference.channelId, message.reference.messageId);
if (referencedMessage) {
response.referenced_message = await mapMessageToResponse({
message: referencedMessage,
withMessageReference: false,
getAuthor,
getReactions,
getReferencedMessage,
setHasReaction,
userCacheService,
requestCache,
mediaService,
attachmentDecayMap,
});
}
}
}
if (message.messageSnapshots && message.messageSnapshots.length > 0) {
response.message_snapshots = message.messageSnapshots.map((snapshot) => ({
content: snapshot.content ?? undefined,
timestamp: snapshot.timestamp.toISOString(),
edited_timestamp: snapshot.editedTimestamp?.toISOString() ?? undefined,
mentions: snapshot.mentionedUserIds.size > 0 ? Array.from(snapshot.mentionedUserIds).map(String) : undefined,
mention_roles: snapshot.mentionedRoleIds.size > 0 ? Array.from(snapshot.mentionedRoleIds).map(String) : undefined,
embeds: snapshot.embeds?.map((embed) => mapMessageEmbedToResponse({embed, mediaService})) ?? undefined,
attachments:
snapshot.attachments?.map((att) =>
mapMessageAttachmentToResponse({
channelId: message.channelId,
attachment: att,
mediaService,
attachmentDecayMap,
}),
) ?? undefined,
type: snapshot.type,
flags: snapshot.flags,
}));
}
if (nonce) {
response.nonce = nonce;
}
if (message.call) {
response.call = {
participants: Array.from(message.call.participantIds).map(String),
ended_timestamp: message.call.endedTimestamp?.toISOString() ?? null,
};
}
return response as MessageResponse;
}

View File

@@ -0,0 +1,286 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {MAX_ATTACHMENTS_PER_MESSAGE, MAX_EMBEDS_PER_MESSAGE} from '~/Constants';
import {
type AttachmentRequestData,
ClientAttachmentReferenceRequest,
ClientAttachmentRequest,
} from '~/channel/AttachmentDTOs';
import {createStringType, createUnboundedStringType, Int32Type, Int64Type, z} from '~/Schema';
import {UserPartialResponse} from '~/user/UserModel';
import {MessageEmbedResponse, RichEmbedRequest} from './EmbedTypes';
const AUTHOR_TYPES = ['user', 'bot', 'webhook'] as const;
const MESSAGE_CONTENT_TYPES = [
'image',
'sound',
'video',
'file',
'sticker',
'embed',
'link',
'poll',
'snapshot',
] as const;
const EMBED_TYPES = ['image', 'video', 'sound', 'article'] as const;
const SORT_FIELDS = ['timestamp', 'relevance'] as const;
const SORT_ORDERS = ['asc', 'desc'] as const;
const MessageAttachmentResponse = z.object({
id: z.string(),
filename: z.string(),
title: z.string().nullish(),
description: z.string().nullish(),
content_type: z.string().nullish(),
content_hash: z.string().nullish(),
size: z.number().int(),
url: z.string().nullish(),
proxy_url: z.string().nullish(),
width: z.number().int().nullish(),
height: z.number().int().nullish(),
placeholder: z.string().nullish(),
flags: z.number().int(),
nsfw: z.boolean().nullish(),
duration: z.number().int().nullish(),
expires_at: z.string().nullish(),
expired: z.boolean().nullish(),
});
export type MessageAttachmentResponse = z.infer<typeof MessageAttachmentResponse>;
const MessageReferenceResponse = z.object({
channel_id: z.string(),
message_id: z.string(),
guild_id: z.string().nullish(),
type: z.number().int(),
});
export type MessageReferenceResponse = z.infer<typeof MessageReferenceResponse>;
const MessageReactionResponse = z.object({
emoji: z.object({
id: z.string().nullish(),
name: z.string(),
animated: z.boolean().nullish(),
}),
count: z.number().int(),
me: z.literal(true).nullish(),
});
export type MessageReactionResponse = z.infer<typeof MessageReactionResponse>;
const MessageStickerResponse = z.object({
id: z.string(),
name: z.string(),
format_type: z.number().int(),
});
export type MessageStickerResponse = z.infer<typeof MessageStickerResponse>;
const MessageSnapshotResponse = z.object({
content: z.string().nullish(),
timestamp: z.iso.datetime(),
edited_timestamp: z.iso.datetime().nullish(),
mentions: z.array(z.string()).nullish(),
mention_roles: z.array(z.string()).nullish(),
embeds: z.array(MessageEmbedResponse).nullish(),
attachments: z.array(MessageAttachmentResponse).nullish(),
stickers: z.array(MessageStickerResponse).nullish(),
type: z.number().int(),
flags: z.number().int(),
});
const MessageCallResponse = z.object({
participants: z.array(z.string()),
ended_timestamp: z.iso.datetime().nullish(),
});
const BaseMessageResponse = z.object({
id: z.string(),
channel_id: z.string(),
author: z.lazy(() => UserPartialResponse),
webhook_id: z.string().nullish(),
type: z.number().int(),
flags: z.number().int(),
content: z.string(),
timestamp: z.iso.datetime(),
edited_timestamp: z.iso.datetime().nullish(),
pinned: z.boolean(),
mention_everyone: z.boolean(),
tts: z.boolean().optional(),
mentions: z.array(z.lazy(() => UserPartialResponse)).nullish(),
mention_roles: z.array(z.string()).nullish(),
embeds: z.array(MessageEmbedResponse).nullish(),
attachments: z.array(MessageAttachmentResponse).nullish(),
stickers: z.array(MessageStickerResponse).nullish(),
reactions: z.array(MessageReactionResponse).nullish(),
message_reference: MessageReferenceResponse.nullish(),
message_snapshots: z.array(MessageSnapshotResponse).nullish(),
nonce: z.string().nullish(),
call: MessageCallResponse.nullish(),
});
export interface MessageResponse extends z.infer<typeof BaseMessageResponse> {
referenced_message?: MessageResponse | null;
}
const MessageResponseSchema = z.object({
...BaseMessageResponse.shape,
referenced_message: BaseMessageResponse.nullish(),
});
const MessageReferenceRequest = z
.object({
message_id: Int64Type,
channel_id: Int64Type.optional(),
guild_id: Int64Type.optional(),
type: z.number().int().optional(),
})
.refine(
(data) => {
if (data.type === 1) {
return data.channel_id !== undefined && data.message_id !== undefined;
}
return true;
},
{
message: 'Forward message reference must include channel_id and message_id',
},
);
export const ALLOWED_MENTIONS_PARSE = ['users', 'roles', 'everyone'] as const;
export const AllowedMentionsRequest = z.object({
parse: z.array(z.enum(ALLOWED_MENTIONS_PARSE)).optional(),
users: z.array(Int64Type).max(100).optional(),
roles: z.array(Int64Type).max(100).optional(),
replied_user: z.boolean().optional(),
});
export type AllowedMentionsRequest = z.infer<typeof AllowedMentionsRequest>;
export const MessageAttachmentRequest = ClientAttachmentRequest;
export type MessageAttachmentRequest = z.infer<typeof ClientAttachmentRequest>;
export const MessageUpdateAttachmentRequest = ClientAttachmentReferenceRequest;
export type MessageUpdateAttachmentRequest = z.infer<typeof MessageUpdateAttachmentRequest>;
const MessageRequestSchema = z
.object({
content: createUnboundedStringType().nullish(),
embeds: z.array(RichEmbedRequest).max(MAX_EMBEDS_PER_MESSAGE),
attachments: z.array(ClientAttachmentRequest).max(MAX_ATTACHMENTS_PER_MESSAGE),
message_reference: MessageReferenceRequest.nullish(),
allowed_mentions: AllowedMentionsRequest.nullish(),
flags: Int32Type.default(0),
nonce: createStringType(1, 32),
favorite_meme_id: Int64Type.nullish(),
sticker_ids: z.array(Int64Type).max(3).nullish(),
tts: z.boolean().optional(),
})
.partial();
export const MessageRequest = MessageRequestSchema;
interface BaseMessageRequestType extends Omit<z.infer<typeof MessageRequestSchema>, 'attachments'> {}
export interface MessageRequest extends BaseMessageRequestType {
attachments?: Array<AttachmentRequestData>;
}
const MessageUpdateRequestSchema = MessageRequestSchema.pick({
content: true,
embeds: true,
allowed_mentions: true,
}).extend({
flags: Int32Type.optional(),
attachments: z.array(MessageUpdateAttachmentRequest).max(MAX_ATTACHMENTS_PER_MESSAGE).optional(),
});
export const MessageUpdateRequest = MessageUpdateRequestSchema;
interface BaseMessageUpdateRequestType extends Omit<z.infer<typeof MessageUpdateRequestSchema>, 'attachments'> {}
export interface MessageUpdateRequest extends BaseMessageUpdateRequestType {
attachments?: Array<AttachmentRequestData>;
}
export const ChannelPinResponse = z.object({
message: MessageResponseSchema.omit({referenced_message: true, reactions: true}),
pinned_at: z.iso.datetime(),
});
export type ChannelPinResponse = z.infer<typeof ChannelPinResponse>;
export const MessageSearchScope = z.enum([
'current',
'open_dms',
'all_dms',
'all_guilds',
'all',
'open_dms_and_all_guilds',
]);
export type MessageSearchScope = z.infer<typeof MessageSearchScope>;
export const MessageSearchRequest = z.object({
hits_per_page: z.number().int().min(1).max(25).default(25),
page: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).default(1),
max_id: Int64Type.optional(),
min_id: Int64Type.optional(),
content: createStringType(1, 1024).optional(),
contents: z.array(createStringType(1, 1024)).max(100).optional(),
channel_id: z.array(Int64Type).max(500).optional(),
exclude_channel_id: z.array(Int64Type).max(500).optional(),
author_type: z.array(z.enum(AUTHOR_TYPES)).optional(),
exclude_author_type: z.array(z.enum(AUTHOR_TYPES)).optional(),
author_id: z.array(Int64Type).optional(),
exclude_author_id: z.array(Int64Type).optional(),
mentions: z.array(Int64Type).optional(),
exclude_mentions: z.array(Int64Type).optional(),
mention_everyone: z.boolean().optional(),
pinned: z.boolean().optional(),
has: z.array(z.enum(MESSAGE_CONTENT_TYPES)).optional(),
exclude_has: z.array(z.enum(MESSAGE_CONTENT_TYPES)).optional(),
embed_type: z.array(z.enum(EMBED_TYPES)).optional(),
exclude_embed_type: z.array(z.enum(EMBED_TYPES)).optional(),
embed_provider: z.array(createStringType(1, 256)).optional(),
exclude_embed_provider: z.array(createStringType(1, 256)).optional(),
link_hostname: z.array(createStringType(1, 255)).optional(),
exclude_link_hostname: z.array(createStringType(1, 255)).optional(),
attachment_filename: z.array(createStringType(1, 1024)).optional(),
exclude_attachment_filename: z.array(createStringType(1, 1024)).optional(),
attachment_extension: z.array(createStringType(1, 32)).optional(),
exclude_attachment_extension: z.array(createStringType(1, 32)).optional(),
sort_by: z.enum(SORT_FIELDS).default('timestamp'),
sort_order: z.enum(SORT_ORDERS).default('desc'),
include_nsfw: z.boolean().default(false),
scope: MessageSearchScope.optional(),
});
export type MessageSearchRequest = z.infer<typeof MessageSearchRequest>;
export const MessageSearchResponse = z.object({
messages: z.array(MessageResponseSchema.omit({referenced_message: true})),
total: z.number().int(),
hits_per_page: z.number().int(),
page: z.number().int(),
});
export type MessageSearchResponse = z.infer<typeof MessageSearchResponse>;

View File

@@ -0,0 +1,116 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createChannelID, createUserID} from '~/BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {createStringType, Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
export const CallController = (app: HonoApp) => {
app.get(
'/channels/:channel_id/call',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CALL_GET),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({channel_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const channelService = ctx.get('channelService');
const {ringable, silent} = await channelService.checkCallEligibility({userId, channelId});
return ctx.json({ringable, silent});
},
);
app.patch(
'/channels/:channel_id/call',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CALL_UPDATE),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({channel_id: Int64Type})),
Validator('json', z.object({region: createStringType(1, 64).optional()})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const {region} = ctx.req.valid('json');
const channelService = ctx.get('channelService');
await channelService.updateCall({userId, channelId, region});
return ctx.body(null, 204);
},
);
app.post(
'/channels/:channel_id/call/ring',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CALL_RING),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({channel_id: Int64Type})),
Validator('json', z.object({recipients: z.array(Int64Type).optional()})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const {recipients} = ctx.req.valid('json');
const channelService = ctx.get('channelService');
const requestCache = ctx.get('requestCache');
const recipientIds = recipients ? recipients.map(createUserID) : undefined;
await channelService.ringCallRecipients({
userId,
channelId,
recipients: recipientIds,
requestCache,
});
return ctx.body(null, 204);
},
);
app.post(
'/channels/:channel_id/call/stop-ringing',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CALL_STOP_RINGING),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({channel_id: Int64Type})),
Validator('json', z.object({recipients: z.array(Int64Type).optional()})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const {recipients} = ctx.req.valid('json');
const channelService = ctx.get('channelService');
const recipientIds = recipients ? recipients.map(createUserID) : undefined;
await channelService.stopRingingCallRecipients({
userId,
channelId,
recipients: recipientIds,
});
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,232 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Context} from 'hono';
import type {HonoApp, HonoEnv} from '~/App';
import {createChannelID, createUserID} from '~/BrandedTypes';
import {ChannelTypes} from '~/Constants';
import {ChannelUpdateRequest, mapChannelToResponse} from '~/channel/ChannelModel';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, QueryBooleanType, z} from '~/Schema';
import {Validator} from '~/Validator';
export const ChannelController = (app: HonoApp) => {
app.get(
'/channels/:channel_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_GET),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const requestCache = ctx.get('requestCache');
const channel = await ctx.get('channelService').getChannel({userId, channelId});
return ctx.json(
await mapChannelToResponse({
channel,
currentUserId: userId,
userCacheService: ctx.get('userCacheService'),
requestCache,
}),
);
},
);
app.get(
'/channels/:channel_id/rtc-regions',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_GET),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({channel_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const regions = await ctx.get('channelService').getAvailableRtcRegions({
userId,
channelId,
});
return ctx.json(
regions.map((region) => ({
id: region.id,
name: region.name,
emoji: region.emoji,
})),
);
},
);
app.patch(
'/channels/:channel_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type})),
Validator('json', ChannelUpdateRequest, {
pre: async (raw: unknown, ctx: Context<HonoEnv>) => {
const userId = ctx.get('user').id;
// @ts-expect-error not well typed
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const existing = await ctx.get('channelService').getChannel({
userId,
channelId,
});
const body = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
return {...body, type: existing.type};
},
}),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const data = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
const channel = await ctx.get('channelService').editChannel({userId, channelId, data, requestCache});
return ctx.json(
await mapChannelToResponse({
channel,
currentUserId: userId,
userCacheService: ctx.get('userCacheService'),
requestCache,
}),
);
},
);
app.delete(
'/channels/:channel_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_DELETE),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type})),
Validator('query', z.object({silent: QueryBooleanType})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const {silent} = ctx.req.valid('query');
const requestCache = ctx.get('requestCache');
const channel = await ctx.get('channelService').getChannel({userId, channelId});
if (channel.type === ChannelTypes.GROUP_DM) {
await ctx.get('channelService').removeRecipientFromChannel({
userId,
channelId,
recipientId: userId,
requestCache,
silent,
});
} else {
await ctx.get('channelService').deleteChannel({userId, channelId, requestCache});
}
return ctx.body(null, 204);
},
);
app.put(
'/channels/:channel_id/recipients/:user_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type, user_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const recipientId = createUserID(ctx.req.valid('param').user_id);
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').groupDms.addRecipientToChannel({
userId,
channelId,
recipientId,
requestCache,
});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/recipients/:user_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type, user_id: Int64Type})),
Validator('query', z.object({silent: QueryBooleanType})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const recipientId = createUserID(ctx.req.valid('param').user_id);
const {silent} = ctx.req.valid('query');
const requestCache = ctx.get('requestCache');
await ctx
.get('channelService')
.removeRecipientFromChannel({userId, channelId, recipientId, requestCache, silent});
return ctx.body(null, 204);
},
);
app.put(
'/channels/:channel_id/permissions/:overwrite_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type, overwrite_id: Int64Type})),
Validator(
'json',
z.object({
type: z.union([z.literal(0), z.literal(1)]),
allow: Int64Type.nullish(),
deny: Int64Type.nullish(),
}),
),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const overwriteId = ctx.req.valid('param').overwrite_id;
const data = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').setChannelPermissionOverwrite({
userId,
channelId,
overwriteId,
overwrite: {
type: data.type,
allow_: data.allow ? data.allow : 0n,
deny_: data.deny ? data.deny : 0n,
},
requestCache,
});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/permissions/:overwrite_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type, overwrite_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const overwriteId = ctx.req.valid('param').overwrite_id;
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').deleteChannelPermissionOverwrite({userId, channelId, overwriteId, requestCache});
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,500 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Context} from 'hono';
import type {HonoApp, HonoEnv} from '~/App';
import {AttachmentDecayService} from '~/attachment/AttachmentDecayService';
import type {ChannelID, UserID} from '~/BrandedTypes';
import {createAttachmentID, createChannelID, createMessageID} from '~/BrandedTypes';
import {MAX_ATTACHMENTS_PER_MESSAGE} from '~/Constants';
import {
type AttachmentRequestData,
type ClientAttachmentReferenceRequest,
type ClientAttachmentRequest,
mergeUploadWithClientData,
type UploadedAttachment,
} from '~/channel/AttachmentDTOs';
import {MessageRequest, MessageUpdateRequest, mapMessageToResponse} from '~/channel/ChannelModel';
import {collectMessageAttachments, isPersonalNotesChannel} from '~/channel/services/message/MessageHelpers';
import {InputValidationError, UnclaimedAccountRestrictedError} from '~/Errors';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {createQueryIntegerType, Int32Type, Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
const DEFAULT_ATTACHMENT_UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export interface ParseMultipartMessageDataOptions {
uploadExpiresAt?: Date;
onPayloadParsed?: (payload: unknown) => void;
}
export async function parseMultipartMessageData(
ctx: Context<HonoEnv>,
userId: UserID,
channelId: ChannelID,
schema: z.ZodTypeAny,
options?: ParseMultipartMessageDataOptions,
): Promise<MessageRequest | MessageUpdateRequest> {
let body: Record<string, string | File | Array<string | File>>;
try {
body = await ctx.req.parseBody();
} catch (_error) {
throw InputValidationError.create(
'multipart_form',
'Failed to parse multipart form data. Please check that all field names and filenames are properly formatted.',
);
}
if (!body.payload_json || typeof body.payload_json !== 'string') {
throw InputValidationError.create('payload_json', 'payload_json field is required for multipart messages');
}
let jsonData: unknown;
try {
jsonData = JSON.parse(body.payload_json);
} catch (_error) {
throw InputValidationError.create('payload_json', 'Invalid JSON in payload_json');
}
options?.onPayloadParsed?.(jsonData);
const validationResult = schema.safeParse(jsonData);
if (!validationResult.success) {
throw InputValidationError.create('message_data', 'Invalid message data');
}
const data = validationResult.data as Partial<MessageRequest> &
Partial<MessageUpdateRequest> & {
attachments?: Array<AttachmentRequestData>;
};
const filesWithIndices: Array<{file: File; index: number}> = [];
const seenIndices = new Set<number>();
const fieldNamePattern = /^files\[(\d+)\]$/;
Object.keys(body).forEach((key) => {
if (key.startsWith('files[')) {
const match = fieldNamePattern.exec(key);
if (!match) {
throw InputValidationError.create(
'files',
`Invalid file field name: ${key}. Expected format: files[N] where N is a number`,
);
}
const index = parseInt(match[1], 10);
if (index >= MAX_ATTACHMENTS_PER_MESSAGE) {
throw InputValidationError.create(
'files',
`File index ${index} exceeds maximum allowed index of ${MAX_ATTACHMENTS_PER_MESSAGE - 1}`,
);
}
if (seenIndices.has(index)) {
throw InputValidationError.create('files', `Duplicate file index: ${index}`);
}
const file = body[key];
if (file instanceof File) {
filesWithIndices.push({file, index});
seenIndices.add(index);
} else if (Array.isArray(file)) {
const validFiles = file.filter((f) => f instanceof File);
if (validFiles.length > 0) {
throw InputValidationError.create('files', `Multiple files for index ${index} not allowed`);
}
}
}
});
if (filesWithIndices.length > MAX_ATTACHMENTS_PER_MESSAGE) {
throw InputValidationError.create('files', `Too many files. Maximum ${MAX_ATTACHMENTS_PER_MESSAGE} files allowed`);
}
if (filesWithIndices.length > 0) {
if (!data.attachments || !Array.isArray(data.attachments) || data.attachments.length === 0) {
throw InputValidationError.create('attachments', 'Attachments metadata array is required when uploading files');
}
type AttachmentMetadata = ClientAttachmentRequest | ClientAttachmentReferenceRequest;
const attachmentMetadata = data.attachments as Array<AttachmentMetadata>;
const newAttachments = attachmentMetadata.filter(
(a): a is ClientAttachmentRequest => 'filename' in a && a.filename !== undefined,
);
const existingAttachments = attachmentMetadata.filter(
(a): a is ClientAttachmentReferenceRequest => !('filename' in a) || a.filename === undefined,
);
const metadataIds = new Set(newAttachments.map((a) => a.id));
const fileIds = new Set(filesWithIndices.map((f) => f.index));
for (const fileId of fileIds) {
if (!metadataIds.has(fileId)) {
throw InputValidationError.create('attachments', `No metadata provided for file with ID ${fileId}`);
}
}
for (const att of newAttachments) {
if (!fileIds.has(att.id)) {
throw InputValidationError.create('attachments', `No file uploaded for attachment metadata with ID ${att.id}`);
}
}
const uploadExpiresAt = options?.uploadExpiresAt ?? new Date(Date.now() + DEFAULT_ATTACHMENT_UPLOAD_TTL_MS);
const uploadedAttachments: Array<UploadedAttachment> = await ctx.get('channelService').uploadFormDataAttachments({
userId,
channelId,
files: filesWithIndices,
attachmentMetadata: newAttachments,
expiresAt: uploadExpiresAt,
});
const uploadedMap = new Map(uploadedAttachments.map((u) => [u.id, u]));
const processedNewAttachments = newAttachments.map((clientData) => {
const uploaded = uploadedMap.get(clientData.id);
if (!uploaded) {
throw InputValidationError.create('attachments', `No file uploaded for attachment with ID ${clientData.id}`);
}
if (clientData.filename !== uploaded.filename) {
throw InputValidationError.create(
'attachments',
`Filename mismatch for attachment ${clientData.id}: metadata specifies "${clientData.filename}" but this doesn't match`,
);
}
return mergeUploadWithClientData(uploaded, clientData);
});
data.attachments = [...existingAttachments, ...processedNewAttachments];
} else if (
data.attachments?.some((a: unknown) => {
const attachment = a as ClientAttachmentRequest | ClientAttachmentReferenceRequest;
return 'filename' in attachment && attachment.filename;
})
) {
throw InputValidationError.create(
'attachments',
'Attachment metadata with filename provided but no files uploaded',
);
}
return data as MessageRequest | MessageUpdateRequest;
}
export const MessageController = (app: HonoApp) => {
const decayService = new AttachmentDecayService();
app.get(
'/channels/:channel_id/messages',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGES_GET),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type})),
Validator(
'query',
z.object({
limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 100}),
before: z.optional(Int64Type),
after: z.optional(Int64Type),
around: z.optional(Int64Type),
}),
),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const {limit, before, after, around} = ctx.req.valid('query');
const requestCache = ctx.get('requestCache');
const messages = await ctx.get('channelService').getMessages({
userId,
channelId,
limit,
before: before ? createMessageID(before) : undefined,
after: after ? createMessageID(after) : undefined,
around: around ? createMessageID(around) : undefined,
});
const allAttachments = messages.flatMap((message) => collectMessageAttachments(message));
const attachmentDecayMap =
allAttachments.length > 0
? await decayService.fetchMetadata(allAttachments.map((att) => ({attachmentId: att.id})))
: undefined;
return ctx.json(
await Promise.all(
messages.map((message) =>
mapMessageToResponse({
message,
currentUserId: userId,
userCacheService: ctx.get('userCacheService'),
requestCache,
mediaService: ctx.get('mediaService'),
attachmentDecayMap,
getReactions: (channelId, messageId) =>
ctx.get('channelService').getMessageReactions({userId, channelId, messageId}),
setHasReaction: (channelId, messageId, hasReaction) =>
ctx.get('channelService').setHasReaction(channelId, messageId, hasReaction),
getReferencedMessage: (channelId, messageId) =>
ctx.get('channelRepository').getMessage(channelId, messageId),
}),
),
),
);
},
);
app.get(
'/channels/:channel_id/messages/:message_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_GET),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
const message = await ctx.get('channelService').getMessage({userId, channelId, messageId});
const messageAttachments = collectMessageAttachments(message);
const attachmentDecayMap =
messageAttachments.length > 0
? await decayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
: undefined;
return ctx.json(
await mapMessageToResponse({
message,
currentUserId: userId,
userCacheService: ctx.get('userCacheService'),
requestCache,
mediaService: ctx.get('mediaService'),
attachmentDecayMap,
getReactions: (channelId, messageId) =>
ctx.get('channelService').getMessageReactions({userId, channelId, messageId}),
setHasReaction: (channelId, messageId, hasReaction) =>
ctx.get('channelService').setHasReaction(channelId, messageId, hasReaction),
getReferencedMessage: (channelId, messageId) => ctx.get('channelRepository').getMessage(channelId, messageId),
}),
);
},
);
app.post(
'/channels/:channel_id/messages',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_CREATE),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type})),
async (ctx) => {
const user = ctx.get('user');
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const requestCache = ctx.get('requestCache');
if (!user.passwordHash && !isPersonalNotesChannel({userId: user.id, channelId})) {
throw new UnclaimedAccountRestrictedError('send messages');
}
const contentType = ctx.req.header('content-type');
const validatedData = contentType?.includes('multipart/form-data')
? ((await parseMultipartMessageData(ctx, user.id, channelId, MessageRequest)) as MessageRequest)
: await (async () => {
const data: unknown = await ctx.req.json();
const validationResult = MessageRequest.safeParse(data);
if (!validationResult.success) {
throw InputValidationError.create('message_data', 'Invalid message data');
}
return validationResult.data;
})();
const message = await ctx
.get('channelService')
.sendMessage({user, channelId, data: validatedData as MessageRequest, requestCache});
const messageAttachments = collectMessageAttachments(message);
const attachmentDecayMap =
messageAttachments.length > 0
? await decayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
: undefined;
return ctx.json(
await mapMessageToResponse({
message,
currentUserId: user.id,
nonce: validatedData.nonce,
userCacheService: ctx.get('userCacheService'),
requestCache,
mediaService: ctx.get('mediaService'),
attachmentDecayMap,
getReferencedMessage: (channelId, messageId) => ctx.get('channelRepository').getMessage(channelId, messageId),
}),
);
},
);
app.patch(
'/channels/:channel_id/messages/:message_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_UPDATE),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
const contentType = ctx.req.header('content-type');
const validatedData = contentType?.includes('multipart/form-data')
? ((await parseMultipartMessageData(ctx, userId, channelId, MessageUpdateRequest)) as MessageUpdateRequest)
: await (async () => {
const data: unknown = await ctx.req.json();
const validationResult = MessageUpdateRequest.safeParse(data);
if (!validationResult.success) {
throw InputValidationError.create('message_data', 'Invalid message data');
}
return validationResult.data;
})();
const message = await ctx.get('channelService').editMessage({
userId,
channelId,
messageId,
data: validatedData as MessageUpdateRequest,
requestCache,
});
const messageAttachments = collectMessageAttachments(message);
const attachmentDecayMap =
messageAttachments.length > 0
? await decayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
: undefined;
return ctx.json(
await mapMessageToResponse({
message,
currentUserId: userId,
userCacheService: ctx.get('userCacheService'),
requestCache,
mediaService: ctx.get('mediaService'),
attachmentDecayMap,
getReferencedMessage: (channelId, messageId) => ctx.get('channelRepository').getMessage(channelId, messageId),
}),
);
},
);
app.delete(
'/channels/:channel_id/messages/ack',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_READ_STATE_DELETE),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({channel_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
await ctx.get('channelService').deleteReadState({userId, channelId});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/messages/:message_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_DELETE),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').deleteMessage({userId, channelId, messageId, requestCache});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/messages/:message_id/attachments/:attachment_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_DELETE),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type, attachment_id: Int64Type})),
async (ctx) => {
const {channel_id, message_id, attachment_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const attachmentId = createAttachmentID(attachment_id);
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').deleteAttachment({
userId,
channelId,
messageId: messageId,
attachmentId,
requestCache,
});
return ctx.body(null, 204);
},
);
app.post(
'/channels/:channel_id/messages/bulk-delete',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_BULK_DELETE),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type})),
Validator('json', z.object({message_ids: z.array(Int64Type)})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const messageIds = ctx.req.valid('json').message_ids.map(createMessageID);
await ctx.get('channelService').bulkDeleteMessages({userId, channelId, messageIds});
return ctx.body(null, 204);
},
);
app.post(
'/channels/:channel_id/typing',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_TYPING),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
await ctx.get('channelService').startTyping({userId, channelId});
return ctx.body(null, 204);
},
);
app.post(
'/channels/:channel_id/messages/:message_id/ack',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_ACK),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
Validator('json', z.object({mention_count: Int32Type.optional(), manual: z.optional(z.boolean())})),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const {mention_count: mentionCount, manual} = ctx.req.valid('json');
await ctx.get('channelService').ackMessage({
userId,
channelId,
messageId,
mentionCount: mentionCount ?? 0,
manual,
});
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,288 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createChannelID, createMessageID, createUserID} from '~/BrandedTypes';
import {isPersonalNotesChannel} from '~/channel/services/message/MessageHelpers';
import {UnclaimedAccountRestrictedError} from '~/Errors';
import {LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {createStringType, Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
export const MessageInteractionController = (app: HonoApp) => {
app.get(
'/channels/:channel_id/messages/pins',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type})),
Validator(
'query',
z.object({
limit: z.coerce.number().int().min(1).max(50).optional(),
before: z.coerce.date().optional(),
}),
),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const requestCache = ctx.get('requestCache');
const {limit, before} = ctx.req.valid('query');
return ctx.json(
await ctx
.get('channelService')
.getChannelPins({userId, channelId, requestCache, limit, beforeTimestamp: before}),
);
},
);
app.post(
'/channels/:channel_id/pins/ack',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const {channel} = await ctx.get('channelService').getChannelAuthenticated({userId, channelId});
const timestamp = channel.lastPinTimestamp;
if (timestamp != null) {
await ctx.get('channelService').ackPins({userId, channelId, timestamp});
}
return ctx.body(null, 204);
},
);
app.put(
'/channels/:channel_id/pins/:message_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').pinMessage({
userId,
channelId,
messageId,
requestCache,
});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/pins/:message_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_PINS),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').unpinMessage({
userId,
channelId,
messageId,
requestCache,
});
return ctx.body(null, 204);
},
);
app.get(
'/channels/:channel_id/messages/:message_id/reactions/:emoji',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator(
'param',
z.object({
channel_id: Int64Type,
message_id: Int64Type,
emoji: createStringType(1, 64),
}),
),
Validator(
'query',
z.object({
limit: z.coerce.number().int().min(1).max(100).optional(),
after: Int64Type.optional(),
}),
),
async (ctx) => {
const {channel_id, message_id, emoji} = ctx.req.valid('param');
const {limit, after} = ctx.req.valid('query');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const afterUserId = after ? createUserID(after) : undefined;
return ctx.json(
await ctx
.get('channelService')
.getUsersForReaction({userId, channelId, messageId, emoji, limit, after: afterUserId}),
);
},
);
app.put(
'/channels/:channel_id/messages/:message_id/reactions/:emoji/@me',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator(
'param',
z.object({
channel_id: Int64Type,
message_id: Int64Type,
emoji: createStringType(1, 64),
}),
),
Validator('query', z.object({session_id: z.optional(createStringType(1, 64))})),
async (ctx) => {
const {channel_id, message_id, emoji} = ctx.req.valid('param');
const user = ctx.get('user');
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const sessionId = ctx.req.valid('query').session_id;
const requestCache = ctx.get('requestCache');
if (!user.passwordHash && !isPersonalNotesChannel({userId: user.id, channelId})) {
throw new UnclaimedAccountRestrictedError('add reactions');
}
await ctx.get('channelService').addReaction({
userId: user.id,
sessionId,
channelId,
messageId,
emoji,
requestCache,
});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/messages/:message_id/reactions/:emoji/@me',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator(
'param',
z.object({
channel_id: Int64Type,
message_id: Int64Type,
emoji: createStringType(1, 64),
}),
),
Validator('query', z.object({session_id: z.optional(createStringType(1, 64))})),
async (ctx) => {
const {channel_id, message_id, emoji} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const sessionId = ctx.req.valid('query').session_id;
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').removeOwnReaction({
userId,
sessionId,
channelId,
messageId,
emoji,
requestCache,
});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/messages/:message_id/reactions/:emoji/:target_id',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator(
'param',
z.object({
channel_id: Int64Type,
message_id: Int64Type,
emoji: createStringType(1, 64),
target_id: Int64Type,
}),
),
Validator('query', z.object({session_id: z.optional(createStringType(1, 64))})),
async (ctx) => {
const {channel_id, message_id, emoji, target_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const targetId = createUserID(target_id);
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const sessionId = ctx.req.valid('query').session_id;
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').removeReaction({
userId,
sessionId,
channelId,
messageId,
emoji,
targetId,
requestCache,
});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/messages/:message_id/reactions/:emoji',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator(
'param',
z.object({
channel_id: Int64Type,
message_id: Int64Type,
emoji: createStringType(1, 64),
}),
),
async (ctx) => {
const {channel_id, message_id, emoji} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
await ctx.get('channelService').removeAllReactionsForEmoji({userId, channelId, messageId, emoji});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/messages/:message_id/reactions',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_REACTIONS),
LoginRequired,
Validator('param', z.object({channel_id: Int64Type, message_id: Int64Type})),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
await ctx.get('channelService').removeAllReactions({userId, channelId, messageId});
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,58 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createChannelID} from '~/BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
import {parseScheduledMessageInput} from './ScheduledMessageParsing';
export const ScheduledMessageController = (app: HonoApp) => {
app.post(
'/channels/:channel_id/messages/schedule',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_CREATE),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({channel_id: Int64Type})),
async (ctx) => {
const user = ctx.get('user');
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const scheduledMessageService = ctx.get('scheduledMessageService');
const {message, scheduledLocalAt, timezone} = await parseScheduledMessageInput({
ctx,
userId: user.id,
channelId,
});
const scheduledMessage = await scheduledMessageService.createScheduledMessage({
user,
channelId,
data: message,
scheduledLocalAt,
timezone,
});
return ctx.json(scheduledMessage.toResponse(), 201);
},
);
};

View File

@@ -0,0 +1,93 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Context} from 'hono';
import type {HonoEnv} from '~/App';
import type {ChannelID, UserID} from '~/BrandedTypes';
import type {MessageRequest} from '~/channel/ChannelModel';
import {MessageRequest as MessageRequestSchema} from '~/channel/ChannelModel';
import {InputValidationError} from '~/Errors';
import {createStringType, type z} from '~/Schema';
import {parseMultipartMessageData} from './MessageController';
export const SCHEDULED_ATTACHMENT_TTL_MS = 32 * 24 * 60 * 60 * 1000;
export const ScheduledMessageSchema = MessageRequestSchema.extend({
scheduled_local_at: createStringType(1, 64),
timezone: createStringType(1, 128),
});
export type ScheduledMessageSchemaType = z.infer<typeof ScheduledMessageSchema>;
export function extractScheduleFields(data: ScheduledMessageSchemaType): {
scheduled_local_at: string;
timezone: string;
message: MessageRequest;
} {
const {scheduled_local_at, timezone, ...messageData} = data;
return {
scheduled_local_at,
timezone,
message: messageData as MessageRequest,
};
}
export async function parseScheduledMessageInput({
ctx,
userId,
channelId,
}: {
ctx: Context<HonoEnv>;
userId: UserID;
channelId: ChannelID;
}): Promise<{message: MessageRequest; scheduledLocalAt: string; timezone: string}> {
const contentType = ctx.req.header('content-type') ?? '';
const isMultipart = contentType.includes('multipart/form-data');
if (isMultipart) {
let parsedPayload: unknown = null;
const message = (await parseMultipartMessageData(ctx, userId, channelId, MessageRequestSchema, {
uploadExpiresAt: new Date(Date.now() + SCHEDULED_ATTACHMENT_TTL_MS),
onPayloadParsed(payload) {
parsedPayload = payload;
},
})) as MessageRequest;
if (!parsedPayload) {
throw InputValidationError.create('scheduled_message', 'Failed to parse multipart payload');
}
const validation = ScheduledMessageSchema.safeParse(parsedPayload);
if (!validation.success) {
throw InputValidationError.create('scheduled_message', 'Invalid scheduled message payload');
}
const {scheduled_local_at, timezone} = extractScheduleFields(validation.data);
return {message, scheduledLocalAt: scheduled_local_at, timezone};
}
const body: unknown = await ctx.req.json();
const validation = ScheduledMessageSchema.safeParse(body);
if (!validation.success) {
throw InputValidationError.create('scheduled_message', 'Invalid scheduled message payload');
}
const {scheduled_local_at, timezone, message} = extractScheduleFields(validation.data);
return {message, scheduledLocalAt: scheduled_local_at, timezone};
}

View File

@@ -0,0 +1,162 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createChannelID} from '~/BrandedTypes';
import {APIErrorCodes} from '~/constants/API';
import {Permissions} from '~/constants/Channel';
import {BadRequestError, MissingPermissionsError} from '~/Errors';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {createStringType, Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
const streamKeyParam = z.object({stream_key: createStringType(1, 256)});
const parseStreamKey = (
streamKey: string,
): {scope: 'guild' | 'dm'; guildId?: string; channelId: string; connectionId: string} | null => {
const parts = streamKey.split(':');
if (parts.length !== 3) return null;
const [scopeRaw, channelId, connectionId] = parts;
if (!channelId || !connectionId) return null;
if (scopeRaw === 'dm') {
return {scope: 'dm', channelId, connectionId};
}
if (!/^[0-9]+$/.test(scopeRaw) || !/^[0-9]+$/.test(channelId)) return null;
return {scope: 'guild', guildId: scopeRaw, channelId, connectionId};
};
export const StreamController = (app: HonoApp) => {
app.patch(
'/streams/:stream_key/stream',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_STREAM_UPDATE),
LoginRequired,
DefaultUserOnly,
Validator('json', z.object({region: createStringType(1, 64).optional()})),
Validator('param', streamKeyParam),
async (ctx) => {
const {region} = ctx.req.valid('json');
const streamKey = ctx.req.valid('param').stream_key;
await ctx.get('cacheService').set(`stream_region:${streamKey}`, {region, updatedAt: Date.now()}, 60 * 60 * 24);
return ctx.body(null, 204);
},
);
app.get(
'/streams/:stream_key/preview',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_STREAM_PREVIEW_GET),
LoginRequired,
DefaultUserOnly,
Validator('param', streamKeyParam),
async (ctx) => {
const streamKey = ctx.req.valid('param').stream_key;
if (!parseStreamKey(streamKey)) {
throw new BadRequestError({code: APIErrorCodes.INVALID_REQUEST, message: 'Invalid stream key format'});
}
const preview = await ctx.get('streamPreviewService').getPreview(streamKey);
if (!preview) {
return ctx.body(null, 404);
}
const payload: ArrayBuffer = preview.buffer.slice().buffer;
const headers = {
'Content-Type': preview.contentType || 'image/jpeg',
'Cache-Control': 'no-store, private',
Pragma: 'no-cache',
};
return ctx.newResponse(payload, 200, headers);
},
);
app.post(
'/streams/:stream_key/preview',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_STREAM_PREVIEW_POST),
LoginRequired,
DefaultUserOnly,
Validator(
'json',
z.object({
channel_id: Int64Type,
thumbnail: createStringType(1, 2_000_000),
content_type: createStringType(1, 64).optional(),
}),
),
Validator('param', streamKeyParam),
async (ctx) => {
const user = ctx.get('user');
const {thumbnail, channel_id, content_type} = ctx.req.valid('json');
const streamKey = ctx.req.valid('param').stream_key;
const channelId = createChannelID(channel_id);
const userId = user.id;
const parsedKey = parseStreamKey(streamKey);
if (!parsedKey) {
throw new BadRequestError({code: APIErrorCodes.INVALID_REQUEST, message: 'Invalid stream key format'});
}
const channel = await ctx.get('channelService').getChannel({userId, channelId});
if (channel.guildId) {
const hasConnect = await ctx.get('gatewayService').checkPermission({
guildId: channel.guildId,
channelId,
userId,
permission: Permissions.CONNECT,
});
if (!hasConnect) throw new MissingPermissionsError();
}
if (channel.guildId && parsedKey.scope !== 'guild') {
throw new BadRequestError({code: APIErrorCodes.INVALID_REQUEST, message: 'Stream key scope mismatch'});
}
if (!channel.guildId && parsedKey.scope !== 'dm') {
throw new BadRequestError({code: APIErrorCodes.INVALID_REQUEST, message: 'Stream key scope mismatch'});
}
if (parsedKey.channelId !== channelId.toString()) {
throw new BadRequestError({code: APIErrorCodes.INVALID_REQUEST, message: 'Stream key channel mismatch'});
}
let body: Uint8Array;
try {
body = Uint8Array.from(Buffer.from(thumbnail, 'base64'));
} catch {
throw new BadRequestError({
code: APIErrorCodes.INVALID_REQUEST,
message: 'Invalid thumbnail payload',
});
}
if (body.byteLength === 0) {
throw new BadRequestError({
code: APIErrorCodes.INVALID_REQUEST,
message: 'Empty thumbnail payload',
});
}
await ctx.get('streamPreviewService').uploadPreview({
streamKey,
channelId,
userId,
body,
contentType: content_type,
});
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,35 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {CallController} from './CallController';
import {ChannelController} from './ChannelController';
import {MessageController} from './MessageController';
import {MessageInteractionController} from './MessageInteractionController';
import {ScheduledMessageController} from './ScheduledMessageController';
import {StreamController} from './StreamController';
export const registerChannelControllers = (app: HonoApp) => {
ChannelController(app);
MessageInteractionController(app);
MessageController(app);
ScheduledMessageController(app);
CallController(app);
StreamController(app);
};

View File

@@ -0,0 +1,162 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, MessageID} from '~/BrandedTypes';
import {
BatchBuilder,
buildPatchFromData,
Db,
executeConditional,
executeVersionedUpdate,
fetchMany,
fetchManyInChunks,
fetchOne,
} from '~/database/Cassandra';
import {CHANNEL_COLUMNS, type ChannelRow} from '~/database/CassandraTypes';
import {Logger} from '~/Logger';
import {Channel} from '~/Models';
import {Channels, ChannelsByGuild} from '~/Tables';
import {IChannelDataRepository} from './IChannelDataRepository';
const FETCH_CHANNEL_BY_ID = Channels.select({
where: [Channels.where.eq('channel_id'), Channels.where.eq('soft_deleted')],
limit: 1,
});
const FETCH_CHANNELS_BY_IDS = Channels.select({
where: [Channels.where.in('channel_id', 'channel_ids'), Channels.where.eq('soft_deleted')],
});
const FETCH_GUILD_CHANNELS_BY_GUILD_ID = ChannelsByGuild.select({
where: ChannelsByGuild.where.eq('guild_id'),
});
const DEFAULT_CAS_RETRIES = 8;
export class ChannelDataRepository extends IChannelDataRepository {
async findUnique(channelId: ChannelID): Promise<Channel | null> {
const channel = await fetchOne<ChannelRow>(
FETCH_CHANNEL_BY_ID.bind({
channel_id: channelId,
soft_deleted: false,
}),
);
return channel ? new Channel(channel) : null;
}
async upsert(data: ChannelRow, oldData?: ChannelRow | null): Promise<Channel> {
const channelId = data.channel_id;
const result = await executeVersionedUpdate<ChannelRow, 'channel_id' | 'soft_deleted'>(
async () => {
if (oldData !== undefined) return oldData;
return await fetchOne<ChannelRow>(FETCH_CHANNEL_BY_ID.bind({channel_id: channelId, soft_deleted: false}));
},
(current) => ({
pk: {channel_id: channelId, soft_deleted: false},
patch: buildPatchFromData(data, current, CHANNEL_COLUMNS, ['channel_id', 'soft_deleted']),
}),
Channels,
{onFailure: 'log'},
);
if (data.guild_id) {
await fetchOne(
ChannelsByGuild.upsertAll({
guild_id: data.guild_id,
channel_id: channelId,
}),
);
}
return new Channel({...data, version: result.finalVersion ?? 0});
}
async updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void> {
for (let i = 0; i < DEFAULT_CAS_RETRIES; i++) {
const existing = await fetchOne<ChannelRow>(
FETCH_CHANNEL_BY_ID.bind({
channel_id: channelId,
soft_deleted: false,
}),
);
if (!existing) return;
const prev = existing.last_message_id ?? null;
if (prev !== null && messageId <= prev) return;
const q = Channels.patchByPkIf(
{channel_id: channelId, soft_deleted: false},
{last_message_id: Db.set(messageId)},
{col: 'last_message_id', expectedParam: 'prev_last_message_id', expectedValue: prev},
);
const res = await executeConditional(q);
if (res.applied) return;
}
Logger.warn(
{channelId: channelId.toString(), messageId: messageId.toString()},
'Failed to advance Channels.last_message_id after retries',
);
}
async delete(channelId: ChannelID, guildId?: GuildID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(
Channels.deleteByPk({
channel_id: channelId,
soft_deleted: false,
}),
);
if (guildId) {
batch.addPrepared(
ChannelsByGuild.deleteByPk({
guild_id: guildId,
channel_id: channelId,
}),
);
}
await batch.execute();
}
async listGuildChannels(guildId: GuildID): Promise<Array<Channel>> {
const guildChannels = await fetchMany<{channel_id: bigint}>(
FETCH_GUILD_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId}),
);
if (guildChannels.length === 0) return [];
const channelIds = guildChannels.map((c) => c.channel_id);
const channels = await fetchManyInChunks<ChannelRow>(FETCH_CHANNELS_BY_IDS, channelIds, (chunk) => ({
channel_ids: chunk,
soft_deleted: false,
}));
return channels.map((channel) => new Channel(channel));
}
async countGuildChannels(guildId: GuildID): Promise<number> {
const guildChannels = await fetchMany<{channel_id: bigint}>(
FETCH_GUILD_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId}),
);
return guildChannels.length;
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ChannelDataRepository} from './ChannelDataRepository';
import {IChannelRepositoryAggregate} from './IChannelRepositoryAggregate';
import {MessageInteractionRepository} from './MessageInteractionRepository';
import {MessageRepository} from './MessageRepository';
export class ChannelRepository extends IChannelRepositoryAggregate {
readonly channelData: ChannelDataRepository;
readonly messages: MessageRepository;
readonly messageInteractions: MessageInteractionRepository;
constructor() {
super();
this.channelData = new ChannelDataRepository();
this.messages = new MessageRepository(this.channelData);
this.messageInteractions = new MessageInteractionRepository(this.messages);
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, MessageID} from '~/BrandedTypes';
import type {ChannelRow} from '~/database/CassandraTypes';
import type {Channel} from '~/Models';
export abstract class IChannelDataRepository {
abstract findUnique(channelId: ChannelID): Promise<Channel | null>;
abstract upsert(data: ChannelRow): Promise<Channel>;
abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void>;
abstract delete(channelId: ChannelID, guildId?: GuildID): Promise<void>;
abstract listGuildChannels(guildId: GuildID): Promise<Array<Channel>>;
abstract countGuildChannels(guildId: GuildID): Promise<number>;
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IChannelDataRepository} from './IChannelDataRepository';
import type {IMessageInteractionRepository} from './IMessageInteractionRepository';
import type {IMessageRepository} from './IMessageRepository';
export abstract class IChannelRepositoryAggregate {
abstract readonly channelData: IChannelDataRepository;
abstract readonly messages: IMessageRepository;
abstract readonly messageInteractions: IMessageInteractionRepository;
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, EmojiID, MessageID, UserID} from '~/BrandedTypes';
import type {Message, MessageReaction} from '~/Models';
export abstract class IMessageInteractionRepository {
abstract listChannelPins(channelId: ChannelID, beforePinnedTimestamp: Date, limit?: number): Promise<Array<Message>>;
abstract addChannelPin(channelId: ChannelID, messageId: MessageID, pinnedTimestamp: Date): Promise<void>;
abstract removeChannelPin(channelId: ChannelID, messageId: MessageID): Promise<void>;
abstract listMessageReactions(channelId: ChannelID, messageId: MessageID): Promise<Array<MessageReaction>>;
abstract listReactionUsers(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
limit?: number,
after?: UserID,
emojiId?: EmojiID,
): Promise<Array<MessageReaction>>;
abstract addReaction(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
emojiAnimated?: boolean,
): Promise<MessageReaction>;
abstract removeReaction(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
): Promise<void>;
abstract removeAllReactions(channelId: ChannelID, messageId: MessageID): Promise<void>;
abstract removeAllReactionsForEmoji(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
emojiId?: EmojiID,
): Promise<void>;
abstract countReactionUsers(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
emojiId?: EmojiID,
): Promise<number>;
abstract countUniqueReactions(channelId: ChannelID, messageId: MessageID): Promise<number>;
abstract checkUserReactionExists(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
): Promise<boolean>;
abstract setHasReaction(channelId: ChannelID, messageId: MessageID, hasReaction: boolean): Promise<void>;
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AttachmentID, ChannelID, MessageID, UserID} from '~/BrandedTypes';
import type {AttachmentLookupRow, MessageRow} from '~/database/CassandraTypes';
import type {Message} from '~/Models';
export interface ListMessagesOptions {
restrictToBeforeBucket?: boolean;
immediateAfter?: boolean;
}
export abstract class IMessageRepository {
abstract listMessages(
channelId: ChannelID,
beforeMessageId?: MessageID,
limit?: number,
afterMessageId?: MessageID,
options?: ListMessagesOptions,
): Promise<Array<Message>>;
abstract getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null>;
abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message>;
abstract deleteMessage(
channelId: ChannelID,
messageId: MessageID,
authorId: UserID,
pinnedTimestamp?: Date,
): Promise<void>;
abstract bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void>;
abstract deleteAllChannelMessages(channelId: ChannelID): Promise<void>;
abstract listMessagesByAuthor(
authorId: UserID,
limit?: number,
lastChannelId?: ChannelID,
lastMessageId?: MessageID,
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>>;
abstract deleteMessagesByAuthor(
authorId: UserID,
channelIds?: Array<ChannelID>,
messageIds?: Array<MessageID>,
): Promise<void>;
abstract anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void>;
abstract authorHasMessage(authorId: UserID, channelId: ChannelID, messageId: MessageID): Promise<boolean>;
abstract lookupAttachmentByChannelAndFilename(
channelId: ChannelID,
attachmentId: AttachmentID,
filename: string,
): Promise<MessageID | null>;
abstract listChannelAttachments(channelId: ChannelID): Promise<Array<AttachmentLookupRow>>;
}

View File

@@ -0,0 +1,362 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, EmojiID, MessageID, UserID} from '~/BrandedTypes';
import {createEmojiID} from '~/BrandedTypes';
import {MAX_USERS_PER_MESSAGE_REACTION} from '~/Constants';
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {ChannelPinRow, MessageReactionRow} from '~/database/CassandraTypes';
import {type Message, MessageReaction} from '~/Models';
import {ChannelPins, MessageReactions, Messages} from '~/Tables';
import * as BucketUtils from '~/utils/BucketUtils';
import {IMessageInteractionRepository} from './IMessageInteractionRepository';
import type {MessageRepository} from './MessageRepository';
const createFetchChannelPinsQuery = (limit: number) =>
ChannelPins.selectCql({
where: [ChannelPins.where.eq('channel_id'), ChannelPins.where.lt('pinned_timestamp', 'before_pinned_timestamp')],
limit,
});
const FETCH_MESSAGE_REACTIONS_BY_CHANNEL_AND_MESSAGE_QUERY = MessageReactions.selectCql({
where: [
MessageReactions.where.eq('channel_id'),
MessageReactions.where.eq('bucket'),
MessageReactions.where.eq('message_id'),
],
});
const CHECK_MESSAGE_HAS_REACTIONS_QUERY = MessageReactions.selectCql({
columns: ['channel_id'],
where: [
MessageReactions.where.eq('channel_id'),
MessageReactions.where.eq('bucket'),
MessageReactions.where.eq('message_id'),
],
limit: 1,
});
const createFetchReactionUsersByEmojiQuery = (limit: number, hasAfter: boolean = false) =>
MessageReactions.selectCql({
where: hasAfter
? [
MessageReactions.where.eq('channel_id'),
MessageReactions.where.eq('bucket'),
MessageReactions.where.eq('message_id'),
MessageReactions.where.eq('emoji_id'),
MessageReactions.where.eq('emoji_name'),
MessageReactions.where.gt('user_id', 'after_user_id'),
]
: [
MessageReactions.where.eq('channel_id'),
MessageReactions.where.eq('bucket'),
MessageReactions.where.eq('message_id'),
MessageReactions.where.eq('emoji_id'),
MessageReactions.where.eq('emoji_name'),
],
limit,
});
const CHECK_USER_REACTION_EXISTS_QUERY = MessageReactions.selectCql({
columns: ['channel_id', 'bucket', 'message_id', 'user_id', 'emoji_id', 'emoji_name'],
where: [
MessageReactions.where.eq('channel_id'),
MessageReactions.where.eq('bucket'),
MessageReactions.where.eq('message_id'),
MessageReactions.where.eq('user_id'),
MessageReactions.where.eq('emoji_id'),
MessageReactions.where.eq('emoji_name'),
],
limit: 1,
});
export class MessageInteractionRepository extends IMessageInteractionRepository {
private messageRepository: MessageRepository;
constructor(messageRepository: MessageRepository) {
super();
this.messageRepository = messageRepository;
}
async listChannelPins(
channelId: ChannelID,
beforePinnedTimestamp: Date,
limit: number = 50,
): Promise<Array<Message>> {
const pins = await fetchMany<ChannelPinRow>(createFetchChannelPinsQuery(limit), {
channel_id: channelId,
before_pinned_timestamp: beforePinnedTimestamp,
});
const messages: Array<Message> = [];
for (const pin of pins) {
const message = await this.messageRepository.getMessage(channelId, pin.message_id);
if (message) {
messages.push(message);
}
}
return messages;
}
async addChannelPin(channelId: ChannelID, messageId: MessageID, pinnedTimestamp: Date): Promise<void> {
await upsertOne(
ChannelPins.upsertAll({
channel_id: channelId,
message_id: messageId,
pinned_timestamp: pinnedTimestamp,
}),
);
}
async removeChannelPin(channelId: ChannelID, messageId: MessageID): Promise<void> {
const message = await this.messageRepository.getMessage(channelId, messageId);
if (!message || !message.pinnedTimestamp) {
return;
}
await deleteOneOrMany(
ChannelPins.deleteByPk({
channel_id: channelId,
pinned_timestamp: message.pinnedTimestamp,
message_id: messageId,
}),
);
}
async listMessageReactions(channelId: ChannelID, messageId: MessageID): Promise<Array<MessageReaction>> {
const bucket = BucketUtils.makeBucket(messageId);
const reactions = await fetchMany<MessageReactionRow>(FETCH_MESSAGE_REACTIONS_BY_CHANNEL_AND_MESSAGE_QUERY, {
channel_id: channelId,
bucket,
message_id: messageId,
});
return reactions.map((reaction) => new MessageReaction(reaction));
}
async listReactionUsers(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
limit: number = 25,
after?: UserID,
emojiId?: EmojiID,
): Promise<Array<MessageReaction>> {
const bucket = BucketUtils.makeBucket(messageId);
const normalizedEmojiId = emojiId ?? createEmojiID(0n);
const hasAfter = !!after;
const reactions = hasAfter
? await fetchMany<MessageReactionRow>(
createFetchReactionUsersByEmojiQuery(Math.min(limit, MAX_USERS_PER_MESSAGE_REACTION), true),
{
channel_id: channelId,
bucket,
message_id: messageId,
emoji_id: normalizedEmojiId,
emoji_name: emojiName,
after_user_id: after!,
},
)
: await fetchMany<MessageReactionRow>(
createFetchReactionUsersByEmojiQuery(Math.min(limit, MAX_USERS_PER_MESSAGE_REACTION), false),
{
channel_id: channelId,
bucket,
message_id: messageId,
emoji_id: normalizedEmojiId,
emoji_name: emojiName,
},
);
return reactions.map((reaction) => new MessageReaction(reaction));
}
async addReaction(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
emojiAnimated: boolean = false,
): Promise<MessageReaction> {
const bucket = BucketUtils.makeBucket(messageId);
const normalizedEmojiId = emojiId ? emojiId : createEmojiID(0n);
const reactionData: MessageReactionRow = {
channel_id: channelId,
bucket,
message_id: messageId,
user_id: userId,
emoji_id: normalizedEmojiId,
emoji_name: emojiName,
emoji_animated: emojiAnimated,
};
await upsertOne(MessageReactions.upsertAll(reactionData));
await this.setHasReaction(channelId, messageId, true);
return new MessageReaction(reactionData);
}
async removeReaction(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
): Promise<void> {
const bucket = BucketUtils.makeBucket(messageId);
const normalizedEmojiId = emojiId ?? createEmojiID(0n);
await deleteOneOrMany(
MessageReactions.deleteByPk({
channel_id: channelId,
bucket,
message_id: messageId,
user_id: userId,
emoji_id: normalizedEmojiId,
emoji_name: emojiName,
}),
);
const hasReactions = await this.messageHasAnyReactions(channelId, messageId);
await this.setHasReaction(channelId, messageId, hasReactions);
}
async removeAllReactions(channelId: ChannelID, messageId: MessageID): Promise<void> {
const bucket = BucketUtils.makeBucket(messageId);
const deleteQuery = MessageReactions.deleteCql({
where: [
MessageReactions.where.eq('channel_id'),
MessageReactions.where.eq('bucket'),
MessageReactions.where.eq('message_id'),
],
});
await deleteOneOrMany(deleteQuery, {
channel_id: channelId,
bucket,
message_id: messageId,
});
const hasReactions = await this.messageHasAnyReactions(channelId, messageId);
await this.setHasReaction(channelId, messageId, hasReactions);
}
async removeAllReactionsForEmoji(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
emojiId?: EmojiID,
): Promise<void> {
const bucket = BucketUtils.makeBucket(messageId);
const normalizedEmojiId = emojiId ?? createEmojiID(0n);
const deleteQuery = MessageReactions.deleteCql({
where: [
MessageReactions.where.eq('channel_id'),
MessageReactions.where.eq('bucket'),
MessageReactions.where.eq('message_id'),
MessageReactions.where.eq('emoji_id'),
MessageReactions.where.eq('emoji_name'),
],
});
await deleteOneOrMany(deleteQuery, {
channel_id: channelId,
bucket,
message_id: messageId,
emoji_id: normalizedEmojiId,
emoji_name: emojiName,
});
const hasReactions = await this.messageHasAnyReactions(channelId, messageId);
await this.setHasReaction(channelId, messageId, hasReactions);
}
async countReactionUsers(
channelId: ChannelID,
messageId: MessageID,
emojiName: string,
emojiId?: EmojiID,
): Promise<number> {
const reactions = await this.listReactionUsers(channelId, messageId, emojiName, undefined, undefined, emojiId);
return reactions.length;
}
async countUniqueReactions(channelId: ChannelID, messageId: MessageID): Promise<number> {
const reactions = await this.listMessageReactions(channelId, messageId);
const uniqueEmojis = new Set<string>();
for (const reaction of reactions) {
const emojiKey = `${reaction.emojiId}:${reaction.emojiName}`;
uniqueEmojis.add(emojiKey);
}
return uniqueEmojis.size;
}
async checkUserReactionExists(
channelId: ChannelID,
messageId: MessageID,
userId: UserID,
emojiName: string,
emojiId?: EmojiID,
): Promise<boolean> {
const bucket = BucketUtils.makeBucket(messageId);
const normalizedEmojiId = emojiId ?? createEmojiID(0n);
const reaction = await fetchOne<MessageReactionRow>(CHECK_USER_REACTION_EXISTS_QUERY, {
channel_id: channelId,
bucket,
message_id: messageId,
user_id: userId,
emoji_id: normalizedEmojiId,
emoji_name: emojiName,
});
return !!reaction;
}
async setHasReaction(channelId: ChannelID, messageId: MessageID, hasReaction: boolean): Promise<void> {
const bucket = BucketUtils.makeBucket(messageId);
await upsertOne(
Messages.patchByPk(
{
channel_id: channelId,
bucket,
message_id: messageId,
},
{
has_reaction: Db.set(hasReaction),
},
),
);
}
private async messageHasAnyReactions(channelId: ChannelID, messageId: MessageID): Promise<boolean> {
const bucket = BucketUtils.makeBucket(messageId);
const row = await fetchOne<Pick<MessageReactionRow, 'channel_id'>>(CHECK_MESSAGE_HAS_REACTIONS_QUERY, {
channel_id: channelId,
bucket,
message_id: messageId,
});
return Boolean(row);
}
}

View File

@@ -0,0 +1,121 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AttachmentID, ChannelID, MessageID, UserID} from '~/BrandedTypes';
import type {AttachmentLookupRow, MessageRow} from '~/database/CassandraTypes';
import type {Message} from '~/Models';
import type {ChannelDataRepository} from './ChannelDataRepository';
import {IMessageRepository, type ListMessagesOptions} from './IMessageRepository';
import {MessageAttachmentRepository} from './message/MessageAttachmentRepository';
import {MessageAuthorRepository} from './message/MessageAuthorRepository';
import {MessageDataRepository} from './message/MessageDataRepository';
import {MessageDeletionRepository} from './message/MessageDeletionRepository';
export class MessageRepository extends IMessageRepository {
private dataRepo: MessageDataRepository;
private deletionRepo: MessageDeletionRepository;
private attachmentRepo: MessageAttachmentRepository;
private authorRepo: MessageAuthorRepository;
private channelDataRepo: ChannelDataRepository;
constructor(channelDataRepo: ChannelDataRepository) {
super();
this.dataRepo = new MessageDataRepository();
this.deletionRepo = new MessageDeletionRepository(this.dataRepo);
this.attachmentRepo = new MessageAttachmentRepository();
this.authorRepo = new MessageAuthorRepository(this.dataRepo, this.deletionRepo);
this.channelDataRepo = channelDataRepo;
}
async listMessages(
channelId: ChannelID,
beforeMessageId?: MessageID,
limit?: number,
afterMessageId?: MessageID,
options?: ListMessagesOptions,
): Promise<Array<Message>> {
return this.dataRepo.listMessages(channelId, beforeMessageId, limit, afterMessageId, options);
}
async getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null> {
return this.dataRepo.getMessage(channelId, messageId);
}
async upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message> {
const message = await this.dataRepo.upsertMessage(data, oldData);
if (!oldData) {
void this.channelDataRepo.updateLastMessageId(data.channel_id, data.message_id);
}
return message;
}
async deleteMessage(
channelId: ChannelID,
messageId: MessageID,
authorId: UserID,
pinnedTimestamp?: Date,
): Promise<void> {
return this.deletionRepo.deleteMessage(channelId, messageId, authorId, pinnedTimestamp);
}
async bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void> {
return this.deletionRepo.bulkDeleteMessages(channelId, messageIds);
}
async deleteAllChannelMessages(channelId: ChannelID): Promise<void> {
return this.deletionRepo.deleteAllChannelMessages(channelId);
}
async listMessagesByAuthor(
authorId: UserID,
limit?: number,
lastChannelId?: ChannelID,
lastMessageId?: MessageID,
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>> {
return this.authorRepo.listMessagesByAuthor(authorId, limit, lastChannelId, lastMessageId);
}
async deleteMessagesByAuthor(
authorId: UserID,
channelIds?: Array<ChannelID>,
messageIds?: Array<MessageID>,
): Promise<void> {
return this.authorRepo.deleteMessagesByAuthor(authorId, channelIds, messageIds);
}
async anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void> {
return this.authorRepo.anonymizeMessage(channelId, messageId, newAuthorId);
}
async authorHasMessage(authorId: UserID, channelId: ChannelID, messageId: MessageID): Promise<boolean> {
return this.authorRepo.hasMessageByAuthor(authorId, channelId, messageId);
}
async lookupAttachmentByChannelAndFilename(
channelId: ChannelID,
attachmentId: AttachmentID,
filename: string,
): Promise<MessageID | null> {
return this.attachmentRepo.lookupAttachmentByChannelAndFilename(channelId, attachmentId, filename);
}
async listChannelAttachments(channelId: ChannelID): Promise<Array<AttachmentLookupRow>> {
return this.attachmentRepo.listChannelAttachments(channelId);
}
}

View File

@@ -0,0 +1,215 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {describe, expect, it, vi} from 'vitest';
import {
BucketScanDirection,
type BucketScanTraceEvent,
BucketScanTraceKind,
scanBucketsWithIndex,
} from './BucketScanEngine';
interface FakeRow {
id: bigint;
}
function makeIndexBuckets(
allBuckets: Array<number>,
direction: BucketScanDirection,
): (query: {minBucket: number; maxBucket: number; limit: number}) => Promise<Array<number>> {
return async (query) => {
const filtered = allBuckets.filter((b) => b >= query.minBucket && b <= query.maxBucket);
filtered.sort((a, b) => (direction === BucketScanDirection.Desc ? b - a : a - b));
return filtered.slice(0, query.limit);
};
}
describe('scanBucketsWithIndex', () => {
it('scans buckets in DESC order and stops when limit is satisfied', async () => {
const listBucketsFromIndex = vi.fn(makeIndexBuckets([5, 4, 3, 2, 1], BucketScanDirection.Desc));
const fetchRowsForBucket = vi.fn(async (bucket: number) => ({rows: [{id: BigInt(bucket)}], unbounded: true}));
const trace: Array<BucketScanTraceEvent> = [];
const result = await scanBucketsWithIndex<FakeRow>(
{
listBucketsFromIndex,
fetchRowsForBucket,
getRowId: (row) => row.id,
trace: (event) => trace.push(event),
},
{
minBucket: 1,
maxBucket: 5,
limit: 3,
direction: BucketScanDirection.Desc,
indexPageSize: 200,
},
);
expect(listBucketsFromIndex).toHaveBeenCalledTimes(1);
expect(fetchRowsForBucket.mock.calls.map((c) => c[0])).toEqual([5, 4, 3]);
expect(result.rows.map((r) => r.id)).toEqual([5n, 4n, 3n]);
const processed = trace.filter((e) => e.kind === BucketScanTraceKind.ProcessBucket).map((e) => e.bucket);
expect(processed).toEqual([5, 4, 3]);
});
it('falls back to the numeric scan when the index is missing buckets', async () => {
const listBucketsFromIndex = vi.fn(makeIndexBuckets([5, 3, 1], BucketScanDirection.Desc));
const fetchRowsForBucket = vi.fn(async (bucket: number) => ({rows: [{id: BigInt(bucket)}], unbounded: true}));
const trace: Array<BucketScanTraceEvent> = [];
const result = await scanBucketsWithIndex<FakeRow>(
{
listBucketsFromIndex,
fetchRowsForBucket,
getRowId: (row) => row.id,
trace: (event) => trace.push(event),
},
{
minBucket: 1,
maxBucket: 5,
limit: 5,
direction: BucketScanDirection.Desc,
indexPageSize: 200,
},
);
expect(result.rows.map((r) => r.id)).toEqual([5n, 3n, 1n, 4n, 2n]);
const processed = trace.filter((e) => e.kind === BucketScanTraceKind.ProcessBucket).map((e) => e.bucket);
expect(processed).toEqual([5, 3, 1, 4, 2]);
});
it('honors stopAfterBucket in DESC scans', async () => {
const listBucketsFromIndex = vi.fn(makeIndexBuckets([5, 4, 3, 2, 1], BucketScanDirection.Desc));
const fetchRowsForBucket = vi.fn(async (bucket: number) => ({rows: [{id: BigInt(bucket)}], unbounded: true}));
const trace: Array<BucketScanTraceEvent> = [];
const result = await scanBucketsWithIndex<FakeRow>(
{
listBucketsFromIndex,
fetchRowsForBucket,
getRowId: (row) => row.id,
trace: (event) => trace.push(event),
},
{
minBucket: 1,
maxBucket: 5,
limit: 100,
direction: BucketScanDirection.Desc,
indexPageSize: 200,
stopAfterBucket: 4,
},
);
expect(result.rows.map((r) => r.id)).toEqual([5n, 4n]);
const processed = trace.filter((e) => e.kind === BucketScanTraceKind.ProcessBucket).map((e) => e.bucket);
expect(processed).toEqual([5, 4]);
});
it('scans buckets in ASC order and returns closest rows first', async () => {
const listBucketsFromIndex = vi.fn(makeIndexBuckets([400, 401], BucketScanDirection.Asc));
const fetchRowsForBucket = vi.fn(async (bucket: number, limit: number) => {
const rowsByBucket = new Map<number, Array<FakeRow>>([
[400, [{id: 51n}, {id: 52n}, {id: 53n}]],
[401, [{id: 100n}, {id: 101n}]],
]);
return {rows: (rowsByBucket.get(bucket) ?? []).slice(0, limit), unbounded: true};
});
const result = await scanBucketsWithIndex<FakeRow>(
{
listBucketsFromIndex,
fetchRowsForBucket,
getRowId: (row) => row.id,
},
{
minBucket: 400,
maxBucket: 401,
limit: 4,
direction: BucketScanDirection.Asc,
indexPageSize: 200,
},
);
expect(result.rows.map((r) => r.id)).toEqual([51n, 52n, 53n, 100n]);
expect(fetchRowsForBucket.mock.calls.map((c) => c[0])).toEqual([400, 401]);
});
it('deduplicates rows using getRowId', async () => {
const listBucketsFromIndex = vi.fn(makeIndexBuckets([5, 4], BucketScanDirection.Desc));
const fetchRowsForBucket = vi.fn(async (bucket: number) => {
const rowsByBucket = new Map<number, Array<FakeRow>>([
[5, [{id: 1n}, {id: 2n}]],
[4, [{id: 2n}, {id: 3n}]],
]);
return {rows: rowsByBucket.get(bucket) ?? [], unbounded: true};
});
const result = await scanBucketsWithIndex<FakeRow>(
{
listBucketsFromIndex,
fetchRowsForBucket,
getRowId: (row) => row.id,
},
{
minBucket: 4,
maxBucket: 5,
limit: 10,
direction: BucketScanDirection.Desc,
indexPageSize: 200,
},
);
expect(result.rows.map((r) => r.id)).toEqual([1n, 2n, 3n]);
});
it('marks empty buckets only when the query was unbounded', async () => {
const listBucketsFromIndex = vi.fn(makeIndexBuckets([3, 2, 1], BucketScanDirection.Desc));
const emptied: Array<number> = [];
const fetchRowsForBucket = vi.fn(async (bucket: number) => {
if (bucket === 3) return {rows: [], unbounded: false};
if (bucket === 2) return {rows: [], unbounded: true};
return {rows: [{id: 1n}], unbounded: true};
});
await scanBucketsWithIndex<FakeRow>(
{
listBucketsFromIndex,
fetchRowsForBucket,
getRowId: (row) => row.id,
onEmptyUnboundedBucket: async (bucket) => {
emptied.push(bucket);
},
},
{
minBucket: 1,
maxBucket: 3,
limit: 1,
direction: BucketScanDirection.Desc,
indexPageSize: 200,
},
);
expect(emptied).toEqual([2]);
});
});

View File

@@ -0,0 +1,358 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export enum BucketScanDirection {
Asc = 'asc',
Desc = 'desc',
}
export enum BucketScanTraceKind {
Start = 'start',
ListBucketsFromIndex = 'listBucketsFromIndex',
ProcessBucket = 'processBucket',
FetchBucket = 'fetchBucket',
MarkBucketEmpty = 'markBucketEmpty',
TouchBucket = 'touchBucket',
StopAfterBucketReached = 'stopAfterBucketReached',
Complete = 'complete',
}
export interface BucketScanTraceEvent {
kind: BucketScanTraceKind;
minBucket: number;
maxBucket: number;
limit: number;
direction: BucketScanDirection;
bucket?: number;
remaining?: number;
indexQuery?: {minBucket: number; maxBucket: number; limit: number};
indexResult?: Array<number>;
fetchResult?: {rowCount: number; unbounded: boolean};
}
export interface BucketScanIndexQuery {
minBucket: number;
maxBucket: number;
limit: number;
}
export interface BucketScanBucketFetchResult<Row> {
rows: Array<Row>;
unbounded: boolean;
}
export interface BucketScanDeps<Row> {
listBucketsFromIndex: (query: BucketScanIndexQuery) => Promise<Array<number>>;
fetchRowsForBucket: (bucket: number, limit: number) => Promise<BucketScanBucketFetchResult<Row>>;
getRowId: (row: Row) => bigint;
onEmptyUnboundedBucket?: (bucket: number) => Promise<void>;
onBucketHasRows?: (bucket: number) => Promise<void>;
trace?: (event: BucketScanTraceEvent) => void;
}
export interface BucketScanOptions {
minBucket: number;
maxBucket: number;
limit: number;
direction: BucketScanDirection;
indexPageSize: number;
stopAfterBucket?: number;
}
export interface BucketScanResult<Row> {
rows: Array<Row>;
}
export async function scanBucketsWithIndex<Row>(
deps: BucketScanDeps<Row>,
opts: BucketScanOptions,
): Promise<BucketScanResult<Row>> {
const trace = deps.trace;
trace?.({
kind: BucketScanTraceKind.Start,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
});
if (opts.limit <= 0) {
trace?.({
kind: BucketScanTraceKind.Complete,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
});
return {rows: []};
}
let remaining = opts.limit;
const out: Array<Row> = [];
const seenRowIds = new Set<bigint>();
const processedBuckets = new Set<number>();
const processBucket = async (bucket: number) => {
trace?.({
kind: BucketScanTraceKind.ProcessBucket,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
bucket,
remaining,
});
trace?.({
kind: BucketScanTraceKind.FetchBucket,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
bucket,
remaining,
});
const {rows, unbounded} = await deps.fetchRowsForBucket(bucket, remaining);
trace?.({
kind: BucketScanTraceKind.FetchBucket,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
bucket,
remaining,
fetchResult: {rowCount: rows.length, unbounded},
});
if (rows.length === 0) {
if (unbounded && deps.onEmptyUnboundedBucket) {
trace?.({
kind: BucketScanTraceKind.MarkBucketEmpty,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
bucket,
remaining,
});
await deps.onEmptyUnboundedBucket(bucket);
}
return;
}
if (deps.onBucketHasRows) {
trace?.({
kind: BucketScanTraceKind.TouchBucket,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
bucket,
remaining,
});
await deps.onBucketHasRows(bucket);
}
for (const row of rows) {
if (remaining <= 0) break;
const rowId = deps.getRowId(row);
if (seenRowIds.has(rowId)) continue;
seenRowIds.add(rowId);
out.push(row);
remaining--;
}
};
const stopAfterBucket = typeof opts.stopAfterBucket === 'number' ? opts.stopAfterBucket : null;
const shouldStopAfterBucket = (bucket: number) => stopAfterBucket !== null && bucket === stopAfterBucket;
if (opts.direction === BucketScanDirection.Desc) {
let cursorMax: number | null = opts.maxBucket;
while (remaining > 0 && cursorMax !== null && cursorMax >= opts.minBucket) {
const query: BucketScanIndexQuery = {
minBucket: opts.minBucket,
maxBucket: cursorMax,
limit: opts.indexPageSize,
};
trace?.({
kind: BucketScanTraceKind.ListBucketsFromIndex,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
indexQuery: query,
});
const buckets = await deps.listBucketsFromIndex(query);
trace?.({
kind: BucketScanTraceKind.ListBucketsFromIndex,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
indexQuery: query,
indexResult: buckets,
});
if (buckets.length === 0) break;
for (const bucket of buckets) {
if (remaining <= 0) break;
if (bucket < opts.minBucket || bucket > opts.maxBucket) continue;
if (processedBuckets.has(bucket)) continue;
processedBuckets.add(bucket);
await processBucket(bucket);
if (shouldStopAfterBucket(bucket)) {
trace?.({
kind: BucketScanTraceKind.StopAfterBucketReached,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
bucket,
remaining,
});
return {rows: out};
}
}
const last = buckets[buckets.length - 1];
const nextCursor = last - 1;
cursorMax = nextCursor >= opts.minBucket ? nextCursor : null;
}
if (remaining > 0) {
for (let bucket = opts.maxBucket; remaining > 0 && bucket >= opts.minBucket; bucket--) {
if (processedBuckets.has(bucket)) continue;
processedBuckets.add(bucket);
await processBucket(bucket);
if (shouldStopAfterBucket(bucket)) {
trace?.({
kind: BucketScanTraceKind.StopAfterBucketReached,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
bucket,
remaining,
});
return {rows: out};
}
}
}
} else {
let cursorMin: number | null = opts.minBucket;
while (remaining > 0 && cursorMin !== null && cursorMin <= opts.maxBucket) {
const query: BucketScanIndexQuery = {
minBucket: cursorMin,
maxBucket: opts.maxBucket,
limit: opts.indexPageSize,
};
trace?.({
kind: BucketScanTraceKind.ListBucketsFromIndex,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
indexQuery: query,
});
const buckets = await deps.listBucketsFromIndex(query);
trace?.({
kind: BucketScanTraceKind.ListBucketsFromIndex,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
indexQuery: query,
indexResult: buckets,
});
if (buckets.length === 0) break;
for (const bucket of buckets) {
if (remaining <= 0) break;
if (bucket < opts.minBucket || bucket > opts.maxBucket) continue;
if (processedBuckets.has(bucket)) continue;
processedBuckets.add(bucket);
await processBucket(bucket);
if (shouldStopAfterBucket(bucket)) {
trace?.({
kind: BucketScanTraceKind.StopAfterBucketReached,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
bucket,
remaining,
});
return {rows: out};
}
}
const last = buckets[buckets.length - 1];
const nextCursor = last + 1;
cursorMin = nextCursor <= opts.maxBucket ? nextCursor : null;
}
if (remaining > 0) {
for (let bucket = opts.minBucket; remaining > 0 && bucket <= opts.maxBucket; bucket++) {
if (processedBuckets.has(bucket)) continue;
processedBuckets.add(bucket);
await processBucket(bucket);
if (shouldStopAfterBucket(bucket)) {
trace?.({
kind: BucketScanTraceKind.StopAfterBucketReached,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
bucket,
remaining,
});
return {rows: out};
}
}
}
}
trace?.({
kind: BucketScanTraceKind.Complete,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
limit: opts.limit,
direction: opts.direction,
});
return {rows: out};
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AttachmentID, ChannelID, MessageID} from '~/BrandedTypes';
import {fetchMany, fetchOne} from '~/database/Cassandra';
import type {AttachmentLookupRow} from '~/database/CassandraTypes';
import {AttachmentLookup} from '~/Tables';
const LOOKUP_ATTACHMENT_BY_CHANNEL_AND_FILENAME_QUERY = AttachmentLookup.selectCql({
where: [
AttachmentLookup.where.eq('channel_id'),
AttachmentLookup.where.eq('attachment_id'),
AttachmentLookup.where.eq('filename'),
],
limit: 1,
});
const LIST_CHANNEL_ATTACHMENTS_QUERY = AttachmentLookup.selectCql({
where: AttachmentLookup.where.eq('channel_id'),
});
export class MessageAttachmentRepository {
async lookupAttachmentByChannelAndFilename(
channelId: ChannelID,
attachmentId: AttachmentID,
filename: string,
): Promise<MessageID | null> {
const result = await fetchOne<AttachmentLookupRow>(LOOKUP_ATTACHMENT_BY_CHANNEL_AND_FILENAME_QUERY, {
channel_id: channelId,
attachment_id: attachmentId,
filename,
});
return result ? result.message_id : null;
}
async listChannelAttachments(channelId: ChannelID): Promise<Array<AttachmentLookupRow>> {
const results = await fetchMany<AttachmentLookupRow>(LIST_CHANNEL_ATTACHMENTS_QUERY, {
channel_id: channelId,
});
return results;
}
}

View 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 type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
import {createChannelID, createMessageID} from '~/BrandedTypes';
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import {Messages, MessagesByAuthor} from '~/Tables';
import * as BucketUtils from '~/utils/BucketUtils';
import type {MessageDataRepository} from './MessageDataRepository';
import type {MessageDeletionRepository} from './MessageDeletionRepository';
const SELECT_MESSAGE_BY_AUTHOR = MessagesByAuthor.select({
where: [
MessagesByAuthor.where.eq('author_id'),
MessagesByAuthor.where.eq('channel_id'),
MessagesByAuthor.where.eq('message_id'),
],
limit: 1,
});
function listMessagesByAuthorQuery(limit: number, usePagination: boolean) {
return MessagesByAuthor.select({
columns: ['channel_id', 'message_id'],
where: usePagination
? [
MessagesByAuthor.where.eq('author_id'),
MessagesByAuthor.where.tupleGt(['channel_id', 'message_id'], ['last_channel_id', 'last_message_id']),
]
: [MessagesByAuthor.where.eq('author_id')],
limit,
});
}
export class MessageAuthorRepository {
constructor(
private messageDataRepo: MessageDataRepository,
private messageDeletionRepo: MessageDeletionRepository,
) {}
async listMessagesByAuthor(
authorId: UserID,
limit: number = 1000,
lastChannelId?: ChannelID,
lastMessageId?: MessageID,
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>> {
const usePagination = Boolean(lastChannelId && lastMessageId);
const q = listMessagesByAuthorQuery(limit, usePagination);
const results = await fetchMany<{channel_id: bigint; message_id: bigint}>(
usePagination
? q.bind({
author_id: authorId,
last_channel_id: lastChannelId!,
last_message_id: lastMessageId!,
})
: q.bind({
author_id: authorId,
}),
);
let filteredResults = results;
if (lastChannelId && lastMessageId) {
filteredResults = results.filter((r) => {
const channelId = createChannelID(r.channel_id);
const messageId = createMessageID(r.message_id);
return channelId > lastChannelId || (channelId === lastChannelId && messageId > lastMessageId);
});
}
return filteredResults.map((r) => ({
channelId: createChannelID(r.channel_id),
messageId: createMessageID(r.message_id),
}));
}
async deleteMessagesByAuthor(
authorId: UserID,
channelIds?: Array<ChannelID>,
messageIds?: Array<MessageID>,
): Promise<void> {
const messagesToDelete = await this.listMessagesByAuthor(authorId);
for (const {channelId, messageId} of messagesToDelete) {
if (channelIds && !channelIds.includes(channelId)) continue;
if (messageIds && !messageIds.includes(messageId)) continue;
const message = await this.messageDataRepo.getMessage(channelId, messageId);
if (message && message.authorId === authorId) {
await this.messageDeletionRepo.deleteMessage(
channelId,
messageId,
authorId,
message.pinnedTimestamp || undefined,
);
}
}
}
async hasMessageByAuthor(authorId: UserID, channelId: ChannelID, messageId: MessageID): Promise<boolean> {
const result = await fetchOne<{channel_id: bigint; message_id: bigint}>(
SELECT_MESSAGE_BY_AUTHOR.bind({
author_id: authorId,
channel_id: channelId,
message_id: messageId,
}),
);
return result !== null;
}
async anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void> {
const bucket = BucketUtils.makeBucket(messageId);
const message = await this.messageDataRepo.getMessage(channelId, messageId);
if (!message) return;
if (message.authorId) {
await deleteOneOrMany(
MessagesByAuthor.deleteByPk({
author_id: message.authorId,
channel_id: channelId,
message_id: messageId,
}),
);
}
await upsertOne(
MessagesByAuthor.upsertAll({
author_id: newAuthorId,
channel_id: channelId,
message_id: messageId,
}),
);
await upsertOne(
Messages.patchByPk(
{
channel_id: channelId,
bucket,
message_id: messageId,
},
{
author_id: Db.set(newAuthorId),
},
),
);
}
}

View File

@@ -0,0 +1,917 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MessageID} from '~/BrandedTypes';
import {
BatchBuilder,
buildPatchFromData,
Db,
deleteOneOrMany,
executeConditional,
executeVersionedUpdate,
fetchMany,
fetchOne,
upsertOne,
} from '~/database/Cassandra';
import type {ChannelMessageBucketRow, ChannelStateRow, MessageRow} from '~/database/CassandraTypes';
import {MESSAGE_COLUMNS} from '~/database/CassandraTypes';
import {Logger} from '~/Logger';
import {Message} from '~/Models';
import {
AttachmentLookup,
ChannelEmptyBuckets,
ChannelMessageBuckets,
ChannelPins,
ChannelState,
Messages,
MessagesByAuthor,
} from '~/Tables';
import * as BucketUtils from '~/utils/BucketUtils';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
import type {ListMessagesOptions} from '../IMessageRepository';
import {BucketScanDirection, scanBucketsWithIndex} from './BucketScanEngine';
const logger = Logger.child({module: 'MessageDataRepository'});
const DEFAULT_MESSAGE_LIMIT = 50;
const DEFAULT_BUCKET_INDEX_PAGE_SIZE = 200;
const DEFAULT_CAS_RETRIES = 8;
const LEGACY_BUCKETS_TO_CHECK = [0];
const FETCH_MESSAGE_BY_CHANNEL_BUCKET_AND_MESSAGE_ID = Messages.select({
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket'), Messages.where.eq('message_id')],
limit: 1,
});
const FETCH_CHANNEL_STATE = ChannelState.select({
where: ChannelState.where.eq('channel_id'),
limit: 1,
});
export class MessageDataRepository {
async listMessages(
channelId: ChannelID,
beforeMessageId?: MessageID,
limit: number = DEFAULT_MESSAGE_LIMIT,
afterMessageId?: MessageID,
options?: ListMessagesOptions,
): Promise<Array<Message>> {
if (limit <= 0) return [];
logger.debug(
{
channelId: channelId.toString(),
before: beforeMessageId?.toString() ?? null,
after: afterMessageId?.toString() ?? null,
limit,
},
'listMessages start',
);
if (beforeMessageId && afterMessageId) {
return this.listMessagesBetween(channelId, afterMessageId, beforeMessageId, limit, options);
}
if (beforeMessageId) {
return this.listMessagesBefore(channelId, beforeMessageId, limit, options);
}
if (afterMessageId) {
return this.listMessagesAfter(channelId, afterMessageId, limit, options);
}
return this.listMessagesLatest(channelId, limit);
}
private makeFetchMessagesBefore(limit: number) {
return Messages.select({
where: [
Messages.where.eq('channel_id'),
Messages.where.eq('bucket'),
Messages.where.lt('message_id', 'before_message_id'),
],
orderBy: {col: 'message_id', direction: 'DESC'},
limit,
});
}
private makeFetchMessagesAfterDesc(limit: number) {
return Messages.select({
where: [
Messages.where.eq('channel_id'),
Messages.where.eq('bucket'),
Messages.where.gt('message_id', 'after_message_id'),
],
orderBy: {col: 'message_id', direction: 'DESC'},
limit,
});
}
private makeFetchMessagesBetween(limit: number) {
return Messages.select({
where: [
Messages.where.eq('channel_id'),
Messages.where.eq('bucket'),
Messages.where.gt('message_id', 'after_message_id'),
Messages.where.lt('message_id', 'before_message_id'),
],
orderBy: {col: 'message_id', direction: 'DESC'},
limit,
});
}
private makeFetchMessagesLatestDesc(limit: number) {
return Messages.select({
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket')],
orderBy: {col: 'message_id', direction: 'DESC'},
limit,
});
}
private makeFetchMessagesAfterAsc(limit: number) {
return Messages.select({
where: [
Messages.where.eq('channel_id'),
Messages.where.eq('bucket'),
Messages.where.gt('message_id', 'after_message_id'),
],
orderBy: {col: 'message_id', direction: 'ASC'},
limit,
});
}
private makeFetchMessagesOldestAsc(limit: number) {
return Messages.select({
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket')],
orderBy: {col: 'message_id', direction: 'ASC'},
limit,
});
}
private async listMessagesLatest(channelId: ChannelID, limit: number): Promise<Array<Message>> {
const state = await this.getChannelState(channelId);
const nowId = SnowflakeUtils.getSnowflake();
const maxBucket = BucketUtils.makeBucket(nowId);
const minBucket = state?.created_bucket ?? BucketUtils.makeBucket(channelId);
return this.scanBucketsDescForMessages(channelId, {
limit,
minBucket,
maxBucket,
});
}
private async listMessagesBefore(
channelId: ChannelID,
before: MessageID,
limit: number,
options?: ListMessagesOptions,
): Promise<Array<Message>> {
const state = await this.getChannelState(channelId);
const maxBucket = BucketUtils.makeBucket(before);
const minBucket = state?.created_bucket ?? BucketUtils.makeBucket(channelId);
logger.debug(
{
channelId: channelId.toString(),
before: before.toString(),
limit,
maxBucket,
minBucket,
stateCreatedBucket: state?.created_bucket ?? null,
restrictToBeforeBucket: options?.restrictToBeforeBucket ?? null,
},
'listMessagesBefore: computed bucket range',
);
return this.scanBucketsDescForMessages(channelId, {
limit,
minBucket,
maxBucket,
before,
restrictToBeforeBucket: options?.restrictToBeforeBucket,
});
}
private async listMessagesAfter(
channelId: ChannelID,
after: MessageID,
limit: number,
options?: ListMessagesOptions,
): Promise<Array<Message>> {
const state = await this.getChannelState(channelId);
const afterBucket = BucketUtils.makeBucket(after);
const createdMin = state?.created_bucket ?? BucketUtils.makeBucket(channelId);
const minBucket = Math.max(afterBucket, createdMin);
const nowBucket = BucketUtils.makeBucket(SnowflakeUtils.getSnowflake());
const maxBucket = Math.max(nowBucket, minBucket);
logger.debug(
{
channelId: channelId.toString(),
action: 'listMessagesAfter',
after: after.toString(),
minBucket,
maxBucket,
limit,
immediateAfter: options?.immediateAfter ?? false,
},
'listMessagesAfter parameters',
);
if (options?.immediateAfter) {
const asc = await this.scanBucketsAscForMessages(channelId, {
limit,
minBucket,
maxBucket,
after,
});
return asc.reverse();
}
return this.scanBucketsDescForMessages(channelId, {
limit,
minBucket,
maxBucket,
after,
});
}
private async listMessagesBetween(
channelId: ChannelID,
after: MessageID,
before: MessageID,
limit: number,
options?: ListMessagesOptions,
): Promise<Array<Message>> {
const state = await this.getChannelState(channelId);
const afterBucket = BucketUtils.makeBucket(after);
const beforeBucket = BucketUtils.makeBucket(before);
const high = Math.max(afterBucket, beforeBucket);
const low = Math.min(afterBucket, beforeBucket);
const createdMin = state?.created_bucket ?? BucketUtils.makeBucket(channelId);
const minBucket = Math.max(low, createdMin);
const maxBucket = high;
logger.debug(
{
channelId: channelId.toString(),
action: 'listMessagesBetween',
after: after.toString(),
before: before.toString(),
minBucket,
maxBucket,
limit,
},
'listMessagesBetween parameters',
);
return this.scanBucketsDescForMessages(channelId, {
limit,
minBucket,
maxBucket,
after,
before,
restrictToBeforeBucket: options?.restrictToBeforeBucket,
});
}
private async scanBucketsDescForMessages(
channelId: ChannelID,
opts: {
limit: number;
minBucket: number;
maxBucket: number;
before?: MessageID;
after?: MessageID;
restrictToBeforeBucket?: boolean;
},
): Promise<Array<Message>> {
const beforeBucket = opts.before ? BucketUtils.makeBucket(opts.before) : null;
const afterBucket = opts.after ? BucketUtils.makeBucket(opts.after) : null;
const stopAfterBucket =
opts.restrictToBeforeBucket === true && opts.before && !opts.after && beforeBucket !== null
? beforeBucket
: undefined;
logger.debug(
{
channelId: channelId.toString(),
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
beforeBucket,
afterBucket,
restrictToBeforeBucket: opts.restrictToBeforeBucket ?? null,
stopAfterBucket: stopAfterBucket ?? null,
},
'scanBucketsDescForMessages: starting scan',
);
const {rows: out} = await scanBucketsWithIndex<MessageRow>(
{
listBucketsFromIndex: async (query) =>
this.listBucketsDescFromIndex(channelId, {
minBucket: query.minBucket,
maxBucket: query.maxBucket,
limit: query.limit,
}),
fetchRowsForBucket: async (bucket, limit) =>
this.fetchRowsForBucket(channelId, bucket, limit, {
before: opts.before,
after: opts.after,
beforeBucket,
afterBucket,
}),
getRowId: (row) => row.message_id,
onEmptyUnboundedBucket: async (bucket) => this.markBucketEmpty(channelId, bucket),
onBucketHasRows: async (bucket) => this.touchBucketWithMessages(channelId, bucket),
},
{
limit: opts.limit,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
direction: BucketScanDirection.Desc,
indexPageSize: DEFAULT_BUCKET_INDEX_PAGE_SIZE,
stopAfterBucket,
},
);
if (out.length === 0) return [];
let maxId: MessageID = out[0].message_id;
let maxBucketForId = out[0].bucket;
for (const row of out) {
if (row.message_id > maxId) {
maxId = row.message_id;
maxBucketForId = row.bucket;
}
}
await this.touchChannelHasMessages(channelId);
await this.advanceChannelStateLastMessageIfNewer(channelId, maxId, maxBucketForId);
return this.repairAndMapMessages(channelId, out);
}
private async scanBucketsAscForMessages(
channelId: ChannelID,
opts: {
limit: number;
minBucket: number;
maxBucket: number;
after: MessageID;
},
): Promise<Array<Message>> {
const afterBucket = BucketUtils.makeBucket(opts.after);
const {rows: out} = await scanBucketsWithIndex<MessageRow>(
{
listBucketsFromIndex: async (query) =>
this.listBucketsAscFromIndex(channelId, {
minBucket: query.minBucket,
maxBucket: query.maxBucket,
limit: query.limit,
}),
fetchRowsForBucket: async (bucket, limit) =>
this.fetchRowsForBucketAsc(channelId, bucket, limit, {
after: opts.after,
afterBucket,
}),
getRowId: (row) => row.message_id,
onEmptyUnboundedBucket: async (bucket) => this.markBucketEmpty(channelId, bucket),
onBucketHasRows: async (bucket) => this.touchBucketWithMessages(channelId, bucket),
},
{
limit: opts.limit,
minBucket: opts.minBucket,
maxBucket: opts.maxBucket,
direction: BucketScanDirection.Asc,
indexPageSize: DEFAULT_BUCKET_INDEX_PAGE_SIZE,
},
);
if (out.length === 0) return [];
let maxId: MessageID = out[0].message_id;
let maxBucketForId = out[0].bucket;
for (const row of out) {
if (row.message_id > maxId) {
maxId = row.message_id;
maxBucketForId = row.bucket;
}
}
await this.touchChannelHasMessages(channelId);
await this.advanceChannelStateLastMessageIfNewer(channelId, maxId, maxBucketForId);
return this.repairAndMapMessages(channelId, out);
}
private async fetchRowsForBucketAsc(
channelId: ChannelID,
bucket: number,
limit: number,
meta: {
after: MessageID;
afterBucket: number;
},
): Promise<{rows: Array<MessageRow>; unbounded: boolean}> {
logger.debug(
{
channelId: channelId.toString(),
bucket,
limit,
meta: {after: meta.after.toString(), afterBucket: meta.afterBucket},
},
'fetchRowsForBucketAsc parameters',
);
if (bucket === meta.afterBucket) {
const q = this.makeFetchMessagesAfterAsc(limit);
const rows = await fetchMany<MessageRow>(
q.bind({
channel_id: channelId,
bucket,
after_message_id: meta.after,
}),
);
return {rows, unbounded: false};
}
const q = this.makeFetchMessagesOldestAsc(limit);
const rows = await fetchMany<MessageRow>(q.bind({channel_id: channelId, bucket}));
return {rows, unbounded: true};
}
private async fetchRowsForBucket(
channelId: ChannelID,
bucket: number,
limit: number,
meta: {
before?: MessageID;
after?: MessageID;
beforeBucket: number | null;
afterBucket: number | null;
},
): Promise<{rows: Array<MessageRow>; unbounded: boolean}> {
logger.debug(
{
channelId: channelId.toString(),
bucket,
limit,
meta: {
before: meta.before?.toString() ?? null,
after: meta.after?.toString() ?? null,
beforeBucket: meta.beforeBucket,
afterBucket: meta.afterBucket,
},
},
'fetchRowsForBucket parameters',
);
if (meta.before && meta.after && meta.beforeBucket === bucket && meta.afterBucket === bucket) {
const q = this.makeFetchMessagesBetween(limit);
const rows = await fetchMany<MessageRow>(
q.bind({
channel_id: channelId,
bucket,
after_message_id: meta.after,
before_message_id: meta.before,
}),
);
return {rows, unbounded: false};
}
if (meta.before && meta.beforeBucket === bucket) {
const q = this.makeFetchMessagesBefore(limit);
const rows = await fetchMany<MessageRow>(
q.bind({
channel_id: channelId,
bucket,
before_message_id: meta.before,
}),
);
return {rows, unbounded: false};
}
if (meta.after && meta.afterBucket === bucket) {
const q = this.makeFetchMessagesAfterDesc(limit);
const rows = await fetchMany<MessageRow>(
q.bind({
channel_id: channelId,
bucket,
after_message_id: meta.after,
}),
);
return {rows, unbounded: false};
}
const q = this.makeFetchMessagesLatestDesc(limit);
const rows = await fetchMany<MessageRow>(q.bind({channel_id: channelId, bucket}));
return {rows, unbounded: true};
}
private async touchBucketWithMessages(channelId: ChannelID, bucket: number): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(
ChannelMessageBuckets.upsertAll({
channel_id: channelId,
bucket,
updated_at: new Date(),
}),
);
batch.addPrepared(
ChannelEmptyBuckets.deleteByPk({
channel_id: channelId,
bucket,
}),
);
await batch.execute(true);
}
private async markBucketEmpty(channelId: ChannelID, bucket: number): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(
ChannelMessageBuckets.deleteByPk({
channel_id: channelId,
bucket,
}),
);
batch.addPrepared(
ChannelEmptyBuckets.upsertAll({
channel_id: channelId,
bucket,
updated_at: new Date(),
}),
);
await batch.execute(true);
}
private async touchChannelHasMessages(channelId: ChannelID): Promise<void> {
await upsertOne(
ChannelState.patchByPk(
{channel_id: channelId},
{
has_messages: Db.set(true),
updated_at: Db.set(new Date()),
},
),
);
}
private async advanceChannelStateLastMessageIfNewer(
channelId: ChannelID,
newLastMessageId: MessageID,
newLastMessageBucket: number,
): Promise<void> {
for (let i = 0; i < DEFAULT_CAS_RETRIES; i++) {
const state = await this.getChannelState(channelId);
const prev = state?.last_message_id ?? null;
if (prev !== null && newLastMessageId <= prev) return;
const q = ChannelState.patchByPkIf(
{channel_id: channelId},
{
has_messages: Db.set(true),
last_message_id: Db.set(newLastMessageId),
last_message_bucket: Db.set(newLastMessageBucket),
updated_at: Db.set(new Date()),
},
{col: 'last_message_id', expectedParam: 'prev_last_message_id', expectedValue: prev},
);
const res = await executeConditional(q);
if (res.applied) return;
}
Logger.warn(
{channelId: channelId.toString(), messageId: newLastMessageId.toString()},
'Failed to advance ChannelState.last_message_id after retries',
);
}
private async getChannelState(channelId: ChannelID): Promise<ChannelStateRow | null> {
return fetchOne<ChannelStateRow>(FETCH_CHANNEL_STATE.bind({channel_id: channelId}));
}
private async listBucketsDescFromIndex(
channelId: ChannelID,
opts: {minBucket?: number; maxBucket?: number; limit: number},
): Promise<Array<number>> {
const where = [ChannelMessageBuckets.where.eq('channel_id')];
if (typeof opts.minBucket === 'number') where.push(ChannelMessageBuckets.where.gte('bucket', 'min_bucket'));
if (typeof opts.maxBucket === 'number') where.push(ChannelMessageBuckets.where.lte('bucket', 'max_bucket'));
const q = ChannelMessageBuckets.select({
columns: ['bucket'],
where,
orderBy: {col: 'bucket', direction: 'DESC'},
limit: opts.limit,
});
const params = {
channel_id: channelId,
...(typeof opts.minBucket === 'number' ? {min_bucket: opts.minBucket} : {}),
...(typeof opts.maxBucket === 'number' ? {max_bucket: opts.maxBucket} : {}),
};
const rows = await fetchMany<Pick<ChannelMessageBucketRow, 'bucket'>>(q.bind(params));
const buckets = rows.map((r) => r.bucket);
logger.debug(
{
channelId: channelId.toString(),
minBucket: opts.minBucket ?? null,
maxBucket: opts.maxBucket ?? null,
limit: opts.limit,
bucketsFound: buckets,
},
'listBucketsDescFromIndex: query result',
);
return buckets;
}
private async listBucketsAscFromIndex(
channelId: ChannelID,
opts: {minBucket?: number; maxBucket?: number; limit: number},
): Promise<Array<number>> {
const where = [ChannelMessageBuckets.where.eq('channel_id')];
if (typeof opts.minBucket === 'number') where.push(ChannelMessageBuckets.where.gte('bucket', 'min_bucket'));
if (typeof opts.maxBucket === 'number') where.push(ChannelMessageBuckets.where.lte('bucket', 'max_bucket'));
const q = ChannelMessageBuckets.select({
columns: ['bucket'],
where,
orderBy: {col: 'bucket', direction: 'ASC'},
limit: opts.limit,
});
const params = {
channel_id: channelId,
...(typeof opts.minBucket === 'number' ? {min_bucket: opts.minBucket} : {}),
...(typeof opts.maxBucket === 'number' ? {max_bucket: opts.maxBucket} : {}),
};
const rows = await fetchMany<Pick<ChannelMessageBucketRow, 'bucket'>>(q.bind(params));
return rows.map((r) => r.bucket);
}
async getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null> {
const bucket = BucketUtils.makeBucket(messageId);
const message = await fetchOne<MessageRow>(
FETCH_MESSAGE_BY_CHANNEL_BUCKET_AND_MESSAGE_ID.bind({
channel_id: channelId,
bucket,
message_id: messageId,
}),
);
if (message) return new Message(message);
const repairedMessage = await this.attemptBucketReadRepair(channelId, messageId, bucket);
return repairedMessage;
}
async upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message> {
const expectedBucket = BucketUtils.makeBucket(data.message_id);
if (data.bucket !== expectedBucket) {
throw new Error(
`Invalid message bucket for ${data.message_id.toString()}: expected ${expectedBucket}, received ${data.bucket}`,
);
}
const batch = new BatchBuilder();
batch.addPrepared(
ChannelEmptyBuckets.deleteByPk({
channel_id: data.channel_id,
bucket: data.bucket,
}),
);
const result = await executeVersionedUpdate<MessageRow, 'channel_id' | 'bucket' | 'message_id'>(
async () => {
if (oldData !== undefined) return oldData;
const pk = {
channel_id: data.channel_id,
bucket: data.bucket,
message_id: data.message_id,
};
const existingMessage = await fetchOne<MessageRow>(FETCH_MESSAGE_BY_CHANNEL_BUCKET_AND_MESSAGE_ID.bind(pk));
return existingMessage ?? null;
},
(current) => ({
pk: {
channel_id: data.channel_id,
bucket: data.bucket,
message_id: data.message_id,
},
patch: buildPatchFromData(data, current, MESSAGE_COLUMNS, ['channel_id', 'bucket', 'message_id']),
}),
Messages,
{onFailure: 'log'},
);
if (!result.applied) {
throw new Error(`Failed to upsert message ${data.message_id} after LWT retries`);
}
const finalVersion = result.finalVersion ?? 1;
if (data.author_id) {
batch.addPrepared(
MessagesByAuthor.upsertAll({
author_id: data.author_id,
channel_id: data.channel_id,
message_id: data.message_id,
}),
);
}
if (data.pinned_timestamp) {
batch.addPrepared(
ChannelPins.upsertAll({
channel_id: data.channel_id,
message_id: data.message_id,
pinned_timestamp: data.pinned_timestamp,
}),
);
}
if (oldData?.pinned_timestamp && !data.pinned_timestamp) {
batch.addPrepared(
ChannelPins.deleteByPk({
channel_id: data.channel_id,
message_id: data.message_id,
pinned_timestamp: oldData.pinned_timestamp,
}),
);
}
if (oldData?.attachments) {
for (const attachment of oldData.attachments) {
batch.addPrepared(
AttachmentLookup.deleteByPk({
channel_id: data.channel_id,
attachment_id: attachment.attachment_id,
filename: attachment.filename,
}),
);
}
}
if (data.attachments) {
for (const attachment of data.attachments) {
batch.addPrepared(
AttachmentLookup.upsertAll({
channel_id: data.channel_id,
attachment_id: attachment.attachment_id,
filename: attachment.filename,
message_id: data.message_id,
}),
);
}
}
batch.addPrepared(
ChannelMessageBuckets.upsertAll({
channel_id: data.channel_id,
bucket: data.bucket,
updated_at: new Date(),
}),
);
const createdBucket = BucketUtils.makeBucket(data.channel_id);
batch.addPrepared(
ChannelState.patchByPk(
{channel_id: data.channel_id},
{
created_bucket: Db.set(createdBucket),
has_messages: Db.set(true),
updated_at: Db.set(new Date()),
},
),
);
await batch.execute();
await this.advanceChannelStateLastMessageIfNewer(data.channel_id, data.message_id, data.bucket);
return new Message({...data, version: finalVersion});
}
private async attemptBucketReadRepair(
channelId: ChannelID,
messageId: MessageID,
expectedBucket: number,
): Promise<Message | null> {
for (const legacyBucket of LEGACY_BUCKETS_TO_CHECK) {
if (legacyBucket === expectedBucket) continue;
const legacyRow = await fetchOne<MessageRow>(
FETCH_MESSAGE_BY_CHANNEL_BUCKET_AND_MESSAGE_ID.bind({
channel_id: channelId,
bucket: legacyBucket,
message_id: messageId,
}),
);
if (!legacyRow) continue;
Logger.warn(
{channelId: channelId.toString(), messageId: messageId.toString(), legacyBucket, expectedBucket},
'Repairing message bucket mismatch',
);
const repairedRow: MessageRow = {
...legacyRow,
bucket: expectedBucket,
};
const repairedMessage = await this.upsertMessage(repairedRow, legacyRow);
await deleteOneOrMany(
Messages.deleteByPk({
channel_id: channelId,
bucket: legacyBucket,
message_id: messageId,
}),
);
return repairedMessage;
}
return null;
}
private async repairAndMapMessages(channelId: ChannelID, messages: Array<MessageRow>): Promise<Array<Message>> {
if (messages.length === 0) return [];
const repaired: Array<Message> = [];
for (const message of messages) {
const expectedBucket = BucketUtils.makeBucket(message.message_id);
if (message.bucket === expectedBucket) {
repaired.push(new Message(message));
continue;
}
const repairedMessage = await this.attemptBucketReadRepair(channelId, message.message_id, expectedBucket);
if (repairedMessage) {
repaired.push(repairedMessage);
continue;
}
Logger.warn(
{
channelId: channelId.toString(),
messageId: message.message_id.toString(),
legacyBucket: message.bucket,
expectedBucket,
},
'Failed to repair message bucket mismatch during listMessages; returning legacy row',
);
repaired.push(new Message(message));
}
return repaired;
}
}

View File

@@ -0,0 +1,334 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {ChannelMessageBucketRow, ChannelStateRow} from '~/database/CassandraTypes';
import type {Message} from '~/Models';
import {
AttachmentLookup,
ChannelEmptyBuckets,
ChannelMessageBuckets,
ChannelPins,
ChannelState,
MessageReactions,
Messages,
MessagesByAuthor,
} from '~/Tables';
import * as BucketUtils from '~/utils/BucketUtils';
import type {MessageDataRepository} from './MessageDataRepository';
const BULK_DELETE_BATCH_SIZE = 100;
const POST_DELETE_BUCKET_CHECK_LIMIT = 25;
const HAS_ANY_MESSAGE_IN_BUCKET = Messages.select({
columns: ['message_id'],
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket')],
limit: 1,
});
const FETCH_CHANNEL_STATE = ChannelState.select({
where: ChannelState.where.eq('channel_id'),
limit: 1,
});
const LIST_BUCKETS_DESC = ChannelMessageBuckets.select({
columns: ['bucket'],
where: ChannelMessageBuckets.where.eq('channel_id'),
orderBy: {col: 'bucket', direction: 'DESC'},
limit: POST_DELETE_BUCKET_CHECK_LIMIT,
});
const FETCH_LATEST_MESSAGE_ID_IN_BUCKET = Messages.select({
columns: ['message_id'],
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket')],
limit: 1,
});
export class MessageDeletionRepository {
constructor(private messageDataRepo: MessageDataRepository) {}
private addMessageDeletionBatchQueries(
batch: BatchBuilder,
channelId: ChannelID,
messageId: MessageID,
bucket: number,
message: Message | null,
authorId?: UserID,
pinnedTimestamp?: Date,
): void {
batch.addPrepared(
Messages.deleteByPk({
channel_id: channelId,
bucket,
message_id: messageId,
}),
);
const effectiveAuthorId = authorId ?? message?.authorId ?? null;
if (effectiveAuthorId) {
batch.addPrepared(
MessagesByAuthor.deleteByPk({
author_id: effectiveAuthorId,
channel_id: channelId,
message_id: messageId,
}),
);
}
const effectivePinned = pinnedTimestamp ?? message?.pinnedTimestamp ?? null;
if (effectivePinned) {
batch.addPrepared(
ChannelPins.deleteByPk({
channel_id: channelId,
message_id: messageId,
pinned_timestamp: effectivePinned,
}),
);
}
batch.addPrepared(
MessageReactions.deletePartition({
channel_id: channelId,
bucket,
message_id: messageId,
}),
);
if (message?.attachments) {
for (const attachment of message.attachments) {
batch.addPrepared(
AttachmentLookup.deleteByPk({
channel_id: channelId,
attachment_id: attachment.id,
filename: attachment.filename,
}),
);
}
}
}
private async markBucketEmpty(channelId: ChannelID, bucket: number): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(
ChannelMessageBuckets.deleteByPk({
channel_id: channelId,
bucket,
}),
);
batch.addPrepared(
ChannelEmptyBuckets.upsertAll({
channel_id: channelId,
bucket,
updated_at: new Date(),
}),
);
await batch.execute(true);
}
private async isBucketEmpty(channelId: ChannelID, bucket: number): Promise<boolean> {
const row = await fetchOne<{message_id: bigint}>(
HAS_ANY_MESSAGE_IN_BUCKET.bind({
channel_id: channelId,
bucket,
}),
);
return row == null;
}
private async reconcileChannelStateIfNeeded(
channelId: ChannelID,
deletedMessageIds: Array<MessageID>,
emptiedBuckets: Set<number>,
): Promise<void> {
const state = await fetchOne<ChannelStateRow>(FETCH_CHANNEL_STATE.bind({channel_id: channelId}));
if (!state) return;
const lastBucket = state.last_message_bucket as number | null | undefined;
const lastId = state.last_message_id as MessageID | null | undefined;
const touchedLast =
(lastBucket != null && emptiedBuckets.has(lastBucket)) || (lastId != null && deletedMessageIds.includes(lastId));
if (!touchedLast) return;
const bucketRows = await fetchMany<Pick<ChannelMessageBucketRow, 'bucket'>>(
LIST_BUCKETS_DESC.bind({channel_id: channelId}),
);
for (const {bucket} of bucketRows) {
const latest = await fetchOne<{message_id: bigint}>(
FETCH_LATEST_MESSAGE_ID_IN_BUCKET.bind({channel_id: channelId, bucket}),
);
if (!latest) {
await this.markBucketEmpty(channelId, bucket);
continue;
}
await upsertOne(
ChannelState.patchByPk(
{channel_id: channelId},
{
has_messages: Db.set(true),
last_message_bucket: Db.set(bucket),
last_message_id: Db.set(latest.message_id as MessageID),
updated_at: Db.set(new Date()),
},
),
);
return;
}
await upsertOne(
ChannelState.patchByPk(
{channel_id: channelId},
{
has_messages: Db.set(false),
last_message_bucket: Db.clear(),
last_message_id: Db.clear(),
updated_at: Db.set(new Date()),
},
),
);
}
private async postDeleteMaintenance(
channelId: ChannelID,
affectedBuckets: Set<number>,
deletedMessageIds: Array<MessageID>,
): Promise<void> {
const emptiedBuckets = new Set<number>();
for (const bucket of affectedBuckets) {
const empty = await this.isBucketEmpty(channelId, bucket);
if (!empty) continue;
emptiedBuckets.add(bucket);
await this.markBucketEmpty(channelId, bucket);
}
if (emptiedBuckets.size > 0 || deletedMessageIds.length > 0) {
await this.reconcileChannelStateIfNeeded(channelId, deletedMessageIds, emptiedBuckets);
}
}
async deleteMessage(
channelId: ChannelID,
messageId: MessageID,
authorId: UserID,
pinnedTimestamp?: Date,
): Promise<void> {
const bucket = BucketUtils.makeBucket(messageId);
const message = await this.messageDataRepo.getMessage(channelId, messageId);
const batch = new BatchBuilder();
this.addMessageDeletionBatchQueries(batch, channelId, messageId, bucket, message, authorId, pinnedTimestamp);
await batch.execute();
await this.postDeleteMaintenance(channelId, new Set([bucket]), [messageId]);
}
async bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void> {
if (messageIds.length === 0) return;
for (let i = 0; i < messageIds.length; i += BULK_DELETE_BATCH_SIZE) {
const chunk = messageIds.slice(i, i + BULK_DELETE_BATCH_SIZE);
const messages = await Promise.all(chunk.map((id) => this.messageDataRepo.getMessage(channelId, id)));
const affectedBuckets = new Set<number>();
const batch = new BatchBuilder();
for (let j = 0; j < chunk.length; j++) {
const messageId = chunk[j];
const message = messages[j];
const bucket = BucketUtils.makeBucket(messageId);
affectedBuckets.add(bucket);
this.addMessageDeletionBatchQueries(batch, channelId, messageId, bucket, message);
}
await batch.execute();
await this.postDeleteMaintenance(channelId, affectedBuckets, chunk);
}
}
async deleteAllChannelMessages(channelId: ChannelID): Promise<void> {
const BATCH_SIZE = 50;
let hasMore = true;
let beforeMessageId: MessageID | undefined;
const allDeleted: Array<MessageID> = [];
const affectedBuckets = new Set<number>();
while (hasMore) {
const messages = await this.messageDataRepo.listMessages(channelId, beforeMessageId, 100);
if (messages.length === 0) {
hasMore = false;
break;
}
for (let i = 0; i < messages.length; i += BATCH_SIZE) {
const batch = new BatchBuilder();
const messageBatch = messages.slice(i, i + BATCH_SIZE);
for (const message of messageBatch) {
const bucket = BucketUtils.makeBucket(message.id);
affectedBuckets.add(bucket);
allDeleted.push(message.id);
this.addMessageDeletionBatchQueries(
batch,
channelId,
message.id,
bucket,
message,
message.authorId ?? undefined,
message.pinnedTimestamp || undefined,
);
}
await batch.execute();
}
if (messages.length < 100) {
hasMore = false;
} else {
beforeMessageId = messages[messages.length - 1].id;
}
}
await this.postDeleteMaintenance(channelId, affectedBuckets, allDeleted);
await deleteOneOrMany(
ChannelMessageBuckets.deletePartition({
channel_id: channelId,
}),
);
await deleteOneOrMany(
ChannelEmptyBuckets.deletePartition({
channel_id: channelId,
}),
);
}
}

View File

@@ -0,0 +1,242 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AttachmentID, ChannelID, MessageID, UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
import {GuildOperations, Permissions} from '~/Constants';
import type {UploadedAttachment} from '~/channel/AttachmentDTOs';
import {
FeatureTemporarilyDisabledError,
MissingPermissionsError,
UnknownMessageError,
UnknownUserError,
} from '~/Errors';
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {Attachment, Channel, Message} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
import type {AuthenticatedChannel} from './AuthenticatedChannel';
import {
getContentType,
isMessageEmpty,
isOperationDisabled,
makeAttachmentCdnKey,
makeAttachmentCdnUrl,
purgeMessageAttachments as purgeMessageAttachmentsHelper,
validateAttachmentSizes,
} from './message/MessageHelpers';
interface DeleteAttachmentParams {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
attachmentId: AttachmentID;
requestCache: RequestCache;
}
interface UploadFormDataAttachmentsParams {
userId: UserID;
channelId: ChannelID;
files: Array<{file: File; index: number}>;
attachmentMetadata: Array<{id: number; filename: string}>;
expiresAt?: Date;
}
export class AttachmentUploadService {
constructor(
private channelRepository: IChannelRepositoryAggregate,
private userRepository: IUserRepository,
private storageService: IStorageService,
private cloudflarePurgeQueue: ICloudflarePurgeQueue,
private getChannelAuthenticated: (params: {userId: UserID; channelId: ChannelID}) => Promise<AuthenticatedChannel>,
private ensureTextChannel: (channel: Channel) => void,
private dispatchMessageUpdate: (params: {
channel: Channel;
message: Message;
requestCache: RequestCache;
currentUserId?: UserID;
}) => Promise<void>,
private deleteMessage: (params: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
}) => Promise<void>,
) {}
async uploadFormDataAttachments({
userId,
channelId,
files,
attachmentMetadata,
expiresAt,
}: UploadFormDataAttachmentsParams): Promise<Array<UploadedAttachment>> {
const {channel, guild, checkPermission} = await this.getChannelAuthenticated({userId, channelId});
this.ensureTextChannel(channel);
if (guild) {
await checkPermission(Permissions.SEND_MESSAGES | Permissions.ATTACH_FILES);
}
const user = await this.userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
validateAttachmentSizes(
files.map((fileWithIndex) => ({size: fileWithIndex.file.size})),
user,
);
const metadataMap = new Map(attachmentMetadata.map((m) => [m.id, m]));
const uploadedAttachments = await Promise.all(
files.map(async (fileWithIndex) => {
const {file, index} = fileWithIndex;
const metadata = metadataMap.get(index);
if (!metadata) {
throw new Error(`Internal error: metadata not found for file index ${index}`);
}
const filename = metadata.filename;
const uploadKey = crypto.randomUUID();
const arrayBuffer = await file.arrayBuffer();
const body = new Uint8Array(arrayBuffer);
const contentType = this.resolveContentType(file, filename);
await this.storageService.uploadObject({
bucket: Config.s3.buckets.uploads,
key: uploadKey,
body,
contentType,
expiresAt: expiresAt ?? undefined,
});
const uploaded: UploadedAttachment = {
id: index,
upload_filename: uploadKey,
filename: filename,
file_size: file.size,
content_type: contentType,
};
return uploaded;
}),
);
return uploadedAttachments;
}
async deleteAttachment({
userId,
channelId,
messageId,
attachmentId,
requestCache,
}: DeleteAttachmentParams): Promise<void> {
const {channel, guild} = await this.getChannelAuthenticated({userId, channelId});
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
throw new FeatureTemporarilyDisabledError();
}
const message = await this.channelRepository.messages.getMessage(channelId, messageId);
if (!message) {
throw new UnknownMessageError();
}
if (message.authorId !== userId) {
throw new MissingPermissionsError();
}
if (!message.attachments || message.attachments.length === 0) {
throw new UnknownMessageError();
}
const attachment = message.attachments.find((a: Attachment) => a.id === attachmentId);
if (!attachment) {
throw new UnknownMessageError();
}
const isLastAttachment = message.attachments.length === 1;
const willBeEmpty = isLastAttachment && isMessageEmpty(message, true);
if (willBeEmpty) {
await this.deleteMessage({
userId,
channelId,
messageId,
requestCache,
});
return;
}
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
if (Config.cloudflare.purgeEnabled) {
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
await this.cloudflarePurgeQueue.addUrls([cdnUrl]);
}
const updatedAttachments = message.attachments.filter((a: Attachment) => a.id !== attachmentId);
const updatedRowData = {
...message.toRow(),
attachments:
updatedAttachments.length > 0 ? updatedAttachments.map((a: Attachment) => a.toMessageAttachment()) : null,
};
const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedRowData, message.toRow());
await this.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
}
async purgeChannelAttachments(channel: Channel): Promise<void> {
const batchSize = 100;
let beforeMessageId: MessageID | undefined;
while (true) {
const messages = await this.channelRepository.messages.listMessages(channel.id, beforeMessageId, batchSize);
if (messages.length === 0) {
return;
}
await Promise.all(
messages.map((message: Message) =>
purgeMessageAttachmentsHelper(message, this.storageService, this.cloudflarePurgeQueue),
),
);
if (messages.length < batchSize) {
return;
}
beforeMessageId = messages[messages.length - 1].id;
}
}
private resolveContentType(_file: File, normalizedFilename: string): string {
return getContentType(normalizedFilename);
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
import type {Channel} from '~/Models';
export interface AuthenticatedChannel {
channel: Channel;
guild: GuildResponse | null;
member: GuildMemberResponse | null;
hasPermission: (permission: bigint) => Promise<boolean>;
checkPermission: (permission: bigint) => Promise<void>;
}

View File

@@ -0,0 +1,236 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type ChannelID, channelIdToUserId, type UserID, userIdToChannelId} from '~/BrandedTypes';
import {ChannelTypes, Permissions} from '~/Constants';
import {
MissingPermissionsError,
NsfwContentRequiresAgeVerificationError,
UnknownChannelError,
UnknownUserError,
} from '~/Errors';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import {Channel, type User} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
import {isUserAdult} from '~/utils/AgeUtils';
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
import type {AuthenticatedChannel} from './AuthenticatedChannel';
import {DMPermissionValidator} from './DMPermissionValidator';
export interface ChannelAuthOptions {
errorOnMissingGuild: 'unknown_channel' | 'missing_permissions';
validateNsfw: boolean;
useVirtualPersonalNotes: boolean;
}
export abstract class BaseChannelAuthService {
protected abstract readonly options: ChannelAuthOptions;
protected dmPermissionValidator: DMPermissionValidator;
constructor(
protected channelRepository: IChannelRepositoryAggregate,
protected userRepository: IUserRepository,
protected guildRepository: IGuildRepository,
protected gatewayService: IGatewayService,
) {
this.dmPermissionValidator = new DMPermissionValidator({
userRepository: this.userRepository,
guildRepository: this.guildRepository,
});
}
async getChannelAuthenticated({
userId,
channelId,
}: {
userId: UserID;
channelId: ChannelID;
}): Promise<AuthenticatedChannel> {
if (this.isPersonalNotesChannel({userId, channelId})) {
if (this.options.useVirtualPersonalNotes) {
return this.getVirtualPersonalNotesChannelAuth(channelId);
}
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
return this.getRealPersonalNotesChannelAuth({channel, userId});
}
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (!channel.guildId) {
const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
return this.getDMChannelAuth({channel, recipients, userId});
}
return this.getGuildChannelAuth({channel, userId});
}
isPersonalNotesChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): boolean {
return userIdToChannelId(userId) === channelId;
}
protected createVirtualPersonalNotesChannel(userId: UserID): Channel {
return new Channel({
channel_id: userIdToChannelId(userId),
guild_id: null,
type: ChannelTypes.DM_PERSONAL_NOTES,
name: '',
topic: null,
icon_hash: null,
url: null,
parent_id: null,
position: 0,
owner_id: null,
recipient_ids: new Set(),
nsfw: false,
rate_limit_per_user: 0,
bitrate: null,
user_limit: null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
});
}
protected getVirtualPersonalNotesChannelAuth(channelId: ChannelID): AuthenticatedChannel {
const channel = this.createVirtualPersonalNotesChannel(channelIdToUserId(channelId));
return {
channel,
guild: null,
member: null,
hasPermission: async () => true,
checkPermission: async () => {},
};
}
protected async getRealPersonalNotesChannelAuth({
channel,
userId,
}: {
channel: Channel;
userId: UserID;
}): Promise<AuthenticatedChannel> {
if (!this.isPersonalNotesChannel({userId, channelId: channel.id})) {
throw new UnknownChannelError();
}
if (channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
throw new UnknownChannelError();
}
return {
channel,
guild: null,
member: null,
hasPermission: async () => true,
checkPermission: async () => {},
};
}
protected async getDMChannelAuth({
channel,
recipients,
userId,
}: {
channel: Channel;
recipients: Array<User>;
userId: UserID;
}): Promise<AuthenticatedChannel> {
const isRecipient = recipients.some((recipient) => recipient.id === userId);
if (!isRecipient) throw new UnknownChannelError();
return {
channel,
guild: null,
member: null,
hasPermission: async () => true,
checkPermission: async () => {},
};
}
async validateDMSendPermissions({channelId, userId}: {channelId: ChannelID; userId: UserID}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
await this.dmPermissionValidator.validate({recipients, userId});
}
protected async getGuildChannelAuth({
channel,
userId,
}: {
channel: Channel;
userId: UserID;
}): Promise<AuthenticatedChannel> {
const guildId = channel.guildId!;
const [guildDataResult, guildMemberResult] = await Promise.all([
this.gatewayService.getGuildData({guildId, userId}),
this.gatewayService.getGuildMember({guildId, userId}),
]);
if (!guildDataResult) {
this.throwGuildAccessError();
}
if (!guildMemberResult.success || !guildMemberResult.memberData) {
this.throwGuildAccessError();
}
const hasPermission = async (permission: bigint): Promise<boolean> => {
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
};
const checkPermission = async (permission: bigint): Promise<void> => {
const allowed = await hasPermission(permission);
if (!allowed) throw new MissingPermissionsError();
};
await checkPermission(Permissions.VIEW_CHANNEL);
if (this.options.validateNsfw && channel.type === ChannelTypes.GUILD_TEXT && channel.isNsfw) {
const user = await this.userRepository.findUnique(userId);
if (!user) throw new UnknownUserError();
if (!isUserAdult(user.dateOfBirth)) {
throw new NsfwContentRequiresAgeVerificationError();
}
}
return {
channel,
guild: guildDataResult!,
member: guildMemberResult.memberData!,
hasPermission,
checkPermission,
};
}
protected throwGuildAccessError(): never {
if (this.options.errorOnMissingGuild === 'missing_permissions') {
throw new MissingPermissionsError();
}
throw new UnknownChannelError();
}
}

View File

@@ -0,0 +1,505 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type ChannelID, createMessageID, type MessageID, type UserID} from '~/BrandedTypes';
import {ChannelTypes, IncomingCallFlags, MessageTypes, RelationshipTypes} from '~/Constants';
import {mapChannelToResponse, mapMessageToResponse} from '~/channel/ChannelModel';
import {CallAlreadyExistsError, InvalidChannelTypeForCallError, NoActiveCallError, UnknownChannelError} from '~/Errors';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {CallData, IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {ReadStateService} from '~/read_state/ReadStateService';
import type {IUserRepository} from '~/user/IUserRepository';
import * as BucketUtils from '~/utils/BucketUtils';
import {calculateDistance} from '~/utils/GeoUtils';
import type {VoiceAccessContext, VoiceAvailabilityService} from '~/voice/VoiceAvailabilityService';
import type {IChannelRepository} from '../IChannelRepository';
import {DMPermissionValidator} from './DMPermissionValidator';
import {incrementDmMentionCounts} from './message/ReadStateHelpers';
const FALLBACK_VOICE_REGION = 'us-east';
export class CallService {
private dmPermissionValidator: DMPermissionValidator;
constructor(
private channelRepository: IChannelRepository,
private userRepository: IUserRepository,
private guildRepository: IGuildRepository,
private gatewayService: IGatewayService,
private userCacheService: UserCacheService,
private mediaService: IMediaService,
private snowflakeService: SnowflakeService,
private readStateService: ReadStateService,
private voiceAvailabilityService?: VoiceAvailabilityService,
) {
this.dmPermissionValidator = new DMPermissionValidator({
userRepository: this.userRepository,
guildRepository: this.guildRepository,
});
}
async checkCallEligibility({
userId,
channelId,
}: {
userId: UserID;
channelId: ChannelID;
}): Promise<{ringable: boolean; silent?: boolean}> {
const channel = await this.channelRepository.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeForCallError();
}
const call = await this.gatewayService.getCall(channelId);
const alreadyInCall = call ? call.voice_states.some((vs) => vs.user_id === userId.toString()) : false;
if (alreadyInCall) {
return {ringable: false};
}
if (channel.type === ChannelTypes.DM) {
const recipientId = Array.from(channel.recipientIds).find((id) => id !== userId);
if (!recipientId) {
return {ringable: true};
}
const recipientSettings = await this.userRepository.findSettings(recipientId);
if (!recipientSettings) {
return {ringable: true};
}
const incomingCallFlags = recipientSettings.incomingCallFlags;
if ((incomingCallFlags & IncomingCallFlags.NOBODY) === IncomingCallFlags.NOBODY) {
return {ringable: false};
}
const friendship = await this.userRepository.getRelationship(userId, recipientId, RelationshipTypes.FRIEND);
const areFriends = friendship !== null;
if ((incomingCallFlags & IncomingCallFlags.FRIENDS_ONLY) === IncomingCallFlags.FRIENDS_ONLY) {
return {ringable: areFriends};
}
if (areFriends) {
return {ringable: true};
}
const shouldBeSilent =
(incomingCallFlags & IncomingCallFlags.SILENT_EVERYONE) === IncomingCallFlags.SILENT_EVERYONE;
if ((incomingCallFlags & IncomingCallFlags.FRIENDS_OF_FRIENDS) === IncomingCallFlags.FRIENDS_OF_FRIENDS) {
const callerRelationships = await this.userRepository.listRelationships(userId);
const recipientRelationships = await this.userRepository.listRelationships(recipientId);
const callerFriendIds = new Set(
callerRelationships.filter((r) => r.type === RelationshipTypes.FRIEND).map((r) => r.targetUserId.toString()),
);
const hasMutualFriends = recipientRelationships
.filter((r) => r.type === RelationshipTypes.FRIEND)
.some((r) => callerFriendIds.has(r.targetUserId.toString()));
if (hasMutualFriends) {
return {ringable: true, silent: shouldBeSilent};
}
}
if ((incomingCallFlags & IncomingCallFlags.GUILD_MEMBERS) === IncomingCallFlags.GUILD_MEMBERS) {
const callerGuildIds = new Set(
(await this.userRepository.getUserGuildIds(userId)).map((guildId) => guildId.toString()),
);
const recipientGuildIds = await this.userRepository.getUserGuildIds(recipientId);
const hasMutualGuilds = recipientGuildIds.some((guildId) => callerGuildIds.has(guildId.toString()));
if (hasMutualGuilds) {
return {ringable: true, silent: shouldBeSilent};
}
}
if ((incomingCallFlags & IncomingCallFlags.EVERYONE) === IncomingCallFlags.EVERYONE) {
return {ringable: true, silent: shouldBeSilent};
}
return {ringable: false};
}
return {ringable: true};
}
async createOrGetCall({
userId,
channelId,
region,
ringing,
requestCache,
latitude,
longitude,
}: {
userId: UserID;
channelId: ChannelID;
region?: string;
ringing: Array<UserID>;
requestCache: RequestCache;
latitude?: string;
longitude?: string;
}): Promise<CallData> {
const channel = await this.channelRepository.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeForCallError();
}
const recipientIds = Array.from(channel.recipientIds);
const channelRecipients = recipientIds.length > 0 ? await this.userRepository.listUsers(recipientIds) : [];
if (channel.type === ChannelTypes.DM) {
await this.dmPermissionValidator.validate({recipients: channelRecipients, userId});
}
const existingCall = await this.gatewayService.getCall(channelId);
if (existingCall) {
throw new CallAlreadyExistsError();
}
const selectedRegion = region || this.selectOptimalRegionForCall(userId, latitude, longitude);
const allRecipients = Array.from(new Set([userId, ...Array.from(channel.recipientIds)]));
const messageId = createMessageID(this.snowflakeService.generate());
await this.channelRepository.upsertMessage({
channel_id: channelId,
bucket: BucketUtils.makeBucket(messageId),
message_id: messageId,
author_id: userId,
type: MessageTypes.CALL,
webhook_id: null,
webhook_name: null,
webhook_avatar_hash: null,
content: null,
edited_timestamp: null,
pinned_timestamp: null,
flags: 0,
mention_everyone: false,
mention_users: new Set(),
mention_roles: null,
mention_channels: null,
attachments: null,
embeds: null,
sticker_items: null,
message_reference: null,
message_snapshots: null,
call: {
participant_ids: new Set(allRecipients),
ended_timestamp: null,
},
has_reaction: false,
version: 1,
});
const call = await this.gatewayService.createCall(
channelId,
messageId.toString(),
selectedRegion,
ringing.map((id) => id.toString()),
allRecipients.map((id) => id.toString()),
);
const author = await this.userRepository.findUnique(userId);
await incrementDmMentionCounts({
readStateService: this.readStateService,
user: author,
recipients: channelRecipients,
channelId,
});
const message = await this.channelRepository.getMessage(channelId, messageId);
if (message) {
const messageResponse = await mapMessageToResponse({
message,
userCacheService: this.userCacheService,
mediaService: this.mediaService,
requestCache,
getReferencedMessage: (channelId, messageId) => this.channelRepository.getMessage(channelId, messageId),
});
for (const recipientId of allRecipients) {
await this.gatewayService.dispatchPresence({
userId: recipientId,
event: 'MESSAGE_CREATE',
data: messageResponse,
});
}
}
return call;
}
async updateCall({channelId, region}: {userId: UserID; channelId: ChannelID; region?: string}): Promise<void> {
const channel = await this.channelRepository.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeForCallError();
}
const call = await this.gatewayService.getCall(channelId);
if (!call) {
throw new NoActiveCallError();
}
if (region) {
await this.gatewayService.updateCallRegion(channelId, region);
}
}
async ringCallRecipients({
userId,
channelId,
recipients,
requestCache,
latitude,
longitude,
}: {
userId: UserID;
channelId: ChannelID;
recipients?: Array<UserID>;
requestCache: RequestCache;
latitude?: string;
longitude?: string;
}): Promise<void> {
const channel = await this.channelRepository.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeForCallError();
}
if (channel.type === ChannelTypes.DM) {
const channelRecipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
await this.dmPermissionValidator.validate({recipients: channelRecipients, userId});
}
const recipientsToNotify = (recipients || Array.from(channel.recipientIds)).filter((id) => id !== userId);
const recipientsToRing: Array<UserID> = [];
if (channel.type === ChannelTypes.DM) {
const recipientId = Array.from(channel.recipientIds).find((id) => id !== userId);
if (recipientId) {
const recipientSettings = await this.userRepository.findSettings(recipientId);
if (recipientSettings) {
const incomingCallFlags = recipientSettings.incomingCallFlags;
const friendship = await this.userRepository.getRelationship(userId, recipientId, RelationshipTypes.FRIEND);
const areFriends = friendship !== null;
const shouldBeSilent =
!areFriends &&
(incomingCallFlags & IncomingCallFlags.SILENT_EVERYONE) === IncomingCallFlags.SILENT_EVERYONE;
if (!shouldBeSilent) {
recipientsToRing.push(recipientId);
}
} else {
recipientsToRing.push(recipientId);
}
}
} else {
for (const recipientId of recipientsToNotify) {
const recipientSettings = await this.userRepository.findSettings(recipientId);
if (recipientSettings) {
const incomingCallFlags = recipientSettings.incomingCallFlags;
const friendship = await this.userRepository.getRelationship(userId, recipientId, RelationshipTypes.FRIEND);
const areFriends = friendship !== null;
const shouldBeSilent =
!areFriends &&
(incomingCallFlags & IncomingCallFlags.SILENT_EVERYONE) === IncomingCallFlags.SILENT_EVERYONE;
if (!shouldBeSilent) {
recipientsToRing.push(recipientId);
}
} else {
recipientsToRing.push(recipientId);
}
}
}
if (channel.type === ChannelTypes.DM) {
const callerHasChannelOpen = await this.userRepository.isDmChannelOpen(userId, channelId);
if (!callerHasChannelOpen) {
await this.userRepository.openDmForUser(userId, channelId);
const channelResponse = await mapChannelToResponse({
channel,
currentUserId: userId,
userCacheService: this.userCacheService,
requestCache,
});
await this.gatewayService.dispatchPresence({
userId,
event: 'CHANNEL_CREATE',
data: channelResponse,
});
}
for (const recipientId of recipientsToNotify) {
const isOpen = await this.userRepository.isDmChannelOpen(recipientId, channelId);
if (!isOpen) {
await this.userRepository.openDmForUser(recipientId, channelId);
const channelResponse = await mapChannelToResponse({
channel,
currentUserId: recipientId,
userCacheService: this.userCacheService,
requestCache,
});
await this.gatewayService.dispatchPresence({
userId: recipientId,
event: 'CHANNEL_CREATE',
data: channelResponse,
});
}
}
}
const existingCall = await this.gatewayService.getCall(channelId);
if (!existingCall) {
await this.createOrGetCall({
userId,
channelId,
ringing: recipientsToRing,
requestCache,
latitude,
longitude,
});
} else {
await this.gatewayService.ringCallRecipients(
channelId,
recipientsToRing.map((id) => id.toString()),
);
}
}
async stopRingingCallRecipients({
userId,
channelId,
recipients,
}: {
userId: UserID;
channelId: ChannelID;
recipients?: Array<UserID>;
}): Promise<void> {
const channel = await this.channelRepository.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeForCallError();
}
const call = await this.gatewayService.getCall(channelId);
if (!call) {
throw new NoActiveCallError();
}
const toStop = recipients || [userId];
await this.gatewayService.stopRingingCallRecipients(
channelId,
toStop.map((id) => id.toString()),
);
}
private selectOptimalRegionForCall(userId: UserID, latitude?: string, longitude?: string): string {
if (!this.voiceAvailabilityService) {
return FALLBACK_VOICE_REGION;
}
const context: VoiceAccessContext = {
requestingUserId: userId,
};
const availableRegions = this.voiceAvailabilityService.getAvailableRegions(context);
const accessibleRegions = availableRegions.filter((r) => r.isAccessible);
if (accessibleRegions.length === 0) {
throw new Error('No accessible voice regions available');
}
if (latitude && longitude) {
const userLat = parseFloat(latitude);
const userLon = parseFloat(longitude);
if (!Number.isNaN(userLat) && !Number.isNaN(userLon)) {
let closestRegion: string | null = null;
let minDistance = Number.POSITIVE_INFINITY;
for (const region of accessibleRegions) {
const distance = calculateDistance(userLat, userLon, region.latitude, region.longitude);
if (distance < minDistance) {
minDistance = distance;
closestRegion = region.id;
}
}
if (closestRegion) {
return closestRegion;
}
}
}
const defaultRegion = accessibleRegions.find((r) => r.isDefault);
return defaultRegion ? defaultRegion.id : accessibleRegions[0].id;
}
async updateCallMessageEnded({
channelId,
messageId,
participants,
endedTimestamp,
}: {
channelId: ChannelID;
messageId: MessageID;
participants: Array<UserID>;
endedTimestamp: Date;
}): Promise<void> {
const message = await this.channelRepository.getMessage(channelId, messageId);
if (!message) {
return;
}
if (message.type !== MessageTypes.CALL) {
return;
}
const messageRow = message.toRow();
await this.channelRepository.upsertMessage({
...messageRow,
call: {
participant_ids: new Set(participants),
ended_timestamp: endedTimestamp,
},
});
}
}

View File

@@ -0,0 +1,298 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type ChannelID, createUserID, type UserID} from '~/BrandedTypes';
import {ChannelTypes} from '~/Constants';
import {
type ChannelPartialResponse,
type ChannelUpdateRequest,
mapChannelToPartialResponse,
} from '~/channel/ChannelModel';
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {AvatarService} from '~/infrastructure/AvatarService';
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {ILiveKitService} from '~/infrastructure/ILiveKitService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {IVoiceRoomStore} from '~/infrastructure/IVoiceRoomStore';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {IInviteRepository} from '~/invite/IInviteRepository';
import type {Channel} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import type {VoiceAvailabilityService} from '~/voice/VoiceAvailabilityService';
import type {VoiceRegionAvailability} from '~/voice/VoiceModel';
import type {IWebhookRepository} from '~/webhook/IWebhookRepository';
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
import type {AuthenticatedChannel} from './AuthenticatedChannel';
import {ChannelAuthService} from './channel_data/ChannelAuthService';
import {ChannelOperationsService, type ChannelUpdateData} from './channel_data/ChannelOperationsService';
import {ChannelUtilsService} from './channel_data/ChannelUtilsService';
import {GroupDmUpdateService} from './channel_data/GroupDmUpdateService';
import type {MessagePersistenceService} from './message/MessagePersistenceService';
type GuildChannelUpdateRequest = Exclude<ChannelUpdateRequest, {type: typeof ChannelTypes.GROUP_DM}>;
type GuildChannelUpdatePayload = Omit<GuildChannelUpdateRequest, 'type'>;
export class ChannelDataService {
private channelAuthService: ChannelAuthService;
private channelOperationsService: ChannelOperationsService;
public readonly groupDmUpdateService: GroupDmUpdateService;
private channelUtilsService: ChannelUtilsService;
constructor(
channelRepository: IChannelRepositoryAggregate,
userRepository: IUserRepository,
guildRepository: IGuildRepository,
userCacheService: UserCacheService,
storageService: IStorageService,
gatewayService: IGatewayService,
mediaService: IMediaService,
avatarService: AvatarService,
snowflakeService: SnowflakeService,
cloudflarePurgeQueue: ICloudflarePurgeQueue,
voiceRoomStore: IVoiceRoomStore,
liveKitService: ILiveKitService,
voiceAvailabilityService: VoiceAvailabilityService | undefined,
messagePersistenceService: MessagePersistenceService,
guildAuditLogService: GuildAuditLogService,
inviteRepository: IInviteRepository,
webhookRepository: IWebhookRepository,
) {
this.channelUtilsService = new ChannelUtilsService(
channelRepository,
userCacheService,
storageService,
gatewayService,
cloudflarePurgeQueue,
mediaService,
);
this.channelAuthService = new ChannelAuthService(
channelRepository,
userRepository,
guildRepository,
gatewayService,
);
this.channelOperationsService = new ChannelOperationsService(
channelRepository,
userRepository,
gatewayService,
this.channelAuthService,
this.channelUtilsService,
voiceRoomStore,
liveKitService,
voiceAvailabilityService,
guildAuditLogService,
inviteRepository,
webhookRepository,
);
this.groupDmUpdateService = new GroupDmUpdateService(
channelRepository,
avatarService,
snowflakeService,
this.channelUtilsService,
messagePersistenceService,
);
}
async getChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<Channel> {
return this.channelOperationsService.getChannel({userId, channelId});
}
async getChannelAuthenticated({
userId,
channelId,
}: {
userId: UserID;
channelId: ChannelID;
}): Promise<AuthenticatedChannel> {
return this.channelAuthService.getChannelAuthenticated({userId, channelId});
}
async getPublicChannelData(channelId: ChannelID): Promise<ChannelPartialResponse> {
const channel = await this.channelOperationsService.getPublicChannelData(channelId);
return mapChannelToPartialResponse(channel);
}
async getChannelMemberCount(channelId: ChannelID): Promise<number> {
return this.channelOperationsService.getChannelMemberCount(channelId);
}
async getChannelSystem(channelId: ChannelID): Promise<Channel | null> {
return this.channelOperationsService.getChannelSystem(channelId);
}
async editChannel({
userId,
channelId,
data,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
data: Omit<ChannelUpdateRequest, 'type'>;
requestCache: RequestCache;
}): Promise<Channel> {
const {channel} = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
if (channel.type === ChannelTypes.GROUP_DM) {
return await this.updateGroupDmChannel({
userId,
channelId,
name: data.name !== undefined ? data.name : undefined,
icon: data.icon !== undefined ? data.icon : undefined,
ownerId: data.owner_id ? createUserID(data.owner_id) : undefined,
nicks: data.nicks,
requestCache,
});
}
const guildChannelData = data as GuildChannelUpdatePayload;
const channelUpdateData: ChannelUpdateData = {};
if ('name' in guildChannelData && guildChannelData.name !== undefined && guildChannelData.name !== null) {
channelUpdateData.name = guildChannelData.name;
}
if (guildChannelData.topic !== undefined) {
channelUpdateData.topic = guildChannelData.topic ?? null;
}
if (guildChannelData.url !== undefined) {
channelUpdateData.url = guildChannelData.url ?? null;
}
if (guildChannelData.parent_id !== undefined) {
channelUpdateData.parent_id = guildChannelData.parent_id ?? null;
}
if (guildChannelData.bitrate !== undefined) {
channelUpdateData.bitrate = guildChannelData.bitrate ?? null;
}
if (guildChannelData.user_limit !== undefined) {
channelUpdateData.user_limit = guildChannelData.user_limit ?? null;
}
if (guildChannelData.nsfw !== undefined) {
channelUpdateData.nsfw = guildChannelData.nsfw ?? undefined;
}
if (guildChannelData.rate_limit_per_user !== undefined) {
channelUpdateData.rate_limit_per_user = guildChannelData.rate_limit_per_user ?? undefined;
}
if (guildChannelData.permission_overwrites !== undefined) {
channelUpdateData.permission_overwrites = guildChannelData.permission_overwrites ?? null;
}
if (guildChannelData.rtc_region !== undefined) {
channelUpdateData.rtc_region = guildChannelData.rtc_region ?? null;
}
if (guildChannelData.icon !== undefined) {
channelUpdateData.icon = guildChannelData.icon ?? null;
}
if (guildChannelData.owner_id !== undefined) {
channelUpdateData.owner_id = guildChannelData.owner_id ?? null;
}
if (guildChannelData.nicks !== undefined) {
channelUpdateData.nicks = guildChannelData.nicks ?? null;
}
return this.channelOperationsService.editChannel({userId, channelId, data: channelUpdateData, requestCache});
}
async deleteChannel({
userId,
channelId,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
requestCache: RequestCache;
}): Promise<void> {
return this.channelOperationsService.deleteChannel({userId, channelId, requestCache});
}
async getAvailableRtcRegions({
userId,
channelId,
}: {
userId: UserID;
channelId: ChannelID;
}): Promise<Array<VoiceRegionAvailability>> {
return this.channelOperationsService.getAvailableRtcRegions({userId, channelId});
}
async updateGroupDmChannel({
userId,
channelId,
name,
icon,
ownerId,
nicks,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
name?: string | null;
icon?: string | null;
ownerId?: UserID;
nicks?: Record<string, string | null> | null;
requestCache: RequestCache;
}): Promise<Channel> {
return this.groupDmUpdateService.updateGroupDmChannel({
userId,
channelId,
name,
icon,
ownerId,
nicks,
requestCache,
});
}
async setChannelPermissionOverwrite(params: {
userId: UserID;
channelId: ChannelID;
overwriteId: bigint;
overwrite: {type: number; allow_: bigint; deny_: bigint};
requestCache: RequestCache;
}) {
return this.channelOperationsService.setChannelPermissionOverwrite(params);
}
async deleteChannelPermissionOverwrite(params: {
userId: UserID;
channelId: ChannelID;
overwriteId: bigint;
requestCache: RequestCache;
}) {
return this.channelOperationsService.deleteChannelPermissionOverwrite(params);
}
}

View File

@@ -0,0 +1,394 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID} from '~/BrandedTypes';
import {TEXT_BASED_CHANNEL_TYPES} from '~/Constants';
import type {MessageRequest} from '~/channel/ChannelModel';
import {CannotSendMessageToNonTextChannelError} from '~/Errors';
import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository';
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {AvatarService} from '~/infrastructure/AvatarService';
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
import type {EmbedService} from '~/infrastructure/EmbedService';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {ILiveKitService} from '~/infrastructure/ILiveKitService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {IVirusScanService} from '~/infrastructure/IVirusScanService';
import type {IVoiceRoomStore} from '~/infrastructure/IVoiceRoomStore';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {IInviteRepository} from '~/invite/IInviteRepository';
import type {User} from '~/Models';
import type {PackService} from '~/pack/PackService';
import type {ReadStateService} from '~/read_state/ReadStateService';
import type {IUserRepository} from '~/user/IUserRepository';
import type {VoiceAvailabilityService} from '~/voice/VoiceAvailabilityService';
import type {IWebhookRepository} from '~/webhook/IWebhookRepository';
import type {IWorkerService} from '~/worker/IWorkerService';
import type {IChannelRepository} from '../IChannelRepository';
import {AttachmentUploadService} from './AttachmentUploadService';
import {CallService} from './CallService';
import {ChannelDataService} from './ChannelDataService';
import {GroupDmService} from './GroupDmService';
import {MessageInteractionService} from './MessageInteractionService';
import {MessageService} from './MessageService';
import {MessagePersistenceService} from './message/MessagePersistenceService';
export class ChannelService {
public readonly channelData: ChannelDataService;
public readonly messages: MessageService;
public readonly interactions: MessageInteractionService;
public readonly attachments: AttachmentUploadService;
public readonly groupDms: GroupDmService;
public readonly calls: CallService;
constructor(
channelRepository: IChannelRepository,
userRepository: IUserRepository,
guildRepository: IGuildRepository,
packService: PackService,
userCacheService: UserCacheService,
embedService: EmbedService,
readStateService: ReadStateService,
cacheService: ICacheService,
storageService: IStorageService,
gatewayService: IGatewayService,
mediaService: IMediaService,
avatarService: AvatarService,
workerService: IWorkerService,
virusScanService: IVirusScanService,
snowflakeService: SnowflakeService,
rateLimitService: IRateLimitService,
cloudflarePurgeQueue: ICloudflarePurgeQueue,
favoriteMemeRepository: IFavoriteMemeRepository,
guildAuditLogService: GuildAuditLogService,
voiceRoomStore: IVoiceRoomStore,
liveKitService: ILiveKitService,
inviteRepository: IInviteRepository,
webhookRepository: IWebhookRepository,
voiceAvailabilityService?: VoiceAvailabilityService,
) {
const messagePersistenceService = new MessagePersistenceService(
channelRepository,
userRepository,
guildRepository,
packService,
embedService,
storageService,
mediaService,
virusScanService,
snowflakeService,
readStateService,
);
this.channelData = new ChannelDataService(
channelRepository,
userRepository,
guildRepository,
userCacheService,
storageService,
gatewayService,
mediaService,
avatarService,
snowflakeService,
cloudflarePurgeQueue,
voiceRoomStore,
liveKitService,
voiceAvailabilityService,
messagePersistenceService,
guildAuditLogService,
inviteRepository,
webhookRepository,
);
this.messages = new MessageService(
channelRepository,
userRepository,
guildRepository,
userCacheService,
readStateService,
cacheService,
storageService,
gatewayService,
mediaService,
workerService,
snowflakeService,
rateLimitService,
cloudflarePurgeQueue,
favoriteMemeRepository,
guildAuditLogService,
messagePersistenceService,
);
this.interactions = new MessageInteractionService(
channelRepository,
userRepository,
guildRepository,
userCacheService,
readStateService,
gatewayService,
mediaService,
snowflakeService,
messagePersistenceService,
guildAuditLogService,
);
this.attachments = new AttachmentUploadService(
channelRepository,
userRepository,
storageService,
cloudflarePurgeQueue,
this.interactions.getChannelAuthenticated.bind(this.interactions),
(channel) => {
if (!channel) throw new Error('Channel is required');
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
throw new CannotSendMessageToNonTextChannelError();
}
},
this.interactions.dispatchMessageUpdate.bind(this.interactions),
this.messages.deleteMessage.bind(this.messages),
);
this.groupDms = new GroupDmService(
channelRepository,
userRepository,
guildRepository,
userCacheService,
gatewayService,
mediaService,
snowflakeService,
this.channelData.groupDmUpdateService,
this.messages.getMessagePersistenceService(),
);
this.calls = new CallService(
channelRepository,
userRepository,
guildRepository,
gatewayService,
userCacheService,
mediaService,
snowflakeService,
readStateService,
voiceAvailabilityService,
);
}
async getChannel(...args: Parameters<ChannelDataService['getChannel']>) {
return this.channelData.getChannel(...args);
}
async getChannelAuthenticated(...args: Parameters<ChannelDataService['getChannelAuthenticated']>) {
return this.channelData.getChannelAuthenticated(...args);
}
async getPublicChannelData(...args: Parameters<ChannelDataService['getPublicChannelData']>) {
return this.channelData.getPublicChannelData(...args);
}
async getChannelMemberCount(...args: Parameters<ChannelDataService['getChannelMemberCount']>) {
return this.channelData.getChannelMemberCount(...args);
}
async getChannelSystem(...args: Parameters<ChannelDataService['getChannelSystem']>) {
return this.channelData.getChannelSystem(...args);
}
async editChannel(...args: Parameters<ChannelDataService['editChannel']>) {
return this.channelData.editChannel(...args);
}
async deleteChannel(...args: Parameters<ChannelDataService['deleteChannel']>) {
return this.channelData.deleteChannel(...args);
}
async getAvailableRtcRegions(...args: Parameters<ChannelDataService['getAvailableRtcRegions']>) {
return this.channelData.getAvailableRtcRegions(...args);
}
async sendMessage(...args: Parameters<MessageService['sendMessage']>) {
return this.messages.sendMessage(...args);
}
async sendWebhookMessage(...args: Parameters<MessageService['sendWebhookMessage']>) {
return this.messages.sendWebhookMessage(...args);
}
async validateMessageCanBeSent({
user,
channelId,
data,
}: {
user: User;
channelId: ChannelID;
data: MessageRequest;
}): Promise<void> {
return this.messages.validateMessageCanBeSent({user, channelId, data});
}
async editMessage(...args: Parameters<MessageService['editMessage']>) {
return this.messages.editMessage(...args);
}
async deleteMessage(...args: Parameters<MessageService['deleteMessage']>) {
return this.messages.deleteMessage(...args);
}
async getMessage(...args: Parameters<MessageService['getMessage']>) {
return this.messages.getMessage(...args);
}
async getMessages(...args: Parameters<MessageService['getMessages']>) {
return this.messages.getMessages(...args);
}
async bulkDeleteMessages(...args: Parameters<MessageService['bulkDeleteMessages']>) {
return this.messages.bulkDeleteMessages(...args);
}
async deleteUserMessagesInGuild(...args: Parameters<MessageService['deleteUserMessagesInGuild']>) {
return this.messages.deleteUserMessagesInGuild(...args);
}
async sendJoinSystemMessage(...args: Parameters<MessageService['sendJoinSystemMessage']>) {
return this.messages.sendJoinSystemMessage(...args);
}
async searchMessages(...args: Parameters<MessageService['searchMessages']>) {
return this.messages.searchMessages(...args);
}
async anonymizeMessagesByAuthor(...args: Parameters<MessageService['anonymizeMessagesByAuthor']>) {
return this.messages.anonymizeMessagesByAuthor(...args);
}
async addReaction(...args: Parameters<MessageInteractionService['addReaction']>) {
return this.interactions.addReaction(...args);
}
async removeReaction(...args: Parameters<MessageInteractionService['removeReaction']>) {
return this.interactions.removeReaction(...args);
}
async removeOwnReaction(...args: Parameters<MessageInteractionService['removeOwnReaction']>) {
return this.interactions.removeOwnReaction(...args);
}
async removeAllReactions(...args: Parameters<MessageInteractionService['removeAllReactions']>) {
return this.interactions.removeAllReactions(...args);
}
async removeAllReactionsForEmoji(...args: Parameters<MessageInteractionService['removeAllReactionsForEmoji']>) {
return this.interactions.removeAllReactionsForEmoji(...args);
}
async getUsersForReaction(...args: Parameters<MessageInteractionService['getUsersForReaction']>) {
return this.interactions.getUsersForReaction(...args);
}
async getMessageReactions(...args: Parameters<MessageInteractionService['getMessageReactions']>) {
return this.interactions.getMessageReactions(...args);
}
async setHasReaction(...args: Parameters<MessageInteractionService['setHasReaction']>) {
return this.interactions.setHasReaction(...args);
}
async pinMessage(...args: Parameters<MessageInteractionService['pinMessage']>) {
return this.interactions.pinMessage(...args);
}
async unpinMessage(...args: Parameters<MessageInteractionService['unpinMessage']>) {
return this.interactions.unpinMessage(...args);
}
async getChannelPins(...args: Parameters<MessageInteractionService['getChannelPins']>) {
return this.interactions.getChannelPins(...args);
}
async startTyping(...args: Parameters<MessageInteractionService['startTyping']>) {
return this.interactions.startTyping(...args);
}
async ackMessage(...args: Parameters<MessageInteractionService['ackMessage']>) {
return this.interactions.ackMessage(...args);
}
async deleteReadState(...args: Parameters<MessageInteractionService['deleteReadState']>) {
return this.interactions.deleteReadState(...args);
}
async ackPins(...args: Parameters<MessageInteractionService['ackPins']>) {
return this.interactions.ackPins(...args);
}
async uploadFormDataAttachments(...args: Parameters<AttachmentUploadService['uploadFormDataAttachments']>) {
return this.attachments.uploadFormDataAttachments(...args);
}
async deleteAttachment(...args: Parameters<AttachmentUploadService['deleteAttachment']>) {
return this.attachments.deleteAttachment(...args);
}
async purgeChannelAttachments(...args: Parameters<AttachmentUploadService['purgeChannelAttachments']>) {
return this.attachments.purgeChannelAttachments(...args);
}
async removeRecipientFromChannel(...args: Parameters<GroupDmService['removeRecipientFromChannel']>) {
return this.groupDms.removeRecipientFromChannel(...args);
}
async updateGroupDmChannel(...args: Parameters<GroupDmService['updateGroupDmChannel']>) {
return this.groupDms.updateGroupDmChannel(...args);
}
async checkCallEligibility(...args: Parameters<CallService['checkCallEligibility']>) {
return this.calls.checkCallEligibility(...args);
}
async createOrGetCall(...args: Parameters<CallService['createOrGetCall']>) {
return this.calls.createOrGetCall(...args);
}
async updateCall(...args: Parameters<CallService['updateCall']>) {
return this.calls.updateCall(...args);
}
async ringCallRecipients(...args: Parameters<CallService['ringCallRecipients']>) {
return this.calls.ringCallRecipients(...args);
}
async stopRingingCallRecipients(...args: Parameters<CallService['stopRingingCallRecipients']>) {
return this.calls.stopRingingCallRecipients(...args);
}
async setChannelPermissionOverwrite(params: Parameters<ChannelDataService['setChannelPermissionOverwrite']>[0]) {
return this.channelData.setChannelPermissionOverwrite(params);
}
async deleteChannelPermissionOverwrite(
params: Parameters<ChannelDataService['deleteChannelPermissionOverwrite']>[0],
) {
return this.channelData.deleteChannelPermissionOverwrite(params);
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '~/BrandedTypes';
import {RelationshipTypes, UserFlags} from '~/Constants';
import {CannotSendMessagesToUserError, UnclaimedAccountRestrictedError} from '~/Errors';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {User} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
import {checkGuildVerificationWithGuildModel} from '~/utils/GuildVerificationUtils';
interface DMPermissionValidatorDeps {
userRepository: IUserRepository;
guildRepository: IGuildRepository;
}
export class DMPermissionValidator {
constructor(private deps: DMPermissionValidatorDeps) {}
async validate({recipients, userId}: {recipients: Array<User>; userId: UserID}): Promise<void> {
const senderUser = await this.deps.userRepository.findUnique(userId);
if (senderUser && !senderUser.passwordHash && !senderUser.isBot) {
throw new UnclaimedAccountRestrictedError('send direct messages');
}
const targetUser = recipients.find((recipient) => recipient.id !== userId);
if (!targetUser) return;
if (!targetUser.passwordHash && !targetUser.isBot) {
throw new UnclaimedAccountRestrictedError('receive direct messages');
}
const senderBlockedTarget = await this.deps.userRepository.getRelationship(
userId,
targetUser.id,
RelationshipTypes.BLOCKED,
);
if (senderBlockedTarget) {
throw new CannotSendMessagesToUserError();
}
const targetBlockedSender = await this.deps.userRepository.getRelationship(
targetUser.id,
userId,
RelationshipTypes.BLOCKED,
);
if (targetBlockedSender) {
throw new CannotSendMessagesToUserError();
}
const friendship = await this.deps.userRepository.getRelationship(userId, targetUser.id, RelationshipTypes.FRIEND);
if (friendship) return;
if (targetUser.flags & UserFlags.APP_STORE_REVIEWER) {
throw new CannotSendMessagesToUserError();
}
const targetSettings = await this.deps.userRepository.findSettings(targetUser.id);
if (!targetSettings) return;
const dmRestrictionsEnabled = targetSettings.defaultGuildsRestricted || targetSettings.restrictedGuilds.size > 0;
if (!dmRestrictionsEnabled) {
return;
}
const [userGuilds, targetGuilds] = await Promise.all([
this.deps.guildRepository.listUserGuilds(userId),
this.deps.guildRepository.listUserGuilds(targetUser.id),
]);
if (!senderUser) {
throw new CannotSendMessagesToUserError();
}
const userGuildIds = new Set(userGuilds.map((guild) => guild.id));
const mutualGuilds = targetGuilds.filter((guild) => userGuildIds.has(guild.id));
if (mutualGuilds.length === 0) {
throw new CannotSendMessagesToUserError();
}
const restrictedGuildIds = targetSettings.restrictedGuilds;
let hasValidMutualGuild = false;
for (const guild of mutualGuilds) {
if (restrictedGuildIds.has(guild.id)) {
continue;
}
const member = await this.deps.guildRepository.getMember(guild.id, userId);
if (!member) {
continue;
}
try {
checkGuildVerificationWithGuildModel({user: senderUser, guild, member});
hasValidMutualGuild = true;
break;
} catch {}
}
if (!hasValidMutualGuild) {
throw new CannotSendMessagesToUserError();
}
}
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, UserID} from '~/BrandedTypes';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Channel} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IChannelRepository} from '../IChannelRepository';
import type {GroupDmUpdateService} from './channel_data/GroupDmUpdateService';
import {GroupDmOperationsService} from './group_dm/GroupDmOperationsService';
import type {MessagePersistenceService} from './message/MessagePersistenceService';
export class GroupDmService {
private operationsService: GroupDmOperationsService;
constructor(
channelRepository: IChannelRepository,
userRepository: IUserRepository,
guildRepository: IGuildRepository,
userCacheService: UserCacheService,
gatewayService: IGatewayService,
mediaService: IMediaService,
snowflakeService: SnowflakeService,
groupDmUpdateService: GroupDmUpdateService,
messagePersistenceService: MessagePersistenceService,
) {
this.operationsService = new GroupDmOperationsService(
channelRepository,
userRepository,
guildRepository,
userCacheService,
gatewayService,
mediaService,
snowflakeService,
groupDmUpdateService,
messagePersistenceService,
);
}
async addRecipientToChannel(params: {
userId: UserID;
channelId: ChannelID;
recipientId: UserID;
requestCache: RequestCache;
}): Promise<Channel> {
return this.operationsService.addRecipientToChannel(params);
}
async addRecipientViaInvite(params: {
channelId: ChannelID;
recipientId: UserID;
inviterId?: UserID | null;
requestCache: RequestCache;
}): Promise<Channel> {
return this.operationsService.addRecipientViaInvite(params);
}
async removeRecipientFromChannel(params: {
userId: UserID;
channelId: ChannelID;
recipientId: UserID;
requestCache: RequestCache;
silent?: boolean;
}): Promise<void> {
return this.operationsService.removeRecipientFromChannel(params);
}
async updateGroupDmChannel(params: {
userId: UserID;
channelId: ChannelID;
name?: string;
icon?: string | null;
ownerId?: UserID;
nicks?: Record<string, string | null> | null;
requestCache: RequestCache;
}): Promise<Channel> {
return this.operationsService.updateGroupDmChannel(params);
}
}

View File

@@ -0,0 +1,349 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type ChannelID, channelIdToUserId, type MessageID, type UserID} from '~/BrandedTypes';
import {ChannelTypes, type GatewayDispatchEvent, Permissions} from '~/Constants';
import type {ChannelPinResponse} from '~/channel/ChannelModel';
import {mapMessageToResponse} from '~/channel/ChannelModel';
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Channel, Message, MessageReaction} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {ReadStateService} from '~/read_state/ReadStateService';
import type {IUserRepository} from '~/user/IUserRepository';
import type {UserPartialResponse} from '~/user/UserModel';
import type {IChannelRepository} from '../IChannelRepository';
import {MessageInteractionAuthService} from './interaction/MessageInteractionAuthService';
import {MessagePinService} from './interaction/MessagePinService';
import {MessageReactionService} from './interaction/MessageReactionService';
import {MessageReadStateService} from './interaction/MessageReadStateService';
import type {MessagePersistenceService} from './message/MessagePersistenceService';
export class MessageInteractionService {
private authService: MessageInteractionAuthService;
private readStateService: MessageReadStateService;
private pinService: MessagePinService;
private reactionService: MessageReactionService;
constructor(
channelRepository: IChannelRepository,
userRepository: IUserRepository,
guildRepository: IGuildRepository,
private userCacheService: UserCacheService,
readStateService: ReadStateService,
private gatewayService: IGatewayService,
private mediaService: IMediaService,
snowflakeService: SnowflakeService,
messagePersistenceService: MessagePersistenceService,
guildAuditLogService: GuildAuditLogService,
) {
this.authService = new MessageInteractionAuthService(
channelRepository,
userRepository,
guildRepository,
gatewayService,
);
this.readStateService = new MessageReadStateService(gatewayService, readStateService);
this.pinService = new MessagePinService(
gatewayService,
channelRepository,
userCacheService,
mediaService,
snowflakeService,
messagePersistenceService,
guildAuditLogService,
);
this.reactionService = new MessageReactionService(
gatewayService,
channelRepository,
userRepository,
guildRepository,
);
}
async startTyping({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
await authChannel.checkPermission(Permissions.SEND_MESSAGES);
await this.readStateService.startTyping({authChannel, userId});
}
async ackMessage({
userId,
channelId,
messageId,
mentionCount,
manual,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
mentionCount: number;
manual?: boolean;
}): Promise<void> {
await this.authService.getChannelAuthenticated({userId, channelId});
await this.readStateService.ackMessage({userId, channelId, messageId, mentionCount, manual});
}
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
await this.readStateService.deleteReadState({userId, channelId});
}
async ackPins({
userId,
channelId,
timestamp,
}: {
userId: UserID;
channelId: ChannelID;
timestamp: Date;
}): Promise<void> {
await this.authService.getChannelAuthenticated({userId, channelId});
await this.readStateService.ackPins({userId, channelId, timestamp});
}
async getChannelPins({
userId,
channelId,
requestCache,
beforeTimestamp,
limit,
}: {
userId: UserID;
channelId: ChannelID;
requestCache: RequestCache;
beforeTimestamp?: Date;
limit?: number;
}): Promise<{items: Array<ChannelPinResponse>; has_more: boolean}> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
return this.pinService.getChannelPins({authChannel, requestCache, beforeTimestamp, limit});
}
async pinMessage({
userId,
channelId,
messageId,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
await this.authService.validateDMSendPermissions({channelId, userId});
}
await this.pinService.pinMessage({authChannel, messageId, userId, requestCache});
}
async unpinMessage({
userId,
channelId,
messageId,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
await this.authService.validateDMSendPermissions({channelId, userId});
}
await this.pinService.unpinMessage({authChannel, messageId, userId, requestCache});
}
async getUsersForReaction({
userId,
channelId,
messageId,
emoji,
limit,
after,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
emoji: string;
limit?: number;
after?: UserID;
}): Promise<Array<UserPartialResponse>> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
return this.reactionService.getUsersForReaction({authChannel, messageId, emoji, limit, after});
}
async addReaction({
userId,
sessionId,
channelId,
messageId,
emoji,
}: {
userId: UserID;
sessionId?: string;
channelId: ChannelID;
messageId: MessageID;
emoji: string;
requestCache: RequestCache;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
await this.reactionService.addReaction({authChannel, messageId, emoji, userId, sessionId});
}
async removeReaction({
userId,
sessionId,
channelId,
messageId,
emoji,
targetId,
}: {
userId: UserID;
sessionId?: string;
channelId: ChannelID;
messageId: MessageID;
emoji: string;
targetId: UserID;
requestCache: RequestCache;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
await this.reactionService.removeReaction({authChannel, messageId, emoji, targetId, sessionId, actorId: userId});
}
async removeOwnReaction({
userId,
sessionId,
channelId,
messageId,
emoji,
requestCache,
}: {
userId: UserID;
sessionId?: string;
channelId: ChannelID;
messageId: MessageID;
emoji: string;
requestCache: RequestCache;
}): Promise<void> {
await this.removeReaction({userId, sessionId, channelId, messageId, emoji, targetId: userId, requestCache});
}
async removeAllReactionsForEmoji({
userId,
channelId,
messageId,
emoji,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
emoji: string;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
await this.reactionService.removeAllReactionsForEmoji({authChannel, messageId, emoji, actorId: userId});
}
async removeAllReactions({
userId,
channelId,
messageId,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
await this.reactionService.removeAllReactions({authChannel, messageId, actorId: userId});
}
async getMessageReactions({
userId,
channelId,
messageId,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
}): Promise<Array<MessageReaction>> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
return this.reactionService.getMessageReactions({authChannel, messageId});
}
async setHasReaction(channelId: ChannelID, messageId: MessageID, hasReaction: boolean): Promise<void> {
return this.reactionService.setHasReaction(channelId, messageId, hasReaction);
}
async getChannelAuthenticated({userId, channelId}: {userId: UserID; channelId: ChannelID}) {
return this.authService.getChannelAuthenticated({userId, channelId});
}
async dispatchMessageUpdate({
channel,
message,
requestCache,
currentUserId,
}: {
channel: Channel;
message: Message;
requestCache: RequestCache;
currentUserId?: UserID;
}): Promise<void> {
const messageResponse = await mapMessageToResponse({
message,
currentUserId,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
});
await this.dispatchEvent({
channel,
event: 'MESSAGE_UPDATE',
data: messageResponse,
});
}
private async dispatchEvent(params: {channel: Channel; event: GatewayDispatchEvent; data: unknown}): Promise<void> {
const {channel, event, data} = params;
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return this.gatewayService.dispatchPresence({
userId: channelIdToUserId(channel.id),
event,
data,
});
}
if (channel.guildId) {
return this.gatewayService.dispatchGuild({guildId: channel.guildId, event, data});
} else {
for (const recipientId of channel.recipientIds) {
await this.gatewayService.dispatchPresence({userId: recipientId, event, data});
}
}
}
}

View File

@@ -0,0 +1,312 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, MessageID, UserID} from '~/BrandedTypes';
import type {
MessageRequest,
MessageSearchRequest,
MessageSearchResponse,
MessageUpdateRequest,
} from '~/channel/ChannelModel';
import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository';
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Message, User, Webhook} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {ReadStateService} from '~/read_state/ReadStateService';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IWorkerService} from '~/worker/IWorkerService';
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
import {MessageAnonymizationService} from './message/MessageAnonymizationService';
import {MessageChannelAuthService} from './message/MessageChannelAuthService';
import {MessageDispatchService} from './message/MessageDispatchService';
import {MessageMentionService} from './message/MessageMentionService';
import {MessageOperationsService} from './message/MessageOperationsService';
import type {MessagePersistenceService} from './message/MessagePersistenceService';
import {MessageProcessingService} from './message/MessageProcessingService';
import {MessageRetrievalService} from './message/MessageRetrievalService';
import {MessageSearchService} from './message/MessageSearchService';
import {MessageSystemService} from './message/MessageSystemService';
import {MessageValidationService} from './message/MessageValidationService';
export class MessageService {
private validationService: MessageValidationService;
private mentionService: MessageMentionService;
private searchService: MessageSearchService;
private persistenceService: MessagePersistenceService;
private channelAuthService: MessageChannelAuthService;
private dispatchService: MessageDispatchService;
private processingService: MessageProcessingService;
private systemService: MessageSystemService;
private operationsService: MessageOperationsService;
private retrievalService: MessageRetrievalService;
private anonymizationService: MessageAnonymizationService;
constructor(
channelRepository: IChannelRepositoryAggregate,
userRepository: IUserRepository,
guildRepository: IGuildRepository,
userCacheService: UserCacheService,
readStateService: ReadStateService,
cacheService: ICacheService,
storageService: IStorageService,
gatewayService: IGatewayService,
mediaService: IMediaService,
workerService: IWorkerService,
snowflakeService: SnowflakeService,
rateLimitService: IRateLimitService,
cloudflarePurgeQueue: ICloudflarePurgeQueue,
favoriteMemeRepository: IFavoriteMemeRepository,
guildAuditLogService: GuildAuditLogService,
persistenceService: MessagePersistenceService,
) {
this.validationService = new MessageValidationService(cacheService);
this.mentionService = new MessageMentionService(userRepository, guildRepository, workerService);
this.searchService = new MessageSearchService(userRepository, workerService);
this.persistenceService = persistenceService;
this.channelAuthService = new MessageChannelAuthService(
channelRepository,
userRepository,
guildRepository,
gatewayService,
);
this.dispatchService = new MessageDispatchService(
gatewayService,
userCacheService,
mediaService,
channelRepository,
);
this.processingService = new MessageProcessingService(
channelRepository,
userRepository,
userCacheService,
gatewayService,
readStateService,
this.mentionService,
);
this.systemService = new MessageSystemService(
channelRepository,
guildRepository,
snowflakeService,
this.persistenceService,
);
this.operationsService = new MessageOperationsService(
channelRepository,
userRepository,
cacheService,
storageService,
gatewayService,
snowflakeService,
rateLimitService,
cloudflarePurgeQueue,
favoriteMemeRepository,
this.validationService,
this.mentionService,
this.searchService,
this.persistenceService,
this.channelAuthService,
this.processingService,
guildAuditLogService,
this.dispatchService,
);
this.retrievalService = new MessageRetrievalService(
channelRepository,
userCacheService,
mediaService,
this.channelAuthService,
this.processingService,
this.searchService,
userRepository,
);
this.anonymizationService = new MessageAnonymizationService(channelRepository);
}
async sendMessage({
user,
channelId,
data,
requestCache,
}: {
user: User;
channelId: ChannelID;
data: MessageRequest;
requestCache: RequestCache;
}): Promise<Message> {
return this.operationsService.sendMessage({user, channelId, data, requestCache});
}
async sendWebhookMessage({
webhook,
data,
username,
avatar,
requestCache,
}: {
webhook: Webhook;
data: MessageRequest;
username?: string | null;
avatar?: string | null;
requestCache: RequestCache;
}): Promise<Message> {
return this.operationsService.sendWebhookMessage({webhook, data, username, avatar, requestCache});
}
async validateMessageCanBeSent({
user,
channelId,
data,
}: {
user: User;
channelId: ChannelID;
data: MessageRequest;
}): Promise<void> {
return this.operationsService.validateMessageCanBeSent({user, channelId, data});
}
async editMessage({
userId,
channelId,
messageId,
data,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
data: MessageUpdateRequest;
requestCache: RequestCache;
}): Promise<Message> {
return this.operationsService.editMessage({userId, channelId, messageId, data, requestCache});
}
async deleteMessage({
userId,
channelId,
messageId,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
}): Promise<void> {
return this.operationsService.deleteMessage({userId, channelId, messageId, requestCache});
}
async getMessage({
userId,
channelId,
messageId,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
}): Promise<Message> {
return this.retrievalService.getMessage({userId, channelId, messageId});
}
async getMessages({
userId,
channelId,
limit,
before,
after,
around,
}: {
userId: UserID;
channelId: ChannelID;
limit: number;
before?: MessageID;
after?: MessageID;
around?: MessageID;
}): Promise<Array<Message>> {
return this.retrievalService.getMessages({userId, channelId, limit, before, after, around});
}
async bulkDeleteMessages({
userId,
channelId,
messageIds,
}: {
userId: UserID;
channelId: ChannelID;
messageIds: Array<MessageID>;
}): Promise<void> {
return this.operationsService.bulkDeleteMessages({userId, channelId, messageIds});
}
async deleteUserMessagesInGuild({
userId,
guildId,
days,
}: {
userId: UserID;
guildId: GuildID;
days: number;
}): Promise<void> {
return this.operationsService.deleteUserMessagesInGuild({userId, guildId, days});
}
async sendJoinSystemMessage({
guildId,
userId,
requestCache,
}: {
guildId: GuildID;
userId: UserID;
requestCache: RequestCache;
}): Promise<void> {
return this.systemService.sendJoinSystemMessage({
guildId,
userId,
requestCache,
dispatchMessageCreate: this.dispatchService.dispatchMessageCreate.bind(this.dispatchService),
});
}
async searchMessages({
userId,
channelId,
searchParams,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
searchParams: MessageSearchRequest;
requestCache: RequestCache;
}): Promise<MessageSearchResponse> {
return this.retrievalService.searchMessages({userId, channelId, searchParams, requestCache});
}
async anonymizeMessagesByAuthor(originalAuthorId: UserID, newAuthorId: UserID): Promise<void> {
return this.anonymizationService.anonymizeMessagesByAuthor(originalAuthorId, newAuthorId);
}
getMessagePersistenceService(): MessagePersistenceService {
return this.persistenceService;
}
}

View File

@@ -0,0 +1,223 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {DateTime, IANAZone} from 'luxon';
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
import {createMessageID} from '~/BrandedTypes';
import type {MessageRequest} from '~/channel/ChannelModel';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import {FeatureFlags} from '~/constants/FeatureFlags';
import {InputValidationError} from '~/Errors';
import {FeatureTemporarilyDisabledError} from '~/errors/FeatureTemporarilyDisabledError';
import type {FeatureFlagService} from '~/feature_flag/FeatureFlagService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {User} from '~/Models';
import type {ScheduledMessagePayload} from '~/models/ScheduledMessage';
import {ScheduledMessage} from '~/models/ScheduledMessage';
import type {ScheduledMessageRepository} from '~/user/repositories/ScheduledMessageRepository';
import type {WorkerService} from '~/worker/WorkerService';
import type {ChannelService} from './ChannelService';
const MAX_SCHEDULE_DELAY_MS = 30 * 24 * 60 * 60 * 1000;
export const SCHEDULED_MESSAGE_TTL_SECONDS = 31 * 24 * 60 * 60;
const DEFAULT_TIMEZONE = 'UTC';
const WORKER_TASK_NAME = 'sendScheduledMessage';
interface ScheduleParams {
user: User;
channelId: ChannelID;
data: MessageRequest;
scheduledLocalAt: string;
timezone?: string;
}
interface UpdateScheduleParams extends ScheduleParams {
scheduledMessageId: MessageID;
existing?: ScheduledMessage;
}
interface SendScheduledMessageWorkerPayload {
userId: string;
scheduledMessageId: string;
expectedScheduledAt: string;
}
export class ScheduledMessageService {
constructor(
private readonly channelService: ChannelService,
private readonly scheduledMessageRepository: ScheduledMessageRepository,
private readonly workerService: WorkerService,
private readonly snowflakeService: SnowflakeService,
private readonly channelRepository?: IChannelRepository,
private readonly featureFlagService?: FeatureFlagService,
) {}
async listScheduledMessages(userId: UserID): Promise<Array<ScheduledMessage>> {
return await this.scheduledMessageRepository.listScheduledMessages(userId);
}
async getScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<ScheduledMessage | null> {
return await this.scheduledMessageRepository.getScheduledMessage(userId, scheduledMessageId);
}
async createScheduledMessage(params: ScheduleParams): Promise<ScheduledMessage> {
return await this.upsertScheduledMessage({
...params,
scheduledMessageId: createSnowflake(this.snowflakeService),
});
}
async updateScheduledMessage(params: UpdateScheduleParams): Promise<ScheduledMessage> {
return await this.upsertScheduledMessage({
...params,
existing:
params.existing ??
(await this.getScheduledMessage(params.user.id as UserID, params.scheduledMessageId)) ??
undefined,
});
}
async cancelScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<void> {
await this.scheduledMessageRepository.deleteScheduledMessage(userId, scheduledMessageId);
}
async markInvalid(userId: UserID, scheduledMessageId: MessageID, reason: string): Promise<void> {
await this.scheduledMessageRepository.markInvalid(
userId,
scheduledMessageId,
reason,
SCHEDULED_MESSAGE_TTL_SECONDS,
);
}
private async upsertScheduledMessage(params: UpdateScheduleParams): Promise<ScheduledMessage> {
const {user, channelId, data, scheduledLocalAt, timezone} = params;
if (this.featureFlagService && this.channelRepository) {
const channel = await this.channelRepository.findUnique(channelId);
if (channel?.guildId) {
const guildId = channel.guildId.toString();
if (!this.featureFlagService.isFeatureEnabled(FeatureFlags.MESSAGE_SCHEDULING, guildId)) {
throw new FeatureTemporarilyDisabledError();
}
}
}
await this.channelService.messages.validateMessageCanBeSent({
user,
channelId,
data,
});
const scheduledAt = this.resolveScheduledAt(scheduledLocalAt, timezone);
const payload = toScheduledPayload(data);
const existing = params.existing;
const message = new ScheduledMessage({
userId: user.id,
id: params.scheduledMessageId,
channelId,
scheduledAt,
scheduledLocalAt,
timezone: timezone ?? DEFAULT_TIMEZONE,
payload,
status: 'pending',
statusReason: null,
createdAt: existing?.createdAt,
invalidatedAt: null,
});
await this.scheduledMessageRepository.upsertScheduledMessage(message, SCHEDULED_MESSAGE_TTL_SECONDS);
await this.scheduleWorker(message);
return message;
}
private resolveScheduledAt(local: string, timezone?: string): Date {
const zone = timezone?.trim() || DEFAULT_TIMEZONE;
if (!IANAZone.isValidZone(zone)) {
throw InputValidationError.create('timezone', 'Invalid timezone identifier');
}
const dt = DateTime.fromISO(local, {zone});
if (!dt.isValid) {
throw InputValidationError.create('scheduled_local_at', 'Invalid date/time for scheduled send');
}
const scheduledAt = dt.toJSDate();
const now = Date.now();
const diffMs = scheduledAt.getTime() - now;
if (diffMs <= 0) {
throw InputValidationError.create('scheduled_local_at', 'Scheduled time must be in the future');
}
if (diffMs > MAX_SCHEDULE_DELAY_MS) {
throw InputValidationError.create(
'scheduled_local_at',
'Scheduled messages can be at most 30 days in the future',
);
}
return scheduledAt;
}
private async scheduleWorker(message: ScheduledMessage): Promise<void> {
const payload: SendScheduledMessageWorkerPayload = {
userId: message.userId.toString(),
scheduledMessageId: message.id.toString(),
expectedScheduledAt: message.scheduledAt.toISOString(),
};
await this.workerService.addJob(WORKER_TASK_NAME, payload, {runAt: message.scheduledAt});
}
}
function createSnowflake(snowflakeService: SnowflakeService): MessageID {
return createMessageID(snowflakeService.generate());
}
function toScheduledPayload(data: MessageRequest): ScheduledMessagePayload {
return {
content: data.content ?? null,
embeds: data.embeds,
attachments: data.attachments,
message_reference: data.message_reference
? {
message_id: data.message_reference.message_id.toString(),
channel_id: data.message_reference.channel_id?.toString(),
guild_id: data.message_reference.guild_id?.toString(),
type: data.message_reference.type,
}
: undefined,
allowed_mentions: data.allowed_mentions
? {
parse: data.allowed_mentions.parse,
users: data.allowed_mentions.users?.map((id) => id.toString()),
roles: data.allowed_mentions.roles?.map((id) => id.toString()),
replied_user: data.allowed_mentions.replied_user,
}
: undefined,
flags: data.flags,
nonce: data.nonce,
favorite_meme_id: data.favorite_meme_id?.toString(),
sticker_ids: data.sticker_ids?.map((id) => id.toString()),
tts: data.tts,
};
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
import {APIErrorCodes} from '~/constants/API';
import {BadRequestError} from '~/Errors';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IStorageService} from '~/infrastructure/IStorageService';
const MAX_PREVIEW_BYTES = 1_000_000;
const PREVIEW_TTL_SECONDS = 60 * 60 * 24;
interface StreamPreviewMeta {
bucket: string;
key: string;
updatedAt: number;
ownerId: string;
channelId: string;
contentType: string;
}
export class StreamPreviewService {
constructor(
private readonly storageService: IStorageService,
private readonly cacheService: ICacheService,
) {}
private getCacheKey(streamKey: string): string {
return `stream_preview:${streamKey}`;
}
private getObjectKey(streamKey: string): string {
return `stream_previews/${streamKey}.jpg`;
}
private assertJpeg(buffer: Uint8Array, contentType?: string) {
const ct = (contentType || '').toLowerCase();
const isJpeg = ct.includes('jpeg') || ct.includes('jpg') || this.looksLikeJpeg(buffer);
if (!isJpeg) {
throw new BadRequestError({
code: APIErrorCodes.INVALID_REQUEST,
message: 'Preview must be JPEG',
});
}
if (buffer.byteLength > MAX_PREVIEW_BYTES) {
throw new BadRequestError({
code: APIErrorCodes.FILE_SIZE_TOO_LARGE,
message: 'Preview too large (max 1MB)',
});
}
}
private looksLikeJpeg(buffer: Uint8Array): boolean {
return (
buffer.length > 3 &&
buffer[0] === 0xff &&
buffer[1] === 0xd8 &&
buffer[buffer.length - 2] === 0xff &&
buffer[buffer.length - 1] === 0xd9
);
}
async uploadPreview(params: {
streamKey: string;
channelId: ChannelID;
userId: UserID;
body: Uint8Array;
contentType?: string;
}): Promise<void> {
this.assertJpeg(params.body, params.contentType);
const bucket = Config.s3.buckets.uploads;
const key = this.getObjectKey(params.streamKey);
const expiresAt = new Date(Date.now() + PREVIEW_TTL_SECONDS * 1000);
await this.storageService.uploadObject({
bucket,
key,
body: params.body,
contentType: params.contentType ?? 'image/jpeg',
expiresAt,
});
const meta: StreamPreviewMeta = {
bucket,
key,
updatedAt: Date.now(),
ownerId: params.userId.toString(),
channelId: params.channelId.toString(),
contentType: params.contentType ?? 'image/jpeg',
};
await this.cacheService.set(this.getCacheKey(params.streamKey), meta, PREVIEW_TTL_SECONDS);
}
async getPreview(streamKey: string): Promise<{buffer: Uint8Array; contentType: string} | null> {
const meta = await this.cacheService.get<StreamPreviewMeta>(this.getCacheKey(streamKey));
if (!meta) return null;
const buffer = await this.storageService.readObject(meta.bucket, meta.key);
return {buffer, contentType: meta.contentType || 'image/jpeg'};
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {BaseChannelAuthService, type ChannelAuthOptions} from '../BaseChannelAuthService';
export class ChannelAuthService extends BaseChannelAuthService {
protected readonly options: ChannelAuthOptions = {
errorOnMissingGuild: 'missing_permissions',
validateNsfw: true,
useVirtualPersonalNotes: false,
};
}

View File

@@ -0,0 +1,593 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
type ChannelID,
createChannelID,
createGuildID,
createRoleID,
createUserID,
type GuildID,
type RoleID,
type UserID,
} from '~/BrandedTypes';
import {ALL_PERMISSIONS, ChannelTypes, GuildFeatures, MAX_CHANNELS_PER_CATEGORY, Permissions} from '~/Constants';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {
CannotExecuteOnDmError,
InputValidationError,
InvalidChannelTypeError,
MaxCategoryChannelsError,
MissingPermissionsError,
UnknownChannelError,
} from '~/Errors';
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
import {ChannelHelpers} from '~/guild/services/channel/ChannelHelpers';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {ILiveKitService} from '~/infrastructure/ILiveKitService';
import type {IVoiceRoomStore} from '~/infrastructure/IVoiceRoomStore';
import type {IInviteRepository} from '~/invite/IInviteRepository';
import {Logger} from '~/Logger';
import {type Channel, ChannelPermissionOverwrite} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {ChannelNameType} from '~/Schema';
import type {IUserRepository} from '~/user/IUserRepository';
import {serializeChannelForAudit} from '~/utils/AuditSerializationUtils';
import type {VoiceAvailabilityService} from '~/voice/VoiceAvailabilityService';
import type {VoiceRegionAvailability} from '~/voice/VoiceModel';
import type {IWebhookRepository} from '~/webhook/IWebhookRepository';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {ChannelAuthService} from './ChannelAuthService';
import type {ChannelUtilsService} from './ChannelUtilsService';
export interface ChannelUpdateData {
name?: string;
topic?: string | null;
url?: string | null;
parent_id?: bigint | null;
bitrate?: number | null;
user_limit?: number | null;
nsfw?: boolean;
rate_limit_per_user?: number;
permission_overwrites?: Array<{
id: bigint;
type: number;
allow?: bigint;
deny?: bigint;
}> | null;
rtc_region?: string | null;
icon?: string | null;
owner_id?: bigint | null;
nicks?: Record<string, string | null> | null;
}
export class ChannelOperationsService {
constructor(
private channelRepository: IChannelRepositoryAggregate,
private userRepository: IUserRepository,
private gatewayService: IGatewayService,
private channelAuthService: ChannelAuthService,
private channelUtilsService: ChannelUtilsService,
private voiceRoomStore: IVoiceRoomStore,
private liveKitService: ILiveKitService,
private voiceAvailabilityService: VoiceAvailabilityService | undefined,
private readonly guildAuditLogService: GuildAuditLogService,
private inviteRepository: IInviteRepository,
private webhookRepository: IWebhookRepository,
) {}
async getChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<Channel> {
const {channel} = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
return channel;
}
async getPublicChannelData(channelId: ChannelID) {
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
return channel;
}
async getChannelMemberCount(channelId: ChannelID): Promise<number> {
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
return channel.recipientIds.size;
}
async getChannelSystem(channelId: ChannelID): Promise<Channel | null> {
return await this.channelRepository.channelData.findUnique(channelId);
}
async editChannel({
userId,
channelId,
data,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
data: ChannelUpdateData;
requestCache: RequestCache;
}): Promise<Channel> {
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
userId,
channelId,
});
if (channel.type === ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeError();
}
if (!guild) throw new MissingPermissionsError();
await checkPermission(Permissions.MANAGE_CHANNELS);
const guildIdValue = createGuildID(BigInt(guild.id));
let channelName = data.name ?? channel.name;
if (data.name !== undefined && channel.type === ChannelTypes.GUILD_TEXT) {
const hasFlexibleNamesEnabled = guild.features?.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) ?? false;
if (!hasFlexibleNamesEnabled) {
channelName = ChannelNameType.parse(data.name);
}
}
if (data.rtc_region !== undefined && channel.type === ChannelTypes.GUILD_VOICE) {
await checkPermission(Permissions.UPDATE_RTC_REGION);
if (data.rtc_region !== null) {
if (this.voiceAvailabilityService) {
const guildId = createGuildID(BigInt(guild.id));
const availableRegions = this.voiceAvailabilityService.getAvailableRegions({
requestingUserId: userId,
guildId,
guildFeatures: new Set(guild.features ?? []),
});
const regionAllowed = availableRegions.some((region) => region.id === data.rtc_region && region.isAccessible);
if (!regionAllowed) {
throw new InputValidationError([
{
path: 'rtc_region',
message: `Invalid or restricted RTC region: ${data.rtc_region}`,
},
]);
}
} else if (this.liveKitService) {
const availableRegions = this.liveKitService.getRegionMetadata().map((region) => region.id);
if (!availableRegions.includes(data.rtc_region)) {
throw new InputValidationError([
{
path: 'rtc_region',
message: `Invalid RTC region: ${data.rtc_region}. Available regions: ${availableRegions.join(', ')}`,
},
]);
}
}
}
}
const previousPermissionOverwrites = channel.permissionOverwrites;
let permissionOverwrites = channel.permissionOverwrites;
if (data.permission_overwrites !== undefined) {
const guildId = createGuildID(BigInt(guild.id));
const isOwner = guild.owner_id === userId.toString();
const channelPermissions = await this.gatewayService.getUserPermissions({
guildId,
userId,
channelId: channel.id,
});
if (!isOwner) {
for (const overwrite of data.permission_overwrites ?? []) {
const allowPerms = overwrite.allow ? BigInt(overwrite.allow) : 0n;
const denyPerms = overwrite.deny ? BigInt(overwrite.deny) : 0n;
const combinedPerms = (allowPerms | denyPerms) & ALL_PERMISSIONS;
if ((combinedPerms & ~channelPermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
}
permissionOverwrites = new Map();
for (const overwrite of data.permission_overwrites ?? []) {
const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
permissionOverwrites.set(
targetId,
new ChannelPermissionOverwrite({
type: overwrite.type,
allow_: (overwrite.allow ? BigInt(overwrite.allow) : 0n) & ALL_PERMISSIONS,
deny_: (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS,
}),
);
}
}
const requestedParentId =
data.parent_id !== undefined ? (data.parent_id ? createChannelID(data.parent_id) : null) : channel.parentId;
if (requestedParentId && requestedParentId !== (channel.parentId ?? null)) {
await this.ensureCategoryHasCapacity({
guildId: guildIdValue,
categoryId: requestedParentId,
});
}
const updatedChannelData = {
...channel.toRow(),
name: channelName,
topic: data.topic !== undefined ? data.topic : channel.topic,
url: data.url !== undefined && channel.type === ChannelTypes.GUILD_LINK ? data.url : channel.url,
parent_id: requestedParentId,
bitrate: data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE ? data.bitrate : channel.bitrate,
user_limit:
data.user_limit !== undefined && channel.type === ChannelTypes.GUILD_VOICE
? data.user_limit
: channel.userLimit,
rate_limit_per_user:
data.rate_limit_per_user !== undefined && channel.type === ChannelTypes.GUILD_TEXT
? data.rate_limit_per_user
: channel.rateLimitPerUser,
nsfw: data.nsfw !== undefined && channel.type === ChannelTypes.GUILD_TEXT ? data.nsfw : channel.isNsfw,
rtc_region:
data.rtc_region !== undefined && channel.type === ChannelTypes.GUILD_VOICE
? data.rtc_region
: channel.rtcRegion,
permission_overwrites: new Map(
Array.from(permissionOverwrites.entries()).map(([targetId, overwrite]) => [
targetId,
overwrite.toPermissionOverwrite(),
]),
),
};
const updatedChannel = await this.channelRepository.channelData.upsert(updatedChannelData);
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChannel, requestCache});
if (channel.type === ChannelTypes.GUILD_CATEGORY && data.permission_overwrites !== undefined && guild) {
await this.propagatePermissionsToSyncedChildren({
categoryChannel: updatedChannel,
previousPermissionOverwrites,
guildId: createGuildID(BigInt(guild.id)),
requestCache,
});
}
if (
data.rtc_region !== undefined &&
channel.type === ChannelTypes.GUILD_VOICE &&
data.rtc_region !== channel.rtcRegion &&
this.voiceRoomStore
) {
await this.handleRtcRegionSwitch({
guildId: createGuildID(BigInt(guild.id)),
channelId,
});
}
const beforeSnapshot = serializeChannelForAudit(channel);
const afterSnapshot = serializeChannelForAudit(updatedChannel);
const changes = this.guildAuditLogService.computeChanges(beforeSnapshot, afterSnapshot);
if (changes.length > 0) {
const builder = this.guildAuditLogService
.createBuilder(guildIdValue, userId)
.withAction(AuditLogActionType.CHANNEL_UPDATE, channel.id.toString())
.withReason(null)
.withMetadata({
type: updatedChannel.type.toString(),
})
.withChanges(changes);
try {
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: guildIdValue.toString(),
userId: userId.toString(),
action: AuditLogActionType.CHANNEL_UPDATE,
targetId: channel.id.toString(),
},
'Failed to record guild audit log',
);
}
}
return updatedChannel;
}
async deleteChannel({
userId,
channelId,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
requestCache: RequestCache;
}): Promise<void> {
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
userId,
channelId,
});
if (this.channelAuthService.isPersonalNotesChannel({userId, channelId})) {
throw new CannotExecuteOnDmError();
}
if (guild) {
await checkPermission(Permissions.MANAGE_CHANNELS);
const guildId = createGuildID(BigInt(guild.id));
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId);
const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === channelId);
for (const childChannel of childChannels) {
await this.channelRepository.channelData.upsert({
...childChannel.toRow(),
parent_id: null,
});
const updatedChild = await this.channelRepository.channelData.findUnique(childChannel.id);
if (updatedChild) {
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
}
}
}
const [channelInvites, channelWebhooks] = await Promise.all([
this.inviteRepository.listChannelInvites(channelId),
this.webhookRepository.listByChannel(channelId),
]);
await Promise.all([
...channelInvites.map((invite) => this.inviteRepository.delete(invite.code)),
...channelWebhooks.map((webhook) => this.webhookRepository.delete(webhook.id)),
]);
await this.channelUtilsService.purgeChannelAttachments(channel);
await this.channelRepository.messages.deleteAllChannelMessages(channelId);
await this.channelUtilsService.dispatchChannelDelete({channel, requestCache});
const guildIdValue = createGuildID(BigInt(guild.id));
const changes = this.guildAuditLogService.computeChanges(ChannelHelpers.serializeChannelForAudit(channel), null);
const builder = this.guildAuditLogService
.createBuilder(guildIdValue, userId)
.withAction(AuditLogActionType.CHANNEL_DELETE, channel.id.toString())
.withReason(null)
.withMetadata({
type: channel.type.toString(),
})
.withChanges(changes);
try {
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: guildIdValue.toString(),
userId: userId.toString(),
action: AuditLogActionType.CHANNEL_DELETE,
targetId: channel.id.toString(),
},
'Failed to record guild audit log',
);
}
await this.channelRepository.channelData.delete(channelId, guildId);
} else {
await this.userRepository.closeDmForUser(userId, channelId);
await this.channelUtilsService.dispatchDmChannelDelete({channel, userId, requestCache});
}
}
async getAvailableRtcRegions({
userId,
channelId,
}: {
userId: UserID;
channelId: ChannelID;
}): Promise<Array<VoiceRegionAvailability>> {
if (!this.voiceAvailabilityService) {
return [];
}
const {channel, guild} = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
if (channel.type !== ChannelTypes.GUILD_VOICE) {
throw new InvalidChannelTypeError();
}
if (!guild) {
return [];
}
const guildId = createGuildID(BigInt(guild.id));
const regions = this.voiceAvailabilityService.getAvailableRegions({
requestingUserId: userId,
guildId,
guildFeatures: new Set(guild.features ?? []),
});
const accessibleRegions = regions.filter((region) => region.isAccessible);
return accessibleRegions.sort((a, b) => a.name.localeCompare(b.name));
}
private async propagatePermissionsToSyncedChildren({
categoryChannel,
previousPermissionOverwrites,
guildId,
requestCache,
}: {
categoryChannel: Channel;
previousPermissionOverwrites: Map<RoleID | UserID, ChannelPermissionOverwrite>;
guildId: GuildID;
requestCache: RequestCache;
}): Promise<void> {
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId);
const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === categoryChannel.id);
const syncedChannels: Array<Channel> = [];
for (const child of childChannels) {
if (this.arePermissionsEqual(child.permissionOverwrites, previousPermissionOverwrites)) {
syncedChannels.push(child);
}
}
if (syncedChannels.length > 0) {
await Promise.all(
syncedChannels.map(async (child) => {
const updatedChild = await this.channelRepository.channelData.upsert({
...child.toRow(),
permission_overwrites: new Map(
Array.from(categoryChannel.permissionOverwrites.entries()).map(([targetId, overwrite]) => [
targetId,
overwrite.toPermissionOverwrite(),
]),
),
});
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
}),
);
}
}
private arePermissionsEqual(
perms1: Map<RoleID | UserID, ChannelPermissionOverwrite>,
perms2: Map<RoleID | UserID, ChannelPermissionOverwrite>,
): boolean {
if (perms1.size !== perms2.size) return false;
for (const [targetId, overwrite1] of perms1.entries()) {
const overwrite2 = perms2.get(targetId);
if (!overwrite2) return false;
if (
overwrite1.type !== overwrite2.type ||
overwrite1.allow !== overwrite2.allow ||
overwrite1.deny !== overwrite2.deny
) {
return false;
}
}
return true;
}
private async handleRtcRegionSwitch({guildId, channelId}: {guildId: GuildID; channelId: ChannelID}): Promise<void> {
if (!this.voiceRoomStore) {
console.warn('[ChannelOperationsService] VoiceRoomStore not available, skipping region switch');
return;
}
await this.voiceRoomStore.deleteRoomServer(guildId, channelId);
await this.gatewayService.switchVoiceRegion({guildId, channelId});
}
private async ensureCategoryHasCapacity(params: {guildId: GuildID; categoryId: ChannelID}): Promise<void> {
const count = await this.gatewayService.getCategoryChannelCount(params);
if (count >= MAX_CHANNELS_PER_CATEGORY) {
throw new MaxCategoryChannelsError();
}
}
async setChannelPermissionOverwrite(params: {
userId: UserID;
channelId: ChannelID;
overwriteId: bigint;
overwrite: {type: number; allow_: bigint; deny_: bigint};
requestCache: RequestCache;
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel || !channel.guildId) throw new UnknownChannelError();
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
});
const sanitizedAllow = params.overwrite.allow_ & ALL_PERMISSIONS;
const sanitizedDeny = params.overwrite.deny_ & ALL_PERMISSIONS;
const combined = sanitizedAllow | sanitizedDeny;
if ((combined & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const overwrites = new Map(channel.permissionOverwrites ?? []);
const targetId = params.overwrite.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
overwrites.set(
targetId,
new ChannelPermissionOverwrite({
type: params.overwrite.type,
allow_: sanitizedAllow,
deny_: sanitizedDeny,
}),
);
const updated = await this.channelRepository.channelData.upsert({
...channel.toRow(),
permission_overwrites: new Map(
Array.from(overwrites.entries()).map(([id, ow]) => [
id,
(ow as ChannelPermissionOverwrite).toPermissionOverwrite(),
]),
),
});
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
}
async deleteChannelPermissionOverwrite(params: {
userId: UserID;
channelId: ChannelID;
overwriteId: bigint;
requestCache: RequestCache;
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel || !channel.guildId) throw new UnknownChannelError();
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
const overwrites = new Map(channel.permissionOverwrites ?? []);
overwrites.delete(createRoleID(params.overwriteId));
overwrites.delete(createUserID(params.overwriteId));
const updated = await this.channelRepository.channelData.upsert({
...channel.toRow(),
permission_overwrites: new Map(
Array.from(overwrites.entries()).map(([id, ow]) => [
id,
(ow as ChannelPermissionOverwrite).toPermissionOverwrite(),
]),
),
});
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
}
}

View File

@@ -0,0 +1,198 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {channelIdToUserId, type MessageID, type UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
import {ChannelTypes, type GatewayDispatchEvent} from '~/Constants';
import {mapChannelToResponse, mapMessageToResponse} from '~/channel/ChannelModel';
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '~/channel/services/message/MessageHelpers';
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Channel, Message} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
export class ChannelUtilsService {
constructor(
private channelRepository: IChannelRepositoryAggregate,
private userCacheService: UserCacheService,
private storageService: IStorageService,
private gatewayService: IGatewayService,
private cloudflarePurgeQueue: ICloudflarePurgeQueue,
private mediaService: IMediaService,
) {}
async purgeChannelAttachments(channel: Channel): Promise<void> {
const batchSize = 100;
let hasMore = true;
let beforeMessageId: MessageID | undefined;
while (hasMore) {
const messages = await this.channelRepository.messages.listMessages(channel.id, beforeMessageId, batchSize);
if (messages.length === 0) {
hasMore = false;
break;
}
await Promise.all(messages.map((message: Message) => this.purgeMessageAttachments(message)));
if (messages.length < batchSize) {
hasMore = false;
} else {
beforeMessageId = messages[messages.length - 1].id;
}
}
}
private async purgeMessageAttachments(message: Message): Promise<void> {
const cdnUrls: Array<string> = [];
await Promise.all(
message.attachments.map(async (attachment) => {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
if (Config.cloudflare.purgeEnabled) {
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
cdnUrls.push(cdnUrl);
}
}),
);
if (Config.cloudflare.purgeEnabled && cdnUrls.length > 0) {
await this.cloudflarePurgeQueue.addUrls(cdnUrls);
}
}
private async dispatchEvent(params: {channel: Channel; event: GatewayDispatchEvent; data: unknown}): Promise<void> {
const {channel, event, data} = params;
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return this.gatewayService.dispatchPresence({
userId: channelIdToUserId(channel.id),
event,
data,
});
}
if (channel.guildId) {
return this.gatewayService.dispatchGuild({guildId: channel.guildId, event, data});
} else {
for (const recipientId of channel.recipientIds) {
await this.gatewayService.dispatchPresence({userId: recipientId, event, data});
}
}
}
async dispatchChannelUpdate({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise<void> {
if (channel.guildId) {
const channelResponse = await mapChannelToResponse({
channel,
currentUserId: null,
userCacheService: this.userCacheService,
requestCache,
});
await this.dispatchEvent({
channel,
event: 'CHANNEL_UPDATE',
data: channelResponse,
});
return;
}
for (const userId of channel.recipientIds) {
const channelResponse = await mapChannelToResponse({
channel,
currentUserId: userId,
userCacheService: this.userCacheService,
requestCache,
});
await this.gatewayService.dispatchPresence({
userId,
event: 'CHANNEL_UPDATE',
data: channelResponse,
});
}
}
async dispatchChannelDelete({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise<void> {
const channelResponse = await mapChannelToResponse({
channel,
currentUserId: null,
userCacheService: this.userCacheService,
requestCache,
});
await this.dispatchEvent({
channel,
event: 'CHANNEL_DELETE',
data: channelResponse,
});
}
async dispatchDmChannelDelete({
channel,
userId,
requestCache,
}: {
channel: Channel;
userId: UserID;
requestCache: RequestCache;
}): Promise<void> {
await this.gatewayService.dispatchPresence({
userId,
event: 'CHANNEL_DELETE',
data: await mapChannelToResponse({
channel,
currentUserId: null,
userCacheService: this.userCacheService,
requestCache,
}),
});
}
async dispatchMessageCreate({
channel,
message,
requestCache,
}: {
channel: Channel;
message: Message;
requestCache: RequestCache;
}): Promise<void> {
const messageResponse = await mapMessageToResponse({
message,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
});
await this.dispatchEvent({
channel,
event: 'MESSAGE_CREATE',
data: {...messageResponse, channel_type: channel.type},
});
}
}

View File

@@ -0,0 +1,182 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type ChannelID, createMessageID, type UserID} from '~/BrandedTypes';
import {ChannelTypes, MessageTypes} from '~/Constants';
import {
InvalidChannelTypeError,
MissingAccessError,
MissingPermissionsError,
UnknownChannelError,
UnknownUserError,
} from '~/Errors';
import type {AvatarService} from '~/infrastructure/AvatarService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {Channel} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {MessagePersistenceService} from '../message/MessagePersistenceService';
import type {ChannelUtilsService} from './ChannelUtilsService';
export class GroupDmUpdateService {
constructor(
private channelRepository: IChannelRepositoryAggregate,
private avatarService: AvatarService,
private snowflakeService: SnowflakeService,
private channelUtilsService: ChannelUtilsService,
private messagePersistenceService: MessagePersistenceService,
) {}
async updateGroupDmChannel({
userId,
channelId,
name,
icon,
ownerId,
nicks,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
name?: string | null;
icon?: string | null;
ownerId?: UserID;
nicks?: Record<string, string | null> | null;
requestCache: RequestCache;
}): Promise<Channel> {
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeError();
}
if (!channel.recipientIds.has(userId)) {
throw new MissingAccessError();
}
const updates: Partial<ReturnType<Channel['toRow']>> = {};
if (ownerId !== undefined) {
if (channel.ownerId !== userId) {
throw new MissingPermissionsError();
}
if (!channel.recipientIds.has(ownerId)) {
throw new UnknownUserError();
}
updates.owner_id = ownerId;
}
if (name !== undefined) {
updates.name = name;
}
if (nicks !== undefined) {
if (nicks === null) {
if (channel.ownerId !== userId) {
throw new MissingPermissionsError();
}
updates.nicks = null;
} else {
const isOwner = channel.ownerId === userId;
for (const targetUserId of Object.keys(nicks)) {
const targetUserIdBigInt = BigInt(targetUserId) as UserID;
if (!channel.recipientIds.has(targetUserIdBigInt) && targetUserIdBigInt !== userId) {
throw new UnknownUserError();
}
if (!isOwner && targetUserId !== userId.toString()) {
throw new MissingPermissionsError();
}
}
const updatedNicknames = new Map(channel.nicknames);
for (const [targetUserId, nickname] of Object.entries(nicks)) {
if (nickname === null || nickname.trim() === '') {
updatedNicknames.delete(targetUserId);
} else {
updatedNicknames.set(targetUserId, nickname.trim());
}
}
updates.nicks = updatedNicknames.size > 0 ? (updatedNicknames as Map<string, string>) : null;
}
}
let iconHash: string | null = null;
if (icon !== undefined) {
iconHash = await this.avatarService.uploadAvatar({
prefix: 'icons',
entityId: channelId,
errorPath: 'icon',
previousKey: channel.iconHash,
base64Image: icon,
});
updates.icon_hash = iconHash;
}
const updatedChannel = await this.channelRepository.channelData.upsert({
...channel.toRow(),
...updates,
});
if (name !== undefined && name !== channel.name) {
const messageId = createMessageID(this.snowflakeService.generate());
const message = await this.messagePersistenceService.createSystemMessage({
messageId,
channelId,
userId,
type: MessageTypes.CHANNEL_NAME_CHANGE,
content: name,
});
await this.channelUtilsService.dispatchMessageCreate({
channel: updatedChannel,
message,
requestCache,
});
}
if (icon !== undefined && iconHash !== channel.iconHash) {
const messageId = createMessageID(this.snowflakeService.generate());
const message = await this.messagePersistenceService.createSystemMessage({
messageId,
channelId,
userId,
type: MessageTypes.CHANNEL_ICON_CHANGE,
content: iconHash,
});
await this.channelUtilsService.dispatchMessageCreate({
channel: updatedChannel,
message,
requestCache,
});
}
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChannel, requestCache});
return updatedChannel;
}
}

View File

@@ -0,0 +1,129 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AttachmentDecayService} from '~/attachment/AttachmentDecayService';
import {type ChannelID, channelIdToUserId, type MessageID, type UserID} from '~/BrandedTypes';
import {ChannelTypes, type GatewayDispatchEvent} from '~/Constants';
import {mapChannelToResponse} from '~/channel/ChannelModel';
import {collectMessageAttachments} from '~/channel/services/message/MessageHelpers';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Channel, Message} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
export async function dispatchChannelDelete({
channel,
requestCache,
userCacheService,
gatewayService,
}: {
channel: Channel;
requestCache: RequestCache;
userCacheService: UserCacheService;
gatewayService: IGatewayService;
}): Promise<void> {
const channelResponse = await mapChannelToResponse({
channel,
currentUserId: null,
userCacheService,
requestCache,
});
await dispatchEvent({
channel,
event: 'CHANNEL_DELETE',
data: channelResponse,
gatewayService,
});
}
export async function dispatchMessageCreate({
channel,
message,
requestCache,
currentUserId,
nonce,
userCacheService,
gatewayService,
mediaService,
getReferencedMessage,
}: {
channel: Channel;
message: Message;
requestCache: RequestCache;
currentUserId?: UserID;
nonce?: string;
userCacheService: UserCacheService;
gatewayService: IGatewayService;
mediaService: IMediaService;
getReferencedMessage?: (channelId: ChannelID, messageId: MessageID) => Promise<Message | null>;
}): Promise<void> {
const decayService = new AttachmentDecayService();
const messageAttachments = collectMessageAttachments(message);
const attachmentDecayMap =
messageAttachments.length > 0
? await decayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
: undefined;
const messageResponse = await (async () => {
const {mapMessageToResponse} = await import('~/channel/ChannelModel');
return mapMessageToResponse({
message,
currentUserId,
nonce,
userCacheService,
requestCache,
mediaService,
attachmentDecayMap,
getReferencedMessage,
});
})();
await dispatchEvent({
channel,
event: 'MESSAGE_CREATE',
data: {...messageResponse, channel_type: channel.type},
gatewayService,
});
}
async function dispatchEvent(params: {
channel: Channel;
event: GatewayDispatchEvent;
data: unknown;
gatewayService: IGatewayService;
}): Promise<void> {
const {channel, event, data, gatewayService} = params;
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return gatewayService.dispatchPresence({
userId: channelIdToUserId(channel.id),
event,
data,
});
}
if (channel.guildId) {
return gatewayService.dispatchGuild({guildId: channel.guildId, event, data});
} else {
for (const recipientId of channel.recipientIds) {
await gatewayService.dispatchPresence({userId: recipientId, event, data});
}
}
}

View 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 {randomInt} from 'node:crypto';
import {type ChannelID, createMessageID, type UserID} from '~/BrandedTypes';
import {ChannelTypes, MAX_GROUP_DM_RECIPIENTS, MessageTypes, RelationshipTypes} from '~/Constants';
import {mapChannelToResponse, mapMessageToResponse} from '~/channel/ChannelModel';
import {
CannotRemoveOtherRecipientsError,
InputValidationError,
InvalidChannelTypeError,
MaxGroupDmRecipientsError,
MissingAccessError,
NotFriendsWithUserError,
UnknownChannelError,
} from '~/Errors';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Channel} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import {UserPermissionUtils} from '~/utils/UserPermissionUtils';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {GroupDmUpdateService} from '../channel_data/GroupDmUpdateService';
import type {MessagePersistenceService} from '../message/MessagePersistenceService';
import {dispatchChannelDelete} from './GroupDmHelpers';
export class GroupDmOperationsService {
private readonly userPermissionUtils: UserPermissionUtils;
constructor(
private channelRepository: IChannelRepositoryAggregate,
private userRepository: IUserRepository,
guildRepository: IGuildRepository,
private userCacheService: UserCacheService,
private gatewayService: IGatewayService,
private mediaService: IMediaService,
private snowflakeService: SnowflakeService,
private groupDmUpdateService: GroupDmUpdateService,
private messagePersistenceService: MessagePersistenceService,
) {
this.userPermissionUtils = new UserPermissionUtils(userRepository, guildRepository);
}
async addRecipientToChannel({
userId,
channelId,
recipientId,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
recipientId: UserID;
requestCache: RequestCache;
}): Promise<Channel> {
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeError();
}
if (!channel.recipientIds.has(userId)) {
throw new MissingAccessError();
}
const friendship = await this.userRepository.getRelationship(userId, recipientId, RelationshipTypes.FRIEND);
if (!friendship) {
throw new NotFriendsWithUserError();
}
await this.userPermissionUtils.validateGroupDmAddPermissions({
userId,
targetId: recipientId,
});
return this.addRecipientViaInvite({
channelId,
recipientId,
inviterId: userId,
requestCache,
});
}
async addRecipientViaInvite({
channelId,
recipientId,
inviterId,
requestCache,
}: {
channelId: ChannelID;
recipientId: UserID;
inviterId?: UserID | null;
requestCache: RequestCache;
}): Promise<Channel> {
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeError();
}
if (channel.recipientIds.has(recipientId)) {
return channel;
}
if (channel.recipientIds.size >= MAX_GROUP_DM_RECIPIENTS) {
throw new MaxGroupDmRecipientsError();
}
const updatedRecipientIds = new Set([...channel.recipientIds, recipientId]);
const updatedChannel = await this.channelRepository.channelData.upsert({
...channel.toRow(),
recipient_ids: updatedRecipientIds,
});
await this.userRepository.openDmForUser(recipientId, channelId);
const recipientUserResponse = await this.userCacheService.getUserPartialResponse(recipientId, requestCache);
const messageId = createMessageID(this.snowflakeService.generate());
const systemMessage = await this.messagePersistenceService.createSystemMessage({
messageId,
channelId,
userId: inviterId ?? channel.ownerId ?? recipientId,
type: MessageTypes.RECIPIENT_ADD,
mentionUserIds: [recipientId],
});
const channelResponse = await mapChannelToResponse({
channel: updatedChannel,
currentUserId: recipientId,
userCacheService: this.userCacheService,
requestCache,
});
await this.gatewayService.dispatchPresence({
userId: recipientId,
event: 'CHANNEL_CREATE',
data: channelResponse,
});
for (const recId of channel.recipientIds) {
await this.gatewayService.dispatchPresence({
userId: recId,
event: 'CHANNEL_RECIPIENT_ADD',
data: {
channel_id: channelId.toString(),
user: recipientUserResponse,
},
});
}
const messageResponse = await mapMessageToResponse({
message: systemMessage,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
getReferencedMessage: (channelId, messageId) => this.channelRepository.messages.getMessage(channelId, messageId),
});
for (const recipientId of updatedRecipientIds) {
await this.gatewayService.dispatchPresence({
userId: recipientId,
event: 'MESSAGE_CREATE',
data: {...messageResponse, channel_type: updatedChannel.type},
});
}
await Promise.all(
Array.from(updatedRecipientIds).map(async (recId) => {
await this.syncGroupDmRecipientsForUser(recId);
}),
);
return updatedChannel;
}
async removeRecipientFromChannel({
userId,
channelId,
recipientId,
requestCache,
silent,
}: {
userId: UserID;
channelId: ChannelID;
recipientId: UserID;
requestCache: RequestCache;
silent?: boolean;
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type !== ChannelTypes.GROUP_DM) {
throw new InvalidChannelTypeError();
}
if (!channel.recipientIds.has(userId)) {
throw new MissingAccessError();
}
if (!channel.recipientIds.has(recipientId)) {
throw InputValidationError.create('user_id', 'User is not in this channel');
}
if (recipientId !== userId && channel.ownerId !== userId) {
throw new CannotRemoveOtherRecipientsError();
}
const updatedRecipientIds = new Set(channel.recipientIds);
updatedRecipientIds.delete(recipientId);
let newOwnerId = channel.ownerId;
if (recipientId === channel.ownerId && updatedRecipientIds.size > 0) {
const remaining = Array.from(updatedRecipientIds);
newOwnerId = remaining[randomInt(remaining.length)] as UserID;
}
if (updatedRecipientIds.size === 0) {
await this.channelRepository.messages.deleteAllChannelMessages(channelId);
await this.channelRepository.channelData.delete(channelId);
await this.userRepository.closeDmForUser(recipientId, channelId);
await dispatchChannelDelete({
channel,
requestCache,
userCacheService: this.userCacheService,
gatewayService: this.gatewayService,
});
return;
}
const updatedNicknames = new Map(channel.nicknames);
updatedNicknames.delete(recipientId.toString());
const updatedChannel = await this.channelRepository.channelData.upsert({
...channel.toRow(),
owner_id: newOwnerId,
recipient_ids: updatedRecipientIds,
nicks: updatedNicknames.size > 0 ? updatedNicknames : null,
});
await this.userRepository.closeDmForUser(recipientId, channelId);
const recipientUserResponse = await this.userCacheService.getUserPartialResponse(recipientId, requestCache);
for (const recId of updatedRecipientIds) {
await this.gatewayService.dispatchPresence({
userId: recId,
event: 'CHANNEL_RECIPIENT_REMOVE',
data: {
channel_id: channelId.toString(),
user: recipientUserResponse,
},
});
}
if (!silent) {
const messageId = createMessageID(this.snowflakeService.generate());
const systemMessage = await this.messagePersistenceService.createSystemMessage({
messageId,
channelId,
userId,
type: MessageTypes.RECIPIENT_REMOVE,
mentionUserIds: [recipientId],
});
const messageResponse = await mapMessageToResponse({
message: systemMessage,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
getReferencedMessage: (channelId, messageId) =>
this.channelRepository.messages.getMessage(channelId, messageId),
});
for (const remainingRecipientId of updatedRecipientIds) {
await this.gatewayService.dispatchPresence({
userId: remainingRecipientId,
event: 'MESSAGE_CREATE',
data: {...messageResponse, channel_type: updatedChannel.type},
});
}
}
const channelResponse = await mapChannelToResponse({
channel,
currentUserId: null,
userCacheService: this.userCacheService,
requestCache,
});
await this.gatewayService.dispatchPresence({
userId: recipientId,
event: 'CHANNEL_DELETE',
data: channelResponse,
});
await Promise.all(
[...Array.from(updatedRecipientIds), recipientId].map(async (recId) => {
await this.syncGroupDmRecipientsForUser(recId);
}),
);
}
async updateGroupDmChannel(params: {
userId: UserID;
channelId: ChannelID;
name?: string;
icon?: string | null;
ownerId?: UserID;
nicks?: Record<string, string | null> | null;
requestCache: RequestCache;
}): Promise<Channel> {
return this.groupDmUpdateService.updateGroupDmChannel(params);
}
private async syncGroupDmRecipientsForUser(userId: UserID): Promise<void> {
const channels = await this.userRepository.listPrivateChannels(userId);
const groupDmChannels = channels.filter((ch) => ch.type === ChannelTypes.GROUP_DM);
const recipientsByChannel: Record<string, Array<string>> = {};
for (const channel of groupDmChannels) {
const otherRecipients = Array.from(channel.recipientIds)
.filter((recId) => recId !== userId)
.map((recId) => recId.toString());
if (otherRecipients.length > 0) {
recipientsByChannel[channel.id.toString()] = otherRecipients;
}
}
await this.gatewayService.syncGroupDmRecipients({
userId,
recipientsByChannel,
});
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {BaseChannelAuthService, type ChannelAuthOptions} from '../BaseChannelAuthService';
export class MessageInteractionAuthService extends BaseChannelAuthService {
protected readonly options: ChannelAuthOptions = {
errorOnMissingGuild: 'unknown_channel',
validateNsfw: false,
useVirtualPersonalNotes: true,
};
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {channelIdToUserId} from '~/BrandedTypes';
import {ChannelTypes, type GatewayDispatchEvent, TEXT_BASED_CHANNEL_TYPES} from '~/Constants';
import {CannotSendMessageToNonTextChannelError} from '~/Errors';
import type {GuildResponse} from '~/guild/GuildModel';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {Channel} from '~/Models';
export interface ParsedEmoji {
id?: string;
name: string;
animated?: boolean;
}
export abstract class MessageInteractionBase {
constructor(protected gatewayService: IGatewayService) {}
protected isOperationDisabled(guild: GuildResponse | null, operation: number): boolean {
if (!guild) return false;
return (guild.disabled_operations & operation) !== 0;
}
protected ensureTextChannel(channel: Channel): void {
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
throw new CannotSendMessageToNonTextChannelError();
}
}
protected async dispatchEvent(params: {channel: Channel; event: GatewayDispatchEvent; data: unknown}): Promise<void> {
const {channel, event, data} = params;
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return this.gatewayService.dispatchPresence({
userId: channelIdToUserId(channel.id),
event,
data,
});
}
if (channel.guildId) {
return this.gatewayService.dispatchGuild({guildId: channel.guildId, event, data});
} else {
for (const recipientId of channel.recipientIds) {
await this.gatewayService.dispatchPresence({userId: recipientId, event, data});
}
}
}
}

View File

@@ -0,0 +1,314 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createMessageID, type MessageID, type UserID} from '~/BrandedTypes';
import {GuildOperations, MessageTypes, Permissions} from '~/Constants';
import type {ChannelPinResponse} from '~/channel/ChannelModel';
import {mapMessageToResponse} from '~/channel/ChannelModel';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {CannotEditSystemMessageError, FeatureTemporarilyDisabledError, UnknownMessageError} from '~/Errors';
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Channel, Message} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {AuthenticatedChannel} from '../AuthenticatedChannel';
import type {MessagePersistenceService} from '../message/MessagePersistenceService';
import {MessageInteractionBase} from './MessageInteractionBase';
export class MessagePinService extends MessageInteractionBase {
constructor(
gatewayService: IGatewayService,
private channelRepository: IChannelRepositoryAggregate,
private userCacheService: UserCacheService,
private mediaService: IMediaService,
private snowflakeService: SnowflakeService,
private messagePersistenceService: MessagePersistenceService,
private readonly guildAuditLogService: GuildAuditLogService,
) {
super(gatewayService);
}
async getChannelPins({
authChannel,
requestCache,
beforeTimestamp,
limit,
}: {
authChannel: AuthenticatedChannel;
requestCache: RequestCache;
beforeTimestamp?: Date;
limit?: number;
}): Promise<{items: Array<ChannelPinResponse>; has_more: boolean}> {
const {channel} = authChannel;
this.ensureTextChannel(channel);
if (authChannel.guild && !(await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY))) {
return {items: [], has_more: false};
}
const pageSize = Math.min(limit ?? 50, 50);
const effectiveBefore = beforeTimestamp ?? new Date();
const messages = await this.channelRepository.messageInteractions.listChannelPins(
channel.id,
effectiveBefore,
pageSize + 1,
);
const sorted = messages.sort((a, b) => (b.pinnedTimestamp?.getTime() ?? 0) - (a.pinnedTimestamp?.getTime() ?? 0));
const hasMore = sorted.length > pageSize;
const trimmed = hasMore ? sorted.slice(0, pageSize) : sorted;
const items = await Promise.all(
trimmed.map(async (message: Message) => ({
message: await mapMessageToResponse({
message,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
}),
pinned_at: message.pinnedTimestamp!.toISOString(),
})),
);
return {
items,
has_more: hasMore,
};
}
async pinMessage({
authChannel,
messageId,
userId,
requestCache,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
userId: UserID;
requestCache: RequestCache;
}): Promise<void> {
const {channel, guild, checkPermission} = authChannel;
if (guild) {
await checkPermission(Permissions.PIN_MESSAGES);
if (this.isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
throw new FeatureTemporarilyDisabledError();
}
}
this.ensureTextChannel(channel);
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!message) throw new UnknownMessageError();
this.validateMessagePinnable(message);
if (message.pinnedTimestamp) return;
const now = new Date();
const updatedMessageData = {...message.toRow(), pinned_timestamp: now};
await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow());
await this.channelRepository.messageInteractions.addChannelPin(channel.id, messageId, now);
const updatedChannelData = {...channel.toRow(), last_pin_timestamp: now};
const updatedChannel = await this.channelRepository.channelData.upsert(updatedChannelData);
await this.dispatchChannelPinsUpdate(updatedChannel);
await this.sendPinSystemMessage({channel, message, userId, requestCache});
const updatedMessage = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!updatedMessage) throw new UnknownMessageError();
await this.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
if (channel.guildId) {
await this.guildAuditLogService
.createBuilder(channel.guildId, userId)
.withAction(AuditLogActionType.MESSAGE_PIN, messageId.toString())
.withMetadata({
channel_id: channel.id.toString(),
message_id: messageId.toString(),
})
.withReason(null)
.commit();
}
}
async unpinMessage({
authChannel,
messageId,
userId,
requestCache,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
userId: UserID;
requestCache: RequestCache;
}): Promise<void> {
const {channel, guild, checkPermission} = authChannel;
if (guild) {
await checkPermission(Permissions.PIN_MESSAGES);
if (this.isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
throw new FeatureTemporarilyDisabledError();
}
}
this.ensureTextChannel(channel);
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!message) throw new UnknownMessageError();
this.validateMessagePinnable(message);
if (!message.pinnedTimestamp) return;
const updatedMessageData = {...message.toRow(), pinned_timestamp: null};
await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow());
await this.channelRepository.messageInteractions.removeChannelPin(channel.id, messageId);
await this.dispatchChannelPinsUpdate(channel);
const updatedMessage = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!updatedMessage) throw new UnknownMessageError();
await this.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
if (channel.guildId) {
await this.guildAuditLogService
.createBuilder(channel.guildId, userId)
.withAction(AuditLogActionType.MESSAGE_UNPIN, messageId.toString())
.withMetadata({
channel_id: channel.id.toString(),
message_id: messageId.toString(),
})
.withReason(null)
.commit();
}
}
private validateMessagePinnable(message: Message): void {
const pinnableTypes: ReadonlySet<Message['type']> = new Set([MessageTypes.DEFAULT, MessageTypes.REPLY]);
if (!pinnableTypes.has(message.type)) {
throw new CannotEditSystemMessageError();
}
}
private async sendPinSystemMessage({
channel,
message,
userId,
requestCache,
}: {
channel: Channel;
message: Message;
userId: UserID;
requestCache: RequestCache;
}): Promise<void> {
const messageId = createMessageID(this.snowflakeService.generate());
const systemMessage = await this.messagePersistenceService.createSystemMessage({
messageId,
channelId: channel.id,
userId,
type: MessageTypes.CHANNEL_PINNED_MESSAGE,
guildId: channel.guildId,
messageReference: {
channel_id: channel.id,
message_id: message.id,
guild_id: null,
type: 0,
},
});
await this.dispatchMessageCreate({channel, message: systemMessage, requestCache});
}
private async dispatchChannelPinsUpdate(channel: Channel): Promise<void> {
await this.dispatchEvent({
channel,
event: 'CHANNEL_PINS_UPDATE',
data: {
channel_id: channel.id.toString(),
last_pin_timestamp: channel.lastPinTimestamp?.toISOString() ?? null,
},
});
}
private async dispatchMessageCreate({
channel,
message,
requestCache,
currentUserId,
nonce,
}: {
channel: Channel;
message: Message;
requestCache: RequestCache;
currentUserId?: UserID;
nonce?: string;
}): Promise<void> {
const messageResponse = await mapMessageToResponse({
message,
currentUserId,
nonce,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
});
await this.dispatchEvent({
channel,
event: 'MESSAGE_CREATE',
data: {...messageResponse, channel_type: channel.type},
});
}
private async dispatchMessageUpdate({
channel,
message,
requestCache,
currentUserId,
}: {
channel: Channel;
message: Message;
requestCache: RequestCache;
currentUserId?: UserID;
}): Promise<void> {
const messageResponse = await mapMessageToResponse({
message,
currentUserId,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
});
await this.dispatchEvent({
channel,
event: 'MESSAGE_UPDATE',
data: messageResponse,
});
}
}

View File

@@ -0,0 +1,498 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import emojiRegex from 'emoji-regex';
import {type ChannelID, createEmojiID, type MessageID, type UserID} from '~/BrandedTypes';
import {GuildOperations, MAX_REACTIONS_PER_MESSAGE, MAX_USERS_PER_MESSAGE_REACTION, Permissions} from '~/Constants';
import {
CommunicationDisabledError,
FeatureTemporarilyDisabledError,
InputValidationError,
MaxReactionsPerMessageError,
MaxUsersPerMessageReactionError,
MissingPermissionsError,
UnknownMessageError,
} from '~/Errors';
import {isGuildMemberTimedOut} from '~/guild/GuildModel';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import {getMetricsService} from '~/infrastructure/MetricsService';
import type {Channel, Message, MessageReaction} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
import {mapUserToPartialResponse, type UserPartialResponse} from '~/user/UserModel';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {AuthenticatedChannel} from '../AuthenticatedChannel';
import {MessageInteractionBase, type ParsedEmoji} from './MessageInteractionBase';
const REACTION_CUSTOM_EMOJI_REGEX = /^(.+):(\d+)$/;
export class MessageReactionService extends MessageInteractionBase {
constructor(
gatewayService: IGatewayService,
private channelRepository: IChannelRepositoryAggregate,
private userRepository: IUserRepository,
private guildRepository: IGuildRepository,
) {
super(gatewayService);
}
async getUsersForReaction({
authChannel,
messageId,
emoji,
limit,
after,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
emoji: string;
limit?: number;
after?: UserID;
}): Promise<Array<UserPartialResponse>> {
const {channel} = authChannel;
this.ensureTextChannel(channel);
const validatedLimit = limit ? Math.min(Math.max(limit, 1), MAX_USERS_PER_MESSAGE_REACTION) : 25;
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!message) throw new UnknownMessageError();
const parsedEmoji = this.parseEmojiWithoutValidation(emoji);
const afterUserId = after;
const reactions = await this.channelRepository.messageInteractions.listReactionUsers(
channel.id,
messageId,
parsedEmoji.name,
validatedLimit,
afterUserId,
parsedEmoji.id ? createEmojiID(BigInt(parsedEmoji.id)) : undefined,
);
if (!reactions.length) return [];
const userIds = reactions.map((reaction: MessageReaction) => reaction.userId);
const users = await this.userRepository.listUsers(userIds);
return users.map(mapUserToPartialResponse);
}
async addReaction({
authChannel,
messageId,
emoji,
userId,
sessionId,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
emoji: string;
userId: UserID;
sessionId?: string;
}): Promise<void> {
try {
const {channel, guild, hasPermission, checkPermission} = authChannel;
this.ensureTextChannel(channel);
const isMemberTimedOut = isGuildMemberTimedOut(authChannel.member);
if (guild && !(await hasPermission(Permissions.READ_MESSAGE_HISTORY))) {
throw new MissingPermissionsError();
}
if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) {
throw new FeatureTemporarilyDisabledError();
}
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!message) throw new UnknownMessageError();
const parsedEmojiBasic = this.parseEmojiWithoutValidation(emoji);
const emojiId = parsedEmojiBasic.id ? createEmojiID(BigInt(parsedEmojiBasic.id)) : undefined;
const userReactionExists = await this.channelRepository.messageInteractions.checkUserReactionExists(
channel.id,
messageId,
userId,
parsedEmojiBasic.name,
emojiId,
);
if (userReactionExists) return;
const reactionCount = await this.channelRepository.messageInteractions.countReactionUsers(
channel.id,
messageId,
parsedEmojiBasic.name,
emojiId,
);
if (reactionCount === 0 && guild) {
await checkPermission(Permissions.ADD_REACTIONS);
}
let parsedEmoji: ParsedEmoji;
if (reactionCount > 0) {
parsedEmoji = parsedEmojiBasic;
} else {
parsedEmoji = await this.parseAndValidateEmoji({
emoji,
guildId: channel.guildId?.toString() || undefined,
userId,
hasPermission: channel.guildId ? hasPermission : undefined,
});
}
if (reactionCount >= MAX_USERS_PER_MESSAGE_REACTION) {
throw new MaxUsersPerMessageReactionError();
}
const uniqueReactionCount = await this.channelRepository.messageInteractions.countUniqueReactions(
channel.id,
messageId,
);
if (isMemberTimedOut && reactionCount === 0) {
throw new CommunicationDisabledError();
}
if (uniqueReactionCount >= MAX_REACTIONS_PER_MESSAGE) {
throw new MaxReactionsPerMessageError();
}
await this.channelRepository.messageInteractions.addReaction(
channel.id,
messageId,
userId,
parsedEmoji.name,
emojiId,
parsedEmoji.animated ?? false,
);
await this.dispatchMessageReactionAdd({
channel,
messageId,
emoji: parsedEmoji,
userId,
sessionId,
});
getMetricsService().counter({name: 'reaction.add'});
} catch (error) {
getMetricsService().counter({name: 'reaction.add.error'});
throw error;
}
}
async removeReaction({
authChannel,
messageId,
emoji,
targetId,
sessionId,
actorId,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
emoji: string;
targetId: UserID;
sessionId?: string;
actorId: UserID;
}): Promise<void> {
try {
const {channel, guild, hasPermission} = authChannel;
this.ensureTextChannel(channel);
if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) {
throw new FeatureTemporarilyDisabledError();
}
const parsedEmoji = this.parseEmojiWithoutValidation(emoji);
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!message) return;
const isRemovingOwnReaction = targetId === actorId;
if (!isRemovingOwnReaction) {
await this.assertCanModerateMessageReactions({channel, message, actorId, hasPermission});
}
const emojiId = parsedEmoji.id ? createEmojiID(BigInt(parsedEmoji.id)) : undefined;
await this.channelRepository.messageInteractions.removeReaction(
channel.id,
messageId,
targetId,
parsedEmoji.name,
emojiId,
);
await this.dispatchMessageReactionRemove({
channel,
messageId,
emoji: parsedEmoji,
userId: targetId,
sessionId,
});
getMetricsService().counter({name: 'reaction.remove'});
} catch (error) {
getMetricsService().counter({name: 'reaction.remove.error'});
throw error;
}
}
async removeAllReactionsForEmoji({
authChannel,
messageId,
emoji,
actorId,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
emoji: string;
actorId: UserID;
}): Promise<void> {
const {channel, guild, hasPermission} = authChannel;
this.ensureTextChannel(channel);
if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) {
throw new FeatureTemporarilyDisabledError();
}
const parsedEmoji = this.parseEmojiWithoutValidation(emoji);
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!message) return;
await this.assertCanModerateMessageReactions({channel, message, actorId, hasPermission});
const emojiId = parsedEmoji.id ? createEmojiID(BigInt(parsedEmoji.id)) : undefined;
await this.channelRepository.messageInteractions.removeAllReactionsForEmoji(
channel.id,
messageId,
parsedEmoji.name,
emojiId,
);
await this.dispatchMessageReactionRemoveAllForEmoji({channel, messageId, emoji: parsedEmoji});
}
async removeAllReactions({
authChannel,
messageId,
actorId,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
actorId: UserID;
}): Promise<void> {
const {channel, guild, hasPermission} = authChannel;
this.ensureTextChannel(channel);
if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) {
throw new FeatureTemporarilyDisabledError();
}
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!message) return;
await this.assertCanModerateMessageReactions({channel, message, actorId, hasPermission});
await this.channelRepository.messageInteractions.removeAllReactions(channel.id, messageId);
await this.dispatchMessageReactionRemoveAll({channel, messageId});
}
async getMessageReactions({
authChannel,
messageId,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
}): Promise<Array<MessageReaction>> {
return this.channelRepository.messageInteractions.listMessageReactions(authChannel.channel.id, messageId);
}
async setHasReaction(channelId: ChannelID, messageId: MessageID, hasReaction: boolean): Promise<void> {
return this.channelRepository.messageInteractions.setHasReaction(channelId, messageId, hasReaction);
}
private async assertCanModerateMessageReactions({
channel,
message,
actorId,
hasPermission,
}: {
channel: Channel;
message: Message;
actorId: UserID;
hasPermission: (permission: bigint) => Promise<boolean>;
}): Promise<void> {
if (message.authorId === actorId) {
return;
}
if (!channel.guildId) {
throw new MissingPermissionsError();
}
const canManageMessages = await hasPermission(Permissions.MANAGE_MESSAGES);
if (!canManageMessages) {
throw new MissingPermissionsError();
}
}
private parseEmojiWithoutValidation(emoji: string): {name: string; id?: string; animated?: boolean} {
const decodedEmoji = decodeURIComponent(emoji);
const customEmojiMatch = decodedEmoji.match(REACTION_CUSTOM_EMOJI_REGEX);
if (customEmojiMatch) {
const [, name, id] = customEmojiMatch;
return {
id,
name: name || 'unknown',
};
}
return {name: decodedEmoji};
}
private async parseAndValidateEmoji({
emoji,
guildId,
userId,
hasPermission,
}: {
emoji: string;
guildId?: string | undefined;
userId?: UserID;
hasPermission?: (permission: bigint) => Promise<boolean>;
}): Promise<ParsedEmoji> {
const decodedEmoji = decodeURIComponent(emoji);
const customEmojiMatch = decodedEmoji.match(REACTION_CUSTOM_EMOJI_REGEX);
if (customEmojiMatch) {
const [, , id] = customEmojiMatch;
const emojiIdBigInt = createEmojiID(BigInt(id));
let isPremium = false;
if (userId) {
const user = await this.userRepository.findUnique(userId);
isPremium = user?.canUseGlobalExpressions() ?? false;
}
const emoji = await this.guildRepository.getEmojiById(emojiIdBigInt);
if (!emoji) {
throw InputValidationError.create('emoji', 'Custom emoji not found');
}
if (!isPremium && emoji.guildId.toString() !== guildId) {
throw InputValidationError.create('emoji', 'Cannot use custom emojis outside of source guilds without premium');
}
if (hasPermission) {
const canUseExternalEmojis = await hasPermission(Permissions.USE_EXTERNAL_EMOJIS);
if (!canUseExternalEmojis) {
throw new MissingPermissionsError();
}
}
return {
id,
name: emoji.name,
animated: emoji.isAnimated,
};
}
const isValidUnicodeEmoji = emojiRegex().test(decodedEmoji);
if (!isValidUnicodeEmoji) {
throw InputValidationError.create('emoji', 'Not a valid Unicode emoji');
}
return {name: decodedEmoji};
}
private async dispatchMessageReactionAdd(params: {
channel: Channel;
messageId: MessageID;
emoji: ParsedEmoji;
userId: UserID;
sessionId?: string;
}): Promise<void> {
await this.dispatchEvent({
channel: params.channel,
event: 'MESSAGE_REACTION_ADD',
data: {
channel_id: params.channel.id.toString(),
message_id: params.messageId.toString(),
emoji: params.emoji,
user_id: params.userId.toString(),
session_id: params.sessionId,
},
});
}
private async dispatchMessageReactionRemove(params: {
channel: Channel;
messageId: MessageID;
emoji: ParsedEmoji;
userId: UserID;
sessionId?: string;
}): Promise<void> {
await this.dispatchEvent({
channel: params.channel,
event: 'MESSAGE_REACTION_REMOVE',
data: {
channel_id: params.channel.id.toString(),
message_id: params.messageId.toString(),
emoji: params.emoji,
user_id: params.userId.toString(),
session_id: params.sessionId,
},
});
}
private async dispatchMessageReactionRemoveAllForEmoji(params: {
channel: Channel;
messageId: MessageID;
emoji: ParsedEmoji;
}): Promise<void> {
const event = params.channel.guildId ? 'MESSAGE_REACTION_REMOVE_EMOJI' : 'MESSAGE_REACTION_REMOVE_ALL';
await this.dispatchEvent({
channel: params.channel,
event,
data: {
channel_id: params.channel.id.toString(),
message_id: params.messageId.toString(),
emoji: params.emoji,
},
});
}
private async dispatchMessageReactionRemoveAll(params: {channel: Channel; messageId: MessageID}): Promise<void> {
await this.dispatchEvent({
channel: params.channel,
event: 'MESSAGE_REACTION_REMOVE_ALL',
data: {
channel_id: params.channel.id.toString(),
message_id: params.messageId.toString(),
},
});
}
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
import {GuildOperations} from '~/Constants';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {Channel} from '~/Models';
import type {ReadStateService} from '~/read_state/ReadStateService';
import type {AuthenticatedChannel} from '../AuthenticatedChannel';
import {MessageInteractionBase} from './MessageInteractionBase';
export class MessageReadStateService extends MessageInteractionBase {
constructor(
gatewayService: IGatewayService,
private readStateService: ReadStateService,
) {
super(gatewayService);
}
async startTyping({authChannel, userId}: {authChannel: AuthenticatedChannel; userId: UserID}): Promise<void> {
const {channel, guild} = authChannel;
this.ensureTextChannel(channel);
if (this.isOperationDisabled(guild, GuildOperations.TYPING_EVENTS)) {
return;
}
await this.dispatchTypingStart({channel, userId});
}
async ackMessage({
userId,
channelId,
messageId,
mentionCount,
manual,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
mentionCount: number;
manual?: boolean;
}): Promise<void> {
await this.readStateService.ackMessage({userId, channelId, messageId, mentionCount, manual});
}
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
await this.readStateService.deleteReadState({userId, channelId});
}
async ackPins({
userId,
channelId,
timestamp,
}: {
userId: UserID;
channelId: ChannelID;
timestamp: Date;
}): Promise<void> {
await this.readStateService.ackPins({userId, channelId, timestamp});
}
private async dispatchTypingStart({channel, userId}: {channel: Channel; userId: UserID}): Promise<void> {
await this.dispatchEvent({
channel,
event: 'TYPING_START',
data: {
channel_id: channel.id.toString(),
user_id: userId.toString(),
timestamp: Date.now(),
},
});
}
}

View File

@@ -0,0 +1,294 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAttachmentID} from '~/BrandedTypes';
import {Config} from '~/Config';
import {MessageAttachmentFlags} from '~/Constants';
import type {AttachmentToProcess} from '~/channel/AttachmentDTOs';
import type {MessageAttachment} from '~/database/CassandraTypes';
import {InputValidationError} from '~/Errors';
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {IVirusScanService} from '~/infrastructure/IVirusScanService';
import {getMetricsService} from '~/infrastructure/MetricsService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import {Logger} from '~/Logger';
import type {Channel, Message} from '~/Models';
import {getContentType, isMediaFile, makeAttachmentCdnKey, validateAttachmentIds} from './MessageHelpers';
interface ProcessAttachmentParams {
message: Message;
attachment: AttachmentToProcess;
index: number;
channel?: Channel;
guild?: GuildResponse | null;
member?: GuildMemberResponse | null;
isNSFWAllowed: boolean;
}
interface AttachmentCopyOperation {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType: string;
}
interface ProcessedAttachment {
attachment: MessageAttachment;
copyOperation: AttachmentCopyOperation;
hasVirusDetected: boolean;
}
export class AttachmentProcessingService {
constructor(
private storageService: IStorageService,
private mediaService: IMediaService,
private virusScanService: IVirusScanService,
private snowflakeService: SnowflakeService,
) {}
async computeAttachments(params: {
message: Message;
attachments: Array<AttachmentToProcess>;
channel?: Channel;
guild?: GuildResponse | null;
member?: GuildMemberResponse | null;
isNSFWAllowed: boolean;
}): Promise<{attachments: Array<MessageAttachment>; hasVirusDetected: boolean}> {
validateAttachmentIds(params.attachments.map((a) => ({id: BigInt(a.id)})));
const results = await Promise.all(
params.attachments.map((attachment, index) =>
this.processAttachment({
message: params.message,
attachment,
index,
channel: params.channel,
guild: params.guild,
member: params.member,
isNSFWAllowed: params.isNSFWAllowed,
}),
),
);
const hasVirusDetected = results.some((result) => result.hasVirusDetected);
if (hasVirusDetected) {
return {attachments: [], hasVirusDetected: true};
}
const copyResults = await Promise.all(
results.map((result) =>
this.storageService.copyObjectWithJpegProcessing({
sourceBucket: result.copyOperation.sourceBucket,
sourceKey: result.copyOperation.sourceKey,
destinationBucket: result.copyOperation.destinationBucket,
destinationKey: result.copyOperation.destinationKey,
contentType: result.copyOperation.newContentType,
}),
),
);
for (const result of results) {
void this.deleteUploadObject(result.copyOperation.sourceBucket, result.copyOperation.sourceKey);
}
const processedAttachments: Array<MessageAttachment> = results.map((result, index) => {
const maybeDimensions = copyResults[index];
if (maybeDimensions) {
return {
...result.attachment,
width: maybeDimensions.width,
height: maybeDimensions.height,
};
}
return result.attachment;
});
const metrics = getMetricsService();
for (let index = 0; index < processedAttachments.length; index++) {
const result = results[index];
if (result.hasVirusDetected) continue;
const attachment = processedAttachments[index];
const contentType = attachment.content_type ?? 'unknown';
const filename = attachment.filename;
const extension =
(filename?.includes('.') ?? false) ? (filename.split('.').pop()?.toLowerCase() ?? 'unknown') : 'unknown';
const channelType = params.channel?.type ?? 'unknown';
metrics.counter({
name: 'attachment.created',
dimensions: {
content_type: contentType,
attachment_extension: extension,
channel_type: channelType.toString(),
},
});
metrics.counter({
name: 'attachment.storage.bytes',
dimensions: {
content_type: contentType,
channel_type: channelType.toString(),
action: 'create',
},
value: Number(attachment.size),
});
}
return {attachments: processedAttachments, hasVirusDetected: false};
}
private async processAttachment(params: ProcessAttachmentParams): Promise<ProcessedAttachment> {
const {message, attachment, index, isNSFWAllowed} = params;
const uploadedFile = await this.storageService.getObjectMetadata(
Config.s3.buckets.uploads,
attachment.upload_filename,
);
if (!uploadedFile) {
throw InputValidationError.create(`attachments.${index}.upload_filename`, 'File not found');
}
const attachmentId = createAttachmentID(this.snowflakeService.generate());
const cdnKey = makeAttachmentCdnKey(message.channelId, attachmentId, attachment.filename);
let contentType = getContentType(attachment.filename);
let size = BigInt(uploadedFile.contentLength);
const clientFlags =
(attachment.flags ?? 0) & (MessageAttachmentFlags.IS_SPOILER | MessageAttachmentFlags.CONTAINS_EXPLICIT_MEDIA);
let flags = clientFlags;
let width: number | null = null;
let height: number | null = null;
let placeholder: string | null = null;
let duration: number | null = null;
let hasVirusDetected = false;
let nsfw: boolean | null = null;
let contentHash: string | null = null;
const isMedia = isMediaFile(contentType);
const scanResult = await this.scanMalware(attachment);
if (scanResult.isVirusDetected) {
hasVirusDetected = true;
await this.storageService.deleteObject(Config.s3.buckets.uploads, attachment.upload_filename);
return {
attachment: {
attachment_id: attachmentId,
filename: attachment.filename,
size,
title: attachment.title ?? null,
description: attachment.description ?? null,
height,
width,
content_type: contentType,
content_hash: contentHash,
placeholder,
flags,
duration,
nsfw,
},
copyOperation: {
sourceBucket: Config.s3.buckets.uploads,
sourceKey: attachment.upload_filename,
destinationBucket: Config.s3.buckets.cdn,
destinationKey: cdnKey,
newContentType: contentType,
},
hasVirusDetected,
};
}
if (isMedia) {
const metadata = await this.mediaService.getMetadata({
type: 'upload',
upload_filename: attachment.upload_filename,
isNSFWAllowed,
});
if (metadata) {
contentType = metadata.content_type;
contentHash = metadata.content_hash;
size = BigInt(metadata.size);
placeholder = metadata.placeholder ?? null;
duration = metadata.duration && metadata.duration > 0 ? metadata.duration : null;
width = metadata.width ?? null;
height = metadata.height ?? null;
if (metadata.animated) {
flags |= MessageAttachmentFlags.IS_ANIMATED;
}
if (metadata.nsfw) {
flags |= MessageAttachmentFlags.CONTAINS_EXPLICIT_MEDIA;
}
nsfw = metadata.nsfw;
}
}
return {
attachment: {
attachment_id: attachmentId,
filename: attachment.filename,
size,
title: attachment.title ?? null,
description: attachment.description ?? null,
height,
width,
content_type: contentType,
content_hash: contentHash,
placeholder,
flags,
duration,
nsfw,
},
copyOperation: {
sourceBucket: Config.s3.buckets.uploads,
sourceKey: attachment.upload_filename,
destinationBucket: Config.s3.buckets.cdn,
destinationKey: cdnKey,
newContentType: contentType,
},
hasVirusDetected,
};
}
private async scanMalware(attachment: AttachmentToProcess): Promise<{isVirusDetected: boolean}> {
const fileData = await this.storageService.readObject(Config.s3.buckets.uploads, attachment.upload_filename);
if (!fileData) {
throw InputValidationError.create('attachment', 'File not found for scanning');
}
const fileBuffer = Buffer.from(fileData);
const scanResult = await this.virusScanService.scanBuffer(fileBuffer, attachment.filename);
return {isVirusDetected: !scanResult.isClean};
}
private deleteUploadObject(bucket: string, key: string): void {
void this.storageService.deleteObject(bucket, key).catch((error) => {
Logger.warn({bucket, key, error}, 'Failed to delete temporary upload object');
});
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
import {Logger} from '~/Logger';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
export class MessageAnonymizationService {
constructor(private channelRepository: IChannelRepositoryAggregate) {}
async anonymizeMessagesByAuthor(originalAuthorId: UserID, newAuthorId: UserID): Promise<void> {
const CHUNK_SIZE = 100;
let lastChannelId: ChannelID | undefined;
let lastMessageId: MessageID | undefined;
let processedCount = 0;
while (true) {
const messagesToAnonymize = await this.channelRepository.messages.listMessagesByAuthor(
originalAuthorId,
CHUNK_SIZE,
lastChannelId,
lastMessageId,
);
if (messagesToAnonymize.length === 0) {
break;
}
for (const {channelId, messageId} of messagesToAnonymize) {
await this.channelRepository.messages.anonymizeMessage(channelId, messageId, newAuthorId);
}
processedCount += messagesToAnonymize.length;
lastChannelId = messagesToAnonymize[messagesToAnonymize.length - 1].channelId;
lastMessageId = messagesToAnonymize[messagesToAnonymize.length - 1].messageId;
Logger.debug(
{originalAuthorId, processedCount, chunkSize: messagesToAnonymize.length},
'Anonymized message chunk',
);
if (messagesToAnonymize.length < CHUNK_SIZE) {
break;
}
}
Logger.debug({originalAuthorId, newAuthorId, totalProcessed: processedCount}, 'Completed message anonymization');
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
import type {User} from '~/Models';
import {checkGuildVerificationWithResponse} from '~/utils/GuildVerificationUtils';
import {BaseChannelAuthService, type ChannelAuthOptions} from '../BaseChannelAuthService';
export class MessageChannelAuthService extends BaseChannelAuthService {
protected readonly options: ChannelAuthOptions = {
errorOnMissingGuild: 'unknown_channel',
validateNsfw: true,
useVirtualPersonalNotes: true,
};
async checkGuildVerification({
user,
guild,
member,
}: {
user: User;
guild: GuildResponse;
member: GuildMemberResponse;
}): Promise<void> {
checkGuildVerificationWithResponse({user, guild, member});
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildID, UserID, WebhookID} from '~/BrandedTypes';
import {ChannelTypes, GuildExplicitContentFilterTypes} from '~/Constants';
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {Channel} from '~/Models';
import type {PackService} from '~/pack/PackService';
import type {IUserRepository} from '~/user/IUserRepository';
import * as EmojiUtils from '~/utils/EmojiUtils';
export class MessageContentService {
constructor(
private userRepository: IUserRepository,
private guildRepository: IGuildRepository,
private packService: PackService,
) {}
async sanitizeCustomEmojis(params: {
content: string;
userId: UserID | null;
webhookId: WebhookID | null;
guildId: GuildID | null;
hasPermission?: (permission: bigint) => Promise<boolean>;
}): Promise<string> {
const packResolver = await this.packService.createPackExpressionAccessResolver({
userId: params.userId,
type: 'emoji',
});
return await EmojiUtils.sanitizeCustomEmojis({
...params,
userRepository: this.userRepository,
guildRepository: this.guildRepository,
packResolver,
});
}
isNSFWContentAllowed(params: {
channel?: Channel;
guild?: GuildResponse | null;
member?: GuildMemberResponse | null;
}): boolean {
const {channel, guild, member} = params;
if (channel?.type === ChannelTypes.GUILD_TEXT && channel.isNsfw) {
return true;
}
if (!guild) {
return false;
}
const explicitContentFilter = guild.explicit_content_filter;
if (explicitContentFilter === GuildExplicitContentFilterTypes.DISABLED) {
return true;
}
if (explicitContentFilter === GuildExplicitContentFilterTypes.MEMBERS_WITHOUT_ROLES) {
const hasRoles = member && member.roles.length > 0;
return !!hasRoles;
}
return false;
}
}

View File

@@ -0,0 +1,245 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type ChannelID, createMessageID, createUserID, type GuildID, type MessageID, type UserID} from '~/BrandedTypes';
import {GuildOperations, Permissions} from '~/Constants';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {
CannotExecuteOnDmError,
FeatureTemporarilyDisabledError,
InputValidationError,
MissingPermissionsError,
UnknownMessageError,
} from '~/Errors';
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
import type {IStorageService} from '~/infrastructure/IStorageService';
import {getMetricsService} from '~/infrastructure/MetricsService';
import type {Channel, Message} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {getSnowflake} from '~/utils/SnowflakeUtils';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {MessageChannelAuthService} from './MessageChannelAuthService';
import type {MessageDispatchService} from './MessageDispatchService';
import {isOperationDisabled, purgeMessageAttachments} from './MessageHelpers';
import type {MessageSearchService} from './MessageSearchService';
import type {MessageValidationService} from './MessageValidationService';
interface MessageDeleteServiceDeps {
channelRepository: IChannelRepositoryAggregate;
storageService: IStorageService;
cloudflarePurgeQueue: ICloudflarePurgeQueue;
validationService: MessageValidationService;
channelAuthService: MessageChannelAuthService;
dispatchService: MessageDispatchService;
searchService: MessageSearchService;
guildAuditLogService: GuildAuditLogService;
}
export class MessageDeleteService {
private readonly guildAuditLogService: GuildAuditLogService;
constructor(private readonly deps: MessageDeleteServiceDeps) {
this.guildAuditLogService = deps.guildAuditLogService;
}
async deleteMessage({
userId,
channelId,
messageId,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
}): Promise<void> {
try {
const {channel, guild, hasPermission} = await this.deps.channelAuthService.getChannelAuthenticated({
userId,
channelId,
});
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
throw new FeatureTemporarilyDisabledError();
}
const message = await this.deps.channelRepository.messages.getMessage(channelId, messageId);
if (!message) throw new UnknownMessageError();
const canDelete = await this.deps.validationService.canDeleteMessage({message, userId, guild, hasPermission});
if (!canDelete) throw new MissingPermissionsError();
if (message.pinnedTimestamp) {
await this.deps.channelRepository.messageInteractions.removeChannelPin(channelId, messageId);
}
await purgeMessageAttachments(message, this.deps.storageService, this.deps.cloudflarePurgeQueue);
await this.deps.channelRepository.messages.deleteMessage(
channelId,
messageId,
message.authorId || createUserID(0n),
message.pinnedTimestamp || undefined,
);
await this.deps.dispatchService.dispatchMessageDelete({channel, messageId, message});
if (message.pinnedTimestamp) {
await this.deps.dispatchService.dispatchEvent({
channel,
event: 'CHANNEL_PINS_UPDATE',
data: {
channel_id: channel.id.toString(),
last_pin_timestamp: channel.lastPinTimestamp?.toISOString() ?? null,
},
});
}
if (channel.guildId) {
await this.guildAuditLogService
.createBuilder(channel.guildId, userId)
.withAction(AuditLogActionType.MESSAGE_DELETE, message.id.toString())
.withMetadata({channel_id: channel.id.toString()})
.withReason(null)
.commit();
}
if (channel.indexedAt) {
void this.deps.searchService.deleteMessageIndex(messageId);
}
getMetricsService().counter({name: 'message.delete'});
} catch (error) {
getMetricsService().counter({name: 'message.delete.error'});
throw error;
}
}
async bulkDeleteMessages({
userId,
channelId,
messageIds,
}: {
userId: UserID;
channelId: ChannelID;
messageIds: Array<MessageID>;
}): Promise<void> {
if (messageIds.length === 0) {
throw InputValidationError.create('message_ids', 'message_ids cannot be empty');
}
if (messageIds.length > 100) {
throw InputValidationError.create('message_ids', 'Cannot delete more than 100 messages at once');
}
const {channel, guild, checkPermission} = await this.deps.channelAuthService.getChannelAuthenticated({
userId,
channelId,
});
if (!guild) throw new CannotExecuteOnDmError();
await checkPermission(Permissions.MANAGE_MESSAGES);
const messages = await Promise.all(
messageIds.map((id) => this.deps.channelRepository.messages.getMessage(channelId, id)),
);
const existingMessages = messages.filter((m: Message | null) => m && m.channelId === channelId) as Array<Message>;
if (existingMessages.length === 0) return;
await Promise.all(
existingMessages.map((message) =>
purgeMessageAttachments(message, this.deps.storageService, this.deps.cloudflarePurgeQueue),
),
);
await this.deps.channelRepository.messages.bulkDeleteMessages(channelId, messageIds);
await this.deps.dispatchService.dispatchMessageDeleteBulk({channel, messageIds});
if (channel.guildId && existingMessages.length > 0) {
await this.guildAuditLogService
.createBuilder(channel.guildId, userId)
.withAction(AuditLogActionType.MESSAGE_BULK_DELETE, null)
.withMetadata({
channel_id: channel.id.toString(),
count: existingMessages.length.toString(),
})
.withReason(null)
.commit();
}
}
async deleteUserMessagesInGuild({
userId,
guildId,
days,
}: {
userId: UserID;
guildId: GuildID;
days: number;
}): Promise<void> {
const channels = await this.deps.channelRepository.channelData.listGuildChannels(guildId);
const cutoffTimestamp = Date.now() - days * 24 * 60 * 60 * 1000;
const afterSnowflake = createMessageID(getSnowflake(cutoffTimestamp));
await Promise.all(
channels.map(async (channel: Channel) => {
const batchSize = 100;
let beforeMessageId: MessageID | undefined;
let hasMore = true;
while (hasMore) {
const messages = await this.deps.channelRepository.messages.listMessages(
channel.id,
beforeMessageId,
batchSize,
afterSnowflake,
);
if (messages.length === 0) {
hasMore = false;
break;
}
const userMessages = messages.filter((msg: Message) => msg.authorId === userId);
if (userMessages.length > 0) {
const messageIds = userMessages.map((msg: Message) => msg.id);
await Promise.all(
userMessages.map((message: Message) =>
purgeMessageAttachments(message, this.deps.storageService, this.deps.cloudflarePurgeQueue),
),
);
await this.deps.channelRepository.messages.bulkDeleteMessages(channel.id, messageIds);
await this.deps.dispatchService.dispatchMessageDeleteBulk({channel, messageIds});
}
if (messages.length < batchSize) {
hasMore = false;
} else {
beforeMessageId = messages[messages.length - 1].id;
}
}
}),
);
}
}

View File

@@ -0,0 +1,176 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AttachmentDecayService} from '~/attachment/AttachmentDecayService';
import {type ChannelID, channelIdToUserId, type MessageID, type UserID} from '~/BrandedTypes';
import {ChannelTypes, type GatewayDispatchEvent} from '~/Constants';
import {mapMessageToResponse} from '~/channel/ChannelModel';
import type {IChannelRepositoryAggregate} from '~/channel/repositories/IChannelRepositoryAggregate';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Channel, Message} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {collectMessageAttachments} from './MessageHelpers';
export class MessageDispatchService {
constructor(
private gatewayService: IGatewayService,
private userCacheService: UserCacheService,
private mediaService: IMediaService,
private channelRepository: IChannelRepositoryAggregate,
private attachmentDecayService: AttachmentDecayService = new AttachmentDecayService(),
) {}
async dispatchEvent(params: {channel: Channel; event: GatewayDispatchEvent; data: unknown}): Promise<void> {
const {channel, event, data} = params;
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return this.gatewayService.dispatchPresence({
userId: channelIdToUserId(channel.id),
event,
data,
});
}
if (channel.guildId) {
return this.gatewayService.dispatchGuild({guildId: channel.guildId, event, data});
}
await Promise.all(
Array.from(channel.recipientIds).map((recipientId) =>
this.gatewayService.dispatchPresence({userId: recipientId, event, data}),
),
);
}
async dispatchMessageCreate({
channel,
message,
requestCache,
currentUserId,
nonce,
tts,
}: {
channel: Channel;
message: Message;
requestCache: RequestCache;
currentUserId?: UserID;
nonce?: string;
tts?: boolean;
}): Promise<void> {
const messageAttachments = collectMessageAttachments(message);
const attachmentDecayMap =
messageAttachments.length > 0
? await this.attachmentDecayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
: undefined;
const messageResponse = await mapMessageToResponse({
message,
currentUserId,
nonce,
tts,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
attachmentDecayMap,
getReferencedMessage: (channelId: ChannelID, messageId: MessageID) =>
this.channelRepository.messages.getMessage(channelId, messageId),
});
await this.dispatchEvent({
channel,
event: 'MESSAGE_CREATE',
data: {...messageResponse, channel_type: channel.type},
});
}
async dispatchMessageUpdate({
channel,
message,
requestCache,
currentUserId,
}: {
channel: Channel;
message: Message;
requestCache: RequestCache;
currentUserId?: UserID;
}): Promise<void> {
const messageAttachments = collectMessageAttachments(message);
const attachmentDecayMap =
messageAttachments.length > 0
? await this.attachmentDecayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
: undefined;
const messageResponse = await mapMessageToResponse({
message,
currentUserId,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
attachmentDecayMap,
getReferencedMessage: (channelId: ChannelID, messageId: MessageID) =>
this.channelRepository.messages.getMessage(channelId, messageId),
});
await this.dispatchEvent({
channel,
event: 'MESSAGE_UPDATE',
data: messageResponse,
});
}
async dispatchMessageDelete({
channel,
messageId,
message,
}: {
channel: Channel;
messageId: MessageID;
message: Message;
}): Promise<void> {
await this.dispatchEvent({
channel,
event: 'MESSAGE_DELETE',
data: {
channel_id: channel.id.toString(),
id: messageId.toString(),
content: message.content,
author_id: message.authorId?.toString(),
},
});
}
async dispatchMessageDeleteBulk({
channel,
messageIds,
}: {
channel: Channel;
messageIds: Array<MessageID>;
}): Promise<void> {
await this.dispatchEvent({
channel,
event: 'MESSAGE_DELETE_BULK',
data: {
channel_id: channel.id.toString(),
ids: messageIds.map((id) => id.toString()),
},
});
}
}

View File

@@ -0,0 +1,173 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
import {GuildOperations, Permissions} from '~/Constants';
import type {MessageUpdateRequest} from '~/channel/ChannelModel';
import {FeatureTemporarilyDisabledError, MissingPermissionsError, UnknownMessageError} from '~/Errors';
import {getMetricsService} from '~/infrastructure/MetricsService';
import type {Message} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {MessageChannelAuthService} from './MessageChannelAuthService';
import type {MessageDispatchService} from './MessageDispatchService';
import type {MessageEmbedAttachmentResolver} from './MessageEmbedAttachmentResolver';
import {isOperationDisabled} from './MessageHelpers';
import type {MessageMentionService} from './MessageMentionService';
import type {MessagePersistenceService} from './MessagePersistenceService';
import type {MessageProcessingService} from './MessageProcessingService';
import type {MessageSearchService} from './MessageSearchService';
import type {MessageValidationService} from './MessageValidationService';
interface MessageEditServiceDeps {
channelRepository: IChannelRepositoryAggregate;
userRepository: IUserRepository;
validationService: MessageValidationService;
persistenceService: MessagePersistenceService;
channelAuthService: MessageChannelAuthService;
processingService: MessageProcessingService;
dispatchService: MessageDispatchService;
searchService: MessageSearchService;
embedAttachmentResolver: MessageEmbedAttachmentResolver;
mentionService: MessageMentionService;
}
export class MessageEditService {
constructor(private readonly deps: MessageEditServiceDeps) {}
async editMessage({
userId,
channelId,
messageId,
data,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
data: MessageUpdateRequest;
requestCache: RequestCache;
}): Promise<Message> {
try {
const {channel, guild, hasPermission, member} = await this.deps.channelAuthService.getChannelAuthenticated({
userId,
channelId,
});
const [canEmbedLinks, canMentionEveryone] = await Promise.all([
hasPermission(Permissions.EMBED_LINKS),
hasPermission(Permissions.MENTION_EVERYONE),
]);
if (data.embeds && data.embeds.length > 0 && !canEmbedLinks) {
throw new MissingPermissionsError();
}
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
throw new FeatureTemporarilyDisabledError();
}
const message = await this.deps.channelRepository.messages.getMessage(channelId, messageId);
if (!message) throw new UnknownMessageError();
const user = await this.deps.userRepository.findUnique(userId);
this.deps.validationService.validateMessageEditable(message);
this.deps.validationService.validateMessageContent(data, user, true);
this.deps.embedAttachmentResolver.validateAttachmentReferences({
embeds: data.embeds,
attachments: data.attachments,
existingAttachments: message.attachments.map((att) => ({filename: att.filename})),
});
const referencedMessage = message.reference
? await this.deps.channelRepository.messages.getMessage(channelId, message.reference.messageId)
: null;
const hasMentionContentChanges = data.content !== undefined || data.allowed_mentions !== undefined;
if (hasMentionContentChanges) {
const mentionContent = data.content ?? message.content ?? '';
this.deps.mentionService.extractMentions({
content: mentionContent,
referencedMessage,
message: {
id: message.id,
channelId: message.channelId,
authorId: message.authorId ?? userId,
content: mentionContent,
flags: data.flags ?? message.flags,
} as Message,
channelType: channel.type,
allowedMentions: data.allowed_mentions ?? null,
guild,
canMentionEveryone,
});
}
if (message.authorId !== userId) {
return await this.deps.processingService.handleNonAuthorEdit({
message,
messageId,
data,
guild,
hasPermission,
channel,
requestCache,
persistenceService: this.deps.persistenceService,
dispatchMessageUpdate: this.deps.dispatchService.dispatchMessageUpdate.bind(this.deps.dispatchService),
});
}
const updatedMessage = await this.deps.persistenceService.updateMessage({
message,
messageId,
data,
channel,
guild,
member,
allowEmbeds: canEmbedLinks,
});
if (data.content !== undefined || data.allowed_mentions !== undefined) {
await this.deps.processingService.handleMentions({
channel,
message: updatedMessage,
referencedMessageOnSend: referencedMessage,
allowedMentions: data.allowed_mentions ?? null,
guild,
canMentionEveryone,
canMentionRoles: canMentionEveryone,
});
}
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
if (channel.indexedAt) {
void this.deps.searchService.updateMessageIndex(updatedMessage);
}
getMetricsService().counter({name: 'message.edit'});
return updatedMessage;
} catch (error) {
getMetricsService().counter({name: 'message.edit.error'});
throw error;
}
}
}

View File

@@ -0,0 +1,189 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AttachmentID, ChannelID} from '~/BrandedTypes';
import type {AttachmentRequestData} from '~/channel/AttachmentDTOs';
import type {RichEmbedMediaWithMetadata, RichEmbedRequest} from '~/channel/ChannelModel';
import {InputValidationError} from '~/Errors';
import {makeAttachmentCdnUrl} from './MessageHelpers';
interface ProcessedAttachment {
attachment_id: AttachmentID;
filename: string;
width: number | null;
height: number | null;
content_type: string;
content_hash: string | null;
placeholder: string | null;
flags: number;
duration: number | null;
nsfw: boolean | null;
}
interface RichEmbedRequestWithMetadata extends Omit<RichEmbedRequest, 'image' | 'thumbnail'> {
image?: RichEmbedMediaWithMetadata | null;
thumbnail?: RichEmbedMediaWithMetadata | null;
}
const SUPPORTED_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif']);
export class MessageEmbedAttachmentResolver {
validateAttachmentReferences(params: {
embeds: Array<RichEmbedRequest> | undefined;
attachments: Array<AttachmentRequestData> | undefined;
existingAttachments?: Array<{filename: string}>;
}): void {
if (!params.embeds || params.embeds.length === 0) {
return;
}
let availableFilenames: Set<string> | undefined;
if (params.attachments !== undefined) {
const filenames = params.attachments
.map((att) => ('filename' in att ? att.filename : undefined))
.filter((filename): filename is string => typeof filename === 'string' && filename.length > 0);
if (filenames.length > 0) {
availableFilenames = new Set(filenames);
}
} else if (params.existingAttachments) {
availableFilenames = new Set(params.existingAttachments.map((att) => att.filename));
}
if (!availableFilenames || availableFilenames.size === 0) {
for (const embed of params.embeds) {
if (embed.image?.url?.startsWith('attachment://') || embed.thumbnail?.url?.startsWith('attachment://')) {
throw InputValidationError.create('embeds', 'Cannot reference attachments when no attachments are provided');
}
}
return;
}
const validateAttachmentReference = (filename: string, field: string, embedIndex: number) => {
if (!availableFilenames.has(filename)) {
throw InputValidationError.create(
`embeds[${embedIndex}].${field}`,
`Referenced attachment "${filename}" not found in message attachments`,
);
}
const extension = filename.split('.').pop()?.toLowerCase();
if (!extension || !SUPPORTED_IMAGE_EXTENSIONS.has(extension)) {
throw InputValidationError.create(
`embeds[${embedIndex}].${field}`,
`Attachment "${filename}" must be an image file (png, jpg, jpeg, webp, or gif)`,
);
}
};
for (let embedIndex = 0; embedIndex < params.embeds.length; embedIndex++) {
const embed = params.embeds[embedIndex];
if (embed.image?.url?.startsWith('attachment://')) {
const filename = embed.image.url.slice(13);
validateAttachmentReference(filename, 'image.url', embedIndex);
}
if (embed.thumbnail?.url?.startsWith('attachment://')) {
const filename = embed.thumbnail.url.slice(13);
validateAttachmentReference(filename, 'thumbnail.url', embedIndex);
}
}
}
resolveEmbedAttachmentUrls(params: {
embeds: Array<RichEmbedRequest> | undefined;
attachments: Array<ProcessedAttachment>;
channelId: ChannelID;
}): Array<RichEmbedRequestWithMetadata> | undefined {
if (!params.embeds || params.embeds.length === 0) {
return params.embeds as Array<RichEmbedRequestWithMetadata> | undefined;
}
const attachmentMap = new Map<string, {cdnUrl: string; metadata: ProcessedAttachment}>();
for (const attachment of params.attachments) {
const cdnUrl = makeAttachmentCdnUrl(params.channelId, attachment.attachment_id, attachment.filename);
attachmentMap.set(attachment.filename, {cdnUrl, metadata: attachment});
}
const resolveAttachmentUrl = (filename: string, field: string): {cdnUrl: string; metadata: ProcessedAttachment} => {
const attachmentData = attachmentMap.get(filename);
if (!attachmentData) {
throw InputValidationError.create(
field,
`Referenced attachment "${filename}" not found in message attachments`,
);
}
const extension = filename.split('.').pop()?.toLowerCase();
if (!extension || !SUPPORTED_IMAGE_EXTENSIONS.has(extension)) {
throw InputValidationError.create(
field,
`Attachment "${filename}" must be an image file (png, jpg, jpeg, webp, or gif)`,
);
}
return attachmentData;
};
return params.embeds.map((embed) => {
const resolvedEmbed: RichEmbedRequestWithMetadata = {...embed};
if (embed.image?.url?.startsWith('attachment://')) {
const filename = embed.image.url.slice(13);
const {cdnUrl, metadata} = resolveAttachmentUrl(filename, 'embeds.image.url');
resolvedEmbed.image = {
...embed.image,
url: cdnUrl,
_attachmentMetadata: {
width: metadata.width,
height: metadata.height,
content_type: metadata.content_type,
content_hash: metadata.content_hash,
placeholder: metadata.placeholder,
flags: metadata.flags,
duration: metadata.duration,
nsfw: metadata.nsfw,
},
};
}
if (embed.thumbnail?.url?.startsWith('attachment://')) {
const filename = embed.thumbnail.url.slice(13);
const {cdnUrl, metadata} = resolveAttachmentUrl(filename, 'embeds.thumbnail.url');
resolvedEmbed.thumbnail = {
...embed.thumbnail,
url: cdnUrl,
_attachmentMetadata: {
width: metadata.width,
height: metadata.height,
content_type: metadata.content_type,
content_hash: metadata.content_hash,
placeholder: metadata.placeholder,
flags: metadata.flags,
duration: metadata.duration,
nsfw: metadata.nsfw,
},
};
}
return resolvedEmbed;
});
}
}

View File

@@ -0,0 +1,257 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import mime from 'mime';
import {type AttachmentID, type ChannelID, createAttachmentID, type UserID, userIdToChannelId} from '~/BrandedTypes';
import {Config} from '~/Config';
import type {MessageAttachment, MessageSnapshot} from '~/database/CassandraTypes';
import {FileSizeTooLargeError, InputValidationError} from '~/Errors';
import type {GuildResponse} from '~/guild/GuildModel';
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import {Attachment, type Message, type User} from '~/Models';
import {getAttachmentMaxSize} from '~/utils/AttachmentUtils';
import {extractTimestamp} from '~/utils/SnowflakeUtils';
export const MESSAGE_NONCE_TTL = 60 * 5;
export const VIRUS_MESSAGE_PREFIX =
"Hm, it looks like that file might've been a virus. Instead of cooking up trouble, try cooking up a ";
export const VIRUS_RECIPE_SUGGESTIONS = [
'Chocolate Avocado Protein Smoothie: <https://spinach4breakfast.com/chocolate-avocado-protein-smoothie>',
'Spicy Italian Meatball: <https://www.foodnetwork.com/recipes/ree-drummond/spicy-italian-meatballs.html>',
'Hawaiian Banana Nut Bread: <https://www.food.com/recipe/hawaiian-banana-nut-bread-113608>',
'Panang Red Seafood Curry: <https://www.deliaonline.com/recipes/international/asian/chinese/panang-red-seafood-curry>',
'Veggie Tofu Stir Fry: <https://minimalistbaker.com/tofu-that-tastes-good-stir-fry>',
'Chicken Tikka Masala: <https://www.epicurious.com/recipes/food/views/chicken-tikka-masala-51171400>',
'Slow-Cooked Pulled Pork Sliders: <https://www.foodnetwork.com/recipes/food-network-kitchens/slow-cooker-pulled-pork-sandwiches-recipe.html>',
'Beet-Pickled Deviled Eggs: <https://www.thekitchn.com/recipe-beet-pickled-deviled-eggs-151550>',
];
export function isMediaFile(contentType: string): boolean {
return contentType.startsWith('image/') || contentType.startsWith('video/') || contentType.startsWith('audio/');
}
export function cleanTextForMentions(content: string): string {
return content
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]*`/g, '')
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1')
.replace(/https?:\/\/[^\s]+/g, '');
}
export function isPersonalNotesChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): boolean {
return userIdToChannelId(userId) === channelId;
}
export function getContentType(filename: string): string {
return mime.getType(filename) || 'application/octet-stream';
}
export function validateAttachmentIds(attachments: Array<{id: bigint}>): void {
const ids = new Set(attachments.map((a) => a.id));
if (ids.size !== attachments.length) {
throw InputValidationError.create('attachments', 'Duplicate attachment ids are not allowed');
}
}
export function validateAttachmentSizes(attachments: Array<{size: number | bigint}>, user: User): void {
const maxAttachmentSize = getAttachmentMaxSize(user.isPremium());
for (const {size} of attachments) {
if (Number(size) > maxAttachmentSize) {
throw new FileSizeTooLargeError();
}
}
}
export function makeAttachmentCdnKey(
channelId: ChannelID,
attachmentId: AttachmentID | bigint,
filename: string,
): string {
return `attachments/${channelId}/${attachmentId}/${filename}`;
}
export function makeAttachmentCdnUrl(
channelId: ChannelID,
attachmentId: AttachmentID | bigint,
filename: string,
): string {
return `${Config.endpoints.media}/${makeAttachmentCdnKey(channelId, attachmentId, filename)}`;
}
async function cloneAttachments(
attachments: Array<Attachment>,
sourceChannelId: ChannelID,
destinationChannelId: ChannelID,
storageService: IStorageService,
snowflakeService: SnowflakeService,
): Promise<Array<MessageAttachment>> {
const clonedAttachments: Array<MessageAttachment> = [];
for (const attachment of attachments) {
const newAttachmentId = createAttachmentID(snowflakeService.generate());
const sourceKey = makeAttachmentCdnKey(sourceChannelId, attachment.id, attachment.filename);
const destinationKey = makeAttachmentCdnKey(destinationChannelId, newAttachmentId, attachment.filename);
await storageService.copyObject({
sourceBucket: Config.s3.buckets.cdn,
sourceKey,
destinationBucket: Config.s3.buckets.cdn,
destinationKey,
newContentType: attachment.contentType,
});
clonedAttachments.push({
attachment_id: newAttachmentId,
filename: attachment.filename,
size: BigInt(attachment.size),
title: attachment.title,
description: attachment.description,
width: attachment.width,
height: attachment.height,
content_type: attachment.contentType,
content_hash: attachment.contentHash,
placeholder: attachment.placeholder,
flags: attachment.flags ?? 0,
duration: attachment.duration,
nsfw: attachment.nsfw,
});
}
return clonedAttachments;
}
export async function createMessageSnapshotsForForward(
referencedMessage: Message,
user: User,
destinationChannelId: ChannelID,
storageService: IStorageService,
snowflakeService: SnowflakeService,
validateAttachmentSizesOverride?: (attachments: Array<{size: number | bigint}>, user: User) => void,
): Promise<Array<MessageSnapshot>> {
const validate = validateAttachmentSizesOverride ?? validateAttachmentSizes;
if (referencedMessage.messageSnapshots && referencedMessage.messageSnapshots.length > 0) {
const snapshot = referencedMessage.messageSnapshots[0];
const snapshotAttachments = snapshot.attachments ?? [];
validate(snapshotAttachments, user);
const attachmentsForClone = snapshotAttachments.map((att) =>
att instanceof Attachment ? att : new Attachment(att),
);
const clonedAttachments = await cloneAttachments(
attachmentsForClone,
referencedMessage.channelId,
destinationChannelId,
storageService,
snowflakeService,
);
return [
{
content: snapshot.content,
timestamp: snapshot.timestamp,
edited_timestamp: snapshot.editedTimestamp,
mention_users: snapshot.mentionedUserIds,
mention_roles: snapshot.mentionedRoleIds,
mention_channels: snapshot.mentionedChannelIds,
attachments: clonedAttachments.length > 0 ? clonedAttachments : null,
embeds: snapshot.embeds.map((embed) => embed.toMessageEmbed()),
sticker_items: snapshot.stickers.map((sticker) => sticker.toMessageStickerItem()),
type: snapshot.type,
flags: snapshot.flags,
},
];
}
validate(referencedMessage.attachments, user);
const clonedAttachments = await cloneAttachments(
referencedMessage.attachments,
referencedMessage.channelId,
destinationChannelId,
storageService,
snowflakeService,
);
return [
{
content: referencedMessage.content,
timestamp: new Date(extractTimestamp(referencedMessage.id)),
edited_timestamp: referencedMessage.editedTimestamp,
mention_users: referencedMessage.mentionedUserIds.size > 0 ? referencedMessage.mentionedUserIds : null,
mention_roles: referencedMessage.mentionedRoleIds.size > 0 ? referencedMessage.mentionedRoleIds : null,
mention_channels: referencedMessage.mentionedChannelIds.size > 0 ? referencedMessage.mentionedChannelIds : null,
attachments: clonedAttachments.length > 0 ? clonedAttachments : null,
embeds: referencedMessage.embeds.length > 0 ? referencedMessage.embeds.map((e) => e.toMessageEmbed()) : null,
sticker_items:
referencedMessage.stickers.length > 0 ? referencedMessage.stickers.map((s) => s.toMessageStickerItem()) : null,
type: referencedMessage.type,
flags: referencedMessage.flags,
},
];
}
export async function purgeMessageAttachments(
message: Message,
storageService: IStorageService,
cloudflarePurgeQueue: ICloudflarePurgeQueue,
): Promise<void> {
const cdnUrls: Array<string> = [];
await Promise.all(
message.attachments.map(async (attachment) => {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
await storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
if (Config.cloudflare.purgeEnabled) {
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
cdnUrls.push(cdnUrl);
}
}),
);
if (Config.cloudflare.purgeEnabled && cdnUrls.length > 0) {
await cloudflarePurgeQueue.addUrls(cdnUrls);
}
}
export function isOperationDisabled(guild: GuildResponse | null, operation: number): boolean {
if (!guild) return false;
return (guild.disabled_operations & operation) !== 0;
}
export function isMessageEmpty(message: Message, excludingAttachments = false): boolean {
const hasContent = !!message.content;
const hasEmbeds = message.embeds.length > 0;
const hasStickers = message.stickers.length > 0;
const hasAttachments = !excludingAttachments && message.attachments.length > 0;
return !hasContent && !hasEmbeds && !hasStickers && !hasAttachments;
}
export function collectMessageAttachments(message: Message): Array<Attachment> {
return [...message.attachments, ...message.messageSnapshots.flatMap((snapshot) => snapshot.attachments)];
}

View File

@@ -0,0 +1,272 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createRoleID, createUserID, type GuildID, type RoleID, type UserID} from '~/BrandedTypes';
import {
ChannelTypes,
GuildOperations,
MessageTypes,
ROLE_MENTION_REGEX,
SENDABLE_MESSAGE_FLAGS,
USER_MENTION_REGEX,
} from '~/Constants';
import {ALLOWED_MENTIONS_PARSE, type AllowedMentionsRequest} from '~/channel/ChannelModel';
import {InputValidationError} from '~/Errors';
import type {GuildResponse} from '~/guild/GuildModel';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {Channel, Message} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IWorkerService} from '~/worker/IWorkerService';
import {cleanTextForMentions, isOperationDisabled, isPersonalNotesChannel} from './MessageHelpers';
interface MentionData {
userMentions: Set<UserID>;
roleMentions: Set<RoleID>;
flags: number;
mentionsEveryone: boolean;
mentionsHere: boolean;
}
export class MessageMentionService {
constructor(
private userRepository: IUserRepository,
private guildRepository: IGuildRepository,
private workerService: IWorkerService,
) {}
extractMentions({
content,
referencedMessage,
message,
channelType,
allowedMentions,
guild,
canMentionEveryone = true,
}: {
content: string;
referencedMessage: Message | null;
message: Message;
channelType: number;
allowedMentions: AllowedMentionsRequest | null;
guild?: GuildResponse | null;
canMentionEveryone?: boolean;
}): MentionData {
const cleanText = cleanTextForMentions(content);
let mentionsEveryone = cleanText.includes('@everyone');
let mentionsHere = cleanText.includes('@here');
if (guild && isOperationDisabled(guild, GuildOperations.EVERYONE_MENTIONS)) {
mentionsEveryone = false;
mentionsHere = false;
}
const userMentions = new Set(
[...content.matchAll(USER_MENTION_REGEX)]
.map((m) => createUserID(BigInt(m.groups?.userId ?? '0')))
.filter((id) => id !== 0n),
);
const roleMentions = new Set(
[...content.matchAll(ROLE_MENTION_REGEX)]
.map((m) => createRoleID(BigInt(m.groups?.roleId ?? '0')))
.filter((id) => id !== createRoleID(0n)),
);
const isDMChannel = channelType === ChannelTypes.DM || channelType === ChannelTypes.DM_PERSONAL_NOTES;
const shouldAddReferencedUser =
referencedMessage?.authorId &&
referencedMessage.authorId !== message.authorId &&
!isDMChannel &&
(!allowedMentions || allowedMentions.replied_user !== false);
if (shouldAddReferencedUser) {
userMentions.add(referencedMessage!.authorId!);
}
const sendableFlags = message.flags & SENDABLE_MESSAGE_FLAGS;
let flags = message.flags & ~SENDABLE_MESSAGE_FLAGS;
if (allowedMentions) {
const result = this.applyAllowedMentions({
allowedMentions,
userMentions,
roleMentions,
mentionsEveryone,
mentionsHere,
flags,
referencedMessage,
});
flags = result.flags;
mentionsEveryone = result.mentionsEveryone;
mentionsHere = result.mentionsHere;
}
if (!canMentionEveryone && (mentionsEveryone || mentionsHere)) {
mentionsEveryone = false;
mentionsHere = false;
}
return {userMentions, roleMentions, flags: flags | sendableFlags, mentionsEveryone, mentionsHere};
}
private applyAllowedMentions({
allowedMentions,
userMentions,
roleMentions,
mentionsEveryone,
mentionsHere,
flags,
referencedMessage,
}: {
allowedMentions: AllowedMentionsRequest;
userMentions: Set<UserID>;
roleMentions: Set<RoleID>;
mentionsEveryone: boolean;
mentionsHere: boolean;
flags: number;
referencedMessage?: Message | null;
}): {flags: number; mentionsEveryone: boolean; mentionsHere: boolean} {
const parse = allowedMentions.parse ?? ALLOWED_MENTIONS_PARSE;
const users = allowedMentions.users ?? [];
const roles = allowedMentions.roles ?? [];
const hasExplicitParse = allowedMentions.parse !== undefined;
if (hasExplicitParse && parse.length > 0 && (users.length > 0 || roles.length > 0)) {
throw InputValidationError.create(
'allowed_mentions',
'"parse" and ("users" or "roles") cannot be used together.',
);
}
const repliedUserId = referencedMessage?.authorId;
const shouldPreserveRepliedUser = repliedUserId && allowedMentions.replied_user !== false;
let preservedRepliedUser = null;
if (shouldPreserveRepliedUser && userMentions.has(repliedUserId)) {
preservedRepliedUser = repliedUserId;
userMentions.delete(repliedUserId);
}
this.filterMentions({
mentions: userMentions,
allowedList: users.map(createUserID),
shouldParse: parse.includes('users'),
});
this.filterMentions({
mentions: roleMentions,
allowedList: roles.map(createRoleID),
shouldParse: parse.includes('roles'),
});
if (preservedRepliedUser) {
userMentions.add(preservedRepliedUser);
}
const preserveEveryone = parse.includes('everyone');
return {
flags,
mentionsEveryone: preserveEveryone && mentionsEveryone,
mentionsHere: preserveEveryone && mentionsHere,
};
}
private filterMentions<T extends UserID | RoleID>({
mentions,
allowedList,
shouldParse,
}: {
mentions: Set<T>;
allowedList: Array<T>;
shouldParse: boolean;
}): void {
const shouldClear = shouldParse ? allowedList.length === 0 : allowedList.length > 0;
if (shouldClear) {
if (allowedList.length === 0) {
mentions.clear();
} else {
for (const id of Array.from(mentions)) {
if (!allowedList.includes(id)) {
mentions.delete(id);
}
}
}
}
}
async validateMentions({
userMentions,
roleMentions,
channel,
canMentionRoles = true,
}: {
userMentions: Set<UserID>;
roleMentions: Set<RoleID>;
channel: Channel;
canMentionRoles?: boolean;
}): Promise<{validUserIds: Array<UserID>; validRoleIds: Array<RoleID>}> {
if (channel.guildId) {
const [users, roles] = await Promise.all([
this.userRepository.listUsers(Array.from(userMentions)),
this.guildRepository.listRolesByIds(Array.from(roleMentions), channel.guildId),
]);
const filteredRoles = canMentionRoles ? roles : roles.filter((role) => role.isMentionable);
return {
validUserIds: users.map((u) => u.id),
validRoleIds: filteredRoles.map((r) => r.id),
};
}
const recipients = Array.from(channel.recipientIds || []);
const validUserIds = recipients.filter((r) => userMentions.has(r));
return {validUserIds, validRoleIds: []};
}
async handleMentionTasks(params: {
guildId: GuildID | null;
message: Message;
authorId: UserID;
mentionHere?: boolean;
}): Promise<void> {
const {guildId, message, authorId, mentionHere = false} = params;
if (isPersonalNotesChannel({userId: authorId, channelId: message.channelId})) return;
const taskData = {
guildId: guildId?.toString(),
channelId: message.channelId.toString(),
messageId: message.id.toString(),
authorId: authorId.toString(),
mentionHere,
};
const hasMentions =
message.mentionedUserIds?.size > 0 ||
message.mentionedRoleIds?.size > 0 ||
message.mentionEveryone ||
(message.reference && message.type === MessageTypes.REPLY);
if (hasMentions) {
await this.workerService.addJob('handleMentions', taskData);
}
}
}

View 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 ChannelID,
createAttachmentID,
createChannelID,
createMemeID,
createMessageID,
type UserID,
} from '~/BrandedTypes';
import {Config} from '~/Config';
import type {MessageAttachment} from '~/database/CassandraTypes';
import {InputValidationError, UnknownMessageError} from '~/Errors';
import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {Message, User} from '~/Models';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import {makeAttachmentCdnKey} from './MessageHelpers';
interface MessageOperationsHelpersDeps {
channelRepository: IChannelRepositoryAggregate;
cacheService: ICacheService;
storageService: IStorageService;
snowflakeService: SnowflakeService;
favoriteMemeRepository: IFavoriteMemeRepository;
}
export class MessageOperationsHelpers {
constructor(private readonly deps: MessageOperationsHelpersDeps) {}
async findExistingMessage({
userId,
nonce,
expectedChannelId,
}: {
userId: UserID;
nonce?: string;
expectedChannelId: ChannelID;
}): Promise<Message | null> {
if (!nonce) return null;
const existingNonce = await this.deps.cacheService.get<{channel_id: string; message_id: string}>(
`message-nonce:${userId}:${nonce}`,
);
if (!existingNonce) return null;
const cachedChannelId = createChannelID(BigInt(existingNonce.channel_id));
if (cachedChannelId !== expectedChannelId) {
throw new UnknownMessageError();
}
return this.deps.channelRepository.messages.getMessage(
cachedChannelId,
createMessageID(BigInt(existingNonce.message_id)),
);
}
async processFavoriteMeme({
user,
channelId,
favoriteMemeId,
}: {
user: User;
channelId: ChannelID;
favoriteMemeId: bigint;
}): Promise<MessageAttachment> {
const memeId = createMemeID(favoriteMemeId);
const favoriteMeme = await this.deps.favoriteMemeRepository.findById(user.id, memeId);
if (!favoriteMeme) {
throw InputValidationError.create('favorite_meme_id', 'Favorite meme not found');
}
const memeAttachmentId = createAttachmentID(this.deps.snowflakeService.generate());
const sourceKey = favoriteMeme.storageKey;
const destKey = makeAttachmentCdnKey(channelId, memeAttachmentId, favoriteMeme.filename);
await this.deps.storageService.copyObject({
sourceBucket: Config.s3.buckets.cdn,
sourceKey,
destinationBucket: Config.s3.buckets.cdn,
destinationKey: destKey,
newContentType: favoriteMeme.contentType,
});
let flags = 0;
if (favoriteMeme.isGifv || (favoriteMeme.duration != null && favoriteMeme.duration > 0)) {
flags |= 1;
}
return {
attachment_id: memeAttachmentId,
filename: favoriteMeme.filename,
size: favoriteMeme.size,
title: null,
description: favoriteMeme.altText,
width: favoriteMeme.width,
height: favoriteMeme.height,
content_type: favoriteMeme.contentType,
content_hash: favoriteMeme.contentHash,
placeholder: null,
flags,
duration: favoriteMeme.duration,
nsfw: null,
};
}
}

View File

@@ -0,0 +1,217 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, MessageID, UserID} from '~/BrandedTypes';
import type {MessageRequest, MessageUpdateRequest} from '~/channel/ChannelModel';
import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository';
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
import type {ICloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {Message, User, Webhook} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {MessageChannelAuthService} from './MessageChannelAuthService';
import {MessageDeleteService} from './MessageDeleteService';
import type {MessageDispatchService} from './MessageDispatchService';
import {MessageEditService} from './MessageEditService';
import type {MessageMentionService} from './MessageMentionService';
import {MessageOperationsHelpers} from './MessageOperationsHelpers';
import type {MessagePersistenceService} from './MessagePersistenceService';
import type {MessageProcessingService} from './MessageProcessingService';
import type {MessageSearchService} from './MessageSearchService';
import {MessageSendService} from './MessageSendService';
import type {MessageValidationService} from './MessageValidationService';
export class MessageOperationsService {
private readonly sendService: MessageSendService;
private readonly editService: MessageEditService;
private readonly deleteService: MessageDeleteService;
private readonly operationsHelpers: MessageOperationsHelpers;
constructor(
channelRepository: IChannelRepositoryAggregate,
userRepository: IUserRepository,
cacheService: ICacheService,
storageService: IStorageService,
gatewayService: IGatewayService,
snowflakeService: SnowflakeService,
rateLimitService: IRateLimitService,
cloudflarePurgeQueue: ICloudflarePurgeQueue,
favoriteMemeRepository: IFavoriteMemeRepository,
validationService: MessageValidationService,
mentionService: MessageMentionService,
searchService: MessageSearchService,
persistenceService: MessagePersistenceService,
channelAuthService: MessageChannelAuthService,
processingService: MessageProcessingService,
guildAuditLogService: GuildAuditLogService,
dispatchService: MessageDispatchService,
) {
this.operationsHelpers = new MessageOperationsHelpers({
channelRepository,
cacheService,
storageService,
snowflakeService,
favoriteMemeRepository,
});
this.sendService = new MessageSendService({
channelRepository,
storageService,
gatewayService,
snowflakeService,
rateLimitService,
favoriteMemeRepository,
validationService,
mentionService,
searchService,
persistenceService,
channelAuthService,
processingService,
dispatchService,
embedAttachmentResolver: persistenceService.getEmbedAttachmentResolver(),
operationsHelpers: this.operationsHelpers,
});
this.editService = new MessageEditService({
channelRepository,
userRepository,
validationService,
persistenceService,
channelAuthService,
processingService,
dispatchService,
searchService,
embedAttachmentResolver: persistenceService.getEmbedAttachmentResolver(),
mentionService,
});
this.deleteService = new MessageDeleteService({
channelRepository,
storageService,
cloudflarePurgeQueue,
validationService,
channelAuthService,
dispatchService,
searchService,
guildAuditLogService,
});
}
async sendMessage({
user,
channelId,
data,
requestCache,
}: {
user: User;
channelId: ChannelID;
data: MessageRequest;
requestCache: RequestCache;
}): Promise<Message> {
return this.sendService.sendMessage({user, channelId, data, requestCache});
}
async sendWebhookMessage({
webhook,
data,
username,
avatar,
requestCache,
}: {
webhook: Webhook;
data: MessageRequest;
username?: string | null;
avatar?: string | null;
requestCache: RequestCache;
}): Promise<Message> {
return this.sendService.sendWebhookMessage({webhook, data, username, avatar, requestCache});
}
async validateMessageCanBeSent({
user,
channelId,
data,
}: {
user: User;
channelId: ChannelID;
data: MessageRequest;
}): Promise<void> {
return this.sendService.validateMessageCanBeSent({user, channelId, data});
}
async editMessage({
userId,
channelId,
messageId,
data,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
data: MessageUpdateRequest;
requestCache: RequestCache;
}): Promise<Message> {
return this.editService.editMessage({userId, channelId, messageId, data, requestCache});
}
async deleteMessage({
userId,
channelId,
messageId,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
}): Promise<void> {
return this.deleteService.deleteMessage({userId, channelId, messageId, requestCache});
}
async bulkDeleteMessages({
userId,
channelId,
messageIds,
}: {
userId: UserID;
channelId: ChannelID;
messageIds: Array<MessageID>;
}): Promise<void> {
return this.deleteService.bulkDeleteMessages({userId, channelId, messageIds});
}
async deleteUserMessagesInGuild({
userId,
guildId,
days,
}: {
userId: UserID;
guildId: GuildID;
days: number;
}): Promise<void> {
return this.deleteService.deleteUserMessagesInGuild({userId, guildId, days});
}
}

View File

@@ -0,0 +1,552 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AttachmentDecayService} from '~/attachment/AttachmentDecayService';
import {
type AttachmentID,
type ChannelID,
createGuildID,
type GuildID,
type MessageID,
type RoleID,
type StickerID,
type UserID,
type WebhookID,
} from '~/BrandedTypes';
import {MessageFlags, Permissions, SENDABLE_MESSAGE_FLAGS} from '~/Constants';
import type {AttachmentToProcess} from '~/channel/AttachmentDTOs';
import type {AllowedMentionsRequest, MessageUpdateRequest, RichEmbedRequest} from '~/channel/ChannelModel';
import type {IChannelRepositoryAggregate} from '~/channel/repositories/IChannelRepositoryAggregate';
import type {
MessageAttachment,
MessageEmbed,
MessageReference,
MessageSnapshot,
MessageStickerItem,
} from '~/database/CassandraTypes';
import {InputValidationError} from '~/Errors';
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {EmbedService} from '~/infrastructure/EmbedService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {IVirusScanService} from '~/infrastructure/IVirusScanService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {Channel, Message, User} from '~/Models';
import type {PackService} from '~/pack/PackService';
import type {ReadStateService} from '~/read_state/ReadStateService';
import type {IUserRepository} from '~/user/IUserRepository';
import * as BucketUtils from '~/utils/BucketUtils';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
import {AttachmentProcessingService} from './AttachmentProcessingService';
import {MessageContentService} from './MessageContentService';
import {MessageEmbedAttachmentResolver} from './MessageEmbedAttachmentResolver';
import {VIRUS_MESSAGE_PREFIX, VIRUS_RECIPE_SUGGESTIONS} from './MessageHelpers';
import {MessageStickerService} from './MessageStickerService';
interface CreateMessageParams {
messageId: MessageID;
channelId: ChannelID;
user?: User;
userId?: UserID;
webhookId?: WebhookID;
webhookName?: string;
webhookAvatar?: string | null;
type: number;
content: string | null | undefined;
flags: number;
embeds?: Array<RichEmbedRequest>;
attachments?: Array<AttachmentToProcess>;
processedAttachments?: Array<MessageAttachment>;
attachmentDecayExcludedIds?: Array<AttachmentID>;
stickerIds?: Array<StickerID>;
messageReference?: MessageReference;
messageSnapshots?: Array<MessageSnapshot>;
guildId: GuildID | null;
channel?: Channel;
referencedMessage?: Message | null;
allowedMentions?: AllowedMentionsRequest | null;
guild?: GuildResponse | null;
member?: GuildMemberResponse | null;
hasPermission?: (permission: bigint) => Promise<boolean>;
mentionData?: {
flags: number;
mentionUserIds: Array<UserID>;
mentionRoleIds: Array<RoleID>;
mentionEveryone: boolean;
};
allowEmbeds?: boolean;
}
export class MessagePersistenceService {
private readonly attachmentService: AttachmentProcessingService;
private readonly contentService: MessageContentService;
private readonly stickerService: MessageStickerService;
private readonly embedAttachmentResolver: MessageEmbedAttachmentResolver;
private readonly attachmentDecayService: AttachmentDecayService;
constructor(
private channelRepository: IChannelRepositoryAggregate,
private userRepository: IUserRepository,
guildRepository: IGuildRepository,
private packService: PackService,
private embedService: EmbedService,
storageService: IStorageService,
mediaService: IMediaService,
virusScanService: IVirusScanService,
snowflakeService: SnowflakeService,
private readStateService: ReadStateService,
) {
this.attachmentService = new AttachmentProcessingService(
storageService,
mediaService,
virusScanService,
snowflakeService,
);
this.contentService = new MessageContentService(this.userRepository, guildRepository, this.packService);
this.stickerService = new MessageStickerService(this.userRepository, guildRepository, this.packService);
this.embedAttachmentResolver = new MessageEmbedAttachmentResolver();
this.attachmentDecayService = new AttachmentDecayService();
}
getEmbedAttachmentResolver(): MessageEmbedAttachmentResolver {
return this.embedAttachmentResolver;
}
async createMessage(params: CreateMessageParams): Promise<Message> {
const authorId = params.user?.id ?? params.userId ?? null;
const mentionData =
params.mentionData ??
({
flags: params.flags,
mentionUserIds: [],
mentionRoleIds: [],
mentionEveryone: false,
} as const);
const isNSFWAllowed = this.contentService.isNSFWContentAllowed({
channel: params.channel,
guild: params.guild,
member: params.member,
});
const [sanitizedContent, attachmentResult, processedStickers] = await Promise.all([
this.sanitizeContentIfNeeded(params, authorId),
this.processAttachments(params, isNSFWAllowed),
this.processStickers(params, authorId),
]);
let messageContent = sanitizedContent;
let processedAttachments: Array<MessageAttachment> = params.processedAttachments
? [...params.processedAttachments]
: [];
if (attachmentResult) {
if (attachmentResult.hasVirusDetected) {
const randomIndex = Math.floor(Math.random() * VIRUS_RECIPE_SUGGESTIONS.length);
messageContent = VIRUS_MESSAGE_PREFIX + VIRUS_RECIPE_SUGGESTIONS[randomIndex];
processedAttachments = [];
} else {
processedAttachments = [...processedAttachments, ...attachmentResult.attachments];
}
}
const allowEmbeds = params.allowEmbeds ?? true;
let initialEmbeds: Array<MessageEmbed> | null = null;
let hasUncachedUrls = false;
if (allowEmbeds) {
const resolvedEmbeds = this.embedAttachmentResolver.resolveEmbedAttachmentUrls({
embeds: params.embeds,
attachments: processedAttachments.map((att) => ({
attachment_id: att.attachment_id,
filename: att.filename,
width: att.width ?? null,
height: att.height ?? null,
content_type: att.content_type,
content_hash: att.content_hash ?? null,
placeholder: att.placeholder ?? null,
flags: att.flags ?? 0,
duration: att.duration ?? null,
nsfw: att.nsfw ?? null,
})),
channelId: params.channelId,
});
const embedResult = await this.embedService.getInitialEmbeds({
content: messageContent,
customEmbeds: resolvedEmbeds,
isNSFWAllowed,
});
initialEmbeds = embedResult.embeds;
hasUncachedUrls = embedResult.hasUncachedUrls;
}
const messageRowData = {
channel_id: params.channelId,
bucket: BucketUtils.makeBucket(params.messageId),
message_id: params.messageId,
author_id: authorId,
type: params.type,
webhook_id: params.webhookId || null,
webhook_name: params.webhookName || null,
webhook_avatar_hash: params.webhookAvatar || null,
content: messageContent,
edited_timestamp: null,
pinned_timestamp: null,
flags: mentionData.flags,
mention_everyone: mentionData.mentionEveryone,
mention_users: mentionData.mentionUserIds.length > 0 ? new Set(mentionData.mentionUserIds) : null,
mention_roles: mentionData.mentionRoleIds.length > 0 ? new Set(mentionData.mentionRoleIds) : null,
mention_channels: null,
attachments: processedAttachments.length > 0 ? processedAttachments : null,
embeds: allowEmbeds ? initialEmbeds : null,
sticker_items: processedStickers.length > 0 ? processedStickers : null,
message_reference: params.messageReference || null,
message_snapshots: params.messageSnapshots || null,
call: null,
has_reaction: false,
version: 1,
};
const message = await this.channelRepository.messages.upsertMessage(messageRowData);
await this.runPostPersistenceOperations({
params,
authorId,
processedAttachments,
allowEmbeds,
hasUncachedUrls,
isNSFWAllowed,
});
return message;
}
private async sanitizeContentIfNeeded(params: CreateMessageParams, authorId: UserID | null): Promise<string | null> {
const messageContent = params.content ?? null;
if (!messageContent || !params.channel) {
return messageContent;
}
return this.contentService.sanitizeCustomEmojis({
content: messageContent,
userId: authorId,
webhookId: params.webhookId ?? null,
guildId: params.guildId,
hasPermission: params.guildId ? params.hasPermission : undefined,
});
}
private async processAttachments(
params: CreateMessageParams,
isNSFWAllowed: boolean,
): Promise<{attachments: Array<MessageAttachment>; hasVirusDetected: boolean} | null> {
if (!params.attachments || params.attachments.length === 0) {
return null;
}
return this.attachmentService.computeAttachments({
message: {
id: params.messageId,
channelId: params.channelId,
} as Message,
attachments: params.attachments,
channel: params.channel,
guild: params.guild,
member: params.member,
isNSFWAllowed,
});
}
private async processStickers(
params: CreateMessageParams,
authorId: UserID | null,
): Promise<Array<MessageStickerItem>> {
if (!params.stickerIds || params.stickerIds.length === 0) {
return [];
}
return this.stickerService.computeStickerIds({
stickerIds: params.stickerIds,
userId: authorId,
guildId: params.guildId,
hasPermission: params.hasPermission,
});
}
private async runPostPersistenceOperations(context: {
params: CreateMessageParams;
authorId: UserID | null;
processedAttachments: Array<MessageAttachment>;
allowEmbeds: boolean;
hasUncachedUrls: boolean;
isNSFWAllowed: boolean;
}): Promise<void> {
const {params, authorId, processedAttachments, allowEmbeds, hasUncachedUrls, isNSFWAllowed} = context;
const operations: Array<Promise<void>> = [];
const excludedAttachmentIds = new Set(params.attachmentDecayExcludedIds ?? []);
if (processedAttachments.length > 0) {
const attachmentsForDecay = processedAttachments.filter((att) => !excludedAttachmentIds.has(att.attachment_id));
if (attachmentsForDecay.length > 0) {
const uploadedAt = new Date(SnowflakeUtils.extractTimestamp(params.messageId));
const decayPayloads = attachmentsForDecay.map((att) => ({
attachmentId: att.attachment_id,
channelId: params.channelId,
messageId: params.messageId,
filename: att.filename,
sizeBytes: att.size ?? 0n,
uploadedAt,
}));
operations.push(this.attachmentDecayService.upsertMany(decayPayloads));
}
}
if (allowEmbeds && hasUncachedUrls) {
operations.push(
this.embedService.enqueueUrlEmbedExtraction(params.channelId, params.messageId, params.guildId, isNSFWAllowed),
);
}
if (authorId) {
const isBot = params.user?.isBot ?? false;
if (!isBot) {
operations.push(
this.readStateService.ackMessage({
userId: authorId,
channelId: params.channelId,
messageId: params.messageId,
mentionCount: 0,
silent: true,
}),
);
}
}
if (operations.length > 0) {
await Promise.all(operations);
}
}
async updateMessage(params: {
message: Message;
messageId: MessageID;
data: MessageUpdateRequest;
channel: Channel;
guild: GuildResponse | null;
member?: GuildMemberResponse | null;
allowEmbeds?: boolean;
}): Promise<Message> {
const {message, messageId, data, channel, guild, member} = params;
if (message.messageSnapshots && message.messageSnapshots.length > 0) {
throw InputValidationError.create('message', 'Messages with snapshots cannot be edited');
}
const isNSFWAllowed = this.contentService.isNSFWContentAllowed({
channel,
guild,
member,
});
const updatedRowData = {...message.toRow()};
let hasChanges = false;
const allowEmbeds = params.allowEmbeds ?? true;
if (data.content !== undefined && data.content !== message.content) {
let sanitizedContent = data.content;
if (sanitizedContent) {
sanitizedContent = await this.contentService.sanitizeCustomEmojis({
content: sanitizedContent,
userId: message.authorId ?? null,
webhookId: message.webhookId ?? null,
guildId: channel.guildId,
});
}
updatedRowData.content = sanitizedContent;
updatedRowData.edited_timestamp = new Date();
hasChanges = true;
}
if (data.flags !== undefined) {
const preservedFlags = message.flags & ~SENDABLE_MESSAGE_FLAGS;
const newFlags = data.flags & SENDABLE_MESSAGE_FLAGS;
updatedRowData.flags = preservedFlags | newFlags;
hasChanges = true;
}
if (data.attachments !== undefined) {
if (data.attachments.length > 0) {
type EditAttachment =
| (AttachmentToProcess & {upload_filename: string})
| {id: number; title?: string | null; description?: string | null};
const newAttachments: Array<AttachmentToProcess> = [];
const existingAttachments: Array<MessageAttachment> = [];
for (const att of data.attachments as Array<EditAttachment>) {
if ('upload_filename' in att && att.upload_filename) {
newAttachments.push(att as AttachmentToProcess);
} else {
const refId = BigInt(att.id);
let found = message.attachments.find((existing) => existing.id === refId);
if (!found && refId < BigInt(message.attachments.length)) {
found = message.attachments[Number(refId)];
}
if (found) {
const updated = found.toMessageAttachment();
if ('title' in att && att.title !== undefined) {
updated.title = att.title;
}
if ('description' in att && att.description !== undefined) {
updated.description = att.description;
}
existingAttachments.push(updated);
}
}
}
let processedNewAttachments: Array<MessageAttachment> = [];
if (newAttachments.length > 0) {
const attachmentResult = await this.attachmentService.computeAttachments({
message,
attachments: newAttachments,
channel,
guild,
member,
isNSFWAllowed,
});
processedNewAttachments = attachmentResult.attachments;
if (attachmentResult.hasVirusDetected) {
const randomIndex = Math.floor(Math.random() * VIRUS_RECIPE_SUGGESTIONS.length);
updatedRowData.content = VIRUS_MESSAGE_PREFIX + VIRUS_RECIPE_SUGGESTIONS[randomIndex];
}
}
const allAttachments = [...existingAttachments, ...processedNewAttachments];
updatedRowData.attachments = allAttachments.length > 0 ? allAttachments : null;
} else {
updatedRowData.attachments = null;
}
hasChanges = true;
}
if (allowEmbeds && (data.content !== undefined || data.embeds !== undefined)) {
const attachmentsForResolution = updatedRowData.attachments || [];
const resolvedEmbeds = this.embedAttachmentResolver.resolveEmbedAttachmentUrls({
embeds: data.embeds,
attachments: attachmentsForResolution.map((att) => ({
attachment_id: att.attachment_id,
filename: att.filename,
width: att.width ?? null,
height: att.height ?? null,
content_type: att.content_type,
content_hash: att.content_hash ?? null,
placeholder: att.placeholder ?? null,
flags: att.flags ?? 0,
duration: att.duration ?? null,
nsfw: att.nsfw ?? null,
})),
channelId: channel.id,
});
const {embeds: initialEmbeds, hasUncachedUrls: embedUrls} = await this.embedService.getInitialEmbeds({
content: updatedRowData.content ?? null,
customEmbeds: resolvedEmbeds,
isNSFWAllowed,
});
updatedRowData.embeds = initialEmbeds;
hasChanges = true;
if (embedUrls) {
await this.embedService.enqueueUrlEmbedExtraction(
channel.id,
messageId,
guild?.id ? createGuildID(BigInt(guild.id)) : null,
isNSFWAllowed,
);
}
}
let updatedMessage = message;
if (hasChanges) {
updatedMessage = await this.channelRepository.messages.upsertMessage(updatedRowData, message.toRow());
}
return updatedMessage;
}
async handleNonAuthorEdit(params: {
message: Message;
data: MessageUpdateRequest;
guild: GuildResponse | null;
hasPermission: (permission: bigint) => Promise<boolean>;
}): Promise<{canEdit: boolean; updatedFlags?: number}> {
const {message, data, guild, hasPermission} = params;
if (guild && data.flags != null) {
const canManage = await hasPermission(Permissions.MANAGE_MESSAGES);
if (canManage) {
let updatedFlags: number;
if (data.flags & MessageFlags.SUPPRESS_EMBEDS) {
updatedFlags = message.flags | MessageFlags.SUPPRESS_EMBEDS;
} else {
updatedFlags = message.flags & ~MessageFlags.SUPPRESS_EMBEDS;
}
return {canEdit: true, updatedFlags};
}
}
return {canEdit: false};
}
async createSystemMessage(params: {
messageId: MessageID;
channelId: ChannelID;
userId: UserID;
type: number;
content?: string | null;
guildId?: GuildID | null;
mentionUserIds?: Array<UserID>;
messageReference?: MessageReference;
}): Promise<Message> {
return this.createMessage({
messageId: params.messageId,
channelId: params.channelId,
userId: params.userId,
type: params.type,
content: params.content ?? null,
flags: 0,
guildId: params.guildId ?? null,
messageReference: params.messageReference,
mentionData: {
flags: 0,
mentionUserIds: params.mentionUserIds ?? [],
mentionRoleIds: [],
mentionEveryone: false,
},
});
}
}

View File

@@ -0,0 +1,311 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type ChannelID, createGuildID, type MessageID, type UserID} from '~/BrandedTypes';
import {ChannelTypes} from '~/Constants';
import {mapChannelToResponse} from '~/channel/ChannelModel';
import type {AllowedMentionsRequest, MessageRequest, MessageUpdateRequest} from '~/channel/MessageTypes';
import {CannotEditOtherUserMessageError} from '~/Errors';
import type {GuildResponse} from '~/guild/GuildModel';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Channel, Message, User} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {ReadStateService} from '~/read_state/ReadStateService';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import {isPersonalNotesChannel} from './MessageHelpers';
import type {MessageMentionService} from './MessageMentionService';
import type {MessagePersistenceService} from './MessagePersistenceService';
import {incrementDmMentionCounts} from './ReadStateHelpers';
interface RecipientOpenState {
recipientId: UserID;
isOpen: boolean;
}
export class MessageProcessingService {
constructor(
private channelRepository: IChannelRepositoryAggregate,
private userRepository: IUserRepository,
private userCacheService: UserCacheService,
private gatewayService: IGatewayService,
private readStateService: ReadStateService,
private mentionService: MessageMentionService,
) {}
async processMessageAfterCreation(params: {
message: Message;
channel: Channel;
guild: GuildResponse | null;
user: User;
data: MessageRequest;
referencedMessage: Message | null;
mentionHere?: boolean;
}): Promise<void> {
const {message, guild, user, mentionHere = false} = params;
await this.mentionService.handleMentionTasks({
guildId: guild ? createGuildID(BigInt(guild.id)) : null,
message,
authorId: user.id,
mentionHere,
});
}
async updateDMRecipients({
channel,
channelId,
requestCache,
}: {
channel: Channel;
channelId: ChannelID;
requestCache: RequestCache;
}): Promise<void> {
if (channel.guildId || channel.type !== ChannelTypes.DM) return;
if (!channel.recipientIds || channel.recipientIds.size !== 2) return;
const recipientIds = Array.from(channel.recipientIds);
const isGroupDm = false;
const openStates = await this.batchCheckDmChannelOpen(recipientIds, channelId);
const closedRecipients = openStates.filter((state) => !state.isOpen);
if (closedRecipients.length === 0) return;
await Promise.all(
closedRecipients.map((state) =>
this.openDmAndDispatch({
recipientId: state.recipientId,
channelId,
channel,
isGroupDm,
requestCache,
}),
),
);
}
private async batchCheckDmChannelOpen(
recipientIds: Array<UserID>,
channelId: ChannelID,
): Promise<Array<RecipientOpenState>> {
return Promise.all(
recipientIds.map(async (recipientId) => ({
recipientId,
isOpen: await this.userRepository.isDmChannelOpen(recipientId, channelId),
})),
);
}
private async openDmAndDispatch(params: {
recipientId: UserID;
channelId: ChannelID;
channel: Channel;
isGroupDm: boolean;
requestCache: RequestCache;
}): Promise<void> {
const {recipientId, channelId, channel, isGroupDm, requestCache} = params;
await this.userRepository.openDmForUser(recipientId, channelId, isGroupDm);
const channelResponse = await mapChannelToResponse({
channel,
currentUserId: recipientId,
userCacheService: this.userCacheService,
requestCache,
});
await this.gatewayService.dispatchPresence({
userId: recipientId,
event: 'CHANNEL_CREATE',
data: channelResponse,
});
}
async updateReadStates({
user,
guild,
channel,
channelId,
}: {
user: User;
guild: GuildResponse | null;
channel: Channel;
channelId: ChannelID;
messageId: MessageID;
}): Promise<void> {
if (user.isBot) return;
if (!guild) {
const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
await incrementDmMentionCounts({
readStateService: this.readStateService,
user,
recipients,
channelId,
});
}
}
async fetchMessagesAround({
channelId,
limit,
around,
}: {
channelId: ChannelID;
limit: number;
around: MessageID;
}): Promise<Array<Message>> {
const initialOlder = Math.floor(limit / 2);
const initialNewer = Math.max(0, limit - 1 - initialOlder);
const targetPromise = this.channelRepository.messages.getMessage(channelId, around);
let newerMessages = await this.channelRepository.messages.listMessages(channelId, undefined, initialNewer, around, {
immediateAfter: true,
});
let olderMessages = await this.channelRepository.messages.listMessages(channelId, around, initialOlder, undefined, {
restrictToBeforeBucket: false,
});
const targetMessage = await targetPromise;
const haveCenter = targetMessage ? 1 : 0;
const total = newerMessages.length + haveCenter + olderMessages.length;
if (total < limit) {
const missing = limit - total;
if (newerMessages.length < initialNewer) {
const need = missing;
const cursor = olderMessages.length > 0 ? olderMessages[olderMessages.length - 1].id : around;
const more = await this.channelRepository.messages.listMessages(channelId, cursor, need, undefined, {
restrictToBeforeBucket: false,
});
olderMessages = [...olderMessages, ...more];
} else {
const need = missing;
const cursor = newerMessages.length > 0 ? newerMessages[0].id : around;
const more = await this.channelRepository.messages.listMessages(channelId, undefined, need, cursor, {
immediateAfter: true,
});
newerMessages = [...more, ...newerMessages];
}
}
const out: Array<Message> = [];
const seen = new Set<string>();
const push = (m: Message) => {
const id = m.id.toString();
if (seen.has(id)) return;
seen.add(id);
out.push(m);
};
for (const m of newerMessages) push(m);
if (targetMessage) push(targetMessage);
for (const m of olderMessages) push(m);
return out.slice(0, limit);
}
async handleMentions(params: {
channel: Channel;
message: Message;
referencedMessageOnSend: Message | null;
allowedMentions: AllowedMentionsRequest | null;
guild?: GuildResponse | null;
canMentionEveryone: boolean;
canMentionRoles: boolean;
}): Promise<void> {
const {channel, message, referencedMessageOnSend, allowedMentions, guild, canMentionEveryone, canMentionRoles} =
params;
if (message.authorId && isPersonalNotesChannel({userId: message.authorId, channelId: channel.id})) {
return;
}
const content = message.content;
if (!content) return;
const mentions = this.mentionService.extractMentions({
content,
referencedMessage: referencedMessageOnSend,
message,
channelType: channel.type,
allowedMentions,
guild,
canMentionEveryone,
});
const {validUserIds, validRoleIds} = await this.mentionService.validateMentions({
userMentions: mentions.userMentions,
roleMentions: mentions.roleMentions,
channel,
canMentionRoles,
});
const updatedMessageData = {
...message.toRow(),
flags: mentions.flags,
mention_users: validUserIds.length > 0 ? new Set(validUserIds) : null,
mention_roles: validRoleIds.length > 0 ? new Set(validRoleIds) : null,
mention_everyone: mentions.mentionsEveryone,
};
await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow());
}
async handleNonAuthorEdit(params: {
message: Message;
messageId: MessageID;
data: MessageUpdateRequest;
guild: GuildResponse | null;
hasPermission: (permission: bigint) => Promise<boolean>;
channel: Channel;
requestCache: RequestCache;
persistenceService: MessagePersistenceService;
dispatchMessageUpdate: (params: {channel: Channel; message: Message; requestCache: RequestCache}) => Promise<void>;
}): Promise<Message> {
const {message, data, guild, hasPermission, channel, requestCache, persistenceService, dispatchMessageUpdate} =
params;
const editResult = await persistenceService.handleNonAuthorEdit({
message,
data,
guild,
hasPermission,
});
if (editResult.canEdit && editResult.updatedFlags !== undefined) {
const updatedRowData = {
...message.toRow(),
flags: editResult.updatedFlags,
};
const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedRowData, message.toRow());
await dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
return updatedMessage;
}
throw new CannotEditOtherUserMessageError();
}
}

View File

@@ -0,0 +1,291 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AttachmentDecayService} from '~/attachment/AttachmentDecayService';
import {
type AttachmentID,
type ChannelID,
createChannelID,
createMessageID,
type MessageID,
type UserID,
} from '~/BrandedTypes';
import {ChannelTypes, MessageFlags, Permissions} from '~/Constants';
import type {MessageSearchRequest, MessageSearchResponse} from '~/channel/ChannelModel';
import {mapMessageToResponse} from '~/channel/ChannelModel';
import {
ChannelIndexingError,
FeatureTemporarilyDisabledError,
MissingPermissionsError,
UnknownMessageError,
} from '~/Errors';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {getMessageSearchService} from '~/Meilisearch';
import type {Channel, Message} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {buildMessageSearchFilters} from '~/search/buildMessageSearchFilters';
import type {IUserRepository} from '~/user/IUserRepository';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import {getDmChannelIdsForScope} from './dmScopeUtils';
import type {MessageChannelAuthService} from './MessageChannelAuthService';
import {collectMessageAttachments} from './MessageHelpers';
import type {MessageProcessingService} from './MessageProcessingService';
import type {MessageSearchService} from './MessageSearchService';
export class MessageRetrievalService {
constructor(
private channelRepository: IChannelRepositoryAggregate,
private userCacheService: UserCacheService,
private mediaService: IMediaService,
private channelAuthService: MessageChannelAuthService,
private processingService: MessageProcessingService,
private searchService: MessageSearchService,
private userRepository: IUserRepository,
private attachmentDecayService: AttachmentDecayService = new AttachmentDecayService(),
) {}
async getMessage({
userId,
channelId,
messageId,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
}): Promise<Message> {
const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
if (authChannel.guild && !(await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY))) {
throw new MissingPermissionsError();
}
const message = await this.channelRepository.messages.getMessage(channelId, messageId);
if (!message) {
throw new UnknownMessageError();
}
await this.extendAttachments([message]);
return message;
}
async getMessages({
userId,
channelId,
limit,
before,
after,
around,
}: {
userId: UserID;
channelId: ChannelID;
limit: number;
before?: MessageID;
after?: MessageID;
around?: MessageID;
}): Promise<Array<Message>> {
const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
if (authChannel.guild && !(await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY))) {
return [];
}
const messages = around
? await this.processingService.fetchMessagesAround({channelId, limit, around})
: await this.channelRepository.messages.listMessages(channelId, before, limit, after);
await this.extendAttachments(messages);
return messages;
}
async searchMessages({
userId,
channelId,
searchParams,
requestCache,
}: {
userId: UserID;
channelId: ChannelID;
searchParams: MessageSearchRequest;
requestCache: RequestCache;
}): Promise<MessageSearchResponse> {
const authChannel = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
const {channel} = authChannel;
if (authChannel.guild && !(await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY))) {
throw new MissingPermissionsError();
}
const searchService = getMessageSearchService();
if (!searchService) {
throw new FeatureTemporarilyDisabledError();
}
let channelIndexedAt: Date | null = channel.indexedAt;
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
const persistedChannel = await this.channelRepository.channelData.findUnique(channelId);
if (persistedChannel?.indexedAt) {
channelIndexedAt = persistedChannel.indexedAt;
}
}
if (!channelIndexedAt) {
await this.searchService.triggerChannelIndexing(channelId);
throw new ChannelIndexingError();
}
const resolvedSearchParams = await this.applyDmScopeToSearchParams({
userId,
channel,
channelId,
searchParams,
});
const channelIdStrings = resolvedSearchParams.channel_id
? resolvedSearchParams.channel_id.map((id) => id.toString())
: [channelId.toString()];
const filters = buildMessageSearchFilters(resolvedSearchParams, channelIdStrings);
const hitsPerPage = resolvedSearchParams.hits_per_page ?? 25;
const page = resolvedSearchParams.page ?? 1;
const result = await searchService.searchMessages('', filters, {
hitsPerPage,
page,
});
const messageEntries = result.hits.map((hit) => ({
channelId: createChannelID(BigInt(hit.channelId)),
messageId: createMessageID(BigInt(hit.id)),
}));
const messages = await Promise.all(
messageEntries.map(({channelId, messageId}) => this.channelRepository.messages.getMessage(channelId, messageId)),
);
const validMessages = messages.filter((msg: Message | null): msg is Message => msg !== null);
if (validMessages.length > 0) {
await this.extendAttachments(validMessages);
}
const attachmentPayloads = validMessages.flatMap((message) => this.buildAttachmentDecayEntriesForMessage(message));
const attachmentDecayMap =
attachmentPayloads.length > 0
? await this.attachmentDecayService.fetchMetadata(
attachmentPayloads.map((payload) => ({attachmentId: payload.attachmentId})),
)
: undefined;
const messageResponses = await Promise.all(
validMessages.map((message: Message) =>
mapMessageToResponse({
message,
currentUserId: userId,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
attachmentDecayMap,
getReactions: (channelId, messageId) =>
this.channelRepository.messageInteractions.listMessageReactions(channelId, messageId),
setHasReaction: (channelId, messageId, hasReaction) =>
this.channelRepository.messageInteractions.setHasReaction(channelId, messageId, hasReaction),
getReferencedMessage: (channelId, messageId) =>
this.channelRepository.messages.getMessage(channelId, messageId),
}),
),
);
return {
messages: messageResponses,
total: result.total,
hits_per_page: hitsPerPage,
page,
};
}
private async extendAttachments(messages: Array<Message>): Promise<void> {
const payloads = messages.flatMap((message) => {
if (message.flags & MessageFlags.COMPACT_ATTACHMENTS) {
return [];
}
return this.buildAttachmentDecayEntriesForMessage(message);
});
if (payloads.length === 0) return;
await this.attachmentDecayService.extendForAttachments(payloads);
}
private buildAttachmentDecayEntriesForMessage(message: Message): Array<{
attachmentId: AttachmentID;
channelId: ChannelID;
messageId: MessageID;
filename: string;
sizeBytes: bigint;
uploadedAt: Date;
}> {
const attachments = collectMessageAttachments(message);
if (attachments.length === 0) return [];
const uploadedAt = new Date(SnowflakeUtils.extractTimestamp(message.id));
return attachments.map((attachment) => ({
attachmentId: attachment.id,
channelId: message.channelId,
messageId: message.id,
filename: attachment.filename,
sizeBytes: attachment.size,
uploadedAt,
}));
}
private async applyDmScopeToSearchParams({
userId,
channel,
channelId,
searchParams,
}: {
userId: UserID;
channel: Channel;
channelId: ChannelID;
searchParams: MessageSearchRequest;
}): Promise<MessageSearchRequest> {
const scope = searchParams.scope;
const isDmSearch = channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM;
if (!isDmSearch || !scope || scope === 'current') {
return searchParams;
}
if (scope !== 'all_dms' && scope !== 'open_dms') {
return searchParams;
}
const targetChannelIds = await getDmChannelIdsForScope({
scope,
userId,
userRepository: this.userRepository,
includeChannelId: channelId,
});
if (targetChannelIds.length === 0) {
return searchParams;
}
return {...searchParams, channel_id: targetChannelIds.map((id) => BigInt(id))};
}
}

View File

@@ -0,0 +1,146 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MessageID} from '~/BrandedTypes';
import type {MessageSearchRequest} from '~/channel/ChannelModel';
import {getMessageSearchService} from '~/Meilisearch';
import type {Message} from '~/Models';
import type {MessageSearchFilters} from '~/search/MessageSearchService';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IWorkerService} from '~/worker/IWorkerService';
export class MessageSearchService {
constructor(
private userRepository: IUserRepository,
private workerService: IWorkerService,
) {}
async indexMessage(message: Message, authorIsBot: boolean): Promise<void> {
try {
const searchService = getMessageSearchService();
if (!searchService) return;
await searchService.indexMessage(message, authorIsBot);
} catch (error) {
console.error('Failed to index message:', error);
}
}
async updateMessageIndex(message: Message): Promise<void> {
try {
const searchService = getMessageSearchService();
if (!searchService) return;
let authorIsBot = false;
if (message.authorId) {
const user = await this.userRepository.findUnique(message.authorId);
authorIsBot = user?.isBot ?? false;
}
await searchService.updateMessage(message, authorIsBot);
} catch (error) {
console.error('Failed to update message in search index:', error);
}
}
async deleteMessageIndex(messageId: MessageID): Promise<void> {
try {
const searchService = getMessageSearchService();
if (!searchService) return;
await searchService.deleteMessage(messageId);
} catch (error) {
console.error('Failed to delete message from search index:', error);
}
}
buildSearchFilters(channelId: ChannelID, searchParams: MessageSearchRequest): MessageSearchFilters {
const filters: MessageSearchFilters = {};
if (searchParams.max_id) filters.maxId = searchParams.max_id.toString();
if (searchParams.min_id) filters.minId = searchParams.min_id.toString();
if (searchParams.content) filters.content = searchParams.content;
if (searchParams.contents) filters.contents = searchParams.contents;
if (searchParams.channel_id) {
filters.channelIds = Array.isArray(searchParams.channel_id)
? searchParams.channel_id.map((id: bigint) => id.toString())
: [(searchParams.channel_id as bigint).toString()];
} else {
filters.channelId = channelId.toString();
}
if (searchParams.exclude_channel_id) {
filters.excludeChannelIds = Array.isArray(searchParams.exclude_channel_id)
? searchParams.exclude_channel_id.map((id: bigint) => id.toString())
: [(searchParams.exclude_channel_id as bigint).toString()];
}
if (searchParams.author_id) {
filters.authorId = Array.isArray(searchParams.author_id)
? searchParams.author_id.map((id: bigint) => id.toString())
: [(searchParams.author_id as bigint).toString()];
}
if (searchParams.exclude_author_id) {
filters.excludeAuthorIds = Array.isArray(searchParams.exclude_author_id)
? searchParams.exclude_author_id.map((id: bigint) => id.toString())
: [(searchParams.exclude_author_id as bigint).toString()];
}
if (searchParams.author_type) filters.authorType = searchParams.author_type;
if (searchParams.exclude_author_type) filters.excludeAuthorType = searchParams.exclude_author_type;
if (searchParams.mentions) filters.mentions = searchParams.mentions.map((id: bigint) => id.toString());
if (searchParams.exclude_mentions)
filters.excludeMentions = searchParams.exclude_mentions.map((id: bigint) => id.toString());
if (searchParams.mention_everyone !== undefined) filters.mentionEveryone = searchParams.mention_everyone;
if (searchParams.pinned !== undefined) filters.pinned = searchParams.pinned;
if (searchParams.has) filters.has = searchParams.has;
if (searchParams.exclude_has) filters.excludeHas = searchParams.exclude_has;
if (searchParams.embed_type) filters.embedType = searchParams.embed_type;
if (searchParams.exclude_embed_type) filters.excludeEmbedTypes = searchParams.exclude_embed_type;
if (searchParams.embed_provider) filters.embedProvider = searchParams.embed_provider;
if (searchParams.exclude_embed_provider) filters.excludeEmbedProviders = searchParams.exclude_embed_provider;
if (searchParams.link_hostname) filters.linkHostname = searchParams.link_hostname;
if (searchParams.exclude_link_hostname) filters.excludeLinkHostnames = searchParams.exclude_link_hostname;
if (searchParams.attachment_filename) filters.attachmentFilename = searchParams.attachment_filename;
if (searchParams.exclude_attachment_filename)
filters.excludeAttachmentFilenames = searchParams.exclude_attachment_filename;
if (searchParams.attachment_extension) filters.attachmentExtension = searchParams.attachment_extension;
if (searchParams.exclude_attachment_extension)
filters.excludeAttachmentExtensions = searchParams.exclude_attachment_extension;
if (searchParams.sort_by) filters.sortBy = searchParams.sort_by;
if (searchParams.sort_order) filters.sortOrder = searchParams.sort_order;
if (searchParams.include_nsfw !== undefined) filters.includeNsfw = searchParams.include_nsfw;
return filters;
}
async triggerChannelIndexing(channelId: ChannelID): Promise<void> {
await this.workerService.addJob('indexChannelMessages', {
channelId: channelId.toString(),
});
}
}

View File

@@ -0,0 +1,930 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
type ChannelID,
createChannelID,
createGuildID,
createMessageID,
createStickerID,
createUserID,
type GuildID,
type RoleID,
type UserID,
} from '~/BrandedTypes';
import {Config} from '~/Config';
import {
ChannelTypes,
GuildOperations,
MessageReferenceTypes,
MessageTypes,
Permissions,
SENDABLE_MESSAGE_FLAGS,
UserFlags,
} from '~/Constants';
import type {AttachmentRequestData, AttachmentToProcess} from '~/channel/AttachmentDTOs';
import type {MessageRequest} from '~/channel/ChannelModel';
import type {MessageAttachment, MessageReference, MessageSnapshot} from '~/database/CassandraTypes';
import {
CannotExecuteOnDmError,
FeatureTemporarilyDisabledError,
InputValidationError,
MissingPermissionsError,
SlowmodeRateLimitError,
UnknownChannelError,
UnknownMessageError,
} from '~/Errors';
import type {IFavoriteMemeRepository} from '~/favorite_meme/IFavoriteMemeRepository';
import type {GuildResponse} from '~/guild/GuildModel';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import {getMetricsService} from '~/infrastructure/MetricsService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {Message, User, Webhook} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {MessageChannelAuthService} from './MessageChannelAuthService';
import type {MessageDispatchService} from './MessageDispatchService';
import type {MessageEmbedAttachmentResolver} from './MessageEmbedAttachmentResolver';
import {createMessageSnapshotsForForward, isOperationDisabled, isPersonalNotesChannel} from './MessageHelpers';
import type {MessageMentionService} from './MessageMentionService';
import type {MessageOperationsHelpers} from './MessageOperationsHelpers';
import type {MessagePersistenceService} from './MessagePersistenceService';
import type {MessageProcessingService} from './MessageProcessingService';
import type {MessageSearchService} from './MessageSearchService';
import type {MessageValidationService} from './MessageValidationService';
interface MessageSendServiceDeps {
channelRepository: IChannelRepositoryAggregate;
storageService: IStorageService;
gatewayService: IGatewayService;
snowflakeService: SnowflakeService;
rateLimitService: IRateLimitService;
favoriteMemeRepository: IFavoriteMemeRepository;
validationService: MessageValidationService;
mentionService: MessageMentionService;
searchService: MessageSearchService;
persistenceService: MessagePersistenceService;
channelAuthService: MessageChannelAuthService;
processingService: MessageProcessingService;
dispatchService: MessageDispatchService;
operationsHelpers: MessageOperationsHelpers;
embedAttachmentResolver: MessageEmbedAttachmentResolver;
}
export class MessageSendService {
constructor(private readonly deps: MessageSendServiceDeps) {}
private attachmentsToProcess(attachments?: Array<AttachmentRequestData>): Array<AttachmentToProcess> | undefined {
if (!attachments) return undefined;
const processed = attachments.filter(
(att): att is AttachmentToProcess =>
'upload_filename' in att && typeof att.upload_filename === 'string' && att.upload_filename.length > 0,
);
return processed.length > 0 ? processed : undefined;
}
async validateMessageCanBeSent({
user,
channelId,
data,
}: {
user: User;
channelId: ChannelID;
data: MessageRequest;
}): Promise<void> {
const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({
userId: user.id,
channelId,
});
if (!(user.flags & UserFlags.HAS_SESSION_STARTED)) {
throw InputValidationError.create('content', 'You must start a session before sending messages');
}
if (isPersonalNotesChannel({userId: user.id, channelId})) {
await this.validatePersonalNoteMessage({user, channelId, data});
return;
}
const {channel, guild, checkPermission, hasPermission, member} = authChannel;
const [canEmbedLinks, canMentionEveryone] = await Promise.all([
hasPermission(Permissions.EMBED_LINKS),
hasPermission(Permissions.MENTION_EVERYONE),
]);
if (data.embeds && data.embeds.length > 0 && !canEmbedLinks) {
throw new MissingPermissionsError();
}
if (guild) {
if (!member) {
throw new UnknownChannelError();
}
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
throw new FeatureTemporarilyDisabledError();
}
await checkPermission(Permissions.SEND_MESSAGES);
if (data.tts) {
await checkPermission(Permissions.SEND_TTS_MESSAGES);
}
await this.deps.channelAuthService.checkGuildVerification({user, guild, member});
} else if (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM) {
await this.deps.channelAuthService.validateDMSendPermissions({channelId, userId: user.id});
}
this.deps.validationService.ensureTextChannel(channel);
const isForwardMessage = this.ensureMessageRequestIsValid({user, data});
this.deps.embedAttachmentResolver.validateAttachmentReferences({
embeds: data.embeds,
attachments: data.attachments,
});
const {referencedMessage, referencedChannelGuildId} = await this.fetchReferencedMessageForValidation({
data,
channelId,
isForwardMessage,
});
if (data.message_reference && referencedMessage && !isForwardMessage) {
const replyableTypes: ReadonlySet<Message['type']> = new Set([MessageTypes.DEFAULT, MessageTypes.REPLY]);
if (!replyableTypes.has(referencedMessage.type)) {
throw InputValidationError.create('message_reference', 'Cannot reply to system message');
}
}
this.ensureForwardGuildMatches({data, referencedChannelGuildId});
if (data.message_reference && guild) {
await checkPermission(Permissions.READ_MESSAGE_HISTORY);
}
if (data.content && channel && !isForwardMessage) {
const mentions = this.deps.mentionService.extractMentions({
content: data.content,
referencedMessage: referencedMessage || null,
message: {
id: createMessageID(this.deps.snowflakeService.generate()),
channelId,
authorId: user.id,
content: data.content,
flags: this.deps.validationService.calculateMessageFlags(data),
} as Message,
channelType: channel.type,
allowedMentions: data.allowed_mentions || null,
guild,
canMentionEveryone,
});
await this.deps.mentionService.validateMentions({
userMentions: mentions.userMentions,
roleMentions: mentions.roleMentions,
channel,
canMentionRoles: canMentionEveryone,
});
}
await this.ensureAttachmentsExist(data.attachments);
}
private async validatePersonalNoteMessage({
user,
channelId,
data,
}: {
user: User;
channelId: ChannelID;
data: MessageRequest;
}): Promise<void> {
const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({
userId: user.id,
channelId,
});
const {channel} = authChannel;
this.deps.validationService.ensureTextChannel(channel);
const isForwardMessage = this.ensureMessageRequestIsValid({user, data});
this.deps.embedAttachmentResolver.validateAttachmentReferences({
embeds: data.embeds,
attachments: data.attachments,
});
const {referencedMessage, referencedChannelGuildId} = await this.fetchReferencedMessageForValidation({
data,
channelId,
isForwardMessage,
});
if (data.message_reference && referencedMessage && !isForwardMessage) {
const replyableTypes: ReadonlySet<Message['type']> = new Set([MessageTypes.DEFAULT, MessageTypes.REPLY]);
if (!replyableTypes.has(referencedMessage.type)) {
throw InputValidationError.create('message_reference', 'Cannot reply to system message');
}
}
this.ensureForwardGuildMatches({data, referencedChannelGuildId});
if (data.content && channel && !isForwardMessage) {
const mentions = this.deps.mentionService.extractMentions({
content: data.content,
referencedMessage: referencedMessage || null,
message: {
id: createMessageID(this.deps.snowflakeService.generate()),
channelId,
authorId: user.id,
content: data.content,
flags: this.deps.validationService.calculateMessageFlags(data),
} as Message,
channelType: channel.type,
allowedMentions: data.allowed_mentions || null,
guild: null,
canMentionEveryone: true,
});
await this.deps.mentionService.validateMentions({
userMentions: mentions.userMentions,
roleMentions: mentions.roleMentions,
channel,
});
}
await this.ensureAttachmentsExist(data.attachments);
}
private async fetchReferencedMessageForValidation({
data,
channelId,
isForwardMessage,
}: {
data: MessageRequest;
channelId: ChannelID;
isForwardMessage: boolean;
}): Promise<{referencedMessage: Message | null; referencedChannelGuildId?: GuildID | null}> {
if (!data.message_reference) {
return {referencedMessage: null};
}
const referenceChannelId = isForwardMessage ? createChannelID(data.message_reference.channel_id!) : channelId;
const referencedMessage = await this.deps.channelRepository.messages.getMessage(
referenceChannelId,
createMessageID(data.message_reference.message_id),
);
if (!referencedMessage) {
throw new UnknownMessageError();
}
let referencedChannelGuildId: GuildID | null | undefined;
if (isForwardMessage) {
const referencedChannel = await this.deps.channelRepository.channelData.findUnique(referencedMessage.channelId);
if (!referencedChannel) {
throw new UnknownChannelError();
}
referencedChannelGuildId = referencedChannel.guildId ?? null;
}
return {referencedMessage, referencedChannelGuildId};
}
private async ensureAttachmentsExist(attachments?: Array<AttachmentRequestData>): Promise<void> {
if (!attachments || attachments.length === 0) return;
for (let index = 0; index < attachments.length; index++) {
const attachment = attachments[index];
if (!('upload_filename' in attachment) || !attachment.upload_filename) continue;
const metadata = await this.deps.storageService.getObjectMetadata(
Config.s3.buckets.uploads,
attachment.upload_filename,
);
if (!metadata) {
throw InputValidationError.create(
`attachments.${index}.upload_filename`,
`Uploaded attachment ${attachment.filename} was not found`,
);
}
}
}
private ensureMessageRequestIsValid({user, data}: {user: User; data: MessageRequest}): boolean {
const isForwardMessage = data.message_reference?.type === MessageReferenceTypes.FORWARD;
if (isForwardMessage) {
if (!data.message_reference?.channel_id || !data.message_reference?.message_id) {
throw InputValidationError.create(
'message_reference',
'Forward message reference must include channel_id and message_id',
);
}
if (
data.content ||
(data.embeds && data.embeds.length > 0) ||
(data.attachments && data.attachments.length > 0) ||
(data.sticker_ids && data.sticker_ids.length > 0)
) {
throw InputValidationError.create(
'message_reference',
'Forward messages cannot contain content, embeds, attachments, or stickers',
);
}
} else {
this.deps.validationService.validateMessageContent(data, user);
}
return isForwardMessage;
}
private async resolveReferenceContext({
data,
channelId,
isForwardMessage,
user,
}: {
data: MessageRequest;
channelId: ChannelID;
isForwardMessage: boolean;
user: User;
}): Promise<{
referencedMessage: Message | null;
referencedChannelGuildId?: GuildID | null;
messageSnapshots?: Array<MessageSnapshot>;
}> {
const referenceChannelId = isForwardMessage ? createChannelID(data.message_reference!.channel_id!) : channelId;
const referencedMessage = data.message_reference
? await this.deps.channelRepository.messages.getMessage(
referenceChannelId,
createMessageID(data.message_reference.message_id),
)
: null;
if (data.message_reference && !referencedMessage) {
throw new UnknownMessageError();
}
let referencedChannelGuildId: GuildID | null | undefined;
if (isForwardMessage && referencedMessage) {
const referencedChannel = await this.deps.channelRepository.channelData.findUnique(referencedMessage.channelId);
if (!referencedChannel) {
throw new UnknownChannelError();
}
referencedChannelGuildId = referencedChannel.guildId;
}
let messageSnapshots: Array<MessageSnapshot> | undefined;
if (isForwardMessage && referencedMessage) {
messageSnapshots = await createMessageSnapshotsForForward(
referencedMessage,
user,
channelId,
this.deps.storageService,
this.deps.snowflakeService,
this.deps.validationService.validateAttachmentSizes.bind(this.deps.validationService),
);
}
return {referencedMessage, referencedChannelGuildId, messageSnapshots};
}
private ensureForwardGuildMatches({
data,
referencedChannelGuildId,
}: {
data: MessageRequest;
referencedChannelGuildId?: GuildID | null;
}): void {
if (data.message_reference?.type !== MessageReferenceTypes.FORWARD) {
return;
}
if (referencedChannelGuildId === undefined || data.message_reference?.guild_id === undefined) {
return;
}
const providedGuildId = createGuildID(data.message_reference.guild_id);
if (providedGuildId !== referencedChannelGuildId) {
throw InputValidationError.create(
'message_reference.guild_id',
'Guild id must match the channel the referenced message was fetched from',
);
}
}
private async prepareMessageAttachments({
user,
channelId,
data,
}: {
user: User;
channelId: ChannelID;
data: MessageRequest;
}): Promise<{
attachmentsToProcess?: Array<AttachmentToProcess>;
favoriteMemeAttachment?: MessageAttachment;
}> {
const attachmentsToProcess = this.attachmentsToProcess(data.attachments);
let favoriteMemeAttachment: MessageAttachment | undefined;
if (data.favorite_meme_id) {
favoriteMemeAttachment = await this.deps.operationsHelpers.processFavoriteMeme({
user,
channelId,
favoriteMemeId: data.favorite_meme_id,
});
}
return {attachmentsToProcess, favoriteMemeAttachment};
}
private buildMessageReferencePayload({
data,
referencedMessage,
guild,
isForwardMessage,
referencedChannelGuildId,
}: {
data: MessageRequest;
referencedMessage: Message | null;
guild: GuildResponse | null;
isForwardMessage: boolean;
referencedChannelGuildId?: GuildID | null;
}): MessageReference | undefined {
if (!data.message_reference) {
return undefined;
}
const channel_id = referencedMessage
? referencedMessage.channelId
: createChannelID(data.message_reference.channel_id!);
const guild_id = isForwardMessage
? (referencedChannelGuildId ?? null)
: guild?.id
? createGuildID(BigInt(guild.id))
: null;
return {
message_id: createMessageID(data.message_reference.message_id),
channel_id,
guild_id,
type: data.message_reference.type ?? MessageReferenceTypes.DEFAULT,
};
}
async sendMessage({
user,
channelId,
data,
requestCache,
}: {
user: User;
channelId: ChannelID;
data: MessageRequest;
requestCache: RequestCache;
}): Promise<Message> {
try {
const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({
userId: user.id,
channelId,
});
if (!(user.flags & UserFlags.HAS_SESSION_STARTED)) {
throw InputValidationError.create('content', 'You must start a session before sending messages');
}
if (isPersonalNotesChannel({userId: user.id, channelId})) {
return this.sendPersonalNoteMessage({user, channelId, data, requestCache});
}
const {channel, guild, checkPermission, hasPermission, member} = authChannel;
const [canEmbedLinks, canMentionEveryone] = await Promise.all([
hasPermission(Permissions.EMBED_LINKS),
hasPermission(Permissions.MENTION_EVERYONE),
]);
if (data.embeds && data.embeds.length > 0 && !canEmbedLinks) {
throw new MissingPermissionsError();
}
if (guild) {
if (!member) {
throw new UnknownChannelError();
}
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
throw new FeatureTemporarilyDisabledError();
}
await checkPermission(Permissions.SEND_MESSAGES);
if (data.tts) {
await checkPermission(Permissions.SEND_TTS_MESSAGES);
}
await this.deps.channelAuthService.checkGuildVerification({user, guild, member});
if (channel.rateLimitPerUser && channel.rateLimitPerUser > 0 && !user.isBot) {
const hasBypassSlowmode = await hasPermission(Permissions.BYPASS_SLOWMODE);
if (!hasBypassSlowmode) {
const rateLimitKey = `slowmode:${channelId}:${user.id}`;
const result = await this.deps.rateLimitService.checkLimit({
identifier: rateLimitKey,
maxAttempts: 1,
windowMs: channel.rateLimitPerUser * 1000,
});
if (!result.allowed) {
throw new SlowmodeRateLimitError({
message: 'You are sending messages too quickly. Please slow down.',
retryAfter: result.retryAfter!,
});
}
}
}
} else if (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM) {
await this.deps.channelAuthService.validateDMSendPermissions({channelId, userId: user.id});
}
this.deps.validationService.ensureTextChannel(channel);
const isForwardMessage = this.ensureMessageRequestIsValid({user, data});
this.deps.embedAttachmentResolver.validateAttachmentReferences({
embeds: data.embeds,
attachments: data.attachments,
});
const existingMessage = await this.deps.operationsHelpers.findExistingMessage({
userId: user.id,
nonce: data.nonce,
expectedChannelId: channelId,
});
if (existingMessage) {
return existingMessage;
}
if (data.message_reference && guild) {
await checkPermission(Permissions.READ_MESSAGE_HISTORY);
}
const referenceContext = await this.resolveReferenceContext({
data,
channelId,
isForwardMessage,
user,
});
const {referencedMessage, referencedChannelGuildId, messageSnapshots} = referenceContext;
if (data.message_reference && referencedMessage && !isForwardMessage) {
const replyableTypes: ReadonlySet<Message['type']> = new Set([MessageTypes.DEFAULT, MessageTypes.REPLY]);
if (!replyableTypes.has(referencedMessage.type)) {
throw InputValidationError.create('message_reference', 'Cannot reply to system message');
}
}
this.ensureForwardGuildMatches({data, referencedChannelGuildId});
const {attachmentsToProcess, favoriteMemeAttachment} = await this.prepareMessageAttachments({
user,
channelId,
data,
});
const messageId = createMessageID(this.deps.snowflakeService.generate());
let mentionData:
| {
flags: number;
mentionUserIds: Array<UserID>;
mentionRoleIds: Array<RoleID>;
mentionEveryone: boolean;
mentionHere: boolean;
}
| undefined;
if (data.content && channel && !isForwardMessage) {
const mentions = this.deps.mentionService.extractMentions({
content: data.content,
referencedMessage: referencedMessage || null,
message: {
id: messageId,
channelId,
authorId: user.id,
content: data.content,
flags: this.deps.validationService.calculateMessageFlags(data),
} as Message,
channelType: channel.type,
allowedMentions: data.allowed_mentions || null,
guild,
canMentionEveryone,
});
const {validUserIds, validRoleIds} = await this.deps.mentionService.validateMentions({
userMentions: mentions.userMentions,
roleMentions: mentions.roleMentions,
channel,
canMentionRoles: canMentionEveryone,
});
mentionData = {
flags: mentions.flags,
mentionUserIds: validUserIds,
mentionRoleIds: validRoleIds,
mentionEveryone: mentions.mentionsEveryone || mentions.mentionsHere,
mentionHere: mentions.mentionsHere,
};
}
const messageReference = this.buildMessageReferencePayload({
data,
referencedMessage,
guild,
isForwardMessage,
referencedChannelGuildId,
});
const message = await this.deps.persistenceService.createMessage({
messageId,
channelId,
user,
type: referencedMessage ? MessageTypes.REPLY : MessageTypes.DEFAULT,
content: data.content,
flags: this.deps.validationService.calculateMessageFlags(data),
embeds: data.embeds,
attachments: attachmentsToProcess,
processedAttachments: favoriteMemeAttachment ? [favoriteMemeAttachment] : undefined,
attachmentDecayExcludedIds: favoriteMemeAttachment ? [favoriteMemeAttachment.attachment_id] : undefined,
stickerIds: data.sticker_ids ? data.sticker_ids.flatMap((stickerId) => createStickerID(stickerId)) : undefined,
messageReference,
messageSnapshots,
guildId: guild?.id ? createGuildID(BigInt(guild.id)) : null,
channel,
referencedMessage,
allowedMentions: data.allowed_mentions,
guild,
member,
hasPermission: guild ? hasPermission : undefined,
mentionData,
allowEmbeds: canEmbedLinks,
});
await Promise.all([
this.deps.processingService.updateDMRecipients({channel, channelId, requestCache}),
this.deps.processingService.processMessageAfterCreation({
message,
channel,
guild,
user,
data,
referencedMessage,
mentionHere: mentionData?.mentionHere ?? false,
}),
this.deps.processingService.updateReadStates({user, guild, channel, channelId, messageId}),
]);
await this.deps.dispatchService.dispatchMessageCreate({
channel,
message,
requestCache,
currentUserId: user.id,
nonce: data.nonce,
tts: data.tts,
});
if (data.nonce) {
await this.deps.validationService.cacheMessageNonce({userId: user.id, nonce: data.nonce, channelId, messageId});
}
if (channel.indexedAt) {
void this.deps.searchService.indexMessage(message, user.isBot);
}
getMetricsService().counter({name: 'message.send'});
return message;
} catch (error) {
getMetricsService().counter({name: 'message.send.error'});
throw error;
}
}
async sendWebhookMessage({
webhook,
data,
username,
avatar,
requestCache,
}: {
webhook: Webhook;
data: MessageRequest;
username?: string | null;
avatar?: string | null;
requestCache: RequestCache;
}): Promise<Message> {
const channelId = webhook.channelId!;
const channel = await this.deps.channelRepository.channelData.findUnique(channelId);
if (!channel || !channel.guildId) {
throw new CannotExecuteOnDmError();
}
const guild = await this.deps.gatewayService.getGuildData({
guildId: channel.guildId,
userId: createUserID(0n),
skipMembershipCheck: true,
});
this.deps.validationService.validateMessageContent(data, null);
this.deps.embedAttachmentResolver.validateAttachmentReferences({
embeds: data.embeds,
attachments: data.attachments,
});
const messageId = createMessageID(this.deps.snowflakeService.generate());
let mentionData:
| {
flags: number;
mentionUserIds: Array<UserID>;
mentionRoleIds: Array<RoleID>;
mentionEveryone: boolean;
mentionHere: boolean;
}
| undefined;
if (data.content && channel) {
const mentions = this.deps.mentionService.extractMentions({
content: data.content,
referencedMessage: null,
message: {
id: messageId,
channelId,
webhookId: webhook.id,
content: data.content,
flags: this.deps.validationService.calculateMessageFlags(data),
} as Message,
channelType: channel.type,
allowedMentions: data.allowed_mentions || null,
guild,
});
const {validUserIds, validRoleIds} = await this.deps.mentionService.validateMentions({
userMentions: mentions.userMentions,
roleMentions: mentions.roleMentions,
channel,
});
mentionData = {
flags: mentions.flags,
mentionUserIds: validUserIds,
mentionRoleIds: validRoleIds,
mentionEveryone: mentions.mentionsEveryone || mentions.mentionsHere,
mentionHere: mentions.mentionsHere,
};
}
const message = await this.deps.persistenceService.createMessage({
messageId,
channelId,
webhookId: webhook.id,
webhookName: username ?? webhook.name!,
webhookAvatar: avatar ?? webhook.avatarHash,
type: MessageTypes.DEFAULT,
content: data.content,
flags: this.deps.validationService.calculateMessageFlags(data),
embeds: data.embeds,
attachments: this.attachmentsToProcess(data.attachments),
guildId: channel.guildId,
channel,
guild,
mentionData,
allowEmbeds: true,
});
await this.deps.mentionService.handleMentionTasks({
guildId: channel.guildId,
message,
authorId: createUserID(0n),
mentionHere: mentionData?.mentionHere ?? false,
});
await this.deps.dispatchService.dispatchMessageCreate({channel, message, requestCache});
if (channel.indexedAt) {
void this.deps.searchService.indexMessage(message, false);
}
return message;
}
private async sendPersonalNoteMessage({
user,
channelId,
data,
requestCache,
}: {
user: User;
channelId: ChannelID;
data: MessageRequest;
requestCache: RequestCache;
}): Promise<Message> {
const {channel} = await this.deps.channelAuthService.getChannelAuthenticated({userId: user.id, channelId});
const isForwardMessage = this.ensureMessageRequestIsValid({user, data});
this.deps.embedAttachmentResolver.validateAttachmentReferences({
embeds: data.embeds,
attachments: data.attachments,
});
const existingMessage = await this.deps.operationsHelpers.findExistingMessage({
userId: user.id,
nonce: data.nonce,
expectedChannelId: channelId,
});
if (existingMessage) {
return existingMessage;
}
const {referencedMessage, referencedChannelGuildId, messageSnapshots} = await this.resolveReferenceContext({
data,
channelId,
isForwardMessage,
user,
});
this.ensureForwardGuildMatches({data, referencedChannelGuildId});
const {attachmentsToProcess, favoriteMemeAttachment} = await this.prepareMessageAttachments({
user,
channelId,
data,
});
const messageId = createMessageID(this.deps.snowflakeService.generate());
const messageReference = this.buildMessageReferencePayload({
data,
referencedMessage,
guild: null,
isForwardMessage,
referencedChannelGuildId,
});
const message = await this.deps.persistenceService.createMessage({
messageId,
channelId,
user,
type: MessageTypes.DEFAULT,
content: data.content,
flags: data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0,
embeds: data.embeds,
attachments: attachmentsToProcess,
processedAttachments: favoriteMemeAttachment ? [favoriteMemeAttachment] : undefined,
attachmentDecayExcludedIds: favoriteMemeAttachment ? [favoriteMemeAttachment.attachment_id] : undefined,
messageReference,
messageSnapshots,
guildId: null,
channel,
});
await this.deps.dispatchService.dispatchMessageCreate({
channel,
message,
requestCache,
currentUserId: user.id,
nonce: data.nonce,
tts: data.tts,
});
if (data.nonce) {
await this.deps.validationService.cacheMessageNonce({userId: user.id, nonce: data.nonce, channelId, messageId});
}
return message;
}
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildID, StickerID, UserID} from '~/BrandedTypes';
import {Permissions} from '~/Constants';
import type {MessageStickerItem} from '~/database/CassandraTypes';
import {InputValidationError, MissingPermissionsError} from '~/Errors';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {PackService} from '~/pack/PackService';
import type {IUserRepository} from '~/user/IUserRepository';
export class MessageStickerService {
constructor(
private userRepository: IUserRepository,
private guildRepository: IGuildRepository,
private packService: PackService,
) {}
async computeStickerIds(params: {
stickerIds: Array<StickerID>;
userId: UserID | null;
guildId: GuildID | null;
hasPermission?: (permission: bigint) => Promise<boolean>;
}): Promise<Array<MessageStickerItem>> {
const {stickerIds, userId, guildId, hasPermission} = params;
const packResolver = await this.packService.createPackExpressionAccessResolver({
userId,
type: 'sticker',
});
let isPremium = false;
if (userId) {
const user = await this.userRepository.findUnique(userId);
isPremium = user?.canUseGlobalExpressions() ?? false;
}
return Promise.all(
stickerIds.map(async (stickerId) => {
if (!guildId) {
if (!isPremium) {
throw InputValidationError.create('sticker', 'Cannot use custom stickers in DMs without premium');
}
const stickerFromAnyGuild = await this.guildRepository.getStickerById(stickerId);
if (!stickerFromAnyGuild) {
throw InputValidationError.create('sticker', 'Custom sticker not found');
}
const packAccess = await packResolver.resolve(stickerFromAnyGuild.guildId);
if (packAccess === 'not-accessible') {
throw InputValidationError.create('sticker', 'Custom sticker not found');
}
return {
sticker_id: stickerFromAnyGuild.id,
name: stickerFromAnyGuild.name,
format_type: stickerFromAnyGuild.formatType,
};
}
const guildSticker = await this.guildRepository.getSticker(stickerId, guildId);
if (guildSticker) {
return {
sticker_id: guildSticker.id,
name: guildSticker.name,
format_type: guildSticker.formatType,
};
}
const stickerFromOtherGuild = await this.guildRepository.getStickerById(stickerId);
if (!stickerFromOtherGuild) {
throw InputValidationError.create('sticker', 'Custom sticker not found');
}
if (!isPremium) {
throw InputValidationError.create(
'sticker',
'Cannot use custom stickers outside of source guilds without premium',
);
}
if (hasPermission) {
const canUseExternalStickers = await hasPermission(Permissions.USE_EXTERNAL_STICKERS);
if (!canUseExternalStickers) {
throw new MissingPermissionsError();
}
}
const packAccess = await packResolver.resolve(stickerFromOtherGuild.guildId);
if (packAccess === 'not-accessible') {
throw InputValidationError.create('sticker', 'Custom sticker not found');
}
return {
sticker_id: stickerFromOtherGuild.id,
name: stickerFromOtherGuild.name,
format_type: stickerFromOtherGuild.formatType,
};
}),
);
}
}

View 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 {createMessageID, type GuildID, type UserID} from '~/BrandedTypes';
import {MessageTypes} from '~/Constants';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {Channel, Message} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {MessagePersistenceService} from './MessagePersistenceService';
export class MessageSystemService {
constructor(
private channelRepository: IChannelRepositoryAggregate,
private guildRepository: IGuildRepository,
private snowflakeService: SnowflakeService,
private persistenceService: MessagePersistenceService,
) {}
async sendJoinSystemMessage({
guildId,
userId,
requestCache,
dispatchMessageCreate,
}: {
guildId: GuildID;
userId: UserID;
requestCache: RequestCache;
dispatchMessageCreate: (params: {channel: Channel; message: Message; requestCache: RequestCache}) => Promise<void>;
}): Promise<void> {
const guild = await this.guildRepository.findUnique(guildId);
if (!guild?.systemChannelId) return;
const systemChannel = await this.channelRepository.channelData.findUnique(guild.systemChannelId);
if (!systemChannel) return;
const messageId = createMessageID(this.snowflakeService.generate());
const message = await this.persistenceService.createMessage({
messageId,
channelId: systemChannel.id,
userId,
type: MessageTypes.USER_JOIN,
content: null,
flags: 0,
guildId,
channel: systemChannel,
});
await dispatchMessageCreate({channel: systemChannel, message, requestCache});
}
}

View File

@@ -0,0 +1,174 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type ChannelID, createChannelID, type MessageID, type UserID} from '~/BrandedTypes';
import {
isMessageTypeDeletable,
MAX_MESSAGE_LENGTH_NON_PREMIUM,
MAX_MESSAGE_LENGTH_PREMIUM,
MessageFlags,
MessageTypes,
Permissions,
SENDABLE_MESSAGE_FLAGS,
TEXT_BASED_CHANNEL_TYPES,
} from '~/Constants';
import type {MessageRequest, MessageUpdateRequest} from '~/channel/ChannelModel';
import {
CannotEditSystemMessageError,
CannotSendEmptyMessageError,
CannotSendMessageToNonTextChannelError,
FileSizeTooLargeError,
InputValidationError,
UnknownMessageError,
} from '~/Errors';
import type {GuildResponse} from '~/guild/GuildModel';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {Channel, Message, User} from '~/Models';
import {getAttachmentMaxSize} from '~/utils/AttachmentUtils';
import {MESSAGE_NONCE_TTL} from './MessageHelpers';
export class MessageValidationService {
constructor(private cacheService: ICacheService) {}
ensureTextChannel(channel: Channel): void {
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
throw new CannotSendMessageToNonTextChannelError();
}
}
validateMessageContent(data: MessageRequest | MessageUpdateRequest, user: User | null, isUpdate = false): void {
const hasContent = data.content != null && data.content.trim().length > 0;
const hasEmbeds = data.embeds && data.embeds.length > 0;
const hasAttachments = data.attachments && data.attachments.length > 0;
const hasFavoriteMeme = 'favorite_meme_id' in data && data.favorite_meme_id != null;
const hasStickers = 'sticker_ids' in data && data.sticker_ids != null && data.sticker_ids.length > 0;
const hasFlags = data.flags !== undefined && data.flags !== null;
if (!hasContent && !hasEmbeds && !hasAttachments && !hasFavoriteMeme && !hasStickers && (!isUpdate || !hasFlags)) {
throw new CannotSendEmptyMessageError();
}
this.validateContentLength(data.content, user);
}
validateContentLength(content: string | null | undefined, user: User | null): void {
if (content == null) return;
const maxLength = user?.isPremium() ? MAX_MESSAGE_LENGTH_PREMIUM : MAX_MESSAGE_LENGTH_NON_PREMIUM;
if (content.length > maxLength) {
throw InputValidationError.create('content', `Content must not exceed ${maxLength} characters`);
}
}
validateMessageEditable(message: Message): void {
const editableTypes: ReadonlySet<Message['type']> = new Set([MessageTypes.DEFAULT, MessageTypes.REPLY]);
if (!editableTypes.has(message.type)) {
throw new CannotEditSystemMessageError();
}
}
calculateMessageFlags(data: {flags?: number; favorite_meme_id?: bigint | null}): number {
let flags = data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0;
if (data.favorite_meme_id) {
flags |= MessageFlags.COMPACT_ATTACHMENTS;
}
return flags;
}
validateAttachmentSizes(attachments: Array<{size: number | bigint}>, user: User): void {
const maxAttachmentSize = getAttachmentMaxSize(user.isPremium());
for (const attachment of attachments) {
if (Number(attachment.size) > maxAttachmentSize) {
throw new FileSizeTooLargeError();
}
}
}
async findExistingMessage({
userId,
nonce,
expectedChannelId,
}: {
userId: UserID;
nonce?: string;
expectedChannelId: ChannelID;
}): Promise<Message | null> {
if (!nonce) return null;
const existingNonce = await this.cacheService.get<{channel_id: string; message_id: string}>(
`message-nonce:${userId}:${nonce}`,
);
if (!existingNonce) return null;
const cachedChannelId = createChannelID(BigInt(existingNonce.channel_id));
if (cachedChannelId !== expectedChannelId) {
throw new UnknownMessageError();
}
return null;
}
async cacheMessageNonce({
userId,
nonce,
channelId,
messageId,
}: {
userId: UserID;
nonce: string;
channelId: ChannelID;
messageId: MessageID;
}): Promise<void> {
await this.cacheService.set(
`message-nonce:${userId}:${nonce}`,
{
channel_id: channelId.toString(),
message_id: messageId.toString(),
},
MESSAGE_NONCE_TTL,
);
}
async canDeleteMessage({
message,
userId,
guild,
hasPermission,
}: {
message: Message;
userId: UserID;
guild: GuildResponse | null;
hasPermission: (permission: bigint) => Promise<boolean>;
}): Promise<boolean> {
if (!isMessageTypeDeletable(message.type)) {
return false;
}
const isAuthor = message.authorId === userId;
if (!guild) return isAuthor;
const canManageMessages =
(await hasPermission(Permissions.SEND_MESSAGES)) && (await hasPermission(Permissions.MANAGE_MESSAGES));
return isAuthor || canManageMessages;
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID} from '~/BrandedTypes';
import type {User} from '~/Models';
import type {ReadStateService} from '~/read_state/ReadStateService';
interface IncrementDmMentionCountsParams {
readStateService: ReadStateService;
user: User | null;
recipients: Array<User>;
channelId: ChannelID;
}
export async function incrementDmMentionCounts(params: IncrementDmMentionCountsParams): Promise<void> {
const {readStateService, user, recipients, channelId} = params;
if (!user || user.isBot) return;
const validRecipients = recipients.filter((recipient) => recipient.id !== user.id && !recipient.isBot);
if (validRecipients.length === 0) return;
await readStateService.bulkIncrementMentionCounts(
validRecipients.map((recipient) => ({
userId: recipient.id,
channelId,
})),
);
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, UserID} from '~/BrandedTypes';
import {ChannelTypes} from '~/Constants';
import type {IUserRepository} from '~/user/IUserRepository';
export type DmSearchScope = 'all_dms' | 'open_dms';
export interface DmScopeOptions {
scope: DmSearchScope;
userId: UserID;
userRepository: IUserRepository;
includeChannelId?: ChannelID | null;
}
export const getDmChannelIdsForScope = async ({
scope,
userId,
userRepository,
includeChannelId,
}: DmScopeOptions): Promise<Array<string>> => {
const summaryResults = await userRepository.listPrivateChannelSummaries(userId);
const channelIdStrings = new Set<string>();
for (const summary of summaryResults) {
const isDm =
summary.channelType === ChannelTypes.DM || summary.channelType === ChannelTypes.GROUP_DM || summary.isGroupDm;
if (!isDm) {
continue;
}
if (scope === 'open_dms' && !summary.open) {
continue;
}
channelIdStrings.add(summary.channelId.toString());
}
if (includeChannelId) {
channelIdStrings.add(includeChannelId.toString());
}
return Array.from(channelIdStrings);
};