mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(reports): add server-driven report flows (#3330)
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user