initial commit
This commit is contained in:
214
fluxer_api/src/search/AuditLogSearchService.ts
Normal file
214
fluxer_api/src/search/AuditLogSearchService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
231
fluxer_api/src/search/GlobalSearchService.ts
Normal file
231
fluxer_api/src/search/GlobalSearchService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
281
fluxer_api/src/search/GuildSearchService.ts
Normal file
281
fluxer_api/src/search/GuildSearchService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
75
fluxer_api/src/search/MessageSearchResponseMapper.ts
Normal file
75
fluxer_api/src/search/MessageSearchResponseMapper.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
565
fluxer_api/src/search/MessageSearchService.ts
Normal file
565
fluxer_api/src/search/MessageSearchService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
388
fluxer_api/src/search/ReportSearchService.ts
Normal file
388
fluxer_api/src/search/ReportSearchService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
323
fluxer_api/src/search/UserSearchService.ts
Normal file
323
fluxer_api/src/search/UserSearchService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
131
fluxer_api/src/search/buildMessageSearchFilters.ts
Normal file
131
fluxer_api/src/search/buildMessageSearchFilters.ts
Normal 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;
|
||||
};
|
||||
20
fluxer_api/src/search/constants.ts
Normal file
20
fluxer_api/src/search/constants.ts
Normal 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;
|
||||
142
fluxer_api/src/search/controllers/SearchController.ts
Normal file
142
fluxer_api/src/search/controllers/SearchController.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user