mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 12:42:27 +09:00
750 lines
28 KiB
TypeScript
750 lines
28 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import type {SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
|
import type {
|
|
AuditLogSearchFilters,
|
|
GuildMemberSearchFilters,
|
|
GuildSearchFilters,
|
|
MessageSearchFilters,
|
|
ReportSearchFilters,
|
|
SearchableAuditLog,
|
|
SearchableGuild,
|
|
SearchableGuildMember,
|
|
SearchableMessage,
|
|
SearchableReport,
|
|
SearchableUser,
|
|
UserSearchFilters,
|
|
} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
|
import type {AdminAuditLog} from '../../admin/IAdminRepository';
|
|
import type {ChannelID, GuildID, MessageID, ReportID, UserID} from '../../BrandedTypes';
|
|
import type {Guild} from '../../models/Guild';
|
|
import type {GuildMember} from '../../models/GuildMember';
|
|
import type {Message} from '../../models/Message';
|
|
import type {User} from '../../models/User';
|
|
import type {IARSubmission} from '../../report/IReportRepository';
|
|
import {convertToSearchableAuditLog} from '../../search/auditlog/AuditLogSearchSerializer';
|
|
import type {GuildDiscoveryContext} from '../../search/guild/GuildSearchSerializer';
|
|
import {convertToSearchableGuild} from '../../search/guild/GuildSearchSerializer';
|
|
import {convertToSearchableGuildMember} from '../../search/guild_member/GuildMemberSearchSerializer';
|
|
import type {IAuditLogSearchService} from '../../search/IAuditLogSearchService';
|
|
import type {IGuildMemberSearchService} from '../../search/IGuildMemberSearchService';
|
|
import type {IGuildSearchService} from '../../search/IGuildSearchService';
|
|
import type {IMessageSearchService} from '../../search/IMessageSearchService';
|
|
import type {IReportSearchService} from '../../search/IReportSearchService';
|
|
import type {ISearchProvider} from '../../search/ISearchProvider';
|
|
import type {IUserSearchService} from '../../search/IUserSearchService';
|
|
import {convertToSearchableMessage} from '../../search/message/MessageSearchSerializer';
|
|
import {convertToSearchableReport} from '../../search/report/ReportSearchSerializer';
|
|
import {convertToSearchableUser} from '../../search/user/UserSearchSerializer';
|
|
|
|
interface SearchableDocument {
|
|
id: string;
|
|
}
|
|
|
|
type Matcher<TDocument, TFilters> = (doc: TDocument, filters: TFilters) => boolean;
|
|
type TextCollector<TDocument> = (doc: TDocument) => Array<string | null>;
|
|
type Sorter<TDocument, TFilters> = (left: TDocument, right: TDocument, filters: TFilters, query: string) => number;
|
|
|
|
const DEFAULT_LIMIT = 25;
|
|
|
|
function normalize(value: string | null | undefined): string {
|
|
return (value ?? '').toLowerCase();
|
|
}
|
|
|
|
function containsText(value: string | null | undefined, query: string): boolean {
|
|
const normalizedQuery = normalize(query).trim();
|
|
if (normalizedQuery.length === 0) {
|
|
return true;
|
|
}
|
|
const normalizedValue = normalize(value);
|
|
if (normalizedValue.includes(normalizedQuery)) {
|
|
return true;
|
|
}
|
|
const terms = normalizedQuery.split(/\s+/).filter((term) => term.length > 0);
|
|
return terms.length > 0 && terms.every((term) => normalizedValue.includes(term));
|
|
}
|
|
|
|
function arrayIncludesAny(values: ReadonlyArray<string>, needles: ReadonlyArray<string> | undefined): boolean {
|
|
return !needles || needles.length === 0 || needles.some((needle) => values.includes(needle));
|
|
}
|
|
|
|
function arrayExcludesAll(values: ReadonlyArray<string>, needles: ReadonlyArray<string> | undefined): boolean {
|
|
return !needles || needles.every((needle) => !values.includes(needle));
|
|
}
|
|
|
|
function stringArrayContainsText(values: ReadonlyArray<string>, query: string): boolean {
|
|
return values.some((value) => containsText(value, query));
|
|
}
|
|
|
|
function paginate<T>(items: Array<T>, options?: SearchOptions): Array<T> {
|
|
const limit = options?.limit ?? options?.hitsPerPage ?? DEFAULT_LIMIT;
|
|
const offset = options?.offset ?? ((options?.page ?? 1) - 1) * limit;
|
|
return items.slice(offset, offset + limit);
|
|
}
|
|
|
|
function relevanceScore(values: Array<string | null>, query: string): number {
|
|
const normalizedQuery = normalize(query).trim();
|
|
if (!normalizedQuery) {
|
|
return 0;
|
|
}
|
|
let score = 0;
|
|
for (const value of values) {
|
|
const text = normalize(value);
|
|
if (text === normalizedQuery) {
|
|
score += 100;
|
|
}
|
|
let index = text.indexOf(normalizedQuery);
|
|
while (index !== -1) {
|
|
score += 1;
|
|
index = text.indexOf(normalizedQuery, index + normalizedQuery.length);
|
|
}
|
|
}
|
|
return score;
|
|
}
|
|
|
|
class InMemorySearchServiceBase<TFilters, TDocument extends SearchableDocument> {
|
|
private readonly docs = new Map<string, TDocument>();
|
|
|
|
constructor(
|
|
private readonly matchesFilters: Matcher<TDocument, TFilters>,
|
|
private readonly collectText: TextCollector<TDocument>,
|
|
private readonly sortDocuments: Sorter<TDocument, TFilters>,
|
|
) {}
|
|
|
|
async initialize(): Promise<void> {}
|
|
|
|
async shutdown(): Promise<void> {
|
|
this.docs.clear();
|
|
}
|
|
|
|
isAvailable(): boolean {
|
|
return true;
|
|
}
|
|
|
|
async indexDocument(doc: TDocument): Promise<void> {
|
|
this.docs.set(doc.id, doc);
|
|
}
|
|
|
|
async indexDocuments(docs: Array<TDocument>): Promise<void> {
|
|
for (const doc of docs) {
|
|
this.docs.set(doc.id, doc);
|
|
}
|
|
}
|
|
|
|
async updateDocument(doc: TDocument): Promise<void> {
|
|
this.docs.set(doc.id, doc);
|
|
}
|
|
|
|
async deleteDocument(id: string): Promise<void> {
|
|
this.docs.delete(id);
|
|
}
|
|
|
|
async deleteDocuments(ids: Array<string>): Promise<void> {
|
|
for (const id of ids) {
|
|
this.docs.delete(id);
|
|
}
|
|
}
|
|
|
|
async deleteAllDocuments(): Promise<void> {
|
|
this.docs.clear();
|
|
}
|
|
|
|
async bulkIndexDocuments(docs: Array<TDocument>): Promise<void> {
|
|
await this.indexDocuments(docs);
|
|
}
|
|
|
|
async refreshIndex(): Promise<void> {}
|
|
|
|
async search(query: string, filters: TFilters, options?: SearchOptions): Promise<SearchResult<TDocument>> {
|
|
const hits = Array.from(this.docs.values())
|
|
.filter((doc) => this.matchesFilters(doc, filters))
|
|
.filter((doc) => {
|
|
const trimmed = query.trim();
|
|
return trimmed.length === 0 || stringArrayContainsText(this.collectText(doc).filter(isString), trimmed);
|
|
})
|
|
.sort((left, right) => this.sortDocuments(left, right, filters, query));
|
|
const facets = options?.facets;
|
|
return {
|
|
hits: paginate(hits, options),
|
|
total: hits.length,
|
|
...(facets && facets.length > 0 ? {facetCounts: countFacets(hits, facets)} : {}),
|
|
};
|
|
}
|
|
}
|
|
|
|
function countFacets<TDocument>(docs: Array<TDocument>, facets: Array<string>): Record<string, Record<string, number>> {
|
|
const counts: Record<string, Record<string, number>> = {};
|
|
for (const facet of facets) {
|
|
const facetCounts: Record<string, number> = {};
|
|
for (const doc of docs) {
|
|
const value = (doc as Record<string, unknown>)[facet];
|
|
if (value == null) {
|
|
continue;
|
|
}
|
|
for (const entry of Array.isArray(value) ? value : [value]) {
|
|
const key = String(entry);
|
|
facetCounts[key] = (facetCounts[key] ?? 0) + 1;
|
|
}
|
|
}
|
|
counts[facet] = facetCounts;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
function isString(value: string | null): value is string {
|
|
return value !== null;
|
|
}
|
|
|
|
function matchesHasFilter(doc: SearchableMessage, has: string): boolean {
|
|
switch (has) {
|
|
case 'link':
|
|
return doc.hasLink;
|
|
case 'embed':
|
|
return doc.hasEmbed;
|
|
case 'poll':
|
|
return doc.hasPoll;
|
|
case 'file':
|
|
return doc.hasFile;
|
|
case 'video':
|
|
return doc.hasVideo;
|
|
case 'image':
|
|
return doc.hasImage;
|
|
case 'sound':
|
|
return doc.hasSound;
|
|
case 'sticker':
|
|
return doc.hasSticker;
|
|
case 'snapshot':
|
|
return doc.hasForward;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function matchesMessageFilters(doc: SearchableMessage, filters: MessageSearchFilters): boolean {
|
|
if (filters.maxId && BigInt(doc.id) >= BigInt(filters.maxId)) return false;
|
|
if (filters.minId && BigInt(doc.id) <= BigInt(filters.minId)) return false;
|
|
if (filters.guildId !== undefined && doc.guildId !== filters.guildId) return false;
|
|
if (filters.channelId !== undefined && doc.channelId !== filters.channelId) return false;
|
|
if (filters.channelIds && !filters.channelIds.includes(doc.channelId)) return false;
|
|
if (filters.excludeChannelIds?.includes(doc.channelId)) return false;
|
|
if (filters.authorId && (!doc.authorId || !filters.authorId.includes(doc.authorId))) return false;
|
|
if (filters.excludeAuthorIds?.includes(doc.authorId ?? '')) return false;
|
|
if (filters.authorType && !filters.authorType.includes(doc.authorType)) return false;
|
|
if (filters.excludeAuthorType?.includes(doc.authorType)) return false;
|
|
if (filters.mentions && !filters.mentions.every((id) => doc.mentionedUserIds.includes(id))) return false;
|
|
if (filters.excludeMentions?.some((id) => doc.mentionedUserIds.includes(id))) return false;
|
|
if (filters.mentionEveryone !== undefined && doc.mentionEveryone !== filters.mentionEveryone) return false;
|
|
if (filters.pinned !== undefined && doc.isPinned !== filters.pinned) return false;
|
|
if (filters.has && !filters.has.every((entry) => matchesHasFilter(doc, entry))) return false;
|
|
if (filters.excludeHas?.some((entry) => matchesHasFilter(doc, entry))) return false;
|
|
if (!arrayIncludesAny(doc.embedTypes, filters.embedType)) return false;
|
|
if (!arrayExcludesAll(doc.embedTypes, filters.excludeEmbedTypes)) return false;
|
|
if (!arrayIncludesAny(doc.embedProviders, filters.embedProvider)) return false;
|
|
if (!arrayExcludesAll(doc.embedProviders, filters.excludeEmbedProviders)) return false;
|
|
if (!arrayIncludesAny(doc.linkHostnames, filters.linkHostname)) return false;
|
|
if (!arrayExcludesAll(doc.linkHostnames, filters.excludeLinkHostnames)) return false;
|
|
if (!arrayIncludesAny(doc.attachmentFilenames, filters.attachmentFilename)) return false;
|
|
if (!arrayExcludesAll(doc.attachmentFilenames, filters.excludeAttachmentFilenames)) return false;
|
|
if (!arrayIncludesAny(doc.attachmentExtensions, filters.attachmentExtension)) return false;
|
|
if (!arrayExcludesAll(doc.attachmentExtensions, filters.excludeAttachmentExtensions)) return false;
|
|
if (filters.content && !messageContains(doc, filters.content)) return false;
|
|
if (filters.contents && !filters.contents.some((content) => messageContains(doc, content))) return false;
|
|
if (filters.exactPhrases && !filters.exactPhrases.every((phrase) => (doc.content ?? '').includes(phrase)))
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
function messageContains(doc: SearchableMessage, query: string): boolean {
|
|
return containsText(doc.content, query) || stringArrayContainsText(doc.embedContent, query);
|
|
}
|
|
|
|
function collectMessageText(doc: SearchableMessage): Array<string | null> {
|
|
return [doc.content, ...doc.embedContent, ...doc.attachmentFilenames, ...doc.linkHostnames];
|
|
}
|
|
|
|
function sortMessageDocs(
|
|
left: SearchableMessage,
|
|
right: SearchableMessage,
|
|
filters: MessageSearchFilters,
|
|
query: string,
|
|
): number {
|
|
if (filters.sortBy === 'timestamp') {
|
|
const direction = filters.sortOrder === 'asc' ? 1 : -1;
|
|
return (left.createdAt - right.createdAt || Number(BigInt(left.id) - BigInt(right.id))) * direction;
|
|
}
|
|
const scoreDelta = relevanceScore(collectMessageText(right), query) - relevanceScore(collectMessageText(left), query);
|
|
if (scoreDelta !== 0) {
|
|
return scoreDelta;
|
|
}
|
|
return Number(BigInt(right.id) - BigInt(left.id));
|
|
}
|
|
|
|
class InMemoryMessageSearchService
|
|
extends InMemorySearchServiceBase<MessageSearchFilters, SearchableMessage>
|
|
implements IMessageSearchService
|
|
{
|
|
constructor() {
|
|
super(matchesMessageFilters, collectMessageText, sortMessageDocs);
|
|
}
|
|
|
|
async indexMessage(message: Message, authorIsBot?: boolean): Promise<void> {
|
|
await this.indexDocument(convertToSearchableMessage(message, authorIsBot));
|
|
}
|
|
|
|
async indexMessages(messages: Array<Message>, authorBotMap?: Map<UserID, boolean>): Promise<void> {
|
|
await this.indexDocuments(
|
|
messages.map((message) => convertToSearchableMessage(message, authorBotMap?.get(message.authorId!))),
|
|
);
|
|
}
|
|
|
|
async updateMessage(message: Message, authorIsBot?: boolean): Promise<void> {
|
|
await this.updateDocument(convertToSearchableMessage(message, authorIsBot));
|
|
}
|
|
|
|
async deleteMessage(messageId: MessageID): Promise<void> {
|
|
await this.deleteDocument(messageId.toString());
|
|
}
|
|
|
|
async deleteMessages(messageIds: Array<MessageID>): Promise<void> {
|
|
await this.deleteDocuments(messageIds.map((id) => id.toString()));
|
|
}
|
|
|
|
async bulkIndexMessages(messages: Array<Message>, authorBotMap?: Map<UserID, boolean>): Promise<void> {
|
|
await this.indexMessages(messages, authorBotMap);
|
|
}
|
|
|
|
async deleteChannelMessages(channelId: ChannelID): Promise<void> {
|
|
const result = await this.search('', {channelId: channelId.toString()}, {limit: Number.MAX_SAFE_INTEGER});
|
|
await this.deleteDocuments(result.hits.map((hit) => hit.id));
|
|
}
|
|
|
|
async deleteGuildMessages(guildId: GuildID): Promise<void> {
|
|
const result = await this.search('', {guildId: guildId.toString()}, {limit: Number.MAX_SAFE_INTEGER});
|
|
await this.deleteDocuments(result.hits.map((hit) => hit.id));
|
|
}
|
|
|
|
searchMessages(
|
|
query: string,
|
|
filters: MessageSearchFilters,
|
|
options?: {hitsPerPage?: number; page?: number; cursor?: Array<string>},
|
|
): Promise<SearchResult<SearchableMessage>> {
|
|
return this.search(query, filters, options);
|
|
}
|
|
}
|
|
|
|
function matchesUserFilters(doc: SearchableUser, filters: UserSearchFilters): boolean {
|
|
if (filters.isBot !== undefined && doc.isBot !== filters.isBot) return false;
|
|
if (filters.isSystem !== undefined && doc.isSystem !== filters.isSystem) return false;
|
|
if (filters.emailVerified !== undefined && doc.emailVerified !== filters.emailVerified) return false;
|
|
if (filters.emailBounced !== undefined && doc.emailBounced !== filters.emailBounced) return false;
|
|
if (filters.hasPremium !== undefined && (doc.premiumType !== null) !== filters.hasPremium) return false;
|
|
if (filters.isTempBanned !== undefined && (doc.tempBannedUntil !== null) !== filters.isTempBanned) return false;
|
|
if (filters.isPendingDeletion !== undefined && (doc.pendingDeletionAt !== null) !== filters.isPendingDeletion) {
|
|
return false;
|
|
}
|
|
if (filters.hasAcl && !filters.hasAcl.every((acl) => doc.acls.includes(acl))) return false;
|
|
if (
|
|
filters.minSuspiciousActivityFlags !== undefined &&
|
|
doc.suspiciousActivityFlags < filters.minSuspiciousActivityFlags
|
|
) {
|
|
return false;
|
|
}
|
|
if (filters.createdAtGreaterThanOrEqual !== undefined && doc.createdAt < filters.createdAtGreaterThanOrEqual) {
|
|
return false;
|
|
}
|
|
if (filters.createdAtLessThanOrEqual !== undefined && doc.createdAt > filters.createdAtLessThanOrEqual) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function collectUserText(doc: SearchableUser): Array<string | null> {
|
|
return [doc.id, doc.username, doc.email, doc.stripeCustomerId, doc.stripeSubscriptionId];
|
|
}
|
|
|
|
function sortNumericField<TDocument extends SearchableDocument, TFilters>(
|
|
field: keyof TDocument,
|
|
defaultOrder: 'asc' | 'desc',
|
|
): Sorter<TDocument, TFilters> {
|
|
return (left, right, filters, query) => {
|
|
const sortFilters = filters as {sortBy?: string; sortOrder?: 'asc' | 'desc'};
|
|
if (sortFilters.sortBy === 'relevance') {
|
|
const delta = relevanceScore(collectObjectText(right), query) - relevanceScore(collectObjectText(left), query);
|
|
if (delta !== 0) return delta;
|
|
}
|
|
const order = sortFilters.sortOrder ?? defaultOrder;
|
|
const direction = order === 'asc' ? 1 : -1;
|
|
const leftValue = Number(left[field] ?? 0);
|
|
const rightValue = Number(right[field] ?? 0);
|
|
return (leftValue - rightValue) * direction;
|
|
};
|
|
}
|
|
|
|
function collectObjectText(doc: SearchableDocument): Array<string | null> {
|
|
return Object.values(doc)
|
|
.flatMap((value) => (Array.isArray(value) ? value : [value]))
|
|
.filter((value): value is string => typeof value === 'string');
|
|
}
|
|
|
|
class InMemoryUserSearchService
|
|
extends InMemorySearchServiceBase<UserSearchFilters, SearchableUser>
|
|
implements IUserSearchService
|
|
{
|
|
constructor() {
|
|
super(matchesUserFilters, collectUserText, sortNumericField<SearchableUser, UserSearchFilters>('createdAt', 'asc'));
|
|
}
|
|
|
|
async indexUser(user: User): Promise<void> {
|
|
await this.indexDocument(convertToSearchableUser(user));
|
|
}
|
|
|
|
async indexUsers(users: Array<User>): Promise<void> {
|
|
await this.indexDocuments(users.map(convertToSearchableUser));
|
|
}
|
|
|
|
async updateUser(user: User): Promise<void> {
|
|
await this.updateDocument(convertToSearchableUser(user));
|
|
}
|
|
|
|
async deleteUser(userId: UserID): Promise<void> {
|
|
await this.deleteDocument(userId.toString());
|
|
}
|
|
|
|
async deleteUsers(userIds: Array<UserID>): Promise<void> {
|
|
await this.deleteDocuments(userIds.map((id) => id.toString()));
|
|
}
|
|
|
|
searchUsers(query: string, filters: UserSearchFilters, options?: {limit?: number; offset?: number}) {
|
|
return this.search(query, filters, options);
|
|
}
|
|
}
|
|
|
|
function matchesGuildFilters(doc: SearchableGuild, filters: GuildSearchFilters): boolean {
|
|
if (filters.ownerId !== undefined && doc.ownerId !== filters.ownerId) return false;
|
|
if (filters.verificationLevel !== undefined && doc.verificationLevel !== filters.verificationLevel) return false;
|
|
if (filters.mfaLevel !== undefined && doc.mfaLevel !== filters.mfaLevel) return false;
|
|
if (filters.nsfwLevel !== undefined && doc.nsfwLevel !== filters.nsfwLevel) return false;
|
|
if (filters.hasFeature && !filters.hasFeature.every((feature) => doc.features.includes(feature))) return false;
|
|
if (filters.isDiscoverable !== undefined && doc.isDiscoverable !== filters.isDiscoverable) return false;
|
|
if (filters.discoveryCategory !== undefined && doc.discoveryCategory !== filters.discoveryCategory) return false;
|
|
if (
|
|
filters.discoveryPrimaryLanguage !== undefined &&
|
|
doc.discoveryPrimaryLanguage !== filters.discoveryPrimaryLanguage
|
|
) {
|
|
return false;
|
|
}
|
|
if (filters.discoveryTag !== undefined && !doc.discoveryTags.includes(filters.discoveryTag)) return false;
|
|
return true;
|
|
}
|
|
|
|
function collectGuildText(doc: SearchableGuild): Array<string | null> {
|
|
return [doc.name, doc.vanityUrlCode, doc.discoveryDescription, ...doc.discoveryTags];
|
|
}
|
|
|
|
const sortGuildsByCreatedAt = sortNumericField<SearchableGuild, GuildSearchFilters>('createdAt', 'asc');
|
|
const sortGuildsByMemberCount = sortNumericField<SearchableGuild, GuildSearchFilters>('memberCount', 'desc');
|
|
|
|
function sortGuilds(left: SearchableGuild, right: SearchableGuild, filters: GuildSearchFilters, query: string): number {
|
|
const sorter = filters.sortBy === 'memberCount' ? sortGuildsByMemberCount : sortGuildsByCreatedAt;
|
|
const delta = sorter(left, right, filters, query);
|
|
if (delta !== 0) return delta;
|
|
const leftId = BigInt(left.id);
|
|
const rightId = BigInt(right.id);
|
|
if (leftId === rightId) return 0;
|
|
return leftId > rightId ? -1 : 1;
|
|
}
|
|
|
|
class InMemoryGuildSearchService
|
|
extends InMemorySearchServiceBase<GuildSearchFilters, SearchableGuild>
|
|
implements IGuildSearchService
|
|
{
|
|
constructor() {
|
|
super(matchesGuildFilters, collectGuildText, sortGuilds);
|
|
}
|
|
|
|
async indexGuild(guild: Guild, discovery?: GuildDiscoveryContext): Promise<void> {
|
|
await this.indexDocument(convertToSearchableGuild(guild, discovery));
|
|
}
|
|
|
|
async indexGuilds(guilds: Array<Guild>): Promise<void> {
|
|
await this.indexDocuments(guilds.map((guild) => convertToSearchableGuild(guild)));
|
|
}
|
|
|
|
async updateGuild(guild: Guild, discovery?: GuildDiscoveryContext): Promise<void> {
|
|
await this.updateDocument(convertToSearchableGuild(guild, discovery));
|
|
}
|
|
|
|
async deleteGuild(guildId: GuildID): Promise<void> {
|
|
await this.deleteDocument(guildId.toString());
|
|
}
|
|
|
|
async deleteGuilds(guildIds: Array<GuildID>): Promise<void> {
|
|
await this.deleteDocuments(guildIds.map((id) => id.toString()));
|
|
}
|
|
|
|
searchGuilds(query: string, filters: GuildSearchFilters, options?: SearchOptions) {
|
|
return this.search(query, filters, options);
|
|
}
|
|
}
|
|
|
|
function matchesReportFilters(doc: SearchableReport, filters: ReportSearchFilters): boolean {
|
|
if (filters.reporterId !== undefined && doc.reporterId !== filters.reporterId) return false;
|
|
if (filters.status !== undefined && doc.status !== filters.status) return false;
|
|
if (filters.reportType !== undefined && doc.reportType !== filters.reportType) return false;
|
|
if (filters.category !== undefined && doc.category !== filters.category) return false;
|
|
if (filters.reportedUserId !== undefined && doc.reportedUserId !== filters.reportedUserId) return false;
|
|
if (filters.reportedGuildId !== undefined && doc.reportedGuildId !== filters.reportedGuildId) return false;
|
|
if (filters.reportedMessageId !== undefined && doc.reportedMessageId !== filters.reportedMessageId) return false;
|
|
if (filters.guildContextId !== undefined && doc.guildContextId !== filters.guildContextId) return false;
|
|
if (filters.resolvedByAdminId !== undefined && doc.resolvedByAdminId !== filters.resolvedByAdminId) return false;
|
|
if (filters.isResolved !== undefined && (doc.resolvedAt !== null) !== filters.isResolved) return false;
|
|
return true;
|
|
}
|
|
|
|
function collectReportText(doc: SearchableReport): Array<string | null> {
|
|
return [
|
|
doc.id,
|
|
doc.category,
|
|
doc.additionalInfo,
|
|
doc.reportedGuildName,
|
|
doc.reportedChannelName,
|
|
doc.publicComment,
|
|
doc.reportedUserId,
|
|
doc.reportedGuildId,
|
|
doc.reportedMessageId,
|
|
];
|
|
}
|
|
|
|
class InMemoryReportSearchService
|
|
extends InMemorySearchServiceBase<ReportSearchFilters, SearchableReport>
|
|
implements IReportSearchService
|
|
{
|
|
constructor() {
|
|
super(
|
|
matchesReportFilters,
|
|
collectReportText,
|
|
sortNumericField<SearchableReport, ReportSearchFilters>('createdAt', 'desc'),
|
|
);
|
|
}
|
|
|
|
async indexReport(report: IARSubmission): Promise<void> {
|
|
await this.indexDocument(convertToSearchableReport(report));
|
|
}
|
|
|
|
async indexReports(reports: Array<IARSubmission>): Promise<void> {
|
|
await this.indexDocuments(reports.map(convertToSearchableReport));
|
|
}
|
|
|
|
async updateReport(report: IARSubmission): Promise<void> {
|
|
await this.updateDocument(convertToSearchableReport(report));
|
|
}
|
|
|
|
async deleteReport(reportId: ReportID): Promise<void> {
|
|
await this.deleteDocument(reportId.toString());
|
|
}
|
|
|
|
async deleteReports(reportIds: Array<ReportID>): Promise<void> {
|
|
await this.deleteDocuments(reportIds.map((id) => id.toString()));
|
|
}
|
|
|
|
searchReports(query: string, filters: ReportSearchFilters, options?: {limit?: number; offset?: number}) {
|
|
return this.search(query, filters, options);
|
|
}
|
|
|
|
listReportsByReporter(reporterId: UserID, limit?: number, offset?: number) {
|
|
return this.searchReports('', {reporterId: reporterId.toString()}, {limit, offset});
|
|
}
|
|
|
|
listReportsByStatus(status: number, limit?: number, offset?: number) {
|
|
return this.searchReports('', {status}, {limit, offset});
|
|
}
|
|
|
|
listReportsByType(reportType: number, limit?: number, offset?: number) {
|
|
return this.searchReports('', {reportType}, {limit, offset});
|
|
}
|
|
|
|
listReportsByReportedUser(reportedUserId: UserID, limit?: number, offset?: number) {
|
|
return this.searchReports('', {reportedUserId: reportedUserId.toString()}, {limit, offset});
|
|
}
|
|
|
|
listReportsByReportedGuild(reportedGuildId: GuildID, limit?: number, offset?: number) {
|
|
return this.searchReports('', {reportedGuildId: reportedGuildId.toString()}, {limit, offset});
|
|
}
|
|
|
|
listReportsByReportedMessage(reportedMessageId: MessageID, limit?: number, offset?: number) {
|
|
return this.searchReports('', {reportedMessageId: reportedMessageId.toString()}, {limit, offset});
|
|
}
|
|
}
|
|
|
|
function matchesAuditLogFilters(doc: SearchableAuditLog, filters: AuditLogSearchFilters): boolean {
|
|
if (filters.adminUserId !== undefined && doc.adminUserId !== filters.adminUserId) return false;
|
|
if (filters.targetType !== undefined && doc.targetType !== filters.targetType) return false;
|
|
if (filters.targetId !== undefined && doc.targetId !== filters.targetId) return false;
|
|
if (filters.action !== undefined && doc.action !== filters.action) return false;
|
|
return true;
|
|
}
|
|
|
|
function collectAuditLogText(doc: SearchableAuditLog): Array<string | null> {
|
|
return [doc.id, doc.adminUserId, doc.targetType, doc.targetId, doc.action, doc.auditLogReason];
|
|
}
|
|
|
|
class InMemoryAuditLogSearchService
|
|
extends InMemorySearchServiceBase<AuditLogSearchFilters, SearchableAuditLog>
|
|
implements IAuditLogSearchService
|
|
{
|
|
constructor() {
|
|
super(
|
|
matchesAuditLogFilters,
|
|
collectAuditLogText,
|
|
sortNumericField<SearchableAuditLog, AuditLogSearchFilters>('createdAt', 'desc'),
|
|
);
|
|
}
|
|
|
|
async indexAuditLog(log: AdminAuditLog): Promise<void> {
|
|
await this.indexDocument(convertToSearchableAuditLog(log));
|
|
}
|
|
|
|
async indexAuditLogs(logs: Array<AdminAuditLog>): Promise<void> {
|
|
await this.indexDocuments(logs.map(convertToSearchableAuditLog));
|
|
}
|
|
|
|
async updateAuditLog(log: AdminAuditLog): Promise<void> {
|
|
await this.updateDocument(convertToSearchableAuditLog(log));
|
|
}
|
|
|
|
async deleteAuditLog(logId: bigint): Promise<void> {
|
|
await this.deleteDocument(logId.toString());
|
|
}
|
|
|
|
async deleteAuditLogs(logIds: Array<bigint>): Promise<void> {
|
|
await this.deleteDocuments(logIds.map((id) => id.toString()));
|
|
}
|
|
|
|
searchAuditLogs(query: string, filters: AuditLogSearchFilters, options?: {limit?: number; offset?: number}) {
|
|
return this.search(query, filters, options);
|
|
}
|
|
}
|
|
|
|
function matchesGuildMemberFilters(doc: SearchableGuildMember, filters: GuildMemberSearchFilters): boolean {
|
|
if (doc.guildId !== filters.guildId) return false;
|
|
if (filters.roleIds && !filters.roleIds.every((roleId) => doc.roleIds.includes(roleId))) return false;
|
|
if (filters.joinedAtGte !== undefined && doc.joinedAt < filters.joinedAtGte) return false;
|
|
if (filters.joinedAtLte !== undefined && doc.joinedAt > filters.joinedAtLte) return false;
|
|
if (filters.joinSourceType && !filters.joinSourceType.includes(doc.joinSourceType!)) return false;
|
|
if (filters.sourceInviteCode && !filters.sourceInviteCode.includes(doc.sourceInviteCode ?? '')) return false;
|
|
if (filters.userCreatedAtGte !== undefined && doc.userCreatedAt < filters.userCreatedAtGte) return false;
|
|
if (filters.userCreatedAtLte !== undefined && doc.userCreatedAt > filters.userCreatedAtLte) return false;
|
|
if (filters.isBot !== undefined && doc.isBot !== filters.isBot) return false;
|
|
const memberQuery = filters.query;
|
|
if (memberQuery && !collectGuildMemberText(doc).some((value) => containsText(value, memberQuery))) return false;
|
|
return true;
|
|
}
|
|
|
|
function collectGuildMemberText(doc: SearchableGuildMember): Array<string | null> {
|
|
return [doc.id, doc.userId, doc.username, doc.usernameSearch, doc.discriminator, doc.globalName, doc.nickname];
|
|
}
|
|
|
|
function sortGuildMemberDocs(
|
|
left: SearchableGuildMember,
|
|
right: SearchableGuildMember,
|
|
filters: GuildMemberSearchFilters,
|
|
query: string,
|
|
): number {
|
|
if (filters.sortBy === 'relevance') {
|
|
const delta =
|
|
relevanceScore(collectGuildMemberText(right), query) - relevanceScore(collectGuildMemberText(left), query);
|
|
if (delta !== 0) return delta;
|
|
}
|
|
const direction = filters.sortOrder === 'asc' ? 1 : -1;
|
|
return (left.joinedAt - right.joinedAt) * direction;
|
|
}
|
|
|
|
class InMemoryGuildMemberSearchService
|
|
extends InMemorySearchServiceBase<GuildMemberSearchFilters, SearchableGuildMember>
|
|
implements IGuildMemberSearchService
|
|
{
|
|
constructor() {
|
|
super(matchesGuildMemberFilters, collectGuildMemberText, sortGuildMemberDocs);
|
|
}
|
|
|
|
async indexMember(member: GuildMember, user: User): Promise<void> {
|
|
await this.indexDocument(convertToSearchableGuildMember(member, user));
|
|
}
|
|
|
|
async indexMembers(members: Array<{member: GuildMember; user: User}>): Promise<void> {
|
|
await this.indexDocuments(members.map(({member, user}) => convertToSearchableGuildMember(member, user)));
|
|
}
|
|
|
|
async updateMember(member: GuildMember, user: User): Promise<void> {
|
|
await this.updateDocument(convertToSearchableGuildMember(member, user));
|
|
}
|
|
|
|
async deleteMember(guildId: GuildID, userId: UserID): Promise<void> {
|
|
await this.deleteDocument(`${guildId}_${userId}`);
|
|
}
|
|
|
|
async deleteGuildMembers(guildId: GuildID): Promise<void> {
|
|
const result = await this.search('', {guildId: guildId.toString()}, {limit: Number.MAX_SAFE_INTEGER});
|
|
await this.deleteDocuments(result.hits.map((hit) => hit.id));
|
|
}
|
|
|
|
searchMembers(query: string, filters: GuildMemberSearchFilters, options?: {limit?: number; offset?: number}) {
|
|
return this.search(query, filters, options);
|
|
}
|
|
}
|
|
|
|
export class InMemorySearchProvider implements ISearchProvider {
|
|
private readonly messages = new InMemoryMessageSearchService();
|
|
private readonly guilds = new InMemoryGuildSearchService();
|
|
private readonly users = new InMemoryUserSearchService();
|
|
private readonly reports = new InMemoryReportSearchService();
|
|
private readonly auditLogs = new InMemoryAuditLogSearchService();
|
|
private readonly guildMembers = new InMemoryGuildMemberSearchService();
|
|
|
|
async initialize(): Promise<void> {
|
|
await Promise.all([
|
|
this.messages.initialize(),
|
|
this.guilds.initialize(),
|
|
this.users.initialize(),
|
|
this.reports.initialize(),
|
|
this.auditLogs.initialize(),
|
|
this.guildMembers.initialize(),
|
|
]);
|
|
}
|
|
|
|
async shutdown(): Promise<void> {
|
|
await Promise.all([
|
|
this.messages.shutdown(),
|
|
this.guilds.shutdown(),
|
|
this.users.shutdown(),
|
|
this.reports.shutdown(),
|
|
this.auditLogs.shutdown(),
|
|
this.guildMembers.shutdown(),
|
|
]);
|
|
}
|
|
|
|
getMessageSearchService(): IMessageSearchService {
|
|
return this.messages;
|
|
}
|
|
|
|
getGuildSearchService(): IGuildSearchService {
|
|
return this.guilds;
|
|
}
|
|
|
|
getUserSearchService(): IUserSearchService {
|
|
return this.users;
|
|
}
|
|
|
|
getReportSearchService(): IReportSearchService {
|
|
return this.reports;
|
|
}
|
|
|
|
getAuditLogSearchService(): IAuditLogSearchService {
|
|
return this.auditLogs;
|
|
}
|
|
|
|
getGuildMemberSearchService(): IGuildMemberSearchService {
|
|
return this.guildMembers;
|
|
}
|
|
}
|