mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import type {
|
|
BrowseChannelRequest,
|
|
SearchChannelMessagesRequest,
|
|
} from '@fluxer/schema/src/domains/admin/AdminMessageBrowseSchemas';
|
|
import type {
|
|
DeleteMessageRequest,
|
|
LookupMessageByAttachmentRequest,
|
|
LookupMessageRequest,
|
|
} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas';
|
|
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
|
import type {ApiContext} from '../../ApiContext';
|
|
import {
|
|
type AttachmentID,
|
|
type ChannelID,
|
|
createAttachmentID,
|
|
createChannelID,
|
|
createMessageID,
|
|
createUserID,
|
|
type MessageID,
|
|
type UserID,
|
|
} from '../../BrandedTypes';
|
|
import type {IChannelRepository} from '../../channel/IChannelRepository';
|
|
import {purgeMessageAttachments} from '../../channel/services/message/MessageHelpers';
|
|
import {
|
|
createMessageResponseDataService,
|
|
type MessageResponseAccessContext,
|
|
messageResponseAccessForChannel,
|
|
messageResponseAccessForGuild,
|
|
} from '../../channel/services/message/MessageResponseDataService';
|
|
import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '../../csam/NcmecSubmissionService';
|
|
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
|
|
import {getPurgeQueue, getStorageService} from '../../middleware/ServiceSingletons';
|
|
import {getMessageSearchService} from '../../SearchFactory';
|
|
import {deleteMessageSearchDocuments} from '../../search/MessageSearchIndexCleanup';
|
|
import {searchExistingMessages} from '../../search/MessageSearchResultReconciler';
|
|
import {assertSafeByteSize} from '../../utils/ByteSizeUtils';
|
|
import type {AdminAuditService} from './AdminAuditService';
|
|
|
|
interface AdminMessageServiceDeps {
|
|
apiContext: ApiContext;
|
|
channelRepository: IChannelRepository;
|
|
guildRepository: IGuildRepositoryAggregate;
|
|
auditService: AdminAuditService;
|
|
ncmecSubmissionService: NcmecSubmissionService;
|
|
}
|
|
|
|
interface ChannelNsfwContext {
|
|
channelNsfw: boolean | null;
|
|
guildNsfwLevel: number | null;
|
|
channelName: string | null;
|
|
guildId: string | null;
|
|
guildName: string | null;
|
|
}
|
|
|
|
export class AdminMessageService {
|
|
constructor(private readonly deps: AdminMessageServiceDeps) {}
|
|
|
|
async lookupAttachment({
|
|
channelId,
|
|
attachmentId,
|
|
filename,
|
|
}: {
|
|
channelId: ChannelID;
|
|
attachmentId: AttachmentID;
|
|
filename: string;
|
|
}): Promise<{
|
|
message_id: MessageID | null;
|
|
}> {
|
|
const {channelRepository} = this.deps;
|
|
const messageId = await channelRepository.lookupAttachmentByChannelAndFilename(channelId, attachmentId, filename);
|
|
return {
|
|
message_id: messageId,
|
|
};
|
|
}
|
|
|
|
async lookupMessage(data: LookupMessageRequest) {
|
|
const channelId = createChannelID(data.channel_id);
|
|
const messageId = createMessageID(data.message_id);
|
|
const channelNsfwContext = await this.getChannelNsfwContext(channelId);
|
|
const messageResponses = (
|
|
await this.listMessageResponsesForAdmin({
|
|
channelId,
|
|
limit: data.context_limit,
|
|
around: messageId,
|
|
})
|
|
).reverse();
|
|
const attachmentStatuses = await this.getAttachmentStatusesForMessages(messageResponses);
|
|
const priorReports = await this.getPriorReportsForMessages(messageResponses);
|
|
const adminMessages = messageResponses.map((message) =>
|
|
this.mapMessageResponseToAdminMessage(message, channelNsfwContext, attachmentStatuses, priorReports),
|
|
);
|
|
return {
|
|
messages: adminMessages,
|
|
message_responses: messageResponses,
|
|
message_id: messageId.toString(),
|
|
};
|
|
}
|
|
|
|
async lookupMessageByAttachment(data: LookupMessageByAttachmentRequest) {
|
|
const channelId = createChannelID(data.channel_id);
|
|
const attachmentId = createAttachmentID(data.attachment_id);
|
|
const messageId = await this.deps.channelRepository.lookupAttachmentByChannelAndFilename(
|
|
channelId,
|
|
attachmentId,
|
|
data.filename,
|
|
);
|
|
if (!messageId) {
|
|
return {
|
|
messages: [],
|
|
message_responses: [],
|
|
message_id: null,
|
|
};
|
|
}
|
|
const result = await this.lookupMessage({
|
|
channel_id: data.channel_id,
|
|
message_id: BigInt(messageId),
|
|
context_limit: data.context_limit,
|
|
});
|
|
return {
|
|
messages: result.messages,
|
|
message_responses: result.message_responses,
|
|
message_id: messageId.toString(),
|
|
};
|
|
}
|
|
|
|
async deleteMessage(data: DeleteMessageRequest, adminUserId: UserID, auditLogReason: string | null) {
|
|
const {channelRepository, auditService} = this.deps;
|
|
const {gateway: gatewayService} = this.deps.apiContext.services;
|
|
const channelId = createChannelID(data.channel_id);
|
|
const messageId = createMessageID(data.message_id);
|
|
const channel = await channelRepository.findUnique(channelId);
|
|
const message = await channelRepository.getMessage(channelId, messageId);
|
|
if (message) {
|
|
if (message.attachments.length > 0) {
|
|
await purgeMessageAttachments(message, getStorageService(), getPurgeQueue());
|
|
}
|
|
await channelRepository.deleteMessage(
|
|
channelId,
|
|
messageId,
|
|
message.authorId || createUserID(0n),
|
|
message.pinnedTimestamp || undefined,
|
|
);
|
|
if (channel) {
|
|
if (channel.guildId) {
|
|
await gatewayService.dispatchGuild({
|
|
guildId: channel.guildId,
|
|
event: 'MESSAGE_DELETE',
|
|
data: {
|
|
channel_id: channelId.toString(),
|
|
id: messageId.toString(),
|
|
},
|
|
});
|
|
} else {
|
|
for (const recipientId of channel.recipientIds) {
|
|
await gatewayService.dispatchPresence({
|
|
userId: recipientId,
|
|
event: 'MESSAGE_DELETE',
|
|
data: {
|
|
channel_id: channelId.toString(),
|
|
id: messageId.toString(),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
await deleteMessageSearchDocuments([messageId], {context: {source: 'admin_message_delete'}});
|
|
}
|
|
await auditService.createAuditLog({
|
|
adminUserId,
|
|
targetType: 'message',
|
|
targetId: BigInt(messageId),
|
|
action: 'delete_message',
|
|
auditLogReason,
|
|
metadata: new Map([
|
|
['channel_id', channelId.toString()],
|
|
['message_id', messageId.toString()],
|
|
]),
|
|
});
|
|
return {
|
|
success: true,
|
|
};
|
|
}
|
|
|
|
async browseChannel(data: BrowseChannelRequest) {
|
|
const {snowflake: snowflakeService} = this.deps.apiContext.services;
|
|
const channelId = createChannelID(data.channel_id);
|
|
const limit = data.limit ?? 50;
|
|
const channelNsfwContext = await this.getChannelNsfwContext(channelId);
|
|
let beforeId = data.before ? createMessageID(data.before) : undefined;
|
|
const afterId = data.after ? createMessageID(data.after) : undefined;
|
|
if (!beforeId && !afterId) {
|
|
beforeId = createMessageID(await snowflakeService.generate());
|
|
}
|
|
const messages = await this.listMessageResponsesForAdmin({
|
|
channelId,
|
|
limit,
|
|
before: beforeId,
|
|
after: afterId,
|
|
});
|
|
const messageResponses = afterId ? messages : [...messages].reverse();
|
|
const attachmentStatuses = await this.getAttachmentStatusesForMessages(messageResponses);
|
|
const priorReports = await this.getPriorReportsForMessages(messageResponses);
|
|
const adminMessages = messageResponses.map((message) =>
|
|
this.mapMessageResponseToAdminMessage(message, channelNsfwContext, attachmentStatuses, priorReports),
|
|
);
|
|
return {
|
|
messages: adminMessages,
|
|
message_responses: messageResponses,
|
|
has_more: messages.length >= limit,
|
|
};
|
|
}
|
|
|
|
async searchChannelMessages(data: SearchChannelMessagesRequest) {
|
|
const {channelRepository} = this.deps;
|
|
const channelId = createChannelID(data.channel_id);
|
|
const limit = data.limit ?? 25;
|
|
const channelNsfwContext = await this.getChannelNsfwContext(channelId);
|
|
const searchService = getMessageSearchService();
|
|
if (!searchService) {
|
|
return {messages: [], message_responses: [], total: 0};
|
|
}
|
|
const result = await searchExistingMessages({
|
|
searchService,
|
|
messageRepository: channelRepository,
|
|
query: data.query,
|
|
filters: {channelIds: [channelId.toString()]},
|
|
hitsPerPage: limit,
|
|
page: 1,
|
|
});
|
|
const messageEntries = result.hits.map((hit) => ({
|
|
channelId: createChannelID(BigInt(hit.channelId)),
|
|
messageId: createMessageID(BigInt(hit.id)),
|
|
}));
|
|
const resolvedMessages = await Promise.all(
|
|
messageEntries.map(({channelId, messageId}) => this.getMessageResponseForAdmin(channelId, messageId)),
|
|
);
|
|
const messageResponses = resolvedMessages.filter((message): message is MessageResponse => message !== null);
|
|
const attachmentStatuses = await this.getAttachmentStatusesForMessages(messageResponses);
|
|
const priorReports = await this.getPriorReportsForMessages(messageResponses);
|
|
const adminMessages = messageResponses.map((message) =>
|
|
this.mapMessageResponseToAdminMessage(message, channelNsfwContext, attachmentStatuses, priorReports),
|
|
);
|
|
return {
|
|
messages: adminMessages,
|
|
message_responses: messageResponses,
|
|
total: result.total,
|
|
};
|
|
}
|
|
|
|
private async getAttachmentStatusesForMessages(
|
|
messages: Array<MessageResponse>,
|
|
): Promise<Map<string, NcmecAttachmentStatusResponse>> {
|
|
const attachmentIds = messages.flatMap((message) =>
|
|
(message.attachments ?? []).map((attachment) => createAttachmentID(BigInt(attachment.id))),
|
|
);
|
|
return this.deps.ncmecSubmissionService.getAttachmentStatuses(attachmentIds);
|
|
}
|
|
|
|
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
|
|
const channel = await this.deps.channelRepository.findUnique(channelId);
|
|
return channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
|
}
|
|
|
|
private async listMessageResponsesForAdmin(params: {
|
|
channelId: ChannelID;
|
|
limit: number;
|
|
before?: MessageID;
|
|
after?: MessageID;
|
|
around?: MessageID;
|
|
}): Promise<Array<MessageResponse>> {
|
|
const access = await this.getMessageResponseAccessForAdmin(params.channelId);
|
|
return createMessageResponseDataService().listMessages({
|
|
userId: createUserID(0n),
|
|
channelId: params.channelId,
|
|
limit: params.limit,
|
|
before: params.before,
|
|
after: params.after,
|
|
around: params.around,
|
|
access,
|
|
});
|
|
}
|
|
|
|
private async getMessageResponseForAdmin(
|
|
channelId: ChannelID,
|
|
messageId: MessageID,
|
|
): Promise<MessageResponse | null> {
|
|
const access = await this.getMessageResponseAccessForAdmin(channelId);
|
|
return createMessageResponseDataService().getMessage({
|
|
userId: createUserID(0n),
|
|
channelId,
|
|
messageId,
|
|
access,
|
|
});
|
|
}
|
|
|
|
private async getPriorReportsForMessages(messages: Array<MessageResponse>): Promise<Map<string, Array<string>>> {
|
|
const authorIds = messages.map((message) => createUserID(BigInt(message.author.id)));
|
|
return this.deps.ncmecSubmissionService.getUserPriorReportIds(authorIds);
|
|
}
|
|
|
|
private mapMessageResponseToAdminMessage(
|
|
message: MessageResponse,
|
|
channelNsfwContext: ChannelNsfwContext,
|
|
attachmentStatuses: Map<string, NcmecAttachmentStatusResponse>,
|
|
priorReports: Map<string, Array<string>>,
|
|
) {
|
|
return {
|
|
id: message.id,
|
|
channel_id: message.channel_id ?? '',
|
|
channel_name: channelNsfwContext.channelName,
|
|
channel_nsfw: channelNsfwContext.channelNsfw,
|
|
guild_id: channelNsfwContext.guildId,
|
|
guild_name: channelNsfwContext.guildName,
|
|
guild_nsfw_level: channelNsfwContext.guildNsfwLevel,
|
|
author_id: message.author.id,
|
|
author_username: message.author.username,
|
|
author_global_name: message.author.global_name ?? null,
|
|
author_discriminator: message.author.discriminator,
|
|
author_avatar: message.author.avatar,
|
|
content: message.content ?? '',
|
|
timestamp: message.timestamp,
|
|
user_prior_ncmec_report_ids: priorReports.get(message.author.id) ?? [],
|
|
attachments:
|
|
message.attachments?.map((attachment) => ({
|
|
id: attachment.id,
|
|
filename: attachment.filename,
|
|
url: attachment.url,
|
|
nsfw: attachment.nsfw ?? null,
|
|
content_type: attachment.content_type ?? null,
|
|
width: attachment.width ?? null,
|
|
height: attachment.height ?? null,
|
|
size: attachment.size == null ? null : assertSafeByteSize(attachment.size, 'admin message attachment size'),
|
|
ncmec_status: attachmentStatuses.get(attachment.id)?.status ?? 'not_submitted',
|
|
ncmec_report_id: attachmentStatuses.get(attachment.id)?.ncmec_report_id ?? null,
|
|
ncmec_failure_reason: attachmentStatuses.get(attachment.id)?.failure_reason ?? null,
|
|
})) ?? [],
|
|
};
|
|
}
|
|
|
|
private async getChannelNsfwContext(channelId: ChannelID): Promise<ChannelNsfwContext> {
|
|
const {channelRepository, guildRepository} = this.deps;
|
|
const channel = await channelRepository.findUnique(channelId);
|
|
if (!channel) {
|
|
return {
|
|
channelNsfw: null,
|
|
guildNsfwLevel: null,
|
|
channelName: null,
|
|
guildId: null,
|
|
guildName: null,
|
|
};
|
|
}
|
|
if (!channel.guildId) {
|
|
return {
|
|
channelNsfw: channel.isNsfw,
|
|
guildNsfwLevel: null,
|
|
channelName: channel.name ?? null,
|
|
guildId: null,
|
|
guildName: null,
|
|
};
|
|
}
|
|
const guild = await guildRepository.findUnique(channel.guildId);
|
|
return {
|
|
channelNsfw: channel.isNsfw,
|
|
guildNsfwLevel: guild?.nsfwLevel ?? null,
|
|
channelName: channel.name ?? null,
|
|
guildId: channel.guildId.toString(),
|
|
guildName: guild?.name ?? null,
|
|
};
|
|
}
|
|
}
|