feat(reports): add server-driven report flows (#3330)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:38:00 +02:00
1 parent bc17922b02
commit ece622e800
471 files changed
+56262 -35932

No files matched your search

@@ -2,7 +2,10 @@
import type {ApiContext} from '@app/api/ApiContext';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {trySendAdminNotification} from '@app/api/admin/services/AdminNotification';
import {
describeReporterNotice,
type ReporterResolutionNotifier,
} from '@app/api/admin/services/ReporterResolutionNotifier';
import {
type ChannelID,
createReportID,
@@ -13,7 +16,6 @@ import {
} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import {makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers';
import {
createMessageResponseDataService,
@@ -22,29 +24,35 @@ import {
messageResponseAccessForGuild,
} from '@app/api/channel/services/message/MessageResponseDataService';
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
import {SYSTEM_THREAD_VIEWER} from '@app/api/experiment/ChannelThreadsGate';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {Logger} from '@app/api/Logger';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {describeReportFlowAnswers} from '@app/api/report/flows/ReportFlowRegistry';
import {findReportReason, listReportReasons} from '@app/api/report/flows/ReportReasonCatalog';
import type {IARMessageContext, IARSubmission} from '@app/api/report/IReportRepository';
import type {ReportService} from '@app/api/report/ReportService';
import {getReportSearchService} from '@app/api/SearchFactory';
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
import {formatUserTag} from '@app/api/user/UserTag';
import {assertSafeByteSize} from '@app/api/utils/ByteSizeUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {DELETED_USER_DISCRIMINATOR, DELETED_USER_USERNAME} from '@fluxer/constants/src/UserConstants';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import type {SearchReportsRequest, UpdateReportRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
import type {ReportSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {
AdminReportReasonsResponse,
SearchReportsRequest,
UpdateReportRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
import type {ReportProfileAssetSnapshot} from '@fluxer/schema/src/domains/report/ReportProfileSnapshotSchemas';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {seconds} from 'itty-time';
interface AdminReportServiceDeps {
@@ -52,25 +60,68 @@ interface AdminReportServiceDeps {
reportService: ReportService;
guildRepository: IGuildRepositoryAggregate;
channelRepository: IChannelRepository;
channelService: ChannelService;
storageService: IStorageService;
auditService: AdminAuditService;
userCacheService: UserCacheService;
userChannelService: UserChannelService;
ncmecSubmissionService: NcmecSubmissionService;
reporterResolutionNotifier: ReporterResolutionNotifier;
}
type StaffReportResolution = NonNullable<UpdateReportRequest['resolution']>;
interface ReportNsfwLookupCache {
interface ReportLookupCache {
channelNsfwByChannelId: Map<string, boolean | null>;
guildNsfwLevelByGuildId: Map<string, number | null>;
reporterEmailByReporterId: Map<string, Promise<string | null>>;
}
function createReportNsfwLookupCache(): ReportNsfwLookupCache {
function createReportLookupCache(): ReportLookupCache {
return {
channelNsfwByChannelId: new Map(),
guildNsfwLevelByGuildId: new Map(),
reporterEmailByReporterId: new Map(),
};
}
function mapUserPartialBotFlag(user: UserPartialResponse): boolean | null {
if (user.bot) {
return true;
}
const isUnresolved =
user.username === DELETED_USER_USERNAME &&
user.discriminator === DELETED_USER_DISCRIMINATOR.toString().padStart(4, '0');
return isUnresolved ? null : false;
}
function mapMissingAttachmentToResponse(attachment: MessageAttachment) {
return {
id: attachment.attachment_id.toString(),
filename: String(attachment.filename),
nsfw: attachment.nsfw ?? null,
content_type: attachment.content_type ?? null,
width: attachment.width ?? null,
height: attachment.height ?? null,
size: attachment.size != null ? assertSafeByteSize(attachment.size, 'admin report attachment size') : null,
};
}
function mapReportReasonFields(report: IARSubmission) {
const reason = report.reason ? findReportReason(report.reason) : null;
const flow =
report.flowSteps && report.flowRevision && report.flowSurface
? describeReportFlowAnswers({
revisionHash: report.flowRevision,
surface: report.flowSurface,
locale: report.flowLocale,
steps: report.flowSteps,
})
: null;
return {
reason: report.reason,
reason_label: reason?.label ?? report.reason,
reason_highest_priority: reason?.highestPriority ?? null,
flow,
reporter_good_faith_confirmed: report.reporterGoodFaithConfirmed,
};
}
@@ -83,10 +134,10 @@ export class AdminReportService {
const currentOffset = offset || 0;
const {reports, total} = await reportService.listReportsByStatus(status, requestedLimit, currentOffset);
const requestCache = createRequestCache();
const reportNsfwLookupCache = createReportNsfwLookupCache();
const reportLookupCache = createReportLookupCache();
const reportResponses = await Promise.all(
reports.map((report: IARSubmission) =>
this.mapReportToResponse(report, false, requestCache, acls, reportNsfwLookupCache),
this.mapReportToResponse(report, false, requestCache, acls, reportLookupCache),
),
);
return {
@@ -97,12 +148,25 @@ export class AdminReportService {
};
}
listReportReasons(): AdminReportReasonsResponse {
return {
reasons: listReportReasons().map((reason) => ({
key: reason.key,
label: reason.label,
highest_priority: reason.highestPriority,
legacy_category_message: reason.legacyCategories.message,
legacy_category_user: reason.legacyCategories.user,
legacy_category_guild: reason.legacyCategories.guild,
})),
};
}
async getReport(reportId: ReportID, acls: ReadonlySet<string>) {
const {reportService} = this.deps;
const report = await reportService.getReport(reportId);
const requestCache = createRequestCache();
const reportNsfwLookupCache = createReportNsfwLookupCache();
return this.mapReportToResponse(report, true, requestCache, acls, reportNsfwLookupCache);
const reportLookupCache = createReportLookupCache();
return this.mapReportToResponse(report, true, requestCache, acls, reportLookupCache);
}
async resolveReport(
@@ -113,38 +177,14 @@ export class AdminReportService {
notifyReporter: boolean,
resolution?: StaffReportResolution,
) {
const {reportService, auditService} = this.deps;
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason, {
const {reportService, auditService, reporterResolutionNotifier} = this.deps;
const sentComment = notifyReporter ? publicComment : null;
const internalComment = notifyReporter ? null : publicComment;
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, sentComment, auditLogReason, {
outcome: resolution,
resolvedBy: 'staff',
});
let reporterDmSent = false;
let reporterEmailSent = false;
const reporter =
notifyReporter && resolvedReport.reporterId ? await userRepository.findUnique(resolvedReport.reporterId) : null;
if (reporter) {
const commentForTemplate = publicComment ?? '';
reporterDmSent = await this.sendResolvedReportSystemDm({
reporter,
reportId,
publicComment: commentForTemplate,
});
const email = reporter.email;
if (email) {
reporterEmailSent = await trySendAdminNotification(
() =>
emailService.sendReportResolvedEmail(
email,
reporter.username,
reportId.toString(),
commentForTemplate,
reporter.locale,
),
{action: 'resolve_report', targetId: reportId.toString()},
);
}
}
const notice = await reporterResolutionNotifier.notifyReporterOfResolution(resolvedReport, sentComment);
await auditService.createAuditLog({
adminUserId,
targetType: 'report',
@@ -155,8 +195,8 @@ export class AdminReportService {
['report_id', reportId.toString()],
['report_type', resolvedReport.reportType.toString()],
['notify_reporter', notifyReporter ? 'true' : 'false'],
['reporter_dm_sent', reporterDmSent ? 'true' : 'false'],
['reporter_email_sent', reporterEmailSent ? 'true' : 'false'],
...(internalComment ? [['internal_comment', internalComment] as [string, string]] : []),
...describeReporterNotice(notice),
...(resolution ? [['resolution', resolution] as [string, string]] : []),
]),
});
@@ -168,135 +208,91 @@ export class AdminReportService {
};
}
private async sendResolvedReportSystemDm({
reporter,
reportId,
publicComment,
}: {
reporter: User;
reportId: ReportID;
publicComment: string;
}): Promise<boolean> {
const {users: userRepository} = this.deps.apiContext.services;
const template = getEmailTemplate('report_resolved', reporter.locale, {
username: reporter.username,
reportId: reportId.toString(),
publicComment,
hasComment: publicComment ? 'yes' : 'no',
});
if (!template.ok) {
Logger.warn(
{
reportId: reportId.toString(),
reporterId: reporter.id.toString(),
locale: reporter.locale,
error: template.error,
},
'Skipping report review system DM because the email template could not be resolved',
);
return false;
}
const requestCache = createRequestCache();
try {
const systemUser = await userRepository.findUniqueAssert(SYSTEM_USER_ID);
const dmChannel = await this.deps.userChannelService.ensureDmOpenForBothUsers({
userId: systemUser.id,
recipientId: reporter.id,
userCacheService: this.deps.userCacheService,
requestCache,
});
await this.deps.channelService.messages.send.sendMessage({
user: systemUser,
viewer: SYSTEM_THREAD_VIEWER,
channelId: dmChannel.id,
data: {
content: template.value.body,
},
requestCache,
});
return true;
} catch (error) {
Logger.warn(
{reportId: reportId.toString(), reporterId: reporter.id.toString(), error},
'Failed to send report review system DM',
);
return false;
} finally {
requestCache.clear();
}
}
async searchReports(data: SearchReportsRequest, acls: ReadonlySet<string>) {
const reportSearchService = getReportSearchService();
if (!reportSearchService) {
throw new FeatureTemporarilyDisabledError();
}
const filters: Record<string, string | number> = {};
const filters: ReportSearchFilters = {};
if (data.reporter_id !== undefined) {
filters['reporterId'] = data.reporter_id.toString();
filters.reporterId = data.reporter_id.toString();
}
if (data.status !== undefined) {
filters['status'] = data.status;
filters.status = data.status;
}
if (data.report_type !== undefined) {
filters['reportType'] = data.report_type;
filters.reportType = data.report_type;
}
if (data.category !== undefined) {
filters['category'] = data.category;
filters.category = data.category;
}
if (data.reason !== undefined) {
filters.reason = data.reason;
}
if (data.reported_user_id !== undefined) {
filters['reportedUserId'] = data.reported_user_id.toString();
filters.reportedUserId = data.reported_user_id.toString();
}
if (data.reported_webhook_id !== undefined) {
filters.reportedWebhookId = data.reported_webhook_id.toString();
}
if (data.reported_guild_id !== undefined) {
filters['reportedGuildId'] = data.reported_guild_id.toString();
filters.reportedGuildId = data.reported_guild_id.toString();
}
if (data.reported_channel_id !== undefined) {
filters['reportedChannelId'] = data.reported_channel_id.toString();
filters.reportedChannelId = data.reported_channel_id.toString();
}
if (data.guild_context_id !== undefined) {
filters['guildContextId'] = data.guild_context_id.toString();
filters.guildContextId = data.guild_context_id.toString();
}
if (data.resolved_by_admin_id !== undefined) {
filters['resolvedByAdminId'] = data.resolved_by_admin_id.toString();
filters.resolvedByAdminId = data.resolved_by_admin_id.toString();
}
if (data.sort_by) {
filters['sortBy'] = data.sort_by;
filters.sortBy = data.sort_by;
}
if (data.sort_order) {
filters['sortOrder'] = data.sort_order;
filters.sortOrder = data.sort_order;
}
const {hits, total} = await reportSearchService.searchReports(data.query || '', filters, {
limit: data.limit,
offset: data.offset,
});
const requestCache = createRequestCache();
const reportNsfwLookupCache = createReportNsfwLookupCache();
const orderedReports = await this.loadReportsInSearchOrder(hits.map((hit) => createReportID(BigInt(hit.id))));
const reportLookupCache = createReportLookupCache();
const reportIds = hits.map((hit) => createReportID(BigInt(hit.id)));
const loaded = await this.loadReportsInSearchOrder(reportIds);
const orderedReports = loaded.filter((report): report is IARSubmission => report !== null);
const orphanedReportIds = reportIds.filter((_, index) => loaded[index] === null).map((id) => id.toString());
if (orphanedReportIds.length > 0) {
Logger.warn(
{orphanedReportIds},
'Report search index lists reports that are no longer stored, run refresh_search_index reports',
);
}
const reports = await Promise.all(
orderedReports.map((report) =>
this.mapReportToResponse(report, false, requestCache, acls, reportNsfwLookupCache),
),
orderedReports.map((report) => this.mapReportToResponse(report, false, requestCache, acls, reportLookupCache)),
);
const missingCount = hits.length - orderedReports.length;
return {
reports,
total: Math.max(0, total - missingCount),
total,
offset: data.offset,
limit: data.limit,
};
}
private async loadReportsInSearchOrder(reportIds: Array<ReportID>): Promise<Array<IARSubmission>> {
const reports = await Promise.all(
private loadReportsInSearchOrder(reportIds: Array<ReportID>): Promise<Array<IARSubmission | null>> {
return Promise.all(
reportIds.map(async (reportId) => {
try {
return await this.deps.reportService.getReport(reportId);
} catch (_error) {
return null;
} catch (error) {
if (error instanceof UnknownReportError) {
return null;
}
throw error;
}
}),
);
return reports.filter((report): report is IARSubmission => report !== null);
}
private async mapReportToResponse(
@@ -304,7 +300,7 @@ export class AdminReportService {
includeContext: boolean,
requestCache: RequestCache,
acls: ReadonlySet<string>,
reportNsfwLookupCache: ReportNsfwLookupCache,
reportLookupCache: ReportLookupCache,
) {
const reporterInfo = await this.buildUserTag(report.reporterId, requestCache);
const reportedUserInfo = await this.buildUserTag(report.reportedUserId, requestCache);
@@ -317,12 +313,12 @@ export class AdminReportService {
: await this.getGuildNsfwLevelForContext(
report.reportedChannelId,
report.reportedGuildId ?? report.guildContextId ?? null,
reportNsfwLookupCache,
reportLookupCache,
);
const reportedChannelNsfw =
report.reportedChannelEffectiveNsfw !== null
? report.reportedChannelEffectiveNsfw
: await this.getChannelNsfwState(report.reportedChannelId, reportNsfwLookupCache);
: await this.getChannelNsfwState(report.reportedChannelId, reportLookupCache);
const baseResponse = {
report_id: report.reportId.toString(),
reporter_id: report.reporterId?.toString() ?? null,
@@ -330,13 +326,14 @@ export class AdminReportService {
reporter_username: reporterInfo?.username ?? null,
reporter_global_name: reporterInfo?.global_name ?? null,
reporter_discriminator: reporterInfo?.discriminator ?? null,
reporter_email: canViewReporterPii ? report.reporterEmail : null,
reporter_email: canViewReporterPii ? await this.getReporterEmail(report, reportLookupCache) : null,
reporter_full_legal_name: canViewReporterPii ? report.reporterFullLegalName : null,
reporter_country_of_residence: canViewReporterPii ? report.reporterCountryOfResidence : null,
reported_at: report.reportedAt.toISOString(),
status: report.status,
report_type: report.reportType,
category: report.category,
...mapReportReasonFields(report),
additional_info: report.additionalInfo,
reported_user_id: report.reportedUserId?.toString() ?? null,
reported_user_tag: reportedUserInfo?.tag ?? null,
@@ -344,6 +341,18 @@ export class AdminReportService {
reported_user_global_name: reportedUserInfo?.global_name ?? null,
reported_user_discriminator: reportedUserInfo?.discriminator ?? null,
reported_user_avatar_hash: report.reportedUserAvatarHash,
reported_user_bot: reportedUserInfo?.bot ?? null,
reported_webhook_id: report.reportedWebhookId?.toString() ?? null,
reported_webhook_name: report.reportedWebhookName,
reported_webhook_avatar_hash: report.reportedWebhookAvatarHash,
reported_webhook_default_name: report.reportedWebhookDefaultName,
reported_webhook_default_avatar_hash: report.reportedWebhookDefaultAvatarHash,
reported_webhook_type: report.reportedWebhookType,
reported_webhook_application_id: report.reportedWebhookApplicationId?.toString() ?? null,
reported_webhook_channel_id: report.reportedWebhookChannelId?.toString() ?? null,
reported_webhook_guild_id: report.reportedWebhookGuildId?.toString() ?? null,
reported_webhook_created_at: report.reportedWebhookCreatedAt?.toISOString() ?? null,
...mapWebhookCreatorFields(report),
reported_guild_id: report.reportedGuildId?.toString() ?? null,
reported_guild_name: report.reportedGuildName,
reported_guild_icon_hash: report.reportedGuildIconHash,
@@ -371,21 +380,22 @@ export class AdminReportService {
}
const attachmentStatusesById = await this.getAttachmentStatusesById(report);
const priorReportsByAuthor = await this.getPriorReportsForContext(report);
const messageContext =
report.messageContext && report.messageContext.length > 0
? await Promise.all(
report.messageContext.map((message) =>
this.mapReportMessageContextToResponse(
message,
report.reportedChannelId ?? null,
report.reportedGuildId ?? report.guildContextId ?? null,
reportNsfwLookupCache,
attachmentStatusesById,
priorReportsByAuthor,
),
),
)
: [];
const authorBotFlags = await this.getContextAuthorBotFlags(report, requestCache);
const messageContext = (
await Promise.all(
(report.messageContext ?? []).map((message) =>
this.mapReportMessageContextToResponse(
message,
report.reportedChannelId ?? null,
report.reportedGuildId ?? report.guildContextId ?? null,
reportLookupCache,
attachmentStatusesById,
priorReportsByAuthor,
authorBotFlags,
),
),
)
).filter((message) => message !== null);
const messageResponses = await this.getLiveMessageResponsesForContext(report);
const mutualDmChannelId = await this.getMutualDmChannelId(report);
return {
@@ -393,9 +403,117 @@ export class AdminReportService {
mutual_dm_channel_id: mutualDmChannelId,
message_context: messageContext,
message_responses: messageResponses,
reported_profile_snapshot: await this.mapProfileSnapshotToResponse(report),
legal_hold_until: report.legalHoldUntil?.toISOString() ?? null,
legal_hold_reason: report.legalHoldReason,
};
}
private getReporterEmail(report: IARSubmission, reportLookupCache: ReportLookupCache): Promise<string | null> {
const reporterId = report.reporterId;
if (!reporterId) {
return Promise.resolve(report.reporterEmail);
}
const key = reporterId.toString();
const cached = reportLookupCache.reporterEmailByReporterId.get(key);
if (cached) {
return cached;
}
const email = this.deps.apiContext.services.users.findUnique(reporterId).then((user) => user?.email ?? null);
reportLookupCache.reporterEmailByReporterId.set(key, email);
return email;
}
private async getContextAuthorBotFlags(
report: IARSubmission,
requestCache: RequestCache,
): Promise<Map<string, boolean | null>> {
const authorIds = [
...new Set((report.messageContext ?? []).flatMap((message) => (message.authorId ? [message.authorId] : []))),
];
if (authorIds.length === 0) {
return new Map();
}
try {
const partials = await this.deps.userCacheService.getUserPartialResponses(authorIds, requestCache);
return new Map([...partials].map(([userId, partial]) => [userId.toString(), mapUserPartialBotFlag(partial)]));
} catch (error) {
Logger.warn({reportId: report.reportId.toString(), error}, 'Failed to resolve author bot flags for report');
return new Map();
}
}
private async mapProfileSnapshotToResponse(report: IARSubmission) {
const snapshot = report.reportedProfileSnapshot;
if (!snapshot) {
return null;
}
const asset = (value: ReportProfileAssetSnapshot | null) => this.mapProfileSnapshotAsset(report.reportId, value);
const {user, member, guild} = snapshot;
return {
captured_at: snapshot.captured_at,
user: user
? {
id: user.id,
username: user.username,
discriminator: user.discriminator === null ? null : user.discriminator.toString().padStart(4, '0'),
global_name: user.global_name,
bio: user.bio,
pronouns: user.pronouns,
avatar: await asset(user.avatar),
banner: await asset(user.banner),
}
: null,
member: member
? {
guild_id: member.guild_id,
nick: member.nick,
bio: member.bio,
pronouns: member.pronouns,
joined_at: member.joined_at,
avatar: await asset(member.avatar),
banner: await asset(member.banner),
}
: null,
guild: guild
? {
id: guild.id,
name: guild.name,
vanity_url_code: guild.vanity_url_code,
icon: await asset(guild.icon),
banner: await asset(guild.banner),
splash: await asset(guild.splash),
}
: null,
};
}
private async mapProfileSnapshotAsset(
reportId: ReportID,
asset: ReportProfileAssetSnapshot | null,
): Promise<{hash: string; url: string | null} | null> {
if (!asset) {
return null;
}
if (!asset.key) {
return {hash: asset.hash, url: null};
}
try {
const url = await this.deps.storageService.getPresignedDownloadURL({
bucket: Config.s3.buckets.reports,
key: asset.key,
expiresIn: seconds('5 minutes'),
});
return {hash: asset.hash, url};
} catch (error) {
Logger.error(
{error, reportId: reportId.toString(), key: asset.key},
'Failed to generate presigned URL for report profile asset',
);
return {hash: asset.hash, url: null};
}
}
private async getLiveMessageResponsesForContext(report: IARSubmission): Promise<Array<MessageResponse>> {
const contexts = report.messageContext ?? [];
if (contexts.length === 0) return [];
@@ -447,13 +565,18 @@ export class AdminReportService {
message: IARMessageContext,
fallbackChannelId: ChannelID | null,
fallbackGuildId: GuildID | null,
reportNsfwLookupCache: ReportNsfwLookupCache,
reportLookupCache: ReportLookupCache,
attachmentStatusesById: Map<string, NcmecAttachmentStatusResponse>,
priorReportsByAuthor: Map<string, Array<string>>,
authorBotFlags: Map<string, boolean | null>,
) {
const channelId = message.channelId ?? fallbackChannelId;
const channelNsfw = await this.getChannelNsfwState(channelId, reportNsfwLookupCache);
const guildNsfwLevel = await this.getGuildNsfwLevelForContext(channelId, fallbackGuildId, reportNsfwLookupCache);
if (!channelId) {
return null;
}
const authorId = (message.authorId ?? message.webhookId ?? 0n).toString();
const channelNsfw = await this.getChannelNsfwState(channelId, reportLookupCache);
const guildNsfwLevel = await this.getGuildNsfwLevelForContext(channelId, fallbackGuildId, reportLookupCache);
const attachments =
message.attachments && message.attachments.length > 0
? (
@@ -482,7 +605,7 @@ export class AdminReportService {
: [];
return {
id: message.messageId.toString(),
channel_id: channelId ? channelId.toString() : '',
channel_id: channelId.toString(),
channel_nsfw: channelNsfw,
channel_content_warning_level: null,
channel_content_warning_text: null,
@@ -494,19 +617,24 @@ export class AdminReportService {
content: message.content ?? '',
timestamp: message.timestamp.toISOString(),
attachments,
author_id: message.authorId.toString(),
author_id: authorId,
author_username: message.authorUsername,
author_global_name: null,
author_discriminator: message.authorDiscriminator.toString().padStart(4, '0'),
author_avatar: message.authorAvatarHash,
user_prior_ncmec_report_ids: priorReportsByAuthor.get(message.authorId.toString()) ?? [],
webhook_id: message.webhookId?.toString() ?? null,
author_bot: message.authorId ? (authorBotFlags.get(authorId) ?? null) : null,
missing_attachments: message.missingAttachments
.filter((attachment) => attachment.attachment_id != null && attachment.filename)
.map(mapMissingAttachmentToResponse),
user_prior_ncmec_report_ids: message.authorId ? (priorReportsByAuthor.get(authorId) ?? []) : [],
};
}
private async getPriorReportsForContext(report: IARSubmission): Promise<Map<string, Array<string>>> {
const authorIds = new Set<string>();
for (const message of report.messageContext ?? []) {
authorIds.add(message.authorId.toString());
if (message.authorId) authorIds.add(message.authorId.toString());
}
if (report.reportedUserId) authorIds.add(report.reportedUserId.toString());
if (authorIds.size === 0) return new Map();
@@ -516,48 +644,48 @@ export class AdminReportService {
private async getChannelNsfwState(
channelId: ChannelID | null,
reportNsfwLookupCache: ReportNsfwLookupCache,
reportLookupCache: ReportLookupCache,
): Promise<boolean | null> {
if (!channelId) {
return null;
}
const channelIdString = channelId.toString();
if (reportNsfwLookupCache.channelNsfwByChannelId.has(channelIdString)) {
return reportNsfwLookupCache.channelNsfwByChannelId.get(channelIdString) ?? null;
if (reportLookupCache.channelNsfwByChannelId.has(channelIdString)) {
return reportLookupCache.channelNsfwByChannelId.get(channelIdString) ?? null;
}
const channel = await this.deps.channelRepository.findUnique(channelId);
const scope = channel
? await resolveNsfwScopeChannel(channel, (id) => this.deps.channelRepository.findUnique(id))
: null;
const channelNsfw = scope?.isNsfw ?? null;
reportNsfwLookupCache.channelNsfwByChannelId.set(channelIdString, channelNsfw);
reportLookupCache.channelNsfwByChannelId.set(channelIdString, channelNsfw);
return channelNsfw;
}
private async getGuildNsfwLevel(
guildId: GuildID | null,
reportNsfwLookupCache: ReportNsfwLookupCache,
reportLookupCache: ReportLookupCache,
): Promise<number | null> {
if (!guildId) {
return null;
}
const guildIdString = guildId.toString();
if (reportNsfwLookupCache.guildNsfwLevelByGuildId.has(guildIdString)) {
return reportNsfwLookupCache.guildNsfwLevelByGuildId.get(guildIdString) ?? null;
if (reportLookupCache.guildNsfwLevelByGuildId.has(guildIdString)) {
return reportLookupCache.guildNsfwLevelByGuildId.get(guildIdString) ?? null;
}
const guild = await this.deps.guildRepository.findUnique(guildId);
const guildNsfwLevel = guild?.nsfwLevel ?? null;
reportNsfwLookupCache.guildNsfwLevelByGuildId.set(guildIdString, guildNsfwLevel);
reportLookupCache.guildNsfwLevelByGuildId.set(guildIdString, guildNsfwLevel);
return guildNsfwLevel;
}
private async getGuildNsfwLevelForContext(
channelId: ChannelID | null,
fallbackGuildId: GuildID | null,
reportNsfwLookupCache: ReportNsfwLookupCache,
reportLookupCache: ReportLookupCache,
): Promise<number | null> {
if (fallbackGuildId) {
return this.getGuildNsfwLevel(fallbackGuildId, reportNsfwLookupCache);
return this.getGuildNsfwLevel(fallbackGuildId, reportLookupCache);
}
if (!channelId) {
return null;
@@ -566,7 +694,7 @@ export class AdminReportService {
if (!channel?.guildId) {
return null;
}
return this.getGuildNsfwLevel(channel.guildId, reportNsfwLookupCache);
return this.getGuildNsfwLevel(channel.guildId, reportLookupCache);
}
private async mapReportAttachmentToResponse(
@@ -637,6 +765,7 @@ export class AdminReportService {
const stored = isHiddenPartial(cached) ? await this.deps.apiContext.services.users.findUnique(userId) : null;
const user = stored
? {
...cached,
username: stored.username,
global_name: stored.globalName,
discriminator: stored.discriminator.toString(),
@@ -653,6 +782,7 @@ export class AdminReportService {
username: user.username,
global_name: user.global_name ?? null,
discriminator,
bot: mapUserPartialBotFlag(user),
};
} catch (error) {
Logger.warn({userId: userId.toString(), error}, 'Failed to resolve user tag for report');
@@ -661,9 +791,32 @@ export class AdminReportService {
}
}
function mapWebhookCreatorFields(report: IARSubmission) {
const creatorDiscriminator = report.reportedWebhookCreatorDiscriminator;
const discriminator = creatorDiscriminator === null ? null : creatorDiscriminator.toString().padStart(4, '0');
const username = report.reportedWebhookCreatorUsername;
const creatorId = report.reportedWebhookCreatorId;
return {
reported_webhook_creator_id: creatorId?.toString() ?? null,
reported_webhook_creator_tag:
username !== null && creatorDiscriminator !== null
? formatUserTag({
username,
discriminator: creatorDiscriminator,
isBot: creatorId !== null && creatorId.toString() === report.reportedWebhookApplicationId?.toString(),
})
: null,
reported_webhook_creator_username: username,
reported_webhook_creator_global_name: report.reportedWebhookCreatorGlobalName,
reported_webhook_creator_discriminator: discriminator,
reported_webhook_creator_avatar_hash: report.reportedWebhookCreatorAvatarHash,
};
}
interface UserTagInfo {
tag: string;
username: string;
global_name: string | null;
discriminator: string;
bot: boolean | null;
}