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,214 @@
/*
* 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 {Index, MeiliSearch} from 'meilisearch';
import type {AdminAuditLog} from '~/admin/IAdminRepository';
import {Logger} from '~/Logger';
import {SEARCH_MAX_TOTAL_HITS} from '~/search/constants';
import {extractTimestamp} from '~/utils/SnowflakeUtils';
const AUDIT_LOG_INDEX_NAME = 'audit_logs';
interface SearchableAuditLog {
logId: string;
adminUserId: string;
targetType: string;
targetId: string;
action: string;
auditLogReason: string | null;
createdAt: number;
}
interface AuditLogSearchFilters {
adminUserId?: string;
targetType?: string;
targetId?: string;
action?: string;
sortBy?: 'createdAt' | 'relevance';
sortOrder?: 'asc' | 'desc';
}
export class AuditLogSearchService {
private meilisearch: MeiliSearch;
private index: Index<SearchableAuditLog> | null = null;
constructor(meilisearch: MeiliSearch) {
this.meilisearch = meilisearch;
}
async initialize(): Promise<void> {
try {
this.index = this.meilisearch.index<SearchableAuditLog>(AUDIT_LOG_INDEX_NAME);
await this.index.updateSettings({
searchableAttributes: ['action', 'auditLogReason', 'targetType', 'adminUserId', 'targetId'],
filterableAttributes: ['adminUserId', 'targetType', 'targetId', 'action', 'createdAt'],
sortableAttributes: ['createdAt'],
rankingRules: ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'],
pagination: {
maxTotalHits: SEARCH_MAX_TOTAL_HITS,
},
});
Logger.debug('Audit log search index initialized successfully');
} catch (error) {
Logger.error({error}, 'Failed to initialize audit log search index');
throw error;
}
}
async indexAuditLog(log: AdminAuditLog): Promise<void> {
if (!this.index) {
throw new Error('Audit log search index not initialized');
}
const searchableLog = this.convertToSearchableAuditLog(log);
try {
await this.index.addDocuments([searchableLog], {primaryKey: 'logId'});
} catch (error) {
Logger.error({logId: log.logId, error}, 'Failed to index audit log');
throw error;
}
}
async indexAuditLogs(logs: Array<AdminAuditLog>): Promise<void> {
if (!this.index) {
throw new Error('Audit log search index not initialized');
}
if (logs.length === 0) return;
const searchableLogs = logs.map((log) => this.convertToSearchableAuditLog(log));
try {
await this.index.addDocuments(searchableLogs, {primaryKey: 'logId'});
} catch (error) {
Logger.error({count: logs.length, error}, 'Failed to index audit logs');
throw error;
}
}
async deleteAuditLog(logId: bigint): Promise<void> {
if (!this.index) {
throw new Error('Audit log search index not initialized');
}
try {
await this.index.deleteDocument(logId.toString());
} catch (error) {
Logger.error({logId, error}, 'Failed to delete audit log from search index');
throw error;
}
}
async searchAuditLogs(
query: string,
filters: AuditLogSearchFilters,
options?: {
limit?: number;
offset?: number;
},
): Promise<{hits: Array<SearchableAuditLog>; total: number}> {
if (!this.index) {
throw new Error('Audit log search index not initialized');
}
const filterStrings = this.buildFilterStrings(filters);
const sortField = this.buildSortField(filters);
try {
const result = await this.index.search(query, {
filter: filterStrings.length > 0 ? filterStrings : undefined,
limit: options?.limit ?? 50,
offset: options?.offset ?? 0,
sort: sortField,
});
return {
hits: result.hits,
total: result.estimatedTotalHits ?? 0,
};
} catch (error) {
Logger.error({query, filters, error}, 'Failed to search audit logs');
throw error;
}
}
async deleteAllDocuments(): Promise<void> {
if (!this.index) {
throw new Error('Audit log search index not initialized');
}
try {
await this.index.deleteAllDocuments();
Logger.debug('All audit log documents deleted from search index');
} catch (error) {
Logger.error({error}, 'Failed to delete all audit log documents');
throw error;
}
}
private buildFilterStrings(filters: AuditLogSearchFilters): Array<string> {
const filterStrings: Array<string> = [];
if (filters.adminUserId) {
filterStrings.push(`adminUserId = "${filters.adminUserId}"`);
}
if (filters.targetType) {
filterStrings.push(`targetType = "${filters.targetType}"`);
}
if (filters.targetId) {
filterStrings.push(`targetId = "${filters.targetId}"`);
}
if (filters.action) {
filterStrings.push(`action = "${filters.action}"`);
}
return filterStrings;
}
private buildSortField(filters: AuditLogSearchFilters): Array<string> {
const sortBy = filters.sortBy ?? 'createdAt';
const sortOrder = filters.sortOrder ?? 'desc';
if (sortBy === 'relevance') {
return [];
}
return [`${sortBy}:${sortOrder}`];
}
private convertToSearchableAuditLog(log: AdminAuditLog): SearchableAuditLog {
const createdAt = Math.floor(extractTimestamp(BigInt(log.logId)) / 1000);
return {
logId: log.logId.toString(),
adminUserId: log.adminUserId.toString(),
targetType: log.targetType,
targetId: log.targetId.toString(),
action: log.action,
auditLogReason: log.auditLogReason,
createdAt,
};
}
}

View File

@@ -0,0 +1,231 @@
/*
* 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 {createChannelID} from '~/BrandedTypes';
import {ChannelTypes} from '~/Constants';
import type {MessageSearchRequest, MessageSearchResponse} from '~/channel/ChannelModel';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import type {ChannelService} from '~/channel/services/ChannelService';
import {type DmSearchScope, getDmChannelIdsForScope} from '~/channel/services/message/dmScopeUtils';
import {FeatureTemporarilyDisabledError, MissingPermissionsError} from '~/Errors';
import type {GuildService} from '~/guild/services/GuildService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {getMessageSearchService} from '~/Meilisearch';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {buildMessageSearchFilters} from '~/search/buildMessageSearchFilters';
import {MessageSearchResponseMapper} from '~/search/MessageSearchResponseMapper';
import type {MessageSearchService} from '~/search/MessageSearchService';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IWorkerService} from '~/worker/IWorkerService';
export class GlobalSearchService {
private readonly responseMapper: MessageSearchResponseMapper;
constructor(
private readonly channelRepository: IChannelRepository,
private readonly channelService: ChannelService,
private readonly guildService: GuildService,
private readonly userRepository: IUserRepository,
private readonly userCacheService: UserCacheService,
private readonly mediaService: IMediaService,
private readonly workerService: IWorkerService,
) {
this.responseMapper = new MessageSearchResponseMapper(
this.channelRepository,
this.channelService,
this.userCacheService,
this.mediaService,
);
}
private getMessageSearchService(): MessageSearchService {
const searchService = getMessageSearchService();
if (!searchService) {
throw new FeatureTemporarilyDisabledError();
}
return searchService;
}
async searchAcrossDms(params: {
userId: UserID;
scope: DmSearchScope;
searchParams: MessageSearchRequest;
requestCache: RequestCache;
includeChannelId?: ChannelID | null;
requestedChannelIds?: Array<ChannelID>;
}): Promise<MessageSearchResponse | {indexing: true}> {
const dmChannelIds = await getDmChannelIdsForScope({
scope: params.scope,
userId: params.userId,
userRepository: this.userRepository,
includeChannelId: params.includeChannelId,
});
const finalChannelIds = this.filterRequestedChannelIds(dmChannelIds, params.requestedChannelIds);
if (finalChannelIds.length === 0) {
const hitsPerPage = params.searchParams.hits_per_page ?? 25;
const page = params.searchParams.page ?? 1;
return {
messages: [],
total: 0,
hits_per_page: hitsPerPage,
page,
};
}
const needsIndexing = await this.ensureChannelsIndexed(finalChannelIds);
if (needsIndexing) {
return {indexing: true};
}
return this.runSearch(finalChannelIds, params.userId, params.searchParams, params.requestCache);
}
async searchAcrossGuildsAndDms(params: {
userId: UserID;
dmScope: DmSearchScope;
searchParams: MessageSearchRequest;
requestCache: RequestCache;
includeChannelId?: ChannelID | null;
requestedChannelIds?: Array<ChannelID>;
}): Promise<MessageSearchResponse | {indexing: true}> {
const {accessibleChannels, unindexedChannelIds} = await this.guildService.collectAccessibleGuildChannels(
params.userId,
);
if (unindexedChannelIds.size > 0) {
await this.queueIndexingChannels(unindexedChannelIds);
return {indexing: true};
}
const guildChannelIds = Array.from(accessibleChannels.keys());
const dmChannelIds = await getDmChannelIdsForScope({
scope: params.dmScope,
userId: params.userId,
userRepository: this.userRepository,
includeChannelId: params.includeChannelId,
});
const combinedChannelSet = new Set<string>([...guildChannelIds, ...dmChannelIds]);
const finalChannelIds = this.filterRequestedChannelIds(Array.from(combinedChannelSet), params.requestedChannelIds);
if (finalChannelIds.length === 0) {
const hitsPerPage = params.searchParams.hits_per_page ?? 25;
const page = params.searchParams.page ?? 1;
return {
messages: [],
total: 0,
hits_per_page: hitsPerPage,
page,
};
}
const needsIndexing = await this.ensureChannelsIndexed(finalChannelIds);
if (needsIndexing) {
return {indexing: true};
}
return this.runSearch(finalChannelIds, params.userId, params.searchParams, params.requestCache);
}
private filterRequestedChannelIds(available: Array<string>, requested?: Array<ChannelID>): Array<string> {
if (!requested || requested.length === 0) {
return available;
}
const availableSet = new Set(available);
const requestedStrings = requested.map((id) => id.toString());
for (const channelId of requestedStrings) {
if (!availableSet.has(channelId)) {
throw new MissingPermissionsError();
}
}
return requestedStrings;
}
private async ensureChannelsIndexed(channelIds: Array<string>): Promise<boolean> {
const unindexed = new Set<string>();
for (const channelId of channelIds) {
const channel = await this.channelRepository.findUnique(createChannelID(BigInt(channelId)));
if (!channel) {
continue;
}
let indexedAt = channel.indexedAt;
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
const persisted = await this.channelRepository.channelData.findUnique(createChannelID(BigInt(channelId)));
if (persisted?.indexedAt) {
indexedAt = persisted.indexedAt;
}
}
if (!indexedAt) {
unindexed.add(channelId);
}
}
if (unindexed.size === 0) {
return false;
}
await this.queueIndexingChannels(unindexed);
return true;
}
private async queueIndexingChannels(channelIds: Iterable<string>): Promise<void> {
await Promise.all(
Array.from(channelIds).map((channelId) =>
this.workerService.addJob(
'indexChannelMessages',
{channelId},
{
jobKey: `indexChannelMessages-${channelId}`,
maxAttempts: 3,
},
),
),
);
}
private async runSearch(
channelIds: Array<string>,
userId: UserID,
searchParams: MessageSearchRequest,
requestCache: RequestCache,
): Promise<MessageSearchResponse | {indexing: true}> {
const searchService = this.getMessageSearchService();
const normalizedSearchParams = {...searchParams, channel_id: undefined};
const filters = buildMessageSearchFilters(normalizedSearchParams, channelIds);
const hitsPerPage = searchParams.hits_per_page ?? 25;
const page = searchParams.page ?? 1;
const result = await searchService.searchMessages(searchParams.content ?? '', filters, {
hitsPerPage,
page,
});
const messageResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
return {
messages: messageResponses,
total: result.total,
hits_per_page: hitsPerPage,
page,
};
}
}

View File

@@ -0,0 +1,281 @@
/*
* 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 {Index, MeiliSearch} from 'meilisearch';
import type {GuildID} from '~/BrandedTypes';
import {Logger} from '~/Logger';
import type {Guild} from '~/Models';
import {SEARCH_MAX_TOTAL_HITS} from '~/search/constants';
import {extractTimestamp} from '~/utils/SnowflakeUtils';
const GUILD_INDEX_NAME = 'guilds';
interface SearchableGuild {
id: string;
ownerId: string;
name: string;
vanityUrlCode: string | null;
iconHash: string | null;
bannerHash: string | null;
splashHash: string | null;
features: Array<string>;
verificationLevel: number;
mfaLevel: number;
nsfwLevel: number;
memberCount: number;
createdAt: number;
}
interface GuildSearchFilters {
ownerId?: string;
minMembers?: number;
maxMembers?: number;
verificationLevel?: number;
mfaLevel?: number;
nsfwLevel?: number;
hasFeature?: Array<string>;
sortBy?: 'createdAt' | 'memberCount' | 'relevance';
sortOrder?: 'asc' | 'desc';
}
export class GuildSearchService {
private meilisearch: MeiliSearch;
private index: Index<SearchableGuild> | null = null;
constructor(meilisearch: MeiliSearch) {
this.meilisearch = meilisearch;
}
async initialize(): Promise<void> {
try {
this.index = this.meilisearch.index<SearchableGuild>(GUILD_INDEX_NAME);
await this.index.updateSettings({
searchableAttributes: ['name', 'id', 'vanityUrlCode', 'ownerId'],
filterableAttributes: [
'ownerId',
'verificationLevel',
'mfaLevel',
'nsfwLevel',
'memberCount',
'features',
'createdAt',
],
sortableAttributes: ['createdAt', 'memberCount'],
rankingRules: ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'],
pagination: {
maxTotalHits: SEARCH_MAX_TOTAL_HITS,
},
});
Logger.debug('Guild search index initialized successfully');
} catch (error) {
Logger.error({error}, 'Failed to initialize guild search index');
throw error;
}
}
async indexGuild(guild: Guild): Promise<void> {
if (!this.index) {
throw new Error('Guild search index not initialized');
}
const searchableGuild = this.convertToSearchableGuild(guild);
try {
await this.index.addDocuments([searchableGuild], {primaryKey: 'id'});
} catch (error) {
Logger.error({guildId: guild.id, error}, 'Failed to index guild');
throw error;
}
}
async indexGuilds(guilds: Array<Guild>): Promise<void> {
if (!this.index) {
throw new Error('Guild search index not initialized');
}
if (guilds.length === 0) return;
const searchableGuilds = guilds.map((guild) => this.convertToSearchableGuild(guild));
try {
await this.index.addDocuments(searchableGuilds, {primaryKey: 'id'});
} catch (error) {
Logger.error({count: guilds.length, error}, 'Failed to index guilds');
throw error;
}
}
async updateGuild(guild: Guild): Promise<void> {
if (!this.index) {
throw new Error('Guild search index not initialized');
}
const searchableGuild = this.convertToSearchableGuild(guild);
try {
await this.index.updateDocuments([searchableGuild], {primaryKey: 'id'});
} catch (error) {
Logger.error({guildId: guild.id, error}, 'Failed to update guild in search index');
throw error;
}
}
async deleteGuild(guildId: GuildID): Promise<void> {
if (!this.index) {
throw new Error('Guild search index not initialized');
}
try {
await this.index.deleteDocument(guildId.toString());
} catch (error) {
Logger.error({guildId, error}, 'Failed to delete guild from search index');
throw error;
}
}
async deleteGuilds(guildIds: Array<GuildID>): Promise<void> {
if (!this.index) {
throw new Error('Guild search index not initialized');
}
if (guildIds.length === 0) return;
try {
await this.index.deleteDocuments(guildIds.map((id) => id.toString()));
} catch (error) {
Logger.error({count: guildIds.length, error}, 'Failed to delete guilds from search index');
throw error;
}
}
async searchGuilds(
query: string,
filters: GuildSearchFilters,
options?: {
limit?: number;
offset?: number;
},
): Promise<{hits: Array<SearchableGuild>; total: number}> {
if (!this.index) {
throw new Error('Guild search index not initialized');
}
const filterStrings = this.buildFilterStrings(filters);
const sortField = this.buildSortField(filters);
try {
const result = await this.index.search(query, {
filter: filterStrings.length > 0 ? filterStrings : undefined,
limit: options?.limit ?? 50,
offset: options?.offset ?? 0,
sort: sortField,
});
return {
hits: result.hits,
total: result.estimatedTotalHits ?? 0,
};
} catch (error) {
Logger.error({query, filters, error}, 'Failed to search guilds');
throw error;
}
}
async deleteAllDocuments(): Promise<void> {
if (!this.index) {
throw new Error('Guild search index not initialized');
}
try {
await this.index.deleteAllDocuments();
Logger.debug('All guild documents deleted from search index');
} catch (error) {
Logger.error({error}, 'Failed to delete all guild documents');
throw error;
}
}
private buildFilterStrings(filters: GuildSearchFilters): Array<string> {
const filterStrings: Array<string> = [];
if (filters.ownerId) {
filterStrings.push(`ownerId = "${filters.ownerId}"`);
}
if (filters.minMembers !== undefined) {
filterStrings.push(`memberCount >= ${filters.minMembers}`);
}
if (filters.maxMembers !== undefined) {
filterStrings.push(`memberCount <= ${filters.maxMembers}`);
}
if (filters.verificationLevel !== undefined) {
filterStrings.push(`verificationLevel = ${filters.verificationLevel}`);
}
if (filters.mfaLevel !== undefined) {
filterStrings.push(`mfaLevel = ${filters.mfaLevel}`);
}
if (filters.nsfwLevel !== undefined) {
filterStrings.push(`nsfwLevel = ${filters.nsfwLevel}`);
}
if (filters.hasFeature && filters.hasFeature.length > 0) {
const featureFilters = filters.hasFeature.map((feature) => `features = "${feature}"`).join(' OR ');
filterStrings.push(`(${featureFilters})`);
}
return filterStrings;
}
private buildSortField(filters: GuildSearchFilters): Array<string> {
const sortBy = filters.sortBy ?? 'createdAt';
const sortOrder = filters.sortOrder ?? 'desc';
if (sortBy === 'relevance') {
return [];
}
return [`${sortBy}:${sortOrder}`];
}
private convertToSearchableGuild(guild: Guild): SearchableGuild {
const createdAt = Math.floor(extractTimestamp(BigInt(guild.id)) / 1000);
return {
id: guild.id.toString(),
ownerId: guild.ownerId.toString(),
name: guild.name,
vanityUrlCode: guild.vanityUrlCode,
iconHash: guild.iconHash,
bannerHash: guild.bannerHash,
splashHash: guild.splashHash,
features: Array.from(guild.features),
verificationLevel: guild.verificationLevel,
mfaLevel: guild.mfaLevel,
nsfwLevel: guild.nsfwLevel,
memberCount: guild.memberCount,
createdAt,
};
}
}

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 {UserID} from '~/BrandedTypes';
import {createChannelID, createMessageID} from '~/BrandedTypes';
import type {MessageSearchResponse} from '~/channel/ChannelModel';
import {mapMessageToResponse} from '~/channel/ChannelModel';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import type {ChannelService} from '~/channel/services/ChannelService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Message} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
export class MessageSearchResponseMapper {
constructor(
private readonly channelRepository: IChannelRepository,
private readonly channelService: ChannelService,
private readonly userCacheService: UserCacheService,
private readonly mediaService: IMediaService,
) {}
async mapSearchResultToResponses(
result: {hits: Array<{channelId: string; id: string}>; total: number},
userId: UserID,
requestCache: RequestCache,
): Promise<Array<MessageSearchResponse['messages'][number]>> {
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((message): message is Message => message !== null);
const messageResponses = await Promise.all(
validMessages.map((message) =>
mapMessageToResponse({
message,
currentUserId: userId,
userCacheService: this.userCacheService,
requestCache,
mediaService: this.mediaService,
getReactions: (channelId, messageId) =>
this.channelService.getMessageReactions({
userId,
channelId,
messageId,
}),
}),
),
);
return messageResponses;
}
}

View File

@@ -0,0 +1,565 @@
/*
* 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 {Index, MeiliSearch, SearchResponse} from 'meilisearch';
import type {GuildID, MessageID, UserID} from '~/BrandedTypes';
import {Logger} from '~/Logger';
import type {Message} from '~/Models';
import {SEARCH_MAX_TOTAL_HITS} from '~/search/constants';
import {extractTimestamp} from '~/utils/SnowflakeUtils';
const MESSAGE_INDEX_NAME = 'messages';
interface SearchableMessage {
id: string;
channelId: string;
authorId: string | null;
authorType: 'user' | 'bot' | 'webhook';
content: string | null;
createdAt: number;
editedAt: number | null;
isPinned: boolean;
mentionedUserIds: Array<string>;
mentionEveryone: boolean;
hasLink: boolean;
hasEmbed: boolean;
hasPoll: boolean;
hasFile: boolean;
hasVideo: boolean;
hasImage: boolean;
hasSound: boolean;
hasSticker: boolean;
hasForward: boolean;
embedTypes: Array<string>;
embedProviders: Array<string>;
linkHostnames: Array<string>;
attachmentFilenames: Array<string>;
attachmentExtensions: Array<string>;
}
export interface MessageSearchFilters {
maxId?: string;
minId?: string;
content?: string;
contents?: Array<string>;
channelId?: string;
channelIds?: Array<string>;
excludeChannelIds?: Array<string>;
authorId?: Array<string>;
authorType?: Array<string>;
excludeAuthorType?: Array<string>;
excludeAuthorIds?: Array<string>;
mentions?: Array<string>;
excludeMentions?: Array<string>;
mentionEveryone?: boolean;
pinned?: boolean;
has?: Array<string>;
excludeHas?: Array<string>;
embedType?: Array<'image' | 'video' | 'sound' | 'article'>;
excludeEmbedTypes?: Array<'image' | 'video' | 'sound' | 'article'>;
embedProvider?: Array<string>;
excludeEmbedProviders?: Array<string>;
linkHostname?: Array<string>;
excludeLinkHostnames?: Array<string>;
attachmentFilename?: Array<string>;
excludeAttachmentFilenames?: Array<string>;
attachmentExtension?: Array<string>;
excludeAttachmentExtensions?: Array<string>;
sortBy?: 'timestamp' | 'relevance';
sortOrder?: 'asc' | 'desc';
includeNsfw?: boolean;
}
export class MessageSearchService {
private meilisearch: MeiliSearch;
private index: Index<SearchableMessage> | null = null;
constructor(meilisearch: MeiliSearch) {
this.meilisearch = meilisearch;
}
async initialize(): Promise<void> {
try {
this.index = this.meilisearch.index<SearchableMessage>(MESSAGE_INDEX_NAME);
await this.index.updateSettings({
searchableAttributes: ['content'],
filterableAttributes: [
'channelId',
'authorId',
'authorType',
'isPinned',
'mentionedUserIds',
'mentionEveryone',
'hasLink',
'hasEmbed',
'hasPoll',
'hasFile',
'hasVideo',
'hasImage',
'hasSound',
'hasSticker',
'hasForward',
'createdAt',
'embedTypes',
'embedProviders',
'linkHostnames',
'attachmentFilenames',
'attachmentExtensions',
],
sortableAttributes: ['createdAt', 'editedAt'],
rankingRules: ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'],
pagination: {
maxTotalHits: SEARCH_MAX_TOTAL_HITS,
},
});
Logger.debug('Message search index initialized successfully');
} catch (error) {
Logger.error({error}, 'Failed to initialize message search index');
throw error;
}
}
async indexMessage(message: Message, authorIsBot?: boolean): Promise<void> {
if (!this.index) {
throw new Error('Message search index not initialized');
}
const searchableMessage = this.convertToSearchableMessage(message, authorIsBot);
try {
await this.index.addDocuments([searchableMessage], {primaryKey: 'id'});
} catch (error) {
Logger.error({messageId: message.id, error}, 'Failed to index message');
throw error;
}
}
async indexMessages(messages: Array<Message>, authorBotMap?: Map<UserID, boolean>): Promise<void> {
if (!this.index) {
throw new Error('Message search index not initialized');
}
if (messages.length === 0) return;
const searchableMessages = messages.map((msg) => {
const isBot = msg.authorId ? (authorBotMap?.get(msg.authorId) ?? false) : false;
return this.convertToSearchableMessage(msg, isBot);
});
try {
await this.index.addDocuments(searchableMessages, {primaryKey: 'id'});
} catch (error) {
Logger.error({count: messages.length, error}, 'Failed to index messages');
throw error;
}
}
async updateMessage(message: Message, authorIsBot?: boolean): Promise<void> {
if (!this.index) {
throw new Error('Message search index not initialized');
}
const searchableMessage = this.convertToSearchableMessage(message, authorIsBot);
try {
await this.index.updateDocuments([searchableMessage], {primaryKey: 'id'});
} catch (error) {
Logger.error({messageId: message.id, error}, 'Failed to update message in search index');
throw error;
}
}
async deleteMessage(messageId: MessageID): Promise<void> {
if (!this.index) {
throw new Error('Message search index not initialized');
}
try {
await this.index.deleteDocument(messageId.toString());
} catch (error) {
Logger.error({messageId, error}, 'Failed to delete message from search index');
throw error;
}
}
async deleteMessages(messageIds: Array<MessageID>): Promise<void> {
if (!this.index) {
throw new Error('Message search index not initialized');
}
if (messageIds.length === 0) return;
try {
await this.index.deleteDocuments(messageIds.map((id) => id.toString()));
} catch (error) {
Logger.error({count: messageIds.length, error}, 'Failed to delete messages from search index');
throw error;
}
}
async deleteGuildMessages(guildId: GuildID): Promise<void> {
if (!this.index) {
throw new Error('Message search index not initialized');
}
try {
await this.index.deleteDocuments({
filter: [`channelId IN ${guildId.toString()}`],
});
} catch (error) {
Logger.error({guildId, error}, 'Failed to delete guild messages from search index');
throw error;
}
}
async searchMessages(
query: string,
filters: MessageSearchFilters,
options?: {
hitsPerPage?: number;
page?: number;
},
): Promise<{hits: Array<SearchableMessage>; total: number}> {
if (!this.index) {
throw new Error('Message search index not initialized');
}
const searchQuery = this.buildSearchQuery(query, filters);
const filterStrings = this.buildFilterStrings(filters);
const sortField = this.buildSortField(filters);
try {
const rawResult = (await this.index.search<SearchableMessage>(searchQuery, {
filter: filterStrings.length > 0 ? filterStrings : undefined,
hitsPerPage: options?.hitsPerPage ?? 25,
page: options?.page ?? 1,
sort: sortField,
})) as SearchResponse<SearchableMessage>;
const totalHits = rawResult.totalHits ?? rawResult.estimatedTotalHits ?? rawResult.hits.length;
return {
hits: rawResult.hits,
total: totalHits,
};
} catch (error) {
Logger.error({query: searchQuery, filters, error}, 'Failed to search messages');
throw error;
}
}
private buildSearchQuery(query: string, filters: MessageSearchFilters): string {
if (filters.contents && filters.contents.length > 0) {
const contentTerms = filters.contents.join(' ');
return query ? `${query} ${contentTerms}` : contentTerms;
}
if (filters.content) {
return query ? `${query} ${filters.content}` : filters.content;
}
return query;
}
private buildFilterStrings(filters: MessageSearchFilters): Array<string> {
const filterStrings: Array<string> = [];
if (filters.channelId) {
filterStrings.push(`channelId = "${filters.channelId}"`);
}
if (filters.channelIds && filters.channelIds.length > 0) {
this.addOrFilter(filterStrings, 'channelId', filters.channelIds);
}
if (filters.excludeChannelIds && filters.excludeChannelIds.length > 0) {
this.addExcludeFilter(filterStrings, 'channelId', filters.excludeChannelIds);
}
if (filters.authorId && filters.authorId.length > 0) {
this.addOrFilter(filterStrings, 'authorId', filters.authorId);
}
if (filters.excludeAuthorIds && filters.excludeAuthorIds.length > 0) {
this.addExcludeFilter(filterStrings, 'authorId', filters.excludeAuthorIds);
}
if (filters.authorType && filters.authorType.length > 0) {
this.addAuthorTypeFilter(filterStrings, filters.authorType);
}
if (filters.excludeAuthorType && filters.excludeAuthorType.length > 0) {
this.addExcludeFilter(filterStrings, 'authorType', filters.excludeAuthorType);
}
if (filters.mentions && filters.mentions.length > 0) {
this.addOrFilter(filterStrings, 'mentionedUserIds', filters.mentions);
}
if (filters.excludeMentions && filters.excludeMentions.length > 0) {
this.addExcludeFilter(filterStrings, 'mentionedUserIds', filters.excludeMentions);
}
if (filters.mentionEveryone !== undefined) {
filterStrings.push(`mentionEveryone = ${filters.mentionEveryone}`);
}
if (filters.pinned !== undefined) {
filterStrings.push(`isPinned = ${filters.pinned}`);
}
if (filters.has && filters.has.length > 0) {
this.addHasFilter(filterStrings, filters.has);
}
if (filters.excludeHas && filters.excludeHas.length > 0) {
this.addHasExcludeFilter(filterStrings, filters.excludeHas);
}
if (filters.embedType && filters.embedType.length > 0) {
this.addOrFilter(filterStrings, 'embedTypes', filters.embedType);
}
if (filters.excludeEmbedTypes && filters.excludeEmbedTypes.length > 0) {
this.addExcludeFilter(filterStrings, 'embedTypes', filters.excludeEmbedTypes);
}
if (filters.embedProvider && filters.embedProvider.length > 0) {
this.addOrFilter(filterStrings, 'embedProviders', filters.embedProvider);
}
if (filters.excludeEmbedProviders && filters.excludeEmbedProviders.length > 0) {
this.addExcludeFilter(filterStrings, 'embedProviders', filters.excludeEmbedProviders);
}
if (filters.linkHostname && filters.linkHostname.length > 0) {
this.addOrFilter(filterStrings, 'linkHostnames', filters.linkHostname);
}
if (filters.excludeLinkHostnames && filters.excludeLinkHostnames.length > 0) {
this.addExcludeFilter(filterStrings, 'linkHostnames', filters.excludeLinkHostnames);
}
if (filters.attachmentFilename && filters.attachmentFilename.length > 0) {
this.addOrFilter(filterStrings, 'attachmentFilenames', filters.attachmentFilename);
}
if (filters.excludeAttachmentFilenames && filters.excludeAttachmentFilenames.length > 0) {
this.addExcludeFilter(filterStrings, 'attachmentFilenames', filters.excludeAttachmentFilenames);
}
if (filters.attachmentExtension && filters.attachmentExtension.length > 0) {
this.addOrFilter(filterStrings, 'attachmentExtensions', filters.attachmentExtension);
}
if (filters.excludeAttachmentExtensions && filters.excludeAttachmentExtensions.length > 0) {
this.addExcludeFilter(filterStrings, 'attachmentExtensions', filters.excludeAttachmentExtensions);
}
if (filters.maxId) {
const timestamp = Math.floor(extractTimestamp(BigInt(filters.maxId)) / 1000);
filterStrings.push(`createdAt < ${timestamp}`);
}
if (filters.minId) {
const timestamp = Math.floor(extractTimestamp(BigInt(filters.minId)) / 1000);
filterStrings.push(`createdAt > ${timestamp}`);
}
return filterStrings;
}
private buildSortField(filters: MessageSearchFilters): Array<string> {
const sortBy = filters.sortBy ?? 'timestamp';
const sortOrder = filters.sortOrder ?? 'desc';
return sortBy === 'relevance' ? [] : [`createdAt:${sortOrder}`];
}
private addOrFilter(filterStrings: Array<string>, field: string, values: Array<string>): void {
const orFilters = values.map((value) => `${field} = "${value}"`).join(' OR ');
filterStrings.push(`(${orFilters})`);
}
private addExcludeFilter(filterStrings: Array<string>, field: string, values: Array<string>): void {
const notFilters = values.map((value) => `${field} != "${value}"`).join(' AND ');
filterStrings.push(`(${notFilters})`);
}
private addAuthorTypeFilter(filterStrings: Array<string>, authorTypes: Array<string>): void {
this.addOrFilter(filterStrings, 'authorType', authorTypes);
}
private addHasFilter(filterStrings: Array<string>, hasTypes: Array<string>): void {
for (const hasType of hasTypes) {
const field = this.getHasField(hasType);
if (field) {
filterStrings.push(`${field} = true`);
}
}
}
private addHasExcludeFilter(filterStrings: Array<string>, hasTypes: Array<string>): void {
for (const hasType of hasTypes) {
const field = this.getHasField(hasType);
if (field) {
filterStrings.push(`${field} = false`);
}
}
}
private getHasField(hasType: string): string | null {
const mapping: Record<string, string> = {
image: 'hasImage',
sound: 'hasSound',
video: 'hasVideo',
file: 'hasFile',
sticker: 'hasSticker',
embed: 'hasEmbed',
link: 'hasLink',
poll: 'hasPoll',
snapshot: 'hasForward',
};
return mapping[hasType] ?? null;
}
private getAuthorType(message: Message, authorIsBot?: boolean): 'user' | 'bot' | 'webhook' {
if (message.webhookId) return 'webhook';
if (authorIsBot) return 'bot';
return 'user';
}
private extractAttachmentTypes(message: Message): {hasVideo: boolean; hasImage: boolean; hasSound: boolean} {
const hasType = (prefix: string) =>
message.attachments.some((att) => att.contentType.trim().toLowerCase().startsWith(prefix));
return {
hasVideo: hasType('video/'),
hasImage: hasType('image/'),
hasSound: hasType('audio/'),
};
}
private extractEmbedTypes(message: Message): Array<string> {
const types: Array<string> = [];
for (const embed of message.embeds) {
if (embed.type && !types.includes(embed.type)) {
types.push(embed.type);
}
}
return types;
}
private extractEmbedProviders(message: Message): Array<string> {
const providers: Array<string> = [];
for (const embed of message.embeds) {
if (embed.provider?.name && !providers.includes(embed.provider.name)) {
providers.push(embed.provider.name);
}
}
return providers;
}
private extractLinkHostnames(message: Message): Array<string> {
const hostnames: Array<string> = [];
if (message.content) {
const urlMatches = message.content.matchAll(/https?:\/\/([^/\s]+)/g);
for (const match of urlMatches) {
const hostname = match[1];
if (hostname && !hostnames.includes(hostname)) {
hostnames.push(hostname);
}
}
}
for (const embed of message.embeds) {
if (embed.url) {
try {
const url = new URL(embed.url);
if (!hostnames.includes(url.hostname)) {
hostnames.push(url.hostname);
}
} catch {}
}
}
return hostnames;
}
private extractAttachmentInfo(message: Message): {
attachmentFilenames: Array<string>;
attachmentExtensions: Array<string>;
} {
const filenames: Array<string> = [];
const extensions: Array<string> = [];
for (const att of message.attachments) {
if (!filenames.includes(att.filename)) {
filenames.push(att.filename);
}
const parts = att.filename.split('.');
if (parts.length > 1) {
const ext = parts[parts.length - 1]!.toLowerCase();
if (ext.length > 0 && ext.length <= 10 && !extensions.includes(ext)) {
extensions.push(ext);
}
}
}
return {attachmentFilenames: filenames, attachmentExtensions: extensions};
}
private convertToSearchableMessage(message: Message, authorIsBot?: boolean): SearchableMessage {
const createdAt = Math.floor(extractTimestamp(BigInt(message.id)) / 1000);
const editedAt = message.editedTimestamp ? Math.floor(message.editedTimestamp.getTime() / 1000) : null;
const authorType = this.getAuthorType(message, authorIsBot);
const {hasVideo, hasImage, hasSound} = this.extractAttachmentTypes(message);
const hasLink = message.content !== null && /(https?:\/\/[^\s]+)/.test(message.content);
const embedTypes = this.extractEmbedTypes(message);
const embedProviders = this.extractEmbedProviders(message);
const linkHostnames = this.extractLinkHostnames(message);
const {attachmentFilenames, attachmentExtensions} = this.extractAttachmentInfo(message);
return {
id: message.id.toString(),
channelId: message.channelId.toString(),
authorId: message.authorId?.toString() ?? null,
authorType,
content: message.content,
createdAt,
editedAt,
isPinned: message.pinnedTimestamp !== null,
mentionedUserIds: Array.from(message.mentionedUserIds).map((id) => id.toString()),
mentionEveryone: message.mentionEveryone,
hasLink,
hasEmbed: message.embeds.length > 0,
hasPoll: false, // TODO: Implement when poll support is added
hasFile: message.attachments.length > 0,
hasVideo,
hasImage,
hasSound,
hasSticker: message.stickers.length > 0,
hasForward: message.reference?.type === 1,
embedTypes,
embedProviders,
linkHostnames,
attachmentFilenames,
attachmentExtensions,
};
}
}

View File

@@ -0,0 +1,388 @@
/*
* 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 {Index, MeiliSearch} from 'meilisearch';
import type {GuildID, MessageID, ReportID, UserID} from '~/BrandedTypes';
import {Logger} from '~/Logger';
import type {IARSubmission} from '~/report/IReportRepository';
import {SEARCH_MAX_TOTAL_HITS} from '~/search/constants';
import {extractTimestamp} from '~/utils/SnowflakeUtils';
const REPORT_INDEX_NAME = 'reports';
interface SearchableReport {
id: string;
reporterId: string;
reportedAt: number;
status: number;
reportType: number;
category: string;
additionalInfo: string | null;
reportedUserId: string | null;
reportedGuildId: string | null;
reportedGuildName: string | null;
reportedMessageId: string | null;
reportedChannelId: string | null;
reportedChannelName: string | null;
guildContextId: string | null;
resolvedAt: number | null;
resolvedByAdminId: string | null;
publicComment: string | null;
createdAt: number;
}
interface ReportSearchFilters {
reporterId?: string;
status?: number;
reportType?: number;
category?: string;
reportedUserId?: string;
reportedGuildId?: string;
reportedMessageId?: string;
guildContextId?: string;
resolvedByAdminId?: string;
isResolved?: boolean;
sortBy?: 'createdAt' | 'reportedAt' | 'resolvedAt' | 'relevance';
sortOrder?: 'asc' | 'desc';
}
export class ReportSearchService {
private meilisearch: MeiliSearch;
private index: Index<SearchableReport> | null = null;
constructor(meilisearch: MeiliSearch) {
this.meilisearch = meilisearch;
}
async initialize(): Promise<void> {
try {
this.index = this.meilisearch.index<SearchableReport>(REPORT_INDEX_NAME);
await this.index.updateSettings({
searchableAttributes: [
'id',
'category',
'additionalInfo',
'reportedGuildName',
'reportedChannelName',
'publicComment',
],
filterableAttributes: [
'reporterId',
'status',
'reportType',
'category',
'reportedUserId',
'reportedGuildId',
'reportedMessageId',
'reportedChannelId',
'guildContextId',
'resolvedByAdminId',
'reportedAt',
'resolvedAt',
'createdAt',
],
sortableAttributes: ['createdAt', 'reportedAt', 'resolvedAt'],
rankingRules: ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'],
pagination: {
maxTotalHits: SEARCH_MAX_TOTAL_HITS,
},
});
Logger.debug('Report search index initialized successfully');
} catch (error) {
Logger.error({error}, 'Failed to initialize report search index');
throw error;
}
}
async indexReport(report: IARSubmission): Promise<void> {
if (!this.index) {
throw new Error('Report search index not initialized');
}
const searchableReport = this.convertToSearchableReport(report);
try {
await this.index.addDocuments([searchableReport], {primaryKey: 'id'});
} catch (error) {
Logger.error({reportId: report.reportId, error}, 'Failed to index report');
throw error;
}
}
async indexReports(reports: Array<IARSubmission>): Promise<void> {
if (!this.index) {
throw new Error('Report search index not initialized');
}
if (reports.length === 0) return;
const searchableReports = reports.map((report) => this.convertToSearchableReport(report));
try {
await this.index.addDocuments(searchableReports, {primaryKey: 'id'});
} catch (error) {
Logger.error({count: reports.length, error}, 'Failed to index reports');
throw error;
}
}
async updateReport(report: IARSubmission): Promise<void> {
if (!this.index) {
throw new Error('Report search index not initialized');
}
const searchableReport = this.convertToSearchableReport(report);
try {
await this.index.updateDocuments([searchableReport], {primaryKey: 'id'});
} catch (error) {
Logger.error({reportId: report.reportId, error}, 'Failed to update report in search index');
throw error;
}
}
async deleteReport(reportId: ReportID): Promise<void> {
if (!this.index) {
throw new Error('Report search index not initialized');
}
try {
await this.index.deleteDocument(reportId.toString());
} catch (error) {
Logger.error({reportId, error}, 'Failed to delete report from search index');
throw error;
}
}
async deleteReports(reportIds: Array<ReportID>): Promise<void> {
if (!this.index) {
throw new Error('Report search index not initialized');
}
if (reportIds.length === 0) return;
try {
await this.index.deleteDocuments(reportIds.map((id) => id.toString()));
} catch (error) {
Logger.error({count: reportIds.length, error}, 'Failed to delete reports from search index');
throw error;
}
}
async searchReports(
query: string,
filters: ReportSearchFilters,
options?: {
limit?: number;
offset?: number;
},
): Promise<{hits: Array<SearchableReport>; total: number}> {
if (!this.index) {
throw new Error('Report search index not initialized');
}
const filterStrings = this.buildFilterStrings(filters);
const sortField = this.buildSortField(filters);
try {
const result = await this.index.search(query, {
filter: filterStrings.length > 0 ? filterStrings : undefined,
limit: options?.limit ?? 50,
offset: options?.offset ?? 0,
sort: sortField,
});
return {
hits: result.hits,
total: result.estimatedTotalHits ?? 0,
};
} catch (error) {
Logger.error({query, filters, error}, 'Failed to search reports');
throw error;
}
}
async listReportsByReporter(
reporterId: UserID,
limit?: number,
offset?: number,
): Promise<{hits: Array<SearchableReport>; total: number}> {
return this.searchReports(
'',
{reporterId: reporterId.toString(), sortBy: 'reportedAt', sortOrder: 'desc'},
{limit, offset},
);
}
async listReportsByStatus(
status: number,
limit?: number,
offset?: number,
): Promise<{hits: Array<SearchableReport>; total: number}> {
return this.searchReports('', {status, sortBy: 'reportedAt', sortOrder: 'desc'}, {limit, offset});
}
async listReportsByType(
reportType: number,
limit?: number,
offset?: number,
): Promise<{hits: Array<SearchableReport>; total: number}> {
return this.searchReports('', {reportType, sortBy: 'reportedAt', sortOrder: 'desc'}, {limit, offset});
}
async listReportsByReportedUser(
reportedUserId: UserID,
limit?: number,
offset?: number,
): Promise<{hits: Array<SearchableReport>; total: number}> {
return this.searchReports(
'',
{reportedUserId: reportedUserId.toString(), sortBy: 'reportedAt', sortOrder: 'desc'},
{limit, offset},
);
}
async listReportsByReportedGuild(
reportedGuildId: GuildID,
limit?: number,
offset?: number,
): Promise<{hits: Array<SearchableReport>; total: number}> {
return this.searchReports(
'',
{reportedGuildId: reportedGuildId.toString(), sortBy: 'reportedAt', sortOrder: 'desc'},
{limit, offset},
);
}
async listReportsByReportedMessage(
reportedMessageId: MessageID,
limit?: number,
offset?: number,
): Promise<{hits: Array<SearchableReport>; total: number}> {
return this.searchReports(
'',
{reportedMessageId: reportedMessageId.toString(), sortBy: 'reportedAt', sortOrder: 'desc'},
{limit, offset},
);
}
async deleteAllDocuments(): Promise<void> {
if (!this.index) {
throw new Error('Report search index not initialized');
}
try {
await this.index.deleteAllDocuments();
Logger.debug('All report documents deleted from search index');
} catch (error) {
Logger.error({error}, 'Failed to delete all report documents');
throw error;
}
}
private buildFilterStrings(filters: ReportSearchFilters): Array<string> {
const filterStrings: Array<string> = [];
if (filters.reporterId) {
filterStrings.push(`reporterId = "${filters.reporterId}"`);
}
if (filters.status !== undefined) {
filterStrings.push(`status = ${filters.status}`);
}
if (filters.reportType !== undefined) {
filterStrings.push(`reportType = ${filters.reportType}`);
}
if (filters.category) {
filterStrings.push(`category = "${filters.category}"`);
}
if (filters.reportedUserId) {
filterStrings.push(`reportedUserId = "${filters.reportedUserId}"`);
}
if (filters.reportedGuildId) {
filterStrings.push(`reportedGuildId = "${filters.reportedGuildId}"`);
}
if (filters.reportedMessageId) {
filterStrings.push(`reportedMessageId = "${filters.reportedMessageId}"`);
}
if (filters.guildContextId) {
filterStrings.push(`guildContextId = "${filters.guildContextId}"`);
}
if (filters.resolvedByAdminId) {
filterStrings.push(`resolvedByAdminId = "${filters.resolvedByAdminId}"`);
}
if (filters.isResolved !== undefined) {
if (filters.isResolved) {
filterStrings.push(`resolvedAt IS NOT NULL`);
} else {
filterStrings.push(`resolvedAt IS NULL`);
}
}
return filterStrings;
}
private buildSortField(filters: ReportSearchFilters): Array<string> {
const sortBy = filters.sortBy ?? 'reportedAt';
const sortOrder = filters.sortOrder ?? 'desc';
if (sortBy === 'relevance') {
return [];
}
return [`${sortBy}:${sortOrder}`];
}
private convertToSearchableReport(report: IARSubmission): SearchableReport {
const createdAt = Math.floor(extractTimestamp(BigInt(report.reportId)) / 1000);
const reportedAt = Math.floor(report.reportedAt.getTime() / 1000);
const resolvedAt = report.resolvedAt ? Math.floor(report.resolvedAt.getTime() / 1000) : null;
return {
id: report.reportId.toString(),
reporterId: report.reporterId ? report.reporterId.toString() : 'anonymous',
reportedAt,
status: report.status,
reportType: report.reportType,
category: report.category,
additionalInfo: report.additionalInfo,
reportedUserId: report.reportedUserId?.toString() || null,
reportedGuildId: report.reportedGuildId?.toString() || null,
reportedGuildName: report.reportedGuildName,
reportedMessageId: report.reportedMessageId?.toString() || null,
reportedChannelId: report.reportedChannelId?.toString() || null,
reportedChannelName: report.reportedChannelName,
guildContextId: report.guildContextId?.toString() || null,
resolvedAt,
resolvedByAdminId: report.resolvedByAdminId?.toString() || null,
publicComment: report.publicComment,
createdAt,
};
}
}

View File

@@ -0,0 +1,323 @@
/*
* 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 {Index, MeiliSearch} from 'meilisearch';
import type {UserID} from '~/BrandedTypes';
import {Logger} from '~/Logger';
import type {User} from '~/Models';
import {SEARCH_MAX_TOTAL_HITS} from '~/search/constants';
import {extractTimestamp} from '~/utils/SnowflakeUtils';
const USER_INDEX_NAME = 'users';
interface SearchableUser {
id: string;
username: string;
discriminator: number;
email: string | null;
phone: string | null;
isBot: boolean;
isSystem: boolean;
flags: string;
premiumType: number | null;
emailVerified: boolean;
emailBounced: boolean;
suspiciousActivityFlags: number;
acls: Array<string>;
createdAt: number;
lastActiveAt: number | null;
tempBannedUntil: number | null;
pendingDeletionAt: number | null;
stripeSubscriptionId: string | null;
stripeCustomerId: string | null;
}
interface UserSearchFilters {
isBot?: boolean;
isSystem?: boolean;
emailVerified?: boolean;
emailBounced?: boolean;
hasPremium?: boolean;
isTempBanned?: boolean;
isPendingDeletion?: boolean;
hasAcl?: Array<string>;
minSuspiciousActivityFlags?: number;
sortBy?: 'createdAt' | 'lastActiveAt' | 'relevance';
sortOrder?: 'asc' | 'desc';
}
export class UserSearchService {
private meilisearch: MeiliSearch;
private index: Index<SearchableUser> | null = null;
constructor(meilisearch: MeiliSearch) {
this.meilisearch = meilisearch;
}
async initialize(): Promise<void> {
try {
this.index = this.meilisearch.index<SearchableUser>(USER_INDEX_NAME);
await this.index.updateSettings({
searchableAttributes: ['username', 'id', 'email', 'phone', 'stripeSubscriptionId', 'stripeCustomerId'],
filterableAttributes: [
'discriminator',
'isBot',
'isSystem',
'emailVerified',
'emailBounced',
'premiumType',
'suspiciousActivityFlags',
'acls',
'createdAt',
'lastActiveAt',
'tempBannedUntil',
'pendingDeletionAt',
],
sortableAttributes: ['createdAt', 'lastActiveAt', 'discriminator'],
rankingRules: ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'],
pagination: {
maxTotalHits: SEARCH_MAX_TOTAL_HITS,
},
});
Logger.debug('User search index initialized successfully');
} catch (error) {
Logger.error({error}, 'Failed to initialize user search index');
throw error;
}
}
async indexUser(user: User): Promise<void> {
if (!this.index) {
throw new Error('User search index not initialized');
}
const searchableUser = this.convertToSearchableUser(user);
try {
await this.index.addDocuments([searchableUser], {primaryKey: 'id'});
} catch (error) {
Logger.error({userId: user.id, error}, 'Failed to index user');
throw error;
}
}
async indexUsers(users: Array<User>): Promise<void> {
if (!this.index) {
throw new Error('User search index not initialized');
}
if (users.length === 0) return;
const searchableUsers = users.map((user) => this.convertToSearchableUser(user));
try {
await this.index.addDocuments(searchableUsers, {primaryKey: 'id'});
} catch (error) {
Logger.error({count: users.length, error}, 'Failed to index users');
throw error;
}
}
async updateUser(user: User): Promise<void> {
if (!this.index) {
throw new Error('User search index not initialized');
}
const searchableUser = this.convertToSearchableUser(user);
try {
await this.index.updateDocuments([searchableUser], {primaryKey: 'id'});
} catch (error) {
Logger.error({userId: user.id, error}, 'Failed to update user in search index');
throw error;
}
}
async deleteUser(userId: UserID): Promise<void> {
if (!this.index) {
throw new Error('User search index not initialized');
}
try {
await this.index.deleteDocument(userId.toString());
} catch (error) {
Logger.error({userId, error}, 'Failed to delete user from search index');
throw error;
}
}
async deleteUsers(userIds: Array<UserID>): Promise<void> {
if (!this.index) {
throw new Error('User search index not initialized');
}
if (userIds.length === 0) return;
try {
await this.index.deleteDocuments(userIds.map((id) => id.toString()));
} catch (error) {
Logger.error({count: userIds.length, error}, 'Failed to delete users from search index');
throw error;
}
}
async searchUsers(
query: string,
filters: UserSearchFilters,
options?: {
limit?: number;
offset?: number;
},
): Promise<{hits: Array<SearchableUser>; total: number}> {
if (!this.index) {
throw new Error('User search index not initialized');
}
const filterStrings = this.buildFilterStrings(filters);
const sortField = this.buildSortField(filters);
try {
const result = await this.index.search(query, {
filter: filterStrings.length > 0 ? filterStrings : undefined,
limit: options?.limit ?? 50,
offset: options?.offset ?? 0,
sort: sortField,
});
return {
hits: result.hits,
total: result.estimatedTotalHits ?? 0,
};
} catch (error) {
Logger.error({query, filters, error}, 'Failed to search users');
throw error;
}
}
async deleteAllDocuments(): Promise<void> {
if (!this.index) {
throw new Error('User search index not initialized');
}
try {
await this.index.deleteAllDocuments();
Logger.debug('All user documents deleted from search index');
} catch (error) {
Logger.error({error}, 'Failed to delete all user documents');
throw error;
}
}
private buildFilterStrings(filters: UserSearchFilters): Array<string> {
const filterStrings: Array<string> = [];
if (filters.isBot !== undefined) {
filterStrings.push(`isBot = ${filters.isBot}`);
}
if (filters.isSystem !== undefined) {
filterStrings.push(`isSystem = ${filters.isSystem}`);
}
if (filters.emailVerified !== undefined) {
filterStrings.push(`emailVerified = ${filters.emailVerified}`);
}
if (filters.emailBounced !== undefined) {
filterStrings.push(`emailBounced = ${filters.emailBounced}`);
}
if (filters.hasPremium !== undefined) {
if (filters.hasPremium) {
filterStrings.push(`premiumType IS NOT NULL`);
} else {
filterStrings.push(`premiumType IS NULL`);
}
}
if (filters.isTempBanned !== undefined) {
if (filters.isTempBanned) {
filterStrings.push(`tempBannedUntil IS NOT NULL`);
} else {
filterStrings.push(`tempBannedUntil IS NULL`);
}
}
if (filters.isPendingDeletion !== undefined) {
if (filters.isPendingDeletion) {
filterStrings.push(`pendingDeletionAt IS NOT NULL`);
} else {
filterStrings.push(`pendingDeletionAt IS NULL`);
}
}
if (filters.hasAcl && filters.hasAcl.length > 0) {
const aclFilters = filters.hasAcl.map((acl) => `acls = "${acl}"`).join(' OR ');
filterStrings.push(`(${aclFilters})`);
}
if (filters.minSuspiciousActivityFlags !== undefined) {
filterStrings.push(`suspiciousActivityFlags >= ${filters.minSuspiciousActivityFlags}`);
}
return filterStrings;
}
private buildSortField(filters: UserSearchFilters): Array<string> {
const sortBy = filters.sortBy ?? 'createdAt';
const sortOrder = filters.sortOrder ?? 'desc';
if (sortBy === 'relevance') {
return [];
}
return [`${sortBy}:${sortOrder}`];
}
private convertToSearchableUser(user: User): SearchableUser {
const createdAt = Math.floor(extractTimestamp(BigInt(user.id)) / 1000);
const lastActiveAt = user.lastActiveAt ? Math.floor(user.lastActiveAt.getTime() / 1000) : null;
const tempBannedUntil = user.tempBannedUntil ? Math.floor(user.tempBannedUntil.getTime() / 1000) : null;
const pendingDeletionAt = user.pendingDeletionAt ? Math.floor(user.pendingDeletionAt.getTime() / 1000) : null;
return {
id: user.id.toString(),
username: user.username,
discriminator: user.discriminator,
email: user.email,
phone: user.phone,
isBot: user.isBot,
isSystem: user.isSystem,
flags: user.flags.toString(),
premiumType: user.premiumType,
emailVerified: user.emailVerified,
emailBounced: user.emailBounced,
suspiciousActivityFlags: user.suspiciousActivityFlags,
acls: Array.from(user.acls),
createdAt,
lastActiveAt,
tempBannedUntil,
pendingDeletionAt,
stripeSubscriptionId: user.stripeSubscriptionId,
stripeCustomerId: user.stripeCustomerId,
};
}
}

View File

@@ -0,0 +1,131 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageSearchRequest} from '~/channel/ChannelModel';
import type {MessageSearchFilters} from '~/search/MessageSearchService';
export const buildMessageSearchFilters = (
searchParams: MessageSearchRequest,
channelIds: Array<string>,
): MessageSearchFilters => {
const filters: MessageSearchFilters = {
channelIds,
};
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.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.author_type) {
filters.authorType = searchParams.author_type;
}
if (searchParams.exclude_author_type) {
filters.excludeAuthorType = searchParams.exclude_author_type;
}
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.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;
};

View File

@@ -0,0 +1,20 @@
/*
* 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 const SEARCH_MAX_TOTAL_HITS = Number.MAX_SAFE_INTEGER;

View File

@@ -0,0 +1,142 @@
/*
* 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, createGuildID} from '~/BrandedTypes';
import {MessageSearchRequest, type MessageSearchResponse} from '~/channel/ChannelModel';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import type {ChannelService} from '~/channel/services/ChannelService';
import {ChannelIndexingError, InputValidationError} from '~/Errors';
import type {GuildService} from '~/guild/services/GuildService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, z} from '~/Schema';
import {GlobalSearchService} from '~/search/GlobalSearchService';
import type {IUserRepository} from '~/user/IUserRepository';
import {Validator} from '~/Validator';
import type {IWorkerService} from '~/worker/IWorkerService';
const SearchMessagesRequest = MessageSearchRequest.extend({
context_channel_id: Int64Type.optional(),
context_guild_id: Int64Type.optional(),
channel_ids: z.array(Int64Type).max(500).optional(),
});
export const SearchController = (app: HonoApp) => {
app.post(
'/search/messages',
RateLimitMiddleware(RateLimitConfigs.SEARCH_MESSAGES),
LoginRequired,
DefaultUserOnly,
Validator('json', SearchMessagesRequest),
async (ctx) => {
const params = ctx.req.valid('json');
const userId = ctx.get('user').id;
const requestCache = ctx.get('requestCache');
const {channel_ids, context_channel_id, context_guild_id, ...searchParams} = params;
const contextChannelId = context_channel_id ? createChannelID(context_channel_id) : null;
const contextGuildId = context_guild_id ? createGuildID(context_guild_id) : null;
const channelIds = channel_ids?.map((id) => createChannelID(id)) ?? [];
const globalSearch = new GlobalSearchService(
ctx.get('channelRepository') as IChannelRepository,
ctx.get('channelService') as ChannelService,
ctx.get('guildService') as GuildService,
ctx.get('userRepository') as IUserRepository,
ctx.get('userCacheService') as UserCacheService,
ctx.get('mediaService') as IMediaService,
ctx.get('workerService') as IWorkerService,
);
const scope = searchParams.scope ?? 'current';
let result: MessageSearchResponse | {indexing: true};
switch (scope) {
case 'all_guilds':
result = await ctx.get('guildService').searchAllGuilds({
userId,
channelIds,
searchParams,
requestCache,
});
break;
case 'all_dms':
case 'open_dms':
result = await globalSearch.searchAcrossDms({
userId,
scope,
searchParams,
requestCache,
includeChannelId: contextChannelId,
requestedChannelIds: channelIds,
});
break;
case 'all':
result = await globalSearch.searchAcrossGuildsAndDms({
userId,
dmScope: 'all_dms',
searchParams,
requestCache,
includeChannelId: contextChannelId,
requestedChannelIds: channelIds,
});
break;
case 'open_dms_and_all_guilds':
result = await globalSearch.searchAcrossGuildsAndDms({
userId,
dmScope: 'open_dms',
searchParams,
requestCache,
includeChannelId: contextChannelId,
requestedChannelIds: channelIds,
});
break;
default:
if (contextGuildId) {
result = await ctx.get('guildService').searchMessages({
userId,
guildId: contextGuildId,
channelIds,
searchParams,
requestCache,
});
} else if (contextChannelId) {
result = await ctx.get('channelService').searchMessages({
userId,
channelId: contextChannelId,
searchParams,
requestCache,
});
} else {
throw InputValidationError.create('context', 'Context channel or guild ID is required');
}
break;
}
if ('indexing' in result && result.indexing) {
throw new ChannelIndexingError();
}
return ctx.json(result);
},
);
};