refactor: squash branch changes
This commit is contained in:
@@ -189,8 +189,6 @@ const HOSTED_ONLY_GUILD_FEATURES: ReadonlyArray<string> = [
|
||||
GuildFeatures.VISIONARY,
|
||||
GuildFeatures.VIP_VOICE,
|
||||
GuildFeatures.OPERATOR,
|
||||
GuildFeatures.MANAGED_MESSAGE_SCHEDULING,
|
||||
GuildFeatures.MANAGED_EXPRESSION_PACKS,
|
||||
];
|
||||
|
||||
export const SELF_HOSTED_GUILD_FEATURES: ReadonlyArray<string> = GUILD_FEATURES.filter(
|
||||
|
||||
@@ -140,6 +140,12 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
|
||||
kv: {
|
||||
url: master.internal.kv,
|
||||
mode: ((master.internal as {kv_mode?: string}).kv_mode ?? 'standalone') as 'standalone' | 'cluster',
|
||||
clusterNodes:
|
||||
(master.internal as {kv_cluster_nodes?: Array<{host: string; port: number}>}).kv_cluster_nodes ?? [],
|
||||
clusterNatMap:
|
||||
(master.internal as {kv_cluster_nat_map?: Record<string, {host: string; port: number}>}).kv_cluster_nat_map ??
|
||||
{},
|
||||
},
|
||||
|
||||
nats: {
|
||||
@@ -240,8 +246,13 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
defaultRegion: master.integrations.voice.default_region,
|
||||
},
|
||||
search: {
|
||||
engine: ((master.integrations.search as {engine?: string}).engine ?? 'meilisearch') as
|
||||
| 'meilisearch'
|
||||
| 'elasticsearch',
|
||||
url: master.integrations.search.url,
|
||||
apiKey: master.integrations.search.api_key,
|
||||
username: (master.integrations.search as {username?: string}).username ?? '',
|
||||
password: (master.integrations.search as {password?: string}).password ?? '',
|
||||
},
|
||||
stripe: {
|
||||
enabled: master.integrations.stripe.enabled,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {ElasticsearchSearchProvider} from '@fluxer/api/src/infrastructure/ElasticsearchSearchProvider';
|
||||
import {MeilisearchSearchProvider} from '@fluxer/api/src/infrastructure/MeilisearchSearchProvider';
|
||||
import {NullSearchProvider} from '@fluxer/api/src/infrastructure/NullSearchProvider';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
@@ -34,6 +35,29 @@ import {DEFAULT_SEARCH_CLIENT_TIMEOUT_MS} from '@fluxer/constants/src/Timeouts';
|
||||
let searchProvider: ISearchProvider | null = null;
|
||||
|
||||
export function createSearchProvider(): ISearchProvider {
|
||||
const engine = Config.search.engine ?? 'meilisearch';
|
||||
|
||||
if (engine === 'elasticsearch') {
|
||||
if (!Config.search.apiKey && !Config.search.username) {
|
||||
Logger.warn('Elasticsearch credentials are not configured; search will be unavailable');
|
||||
return new NullSearchProvider();
|
||||
}
|
||||
|
||||
Logger.info({url: Config.search.url}, 'Using Elasticsearch for search');
|
||||
return new ElasticsearchSearchProvider({
|
||||
config: {
|
||||
node: Config.search.url,
|
||||
auth: Config.search.apiKey
|
||||
? {apiKey: Config.search.apiKey}
|
||||
: Config.search.username
|
||||
? {username: Config.search.username, password: Config.search.password}
|
||||
: undefined,
|
||||
requestTimeoutMs: DEFAULT_SEARCH_CLIENT_TIMEOUT_MS,
|
||||
},
|
||||
logger: Logger,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Config.search.apiKey) {
|
||||
Logger.warn('Search API key is not configured; search will be unavailable');
|
||||
return new NullSearchProvider();
|
||||
|
||||
@@ -27,7 +27,7 @@ import {ScheduledMessage} from '@fluxer/api/src/models/ScheduledMessage';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import type {ScheduledMessageRepository} from '@fluxer/api/src/user/repositories/ScheduledMessageRepository';
|
||||
import {ManagedTraits} from '@fluxer/constants/src/ManagedTraits';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
@@ -134,7 +134,7 @@ export class ScheduledMessageService {
|
||||
private async upsertScheduledMessage(params: UpdateScheduleParams): Promise<ScheduledMessage> {
|
||||
const {user, channelId, data, scheduledLocalAt, timezone} = params;
|
||||
|
||||
if (!user.traits.has(ManagedTraits.MESSAGE_SCHEDULING)) {
|
||||
if ((user.flags & UserFlags.STAFF) === 0n) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {ManagedTraits} from '@fluxer/constants/src/ManagedTraits';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {ScheduledMessageResponseSchema} from '@fluxer/schema/src/domains/message/ScheduledMessageSchemas';
|
||||
import type {z} from 'zod';
|
||||
@@ -45,14 +44,8 @@ export async function createGuildChannel(
|
||||
return json as {id: string};
|
||||
}
|
||||
|
||||
export async function enableMessageSchedulingForGuild(harness: ApiTestHarness, guildId: string): Promise<void> {
|
||||
await createBuilder<void>(harness, '')
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({
|
||||
add_features: [ManagedTraits.MESSAGE_SCHEDULING],
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
export async function grantStaffAccess(harness: ApiTestHarness, userId: string): Promise<void> {
|
||||
await createBuilderWithoutAuth(harness).patch(`/test/users/${userId}/flags`).body({flags: 1}).execute();
|
||||
}
|
||||
|
||||
export async function scheduleMessage(
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createGuildChannel,
|
||||
enableMessageSchedulingForGuild,
|
||||
grantStaffAccess,
|
||||
scheduleMessage,
|
||||
} from '@fluxer/api/src/channel/tests/ScheduledMessageTestUtils';
|
||||
import {createGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
@@ -29,7 +29,7 @@ import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/Ap
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Scheduled message trait gating', () => {
|
||||
describe('Scheduled message staff gating', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -40,7 +40,7 @@ describe('Scheduled message trait gating', () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('rejects scheduling message before trait enabled', async () => {
|
||||
it('rejects scheduling message before staff flag granted', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'scheduled-flag');
|
||||
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled-channel');
|
||||
@@ -56,7 +56,7 @@ describe('Scheduled message trait gating', () => {
|
||||
.expect(403)
|
||||
.execute();
|
||||
|
||||
await enableMessageSchedulingForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, owner.userId);
|
||||
|
||||
const scheduled = await scheduleMessage(harness, channel.id, owner.token, 'enabled now');
|
||||
expect(scheduled.id).toBeDefined();
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createGuildChannel,
|
||||
enableMessageSchedulingForGuild,
|
||||
grantStaffAccess,
|
||||
scheduleMessage,
|
||||
} from '@fluxer/api/src/channel/tests/ScheduledMessageTestUtils';
|
||||
import {createGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
@@ -49,7 +49,7 @@ describe('Scheduled message validation', () => {
|
||||
it('rejects scheduling message with past time', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'sched-validation-past');
|
||||
await enableMessageSchedulingForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, owner.userId);
|
||||
const channel = await createGuildChannel(harness, owner.token, guild.id, 'test');
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('Scheduled message validation', () => {
|
||||
it('rejects scheduling message exceeding 30 days', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'sched-validation-30day');
|
||||
await enableMessageSchedulingForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, owner.userId);
|
||||
const channel = await createGuildChannel(harness, owner.token, guild.id, 'test');
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('Scheduled message validation', () => {
|
||||
it('rejects scheduling message with invalid timezone', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'sched-validation-tz');
|
||||
await enableMessageSchedulingForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, owner.userId);
|
||||
const channel = await createGuildChannel(harness, owner.token, guild.id, 'test');
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('Scheduled message validation', () => {
|
||||
it('accepts scheduling message at 30 day boundary', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'sched-validation-boundary');
|
||||
await enableMessageSchedulingForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, owner.userId);
|
||||
const channel = await createGuildChannel(harness, owner.token, guild.id, 'test');
|
||||
|
||||
const futureTime = new Date(Date.now() + 29 * 24 * 60 * 60 * 1000 + 23 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
@@ -21,9 +21,9 @@ import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createChannelInvite,
|
||||
createGuildChannel,
|
||||
enableMessageSchedulingForGuild,
|
||||
getChannelMessages,
|
||||
getScheduledMessage,
|
||||
grantStaffAccess,
|
||||
joinGuild,
|
||||
messageFromAuthorContains,
|
||||
removeGuildMember,
|
||||
@@ -49,7 +49,7 @@ describe('Scheduled message worker lifecycle', () => {
|
||||
it('delivers scheduled message when permissions remain', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'scheduled-messages');
|
||||
await enableMessageSchedulingForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, owner.userId);
|
||||
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled');
|
||||
|
||||
const content = 'scheduled message goes through';
|
||||
@@ -67,7 +67,7 @@ describe('Scheduled message worker lifecycle', () => {
|
||||
it('reschedules pending message before worker execution', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'scheduled-messages');
|
||||
await enableMessageSchedulingForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, owner.userId);
|
||||
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled');
|
||||
|
||||
const content = 'scheduled message initial content';
|
||||
@@ -106,7 +106,7 @@ describe('Scheduled message worker lifecycle', () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'scheduled-messages');
|
||||
await enableMessageSchedulingForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, member.userId);
|
||||
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled');
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, channel.id);
|
||||
|
||||
@@ -21,8 +21,8 @@ import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createChannelInvite,
|
||||
createGuildChannel,
|
||||
enableMessageSchedulingForGuild,
|
||||
getScheduledMessages,
|
||||
grantStaffAccess,
|
||||
joinGuild,
|
||||
removeGuildMember,
|
||||
scheduleMessage,
|
||||
@@ -47,7 +47,7 @@ describe('Scheduled messages list invalid entry', () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'scheduled-invalid');
|
||||
await enableMessageSchedulingForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, member.userId);
|
||||
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled-invalid');
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, channel.id);
|
||||
|
||||
@@ -21,8 +21,8 @@ import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
cancelScheduledMessage,
|
||||
createGuildChannel,
|
||||
enableMessageSchedulingForGuild,
|
||||
getScheduledMessages,
|
||||
grantStaffAccess,
|
||||
scheduleMessage,
|
||||
} from '@fluxer/api/src/channel/tests/ScheduledMessageTestUtils';
|
||||
import {createGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
@@ -43,7 +43,7 @@ describe('Scheduled messages list lifecycle', () => {
|
||||
it('lists scheduled messages and removes after cancel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'scheduled-list');
|
||||
await enableMessageSchedulingForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, owner.userId);
|
||||
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled-list');
|
||||
|
||||
const content = 'list scheduled';
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface APIConfig {
|
||||
|
||||
kv: {
|
||||
url: string;
|
||||
mode: 'standalone' | 'cluster';
|
||||
clusterNodes: Array<{host: string; port: number}>;
|
||||
clusterNatMap: Record<string, {host: string; port: number}>;
|
||||
};
|
||||
|
||||
nats: {
|
||||
@@ -149,8 +152,11 @@ export interface APIConfig {
|
||||
};
|
||||
|
||||
search: {
|
||||
engine: 'meilisearch' | 'elasticsearch';
|
||||
url: string;
|
||||
apiKey: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
stripe: {
|
||||
|
||||
@@ -36,7 +36,6 @@ import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddlewa
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {GuildManagedTraitService} from '@fluxer/api/src/traits/GuildManagedTraitService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {IWebhookRepository} from '@fluxer/api/src/webhook/IWebhookRepository';
|
||||
import type {GuildCreateRequest, GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
@@ -64,7 +63,6 @@ export class GuildDataService {
|
||||
private readonly webhookRepository: IWebhookRepository,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
private readonly guildManagedTraitService?: GuildManagedTraitService,
|
||||
) {
|
||||
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService);
|
||||
|
||||
@@ -81,7 +79,6 @@ export class GuildDataService {
|
||||
this.helpers,
|
||||
this.limitConfigService,
|
||||
new GuildDiscoveryRepository(),
|
||||
this.guildManagedTraitService,
|
||||
);
|
||||
|
||||
this.vanityService = new GuildVanityService(this.guildRepository, this.inviteRepository, this.helpers);
|
||||
|
||||
@@ -34,7 +34,6 @@ import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheSer
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {GuildManagedTraitService} from '@fluxer/api/src/traits/GuildManagedTraitService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
|
||||
@@ -62,7 +61,6 @@ export class GuildMemberService {
|
||||
rateLimitService: IRateLimitService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
limitConfigService: LimitConfigService,
|
||||
guildManagedTraitService?: GuildManagedTraitService,
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.authService = new GuildMemberAuthService(gatewayService);
|
||||
@@ -82,7 +80,6 @@ export class GuildMemberService {
|
||||
this.validationService,
|
||||
this.guildAuditLogService,
|
||||
limitConfigService,
|
||||
guildManagedTraitService,
|
||||
this.searchIndexService,
|
||||
);
|
||||
this.roleService = new GuildMemberRoleService(
|
||||
|
||||
@@ -47,7 +47,6 @@ import type {GuildAuditLog} from '@fluxer/api/src/models/GuildAuditLog';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {Webhook} from '@fluxer/api/src/models/Webhook';
|
||||
import type {GuildManagedTraitService} from '@fluxer/api/src/traits/GuildManagedTraitService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {getCachedUserPartialResponses} from '@fluxer/api/src/user/UserCacheHelpers';
|
||||
import type {IWebhookRepository} from '@fluxer/api/src/webhook/IWebhookRepository';
|
||||
@@ -169,7 +168,6 @@ export class GuildService {
|
||||
webhookRepository: IWebhookRepository,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
limitConfigService: LimitConfigService,
|
||||
guildManagedTraitService?: GuildManagedTraitService,
|
||||
) {
|
||||
this.gatewayService = gatewayService;
|
||||
this.guildRepository = guildRepository;
|
||||
@@ -189,7 +187,6 @@ export class GuildService {
|
||||
webhookRepository,
|
||||
guildAuditLogService,
|
||||
limitConfigService,
|
||||
guildManagedTraitService,
|
||||
);
|
||||
this.members = new GuildMemberService(
|
||||
guildRepository,
|
||||
@@ -201,7 +198,6 @@ export class GuildService {
|
||||
rateLimitService,
|
||||
guildAuditLogService,
|
||||
limitConfigService,
|
||||
guildManagedTraitService,
|
||||
);
|
||||
this.roles = new GuildRoleService(
|
||||
guildRepository,
|
||||
|
||||
@@ -50,11 +50,9 @@ import {
|
||||
} from '@fluxer/api/src/Tables';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import {withSpan} from '@fluxer/api/src/telemetry/Tracing';
|
||||
import type {GuildManagedTraitService} from '@fluxer/api/src/traits/GuildManagedTraitService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {mapUserSettingsToResponse} from '@fluxer/api/src/user/UserMappers';
|
||||
import {removeGuildFromUserFolders} from '@fluxer/api/src/user/utils/GuildFolderUtils';
|
||||
import {areFeatureSetsEqual} from '@fluxer/api/src/utils/featureUtils';
|
||||
import type {IWebhookRepository} from '@fluxer/api/src/webhook/IWebhookRepository';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {ChannelTypes, DEFAULT_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
@@ -106,7 +104,6 @@ export class GuildOperationsService {
|
||||
private readonly helpers: GuildDataHelpers,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
private readonly discoveryRepository: IGuildDiscoveryRepository,
|
||||
private readonly guildManagedTraitService?: GuildManagedTraitService,
|
||||
) {}
|
||||
|
||||
async getGuild({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildResponse> {
|
||||
@@ -663,9 +660,7 @@ export class GuildOperationsService {
|
||||
sanitizedSystemChannelFlags = data.system_channel_flags & SUPPORTED_SYSTEM_CHANNEL_FLAGS;
|
||||
}
|
||||
|
||||
const previousFeatures = new Set(currentGuild.features);
|
||||
let updatedFeatures = currentGuild.features;
|
||||
let featuresChanged = false;
|
||||
if (data.features !== undefined) {
|
||||
const newFeatures = new Set(currentGuild.features);
|
||||
|
||||
@@ -692,7 +687,6 @@ export class GuildOperationsService {
|
||||
}
|
||||
|
||||
updatedFeatures = newFeatures;
|
||||
featuresChanged = !areFeatureSetsEqual(previousFeatures, updatedFeatures);
|
||||
}
|
||||
|
||||
let messageHistoryCutoff: Date | null | undefined;
|
||||
@@ -764,14 +758,6 @@ export class GuildOperationsService {
|
||||
Logger.error({error, guildId}, 'Failed to commit asset changes after successful guild update');
|
||||
}
|
||||
|
||||
if (featuresChanged && this.guildManagedTraitService) {
|
||||
await this.guildManagedTraitService.reconcileTraitsForGuildFeatureChange({
|
||||
guildId,
|
||||
previousFeatures,
|
||||
newFeatures: updatedFeatures,
|
||||
});
|
||||
}
|
||||
|
||||
await this.helpers.dispatchGuildUpdate(updatedGuild);
|
||||
|
||||
const guildSearchService = getGuildSearchService();
|
||||
|
||||
@@ -43,7 +43,6 @@ import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
|
||||
import type {GuildManagedTraitService} from '@fluxer/api/src/traits/GuildManagedTraitService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '@fluxer/api/src/user/UserMappers';
|
||||
import {removeGuildFromUserFolders} from '@fluxer/api/src/user/utils/GuildFolderUtils';
|
||||
@@ -117,7 +116,6 @@ export class GuildMemberOperationsService {
|
||||
private readonly validationService: GuildMemberValidationService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
private readonly guildManagedTraitService?: GuildManagedTraitService,
|
||||
private readonly searchIndexService?: GuildMemberSearchIndexService,
|
||||
) {}
|
||||
|
||||
@@ -373,10 +371,6 @@ export class GuildMemberOperationsService {
|
||||
});
|
||||
}
|
||||
|
||||
if (guild && this.guildManagedTraitService) {
|
||||
await this.guildManagedTraitService.reconcileTraitsForGuildLeave({guild, userId});
|
||||
}
|
||||
|
||||
await this.gatewayService.leaveGuild({userId: targetId, guildId});
|
||||
succeeded = true;
|
||||
} finally {
|
||||
@@ -502,13 +496,6 @@ export class GuildMemberOperationsService {
|
||||
void this.searchIndexService.indexMember(guildMember, user);
|
||||
}
|
||||
|
||||
if (this.guildManagedTraitService) {
|
||||
await this.guildManagedTraitService.ensureTraitsForGuildJoin({
|
||||
guild,
|
||||
user,
|
||||
});
|
||||
}
|
||||
|
||||
if (sendJoinMessage && !(guild.systemChannelFlags & SystemChannelFlags.SUPPRESS_JOIN_NOTIFICATIONS)) {
|
||||
await this.channelService.sendJoinSystemMessage({guildId, userId, requestCache});
|
||||
}
|
||||
|
||||
@@ -440,6 +440,7 @@ describe('Guild Channel Positions', () => {
|
||||
expect(frontDoorIndex).toBeGreaterThan(milsimsIndex);
|
||||
expect(frontDoorIndex).toBeLessThan(coopGamesIndex);
|
||||
});
|
||||
|
||||
test('should reject text channels being positioned below voice channels via preceding_sibling_id', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
124
packages/api/src/infrastructure/ElasticsearchSearchProvider.tsx
Normal file
124
packages/api/src/infrastructure/ElasticsearchSearchProvider.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 {ILogger} from '@fluxer/api/src/ILogger';
|
||||
import {ElasticsearchAuditLogSearchService} from '@fluxer/api/src/search/elasticsearch/ElasticsearchAuditLogSearchService';
|
||||
import {ElasticsearchGuildMemberSearchService} from '@fluxer/api/src/search/elasticsearch/ElasticsearchGuildMemberSearchService';
|
||||
import {ElasticsearchGuildSearchService} from '@fluxer/api/src/search/elasticsearch/ElasticsearchGuildSearchService';
|
||||
import {ElasticsearchMessageSearchService} from '@fluxer/api/src/search/elasticsearch/ElasticsearchMessageSearchService';
|
||||
import {ElasticsearchReportSearchService} from '@fluxer/api/src/search/elasticsearch/ElasticsearchReportSearchService';
|
||||
import {ElasticsearchUserSearchService} from '@fluxer/api/src/search/elasticsearch/ElasticsearchUserSearchService';
|
||||
import type {IAuditLogSearchService} from '@fluxer/api/src/search/IAuditLogSearchService';
|
||||
import type {IGuildMemberSearchService} from '@fluxer/api/src/search/IGuildMemberSearchService';
|
||||
import type {IGuildSearchService} from '@fluxer/api/src/search/IGuildSearchService';
|
||||
import type {IMessageSearchService} from '@fluxer/api/src/search/IMessageSearchService';
|
||||
import type {IReportSearchService} from '@fluxer/api/src/search/IReportSearchService';
|
||||
import type {ISearchProvider} from '@fluxer/api/src/search/ISearchProvider';
|
||||
import type {IUserSearchService} from '@fluxer/api/src/search/IUserSearchService';
|
||||
import {
|
||||
createElasticsearchClient,
|
||||
type ElasticsearchClientConfig,
|
||||
} from '@fluxer/elasticsearch_search/src/ElasticsearchClient';
|
||||
|
||||
export interface ElasticsearchSearchProviderOptions {
|
||||
config: ElasticsearchClientConfig;
|
||||
logger: ILogger;
|
||||
}
|
||||
|
||||
export class ElasticsearchSearchProvider implements ISearchProvider {
|
||||
private readonly logger: ILogger;
|
||||
private readonly config: ElasticsearchClientConfig;
|
||||
|
||||
private messageService: ElasticsearchMessageSearchService | null = null;
|
||||
private guildService: ElasticsearchGuildSearchService | null = null;
|
||||
private userService: ElasticsearchUserSearchService | null = null;
|
||||
private reportService: ElasticsearchReportSearchService | null = null;
|
||||
private auditLogService: ElasticsearchAuditLogSearchService | null = null;
|
||||
private guildMemberService: ElasticsearchGuildMemberSearchService | null = null;
|
||||
|
||||
constructor(options: ElasticsearchSearchProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.config = options.config;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const client = createElasticsearchClient(this.config);
|
||||
|
||||
this.messageService = new ElasticsearchMessageSearchService({client});
|
||||
this.guildService = new ElasticsearchGuildSearchService({client});
|
||||
this.userService = new ElasticsearchUserSearchService({client});
|
||||
this.reportService = new ElasticsearchReportSearchService({client});
|
||||
this.auditLogService = new ElasticsearchAuditLogSearchService({client});
|
||||
this.guildMemberService = new ElasticsearchGuildMemberSearchService({client});
|
||||
|
||||
await Promise.all([
|
||||
this.messageService.initialize(),
|
||||
this.guildService.initialize(),
|
||||
this.userService.initialize(),
|
||||
this.reportService.initialize(),
|
||||
this.auditLogService.initialize(),
|
||||
this.guildMemberService.initialize(),
|
||||
]);
|
||||
|
||||
this.logger.info({node: this.config.node}, 'ElasticsearchSearchProvider initialised');
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
const services = [
|
||||
this.messageService,
|
||||
this.guildService,
|
||||
this.userService,
|
||||
this.reportService,
|
||||
this.auditLogService,
|
||||
this.guildMemberService,
|
||||
];
|
||||
await Promise.all(services.filter((s) => s != null).map((s) => s.shutdown()));
|
||||
|
||||
this.messageService = null;
|
||||
this.guildService = null;
|
||||
this.userService = null;
|
||||
this.reportService = null;
|
||||
this.auditLogService = null;
|
||||
this.guildMemberService = null;
|
||||
}
|
||||
|
||||
getMessageSearchService(): IMessageSearchService | null {
|
||||
return this.messageService;
|
||||
}
|
||||
|
||||
getGuildSearchService(): IGuildSearchService | null {
|
||||
return this.guildService;
|
||||
}
|
||||
|
||||
getUserSearchService(): IUserSearchService | null {
|
||||
return this.userService;
|
||||
}
|
||||
|
||||
getReportSearchService(): IReportSearchService | null {
|
||||
return this.reportService;
|
||||
}
|
||||
|
||||
getAuditLogSearchService(): IAuditLogSearchService | null {
|
||||
return this.auditLogService;
|
||||
}
|
||||
|
||||
getGuildMemberSearchService(): IGuildMemberSearchService | null {
|
||||
return this.guildMemberService;
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,6 @@ import {SearchService} from '@fluxer/api/src/search/SearchService';
|
||||
import {StripeService} from '@fluxer/api/src/stripe/StripeService';
|
||||
import {TenorService} from '@fluxer/api/src/tenor/TenorService';
|
||||
import {ThemeService} from '@fluxer/api/src/theme/ThemeService';
|
||||
import {GuildManagedTraitService} from '@fluxer/api/src/traits/GuildManagedTraitService';
|
||||
import type {HonoEnv} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {EmailChangeRepository} from '@fluxer/api/src/user/repositories/auth/EmailChangeRepository';
|
||||
import {PasswordChangeRepository} from '@fluxer/api/src/user/repositories/auth/PasswordChangeRepository';
|
||||
@@ -375,12 +374,6 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
||||
const themeService = new ThemeService(storageService);
|
||||
const csamEvidenceRetentionService = new CsamEvidenceRetentionService(storageService);
|
||||
const gatewayService = getGatewayService();
|
||||
const guildManagedTraitService = new GuildManagedTraitService({
|
||||
userRepository,
|
||||
guildRepository,
|
||||
gatewayService,
|
||||
userCacheService,
|
||||
});
|
||||
const alertService = getAlertService();
|
||||
const workerService = getWorkerService();
|
||||
const botMfaMirrorService = new BotMfaMirrorService(applicationRepository, userRepository, gatewayService);
|
||||
@@ -511,7 +504,6 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
||||
webhookRepository,
|
||||
guildAuditLogService,
|
||||
limitConfigService,
|
||||
guildManagedTraitService,
|
||||
);
|
||||
|
||||
const discoveryRepository = new GuildDiscoveryRepository();
|
||||
@@ -963,7 +955,6 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
||||
ctx.set('csamEvidenceRetentionService', csamEvidenceRetentionService);
|
||||
ctx.set('instanceConfigRepository', instanceConfigRepository);
|
||||
ctx.set('limitConfigService', limitConfigService);
|
||||
ctx.set('guildManagedTraitService', guildManagedTraitService);
|
||||
ctx.set('errorI18nService', errorI18nService);
|
||||
|
||||
const ncmecReporter = new NcmecReporter({config: createNcmecApiConfig(), fetch});
|
||||
|
||||
@@ -54,6 +54,9 @@ export function getKVClient(): IKVProvider {
|
||||
if (!_kvClient) {
|
||||
_kvClient = new KVClient({
|
||||
url: Config.kv.url,
|
||||
mode: Config.kv.mode,
|
||||
clusterNodes: Config.kv.clusterNodes,
|
||||
clusterNatMap: Config.kv.clusterNatMap,
|
||||
});
|
||||
}
|
||||
return _kvClient;
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
MAX_INSTALLED_PACKS_NON_PREMIUM,
|
||||
MAX_PACK_EXPRESSIONS,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ManagedTraits} from '@fluxer/constants/src/ManagedTraits';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {FeatureAccessError} from '@fluxer/errors/src/domains/core/FeatureAccessError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError';
|
||||
@@ -83,7 +83,7 @@ export class PackService {
|
||||
|
||||
private async requireExpressionPackAccess(userId: UserID): Promise<void> {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user || !user.traits.has(ManagedTraits.EXPRESSION_PACKS)) {
|
||||
if (!user || (user.flags & UserFlags.STAFF) === 0n) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,9 @@
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createGuild,
|
||||
createPack,
|
||||
enableExpressionPacksForGuild,
|
||||
grantPremium,
|
||||
grantStaffAccess,
|
||||
installPack,
|
||||
listPacks,
|
||||
revokePremium,
|
||||
@@ -45,13 +44,13 @@ describe('Pack Premium Requirements', () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('user without expression_packs trait cannot list packs', async () => {
|
||||
test('user without staff flag cannot list packs', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token).get('/packs').expect(HTTP_STATUS.FORBIDDEN).execute();
|
||||
});
|
||||
|
||||
test('user with trait but no premium cannot create pack', async () => {
|
||||
test('user with staff flag but no premium cannot create pack', async () => {
|
||||
const {account} = await setupNonPremiumPackTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
@@ -61,7 +60,7 @@ describe('Pack Premium Requirements', () => {
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('premium user with trait can create emoji pack', async () => {
|
||||
test('premium user with staff flag can create emoji pack', async () => {
|
||||
const {account} = await setupPackTestAccount(harness);
|
||||
|
||||
const pack = await createPack(harness, account.token, 'emoji', {name: 'My Emoji Pack'});
|
||||
@@ -71,7 +70,7 @@ describe('Pack Premium Requirements', () => {
|
||||
expect(pack.type).toBe('emoji');
|
||||
});
|
||||
|
||||
test('premium user with trait can create sticker pack', async () => {
|
||||
test('premium user with staff flag can create sticker pack', async () => {
|
||||
const {account} = await setupPackTestAccount(harness);
|
||||
|
||||
const pack = await createPack(harness, account.token, 'sticker', {name: 'My Sticker Pack'});
|
||||
@@ -120,7 +119,7 @@ describe('Pack Premium Requirements', () => {
|
||||
expect(installed).toBeTruthy();
|
||||
});
|
||||
|
||||
test('user without trait cannot access pack endpoints', async () => {
|
||||
test('user without staff flag cannot access pack endpoints', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token).get('/packs').expect(HTTP_STATUS.FORBIDDEN).execute();
|
||||
@@ -132,10 +131,9 @@ describe('Pack Premium Requirements', () => {
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('user gains trait when joining guild with expression_packs feature', async () => {
|
||||
test('user gains staff flag and can access expression packs', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Feature Guild');
|
||||
await enableExpressionPacksForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, owner.userId);
|
||||
await grantPremium(harness, owner.userId);
|
||||
|
||||
const dashboard = await listPacks(harness, owner.token);
|
||||
@@ -155,7 +153,7 @@ describe('Pack Premium Requirements', () => {
|
||||
expect(dashboard.sticker.installed_limit).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('pack limits show zero for non-premium user with trait', async () => {
|
||||
test('pack limits show zero for non-premium user with staff flag', async () => {
|
||||
const {account} = await setupNonPremiumPackTestAccount(harness);
|
||||
|
||||
const dashboard = await listPacks(harness, account.token);
|
||||
|
||||
@@ -23,7 +23,6 @@ import {createTestAccount, type TestAccount} from '@fluxer/api/src/auth/tests/Au
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {ManagedTraits} from '@fluxer/constants/src/ManagedTraits';
|
||||
import type {
|
||||
GuildEmojiResponse,
|
||||
GuildEmojiWithUserResponse,
|
||||
@@ -54,13 +53,8 @@ export async function createGuild(harness: ApiTestHarness, token: string, name:
|
||||
return createBuilder<GuildResponse>(harness, token).post('/guilds').body({name}).expect(HTTP_STATUS.OK).execute();
|
||||
}
|
||||
|
||||
export async function enableExpressionPacksForGuild(harness: ApiTestHarness, guildId: string): Promise<void> {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({
|
||||
add_features: [ManagedTraits.EXPRESSION_PACKS],
|
||||
})
|
||||
.execute();
|
||||
export async function grantStaffAccess(harness: ApiTestHarness, userId: string): Promise<void> {
|
||||
await createBuilderWithoutAuth(harness).patch(`/test/users/${userId}/flags`).body({flags: 1}).execute();
|
||||
}
|
||||
|
||||
export async function grantPremium(harness: ApiTestHarness, userId: string): Promise<void> {
|
||||
@@ -90,7 +84,7 @@ export async function setupPackTestAccount(harness: ApiTestHarness): Promise<{
|
||||
}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Pack Test Guild');
|
||||
await enableExpressionPacksForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, account.userId);
|
||||
await grantPremium(harness, account.userId);
|
||||
return {account, guild};
|
||||
}
|
||||
@@ -101,7 +95,7 @@ export async function setupNonPremiumPackTestAccount(harness: ApiTestHarness): P
|
||||
}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Pack Test Guild');
|
||||
await enableExpressionPacksForGuild(harness, guild.id);
|
||||
await grantStaffAccess(harness, account.userId);
|
||||
return {account, guild};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 {AdminAuditLog} from '@fluxer/api/src/admin/IAdminRepository';
|
||||
import {convertToSearchableAuditLog} from '@fluxer/api/src/search/auditlog/AuditLogSearchSerializer';
|
||||
import {ElasticsearchSearchServiceBase} from '@fluxer/api/src/search/elasticsearch/ElasticsearchSearchServiceBase';
|
||||
import type {IAuditLogSearchService} from '@fluxer/api/src/search/IAuditLogSearchService';
|
||||
import {
|
||||
ElasticsearchAuditLogAdapter,
|
||||
type ElasticsearchAuditLogAdapterOptions,
|
||||
} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchAuditLogAdapter';
|
||||
import type {SearchResult as SchemaSearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {AuditLogSearchFilters, SearchableAuditLog} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
export interface ElasticsearchAuditLogSearchServiceOptions extends ElasticsearchAuditLogAdapterOptions {}
|
||||
|
||||
export class ElasticsearchAuditLogSearchService
|
||||
extends ElasticsearchSearchServiceBase<AuditLogSearchFilters, SearchableAuditLog, ElasticsearchAuditLogAdapter>
|
||||
implements IAuditLogSearchService
|
||||
{
|
||||
constructor(options: ElasticsearchAuditLogSearchServiceOptions) {
|
||||
super(new ElasticsearchAuditLogAdapter({client: options.client}));
|
||||
}
|
||||
|
||||
async indexAuditLog(log: AdminAuditLog): Promise<void> {
|
||||
await this.indexDocument(convertToSearchableAuditLog(log));
|
||||
}
|
||||
|
||||
async indexAuditLogs(logs: Array<AdminAuditLog>): Promise<void> {
|
||||
if (logs.length === 0) return;
|
||||
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},
|
||||
): Promise<SchemaSearchResult<SearchableAuditLog>> {
|
||||
return this.search(query, filters, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {ElasticsearchSearchServiceBase} from '@fluxer/api/src/search/elasticsearch/ElasticsearchSearchServiceBase';
|
||||
import {convertToSearchableGuildMember} from '@fluxer/api/src/search/guild_member/GuildMemberSearchSerializer';
|
||||
import type {IGuildMemberSearchService} from '@fluxer/api/src/search/IGuildMemberSearchService';
|
||||
import {
|
||||
ElasticsearchGuildMemberAdapter,
|
||||
type ElasticsearchGuildMemberAdapterOptions,
|
||||
} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchGuildMemberAdapter';
|
||||
import type {SearchResult as SchemaSearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {
|
||||
GuildMemberSearchFilters,
|
||||
SearchableGuildMember,
|
||||
} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
const DEFAULT_LIMIT = 25;
|
||||
|
||||
function toSearchOptions(options?: {limit?: number; offset?: number}): {limit?: number; offset?: number} {
|
||||
return {
|
||||
limit: options?.limit ?? DEFAULT_LIMIT,
|
||||
offset: options?.offset ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ElasticsearchGuildMemberSearchServiceOptions extends ElasticsearchGuildMemberAdapterOptions {}
|
||||
|
||||
export class ElasticsearchGuildMemberSearchService
|
||||
extends ElasticsearchSearchServiceBase<
|
||||
GuildMemberSearchFilters,
|
||||
SearchableGuildMember,
|
||||
ElasticsearchGuildMemberAdapter
|
||||
>
|
||||
implements IGuildMemberSearchService
|
||||
{
|
||||
constructor(options: ElasticsearchGuildMemberSearchServiceOptions) {
|
||||
super(new ElasticsearchGuildMemberAdapter({client: options.client}));
|
||||
}
|
||||
|
||||
async indexMember(member: GuildMember, user: User): Promise<void> {
|
||||
await this.indexDocument(convertToSearchableGuildMember(member, user));
|
||||
}
|
||||
|
||||
async indexMembers(members: Array<{member: GuildMember; user: User}>): Promise<void> {
|
||||
if (members.length === 0) return;
|
||||
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 guildIdString = guildId.toString();
|
||||
while (true) {
|
||||
const result = await this.search('', {guildId: guildIdString}, {limit: 1000, offset: 0});
|
||||
if (result.hits.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.deleteDocuments(result.hits.map((hit) => hit.id));
|
||||
}
|
||||
}
|
||||
|
||||
searchMembers(
|
||||
query: string,
|
||||
filters: GuildMemberSearchFilters,
|
||||
options?: {limit?: number; offset?: number},
|
||||
): Promise<SchemaSearchResult<SearchableGuildMember>> {
|
||||
return this.search(query, filters, toSearchOptions(options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 {GuildID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import {ElasticsearchSearchServiceBase} from '@fluxer/api/src/search/elasticsearch/ElasticsearchSearchServiceBase';
|
||||
import {convertToSearchableGuild, type GuildDiscoveryContext} from '@fluxer/api/src/search/guild/GuildSearchSerializer';
|
||||
import type {IGuildSearchService} from '@fluxer/api/src/search/IGuildSearchService';
|
||||
import {
|
||||
ElasticsearchGuildAdapter,
|
||||
type ElasticsearchGuildAdapterOptions,
|
||||
} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchGuildAdapter';
|
||||
import type {SearchResult as SchemaSearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
export interface ElasticsearchGuildSearchServiceOptions extends ElasticsearchGuildAdapterOptions {}
|
||||
|
||||
export class ElasticsearchGuildSearchService
|
||||
extends ElasticsearchSearchServiceBase<GuildSearchFilters, SearchableGuild, ElasticsearchGuildAdapter>
|
||||
implements IGuildSearchService
|
||||
{
|
||||
constructor(options: ElasticsearchGuildSearchServiceOptions) {
|
||||
super(new ElasticsearchGuildAdapter({client: options.client}));
|
||||
}
|
||||
|
||||
async indexGuild(guild: Guild, discovery?: GuildDiscoveryContext): Promise<void> {
|
||||
await this.indexDocument(convertToSearchableGuild(guild, discovery));
|
||||
}
|
||||
|
||||
async indexGuilds(guilds: Array<Guild>): Promise<void> {
|
||||
if (guilds.length === 0) return;
|
||||
await this.indexDocuments(guilds.map((g) => convertToSearchableGuild(g)));
|
||||
}
|
||||
|
||||
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?: {limit?: number; offset?: number},
|
||||
): Promise<SchemaSearchResult<SearchableGuild>> {
|
||||
return this.search(query, filters, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 {GuildID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import {ElasticsearchSearchServiceBase} from '@fluxer/api/src/search/elasticsearch/ElasticsearchSearchServiceBase';
|
||||
import type {IMessageSearchService} from '@fluxer/api/src/search/IMessageSearchService';
|
||||
import {convertToSearchableMessage} from '@fluxer/api/src/search/message/MessageSearchSerializer';
|
||||
import {
|
||||
ElasticsearchMessageAdapter,
|
||||
type ElasticsearchMessageAdapterOptions,
|
||||
} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchMessageAdapter';
|
||||
import type {SearchResult as SchemaSearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {MessageSearchFilters, SearchableMessage} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
const MESSAGE_DELETE_BATCH_SIZE = 1000;
|
||||
const DEFAULT_HITS_PER_PAGE = 25;
|
||||
|
||||
function toSearchOptions(options?: {hitsPerPage?: number; page?: number}): {limit?: number; offset?: number} {
|
||||
return {
|
||||
limit: options?.hitsPerPage,
|
||||
offset: options?.page ? (options.page - 1) * (options.hitsPerPage ?? DEFAULT_HITS_PER_PAGE) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ElasticsearchMessageSearchServiceOptions extends ElasticsearchMessageAdapterOptions {}
|
||||
|
||||
export class ElasticsearchMessageSearchService
|
||||
extends ElasticsearchSearchServiceBase<MessageSearchFilters, SearchableMessage, ElasticsearchMessageAdapter>
|
||||
implements IMessageSearchService
|
||||
{
|
||||
constructor(options: ElasticsearchMessageSearchServiceOptions) {
|
||||
super(new ElasticsearchMessageAdapter({client: options.client}));
|
||||
}
|
||||
|
||||
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> {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.indexDocuments(
|
||||
messages.map((message) => {
|
||||
const isBot = message.authorId ? (authorBotMap?.get(message.authorId) ?? false) : false;
|
||||
return convertToSearchableMessage(message, isBot);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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 deleteGuildMessages(guildId: GuildID): Promise<void> {
|
||||
const guildIdString = guildId.toString();
|
||||
while (true) {
|
||||
const result = await this.search('', {guildId: guildIdString}, {limit: MESSAGE_DELETE_BATCH_SIZE, offset: 0});
|
||||
if (result.hits.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.deleteDocuments(result.hits.map((hit) => hit.id));
|
||||
}
|
||||
}
|
||||
|
||||
searchMessages(
|
||||
query: string,
|
||||
filters: MessageSearchFilters,
|
||||
options?: {hitsPerPage?: number; page?: number},
|
||||
): Promise<SchemaSearchResult<SearchableMessage>> {
|
||||
return this.search(query, filters, toSearchOptions(options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 {GuildID, MessageID, ReportID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IARSubmission} from '@fluxer/api/src/report/IReportRepository';
|
||||
import {ElasticsearchSearchServiceBase} from '@fluxer/api/src/search/elasticsearch/ElasticsearchSearchServiceBase';
|
||||
import type {IReportSearchService} from '@fluxer/api/src/search/IReportSearchService';
|
||||
import {convertToSearchableReport} from '@fluxer/api/src/search/report/ReportSearchSerializer';
|
||||
import {
|
||||
ElasticsearchReportAdapter,
|
||||
type ElasticsearchReportAdapterOptions,
|
||||
} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchReportAdapter';
|
||||
import type {SearchResult as SchemaSearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {ReportSearchFilters, SearchableReport} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
export interface ElasticsearchReportSearchServiceOptions extends ElasticsearchReportAdapterOptions {}
|
||||
|
||||
export class ElasticsearchReportSearchService
|
||||
extends ElasticsearchSearchServiceBase<ReportSearchFilters, SearchableReport, ElasticsearchReportAdapter>
|
||||
implements IReportSearchService
|
||||
{
|
||||
constructor(options: ElasticsearchReportSearchServiceOptions) {
|
||||
super(new ElasticsearchReportAdapter({client: options.client}));
|
||||
}
|
||||
|
||||
async indexReport(report: IARSubmission): Promise<void> {
|
||||
await this.indexDocument(convertToSearchableReport(report));
|
||||
}
|
||||
|
||||
async indexReports(reports: Array<IARSubmission>): Promise<void> {
|
||||
if (reports.length === 0) return;
|
||||
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},
|
||||
): Promise<SchemaSearchResult<SearchableReport>> {
|
||||
return this.search(query, filters, options);
|
||||
}
|
||||
|
||||
listReportsByReporter(
|
||||
reporterId: UserID,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<SchemaSearchResult<SearchableReport>> {
|
||||
return this.searchReports('', {reporterId: reporterId.toString()}, {limit, offset});
|
||||
}
|
||||
|
||||
listReportsByStatus(status: number, limit?: number, offset?: number): Promise<SchemaSearchResult<SearchableReport>> {
|
||||
return this.searchReports('', {status}, {limit, offset});
|
||||
}
|
||||
|
||||
listReportsByType(
|
||||
reportType: number,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<SchemaSearchResult<SearchableReport>> {
|
||||
return this.searchReports('', {reportType}, {limit, offset});
|
||||
}
|
||||
|
||||
listReportsByReportedUser(
|
||||
reportedUserId: UserID,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<SchemaSearchResult<SearchableReport>> {
|
||||
return this.searchReports('', {reportedUserId: reportedUserId.toString()}, {limit, offset});
|
||||
}
|
||||
|
||||
listReportsByReportedGuild(
|
||||
reportedGuildId: GuildID,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<SchemaSearchResult<SearchableReport>> {
|
||||
return this.searchReports('', {reportedGuildId: reportedGuildId.toString()}, {limit, offset});
|
||||
}
|
||||
|
||||
listReportsByReportedMessage(
|
||||
reportedMessageId: MessageID,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<SchemaSearchResult<SearchableReport>> {
|
||||
return this.searchReports('', {reportedMessageId: reportedMessageId.toString()}, {limit, offset});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 {trackSearchTask} from '@fluxer/api/src/search/SearchTaskTracker';
|
||||
import type {ISearchAdapter, SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
|
||||
export abstract class ElasticsearchSearchServiceBase<
|
||||
TFilters,
|
||||
TDocument,
|
||||
TAdapter extends ISearchAdapter<TFilters, TDocument>,
|
||||
> {
|
||||
protected readonly adapter: TAdapter;
|
||||
|
||||
protected constructor(adapter: TAdapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
initialize(): Promise<void> {
|
||||
return this.adapter.initialize();
|
||||
}
|
||||
|
||||
shutdown(): Promise<void> {
|
||||
return this.adapter.shutdown();
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.adapter.isAvailable();
|
||||
}
|
||||
|
||||
indexDocument(doc: TDocument): Promise<void> {
|
||||
return trackSearchTask(this.adapter.indexDocument(doc));
|
||||
}
|
||||
|
||||
indexDocuments(docs: Array<TDocument>): Promise<void> {
|
||||
return trackSearchTask(this.adapter.indexDocuments(docs));
|
||||
}
|
||||
|
||||
updateDocument(doc: TDocument): Promise<void> {
|
||||
return trackSearchTask(this.adapter.updateDocument(doc));
|
||||
}
|
||||
|
||||
deleteDocument(id: string): Promise<void> {
|
||||
return trackSearchTask(this.adapter.deleteDocument(id));
|
||||
}
|
||||
|
||||
deleteDocuments(ids: Array<string>): Promise<void> {
|
||||
return trackSearchTask(this.adapter.deleteDocuments(ids));
|
||||
}
|
||||
|
||||
deleteAllDocuments(): Promise<void> {
|
||||
return trackSearchTask(this.adapter.deleteAllDocuments());
|
||||
}
|
||||
|
||||
search(query: string, filters: TFilters, options?: SearchOptions): Promise<SearchResult<TDocument>> {
|
||||
return this.adapter.search(query, filters, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 '@fluxer/api/src/BrandedTypes';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {ElasticsearchSearchServiceBase} from '@fluxer/api/src/search/elasticsearch/ElasticsearchSearchServiceBase';
|
||||
import type {IUserSearchService} from '@fluxer/api/src/search/IUserSearchService';
|
||||
import {convertToSearchableUser} from '@fluxer/api/src/search/user/UserSearchSerializer';
|
||||
import {
|
||||
ElasticsearchUserAdapter,
|
||||
type ElasticsearchUserAdapterOptions,
|
||||
} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchUserAdapter';
|
||||
import type {SearchResult as SchemaSearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {SearchableUser, UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
export interface ElasticsearchUserSearchServiceOptions extends ElasticsearchUserAdapterOptions {}
|
||||
|
||||
export class ElasticsearchUserSearchService
|
||||
extends ElasticsearchSearchServiceBase<UserSearchFilters, SearchableUser, ElasticsearchUserAdapter>
|
||||
implements IUserSearchService
|
||||
{
|
||||
constructor(options: ElasticsearchUserSearchServiceOptions) {
|
||||
super(new ElasticsearchUserAdapter({client: options.client}));
|
||||
}
|
||||
|
||||
async indexUser(user: User): Promise<void> {
|
||||
await this.indexDocument(convertToSearchableUser(user));
|
||||
}
|
||||
|
||||
async indexUsers(users: Array<User>): Promise<void> {
|
||||
if (users.length === 0) return;
|
||||
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},
|
||||
): Promise<SchemaSearchResult<SearchableUser>> {
|
||||
return this.search(query, filters, options);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export interface ApiTestHarness {
|
||||
}
|
||||
|
||||
export interface CreateApiTestHarnessOptions {
|
||||
search?: 'disabled' | 'meilisearch';
|
||||
search?: 'disabled' | 'meilisearch' | 'elasticsearch';
|
||||
}
|
||||
|
||||
export async function createApiTestHarness(options: CreateApiTestHarnessOptions = {}): Promise<ApiTestHarness> {
|
||||
|
||||
@@ -58,7 +58,6 @@ import {getKVClient} from '@fluxer/api/src/middleware/ServiceRegistry';
|
||||
import {OAuth2TokenRepository} from '@fluxer/api/src/oauth/repositories/OAuth2TokenRepository';
|
||||
import {IpAuthorizationTokens, OAuth2AccessTokensByUser} from '@fluxer/api/src/Tables';
|
||||
import {resetTestHarnessState} from '@fluxer/api/src/test/TestHarnessReset';
|
||||
import type {GuildManagedTraitService} from '@fluxer/api/src/traits/GuildManagedTraitService';
|
||||
import type {HonoApp, HonoEnv} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {AuthSessionRepository} from '@fluxer/api/src/user/repositories/auth/AuthSessionRepository';
|
||||
import {ScheduledMessageRepository} from '@fluxer/api/src/user/repositories/ScheduledMessageRepository';
|
||||
@@ -67,7 +66,6 @@ import {UserRepository} from '@fluxer/api/src/user/repositories/UserRepository';
|
||||
import {processUserDeletion} from '@fluxer/api/src/user/services/UserDeletionService';
|
||||
import {UserHarvestRepository} from '@fluxer/api/src/user/UserHarvestRepository';
|
||||
import {getExpiryBucket} from '@fluxer/api/src/utils/AttachmentDecay';
|
||||
import {areFeatureSetsEqual} from '@fluxer/api/src/utils/featureUtils';
|
||||
import {ScheduledMessageExecutor} from '@fluxer/api/src/worker/executors/ScheduledMessageExecutor';
|
||||
import {processExpiredAttachments} from '@fluxer/api/src/worker/tasks/ExpireAttachments';
|
||||
import {processInactivityDeletionsCore} from '@fluxer/api/src/worker/tasks/ProcessInactivityDeletions';
|
||||
@@ -75,7 +73,6 @@ import {setWorkerDependencies} from '@fluxer/api/src/worker/WorkerContext';
|
||||
import {initializeWorkerDependencies} from '@fluxer/api/src/worker/WorkerDependencies';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {MAX_GUILD_MEMBERS_VERY_LARGE_GUILD} from '@fluxer/constants/src/LimitConstants';
|
||||
import {isManagedTrait} from '@fluxer/constants/src/ManagedTraits';
|
||||
import {SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
@@ -108,19 +105,6 @@ import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
|
||||
import type {Context} from 'hono';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
function differenceSet<T>(base: Iterable<T>, comparator: Iterable<T>): Set<T> {
|
||||
const comparatorSet = new Set(comparator);
|
||||
const result = new Set<T>();
|
||||
|
||||
for (const entry of base) {
|
||||
if (!comparatorSet.has(entry)) {
|
||||
result.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const TEST_EMAIL_ENDPOINT = '/test/emails';
|
||||
const TEST_AUTH_HEADER = 'x-test-token';
|
||||
const MAX_TEST_PRIVATE_CHANNELS = 1000;
|
||||
@@ -1093,7 +1077,6 @@ export function TestHarnessController(app: HonoApp) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const previousFeatures = guild.features ? new Set(guild.features) : null;
|
||||
const newFeatures = new Set(guild.features);
|
||||
|
||||
if (Array.isArray(addFeatures)) {
|
||||
@@ -1112,66 +1095,12 @@ export function TestHarnessController(app: HonoApp) {
|
||||
}
|
||||
}
|
||||
|
||||
const featuresChanged = !areFeatureSetsEqual(previousFeatures, newFeatures);
|
||||
const guildRow = guild.toRow();
|
||||
await guildRepository.upsert({
|
||||
...guildRow,
|
||||
features: newFeatures,
|
||||
});
|
||||
|
||||
const guildManagedTraitService = ctx.get('guildManagedTraitService') as GuildManagedTraitService | undefined;
|
||||
if (featuresChanged) {
|
||||
if (guildManagedTraitService) {
|
||||
try {
|
||||
await guildManagedTraitService.reconcileTraitsForGuildFeatureChange({
|
||||
guildId,
|
||||
previousFeatures,
|
||||
newFeatures,
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, guildId: guildId.toString()},
|
||||
'Failed to reconcile managed traits after test harness guild feature update',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const userRepository = ctx.get('userRepository');
|
||||
const userCacheService = ctx.get('userCacheService');
|
||||
const members = await guildRepository.listMembers(guildId);
|
||||
|
||||
const addedTraits = differenceSet(new Set(addFeatures || []), new Set(previousFeatures || []));
|
||||
const removedTraits = differenceSet(new Set(previousFeatures || []), new Set(addFeatures || []));
|
||||
|
||||
for (const member of members) {
|
||||
const user = await userRepository.findUnique(member.userId);
|
||||
if (!user) continue;
|
||||
|
||||
const updatedTraits = new Set(user.traits);
|
||||
let changed = false;
|
||||
|
||||
for (const trait of addedTraits) {
|
||||
if (isManagedTrait(trait) && !updatedTraits.has(trait)) {
|
||||
updatedTraits.add(trait);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const trait of removedTraits) {
|
||||
if (updatedTraits.has(trait)) {
|
||||
updatedTraits.delete(trait);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
const traitValue = updatedTraits.size > 0 ? new Set(updatedTraits) : null;
|
||||
await userRepository.patchUpsert(member.userId, {traits: traitValue}, user.toRow());
|
||||
await userCacheService.invalidateUserCache(member.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
features: Array.from(newFeatures),
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
/*
|
||||
* 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 {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {Guild} from '@fluxer/api/src/models/Guild';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {BaseUserUpdatePropagator} from '@fluxer/api/src/user/services/BaseUserUpdatePropagator';
|
||||
import {isManagedTrait} from '@fluxer/constants/src/ManagedTraits';
|
||||
|
||||
interface GuildManagedTraitServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
gatewayService: IGatewayService;
|
||||
userCacheService: UserCacheService;
|
||||
}
|
||||
|
||||
export class GuildManagedTraitService {
|
||||
private readonly updatePropagator: BaseUserUpdatePropagator;
|
||||
|
||||
constructor(private readonly deps: GuildManagedTraitServiceDeps) {
|
||||
this.updatePropagator = new BaseUserUpdatePropagator({
|
||||
userCacheService: deps.userCacheService,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
}
|
||||
|
||||
async ensureTraitsForGuildJoin({guild, user}: {guild: Guild; user: User}): Promise<void> {
|
||||
const managedTraits = this.getManagedTraitsFromIterable(guild.features);
|
||||
if (managedTraits.size === 0) return;
|
||||
|
||||
const updatedTraits = new Set(user.traits);
|
||||
let changed = false;
|
||||
for (const trait of managedTraits) {
|
||||
if (!updatedTraits.has(trait)) {
|
||||
updatedTraits.add(trait);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.updateUserTraits(user.id, updatedTraits, user);
|
||||
}
|
||||
|
||||
async reconcileTraitsForGuildLeave({guild, userId}: {guild: Guild; userId: UserID}): Promise<void> {
|
||||
const managedTraits = this.getManagedTraitsFromIterable(guild.features);
|
||||
if (managedTraits.size === 0) return;
|
||||
|
||||
await this.removeTraitsIfNoProviders(userId, managedTraits, guild.id);
|
||||
}
|
||||
|
||||
async reconcileTraitsForGuildFeatureChange(params: {
|
||||
guildId: GuildID;
|
||||
previousFeatures: Iterable<string> | null | undefined;
|
||||
newFeatures: Iterable<string> | null | undefined;
|
||||
}): Promise<void> {
|
||||
const previousTraits = this.getManagedTraitsFromIterable(params.previousFeatures);
|
||||
const newTraits = this.getManagedTraitsFromIterable(params.newFeatures);
|
||||
|
||||
const addedTraits = GuildManagedTraitService.difference(newTraits, previousTraits);
|
||||
const removedTraits = GuildManagedTraitService.difference(previousTraits, newTraits);
|
||||
|
||||
if (addedTraits.size === 0 && removedTraits.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await this.deps.guildRepository.listMembers(params.guildId);
|
||||
|
||||
for (const member of members) {
|
||||
try {
|
||||
if (addedTraits.size > 0) {
|
||||
await this.ensureTraitSetForUser(member.userId, addedTraits);
|
||||
}
|
||||
if (removedTraits.size > 0) {
|
||||
await this.removeTraitsIfNoProviders(member.userId, removedTraits, params.guildId);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
guildId: params.guildId.toString(),
|
||||
userId: member.userId.toString(),
|
||||
error,
|
||||
},
|
||||
'Failed to reconcile managed traits for guild member',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureTraitSetForUser(userId: UserID, traits: Set<string>): Promise<void> {
|
||||
const user = await this.deps.userRepository.findUnique(userId);
|
||||
if (!user) return;
|
||||
|
||||
const updatedTraits = new Set(user.traits);
|
||||
let changed = false;
|
||||
for (const trait of traits) {
|
||||
if (!updatedTraits.has(trait)) {
|
||||
updatedTraits.add(trait);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.updateUserTraits(userId, updatedTraits, user);
|
||||
}
|
||||
|
||||
private async updateUserTraits(
|
||||
userId: UserID,
|
||||
traits: Set<string>,
|
||||
existingUser?: User | null,
|
||||
): Promise<User | null> {
|
||||
const user = existingUser ?? (await this.deps.userRepository.findUnique(userId));
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (GuildManagedTraitService.areSetsEqual(user.traits, traits)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const traitValue = traits.size > 0 ? new Set(traits) : null;
|
||||
const updatedUser = await this.deps.userRepository.patchUpsert(userId, {traits: traitValue}, user.toRow());
|
||||
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
private async collectManagedTraitsFromGuilds(
|
||||
guildIds: Array<GuildID>,
|
||||
options?: {excludeGuildId?: GuildID},
|
||||
): Promise<Set<string>> {
|
||||
const traits = new Set<string>();
|
||||
for (const guildId of guildIds) {
|
||||
if (options?.excludeGuildId && guildId === options.excludeGuildId) {
|
||||
continue;
|
||||
}
|
||||
const guild = await this.deps.guildRepository.findUnique(guildId);
|
||||
if (!guild) continue;
|
||||
for (const trait of this.getManagedTraitsFromIterable(guild.features)) {
|
||||
traits.add(trait);
|
||||
}
|
||||
}
|
||||
return traits;
|
||||
}
|
||||
|
||||
private async removeTraitsIfNoProviders(
|
||||
userId: UserID,
|
||||
traits: Set<string>,
|
||||
excludeGuildId?: GuildID,
|
||||
): Promise<void> {
|
||||
if (traits.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingGuildIds = await this.deps.userRepository.getUserGuildIds(userId);
|
||||
const remainingTraits = await this.collectManagedTraitsFromGuilds(remainingGuildIds, {
|
||||
excludeGuildId,
|
||||
});
|
||||
|
||||
const user = await this.deps.userRepository.findUnique(userId);
|
||||
if (!user) return;
|
||||
|
||||
const updatedTraits = new Set(user.traits);
|
||||
let changed = false;
|
||||
for (const trait of traits) {
|
||||
if (updatedTraits.has(trait) && !remainingTraits.has(trait)) {
|
||||
updatedTraits.delete(trait);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.updateUserTraits(userId, updatedTraits, user);
|
||||
}
|
||||
|
||||
private getManagedTraitsFromIterable(features: Iterable<string> | null | undefined): Set<string> {
|
||||
const traits = new Set<string>();
|
||||
if (!features) {
|
||||
return traits;
|
||||
}
|
||||
|
||||
for (const feature of features) {
|
||||
if (feature && isManagedTrait(feature)) {
|
||||
traits.add(feature);
|
||||
}
|
||||
}
|
||||
|
||||
return traits;
|
||||
}
|
||||
|
||||
private static difference(base: Set<string>, comparator: Set<string>): Set<string> {
|
||||
const result = new Set<string>();
|
||||
for (const entry of base) {
|
||||
if (!comparator.has(entry)) {
|
||||
result.add(entry);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static areSetsEqual(a: Set<string>, b: Set<string>): boolean {
|
||||
if (a.size !== b.size) {
|
||||
return false;
|
||||
}
|
||||
for (const entry of a) {
|
||||
if (!b.has(entry)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,6 @@ import type {RpcService} from '@fluxer/api/src/rpc/RpcService';
|
||||
import type {SearchService} from '@fluxer/api/src/search/SearchService';
|
||||
import type {StripeService} from '@fluxer/api/src/stripe/StripeService';
|
||||
import type {ThemeService} from '@fluxer/api/src/theme/ThemeService';
|
||||
import type {GuildManagedTraitService} from '@fluxer/api/src/traits/GuildManagedTraitService';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {EmailChangeService} from '@fluxer/api/src/user/services/EmailChangeService';
|
||||
import type {PasswordChangeService} from '@fluxer/api/src/user/services/PasswordChangeService';
|
||||
@@ -154,7 +153,6 @@ export interface HonoEnv {
|
||||
alertService: AlertService;
|
||||
discoveryService: IGuildDiscoveryService;
|
||||
guildService: GuildService;
|
||||
guildManagedTraitService: GuildManagedTraitService;
|
||||
packService: PackService;
|
||||
packRequestService: PackRequestService;
|
||||
packRepository: PackRepository;
|
||||
|
||||
@@ -38,61 +38,91 @@ export class VoiceDataInitializer {
|
||||
|
||||
try {
|
||||
const repository = new VoiceRepository();
|
||||
|
||||
const serverId = `${defaultRegion.id}-server-1`;
|
||||
const livekitEndpoint = this.resolveLivekitEndpoint();
|
||||
const existingRegions = await repository.listRegions();
|
||||
if (existingRegions.length > 0) {
|
||||
|
||||
if (existingRegions.length === 0) {
|
||||
Logger.info('[VoiceDataInitializer] Creating default voice region from config...');
|
||||
await repository.createRegion({
|
||||
id: defaultRegion.id,
|
||||
name: defaultRegion.name,
|
||||
emoji: defaultRegion.emoji,
|
||||
latitude: defaultRegion.latitude,
|
||||
longitude: defaultRegion.longitude,
|
||||
isDefault: true,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
});
|
||||
Logger.info(`[VoiceDataInitializer] Created region: ${defaultRegion.name} (${defaultRegion.id})`);
|
||||
|
||||
await repository.createServer({
|
||||
regionId: defaultRegion.id,
|
||||
serverId,
|
||||
endpoint: livekitEndpoint,
|
||||
apiKey: livekitApiKey,
|
||||
apiSecret: livekitApiSecret,
|
||||
isActive: true,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
});
|
||||
|
||||
Logger.info(`[VoiceDataInitializer] Created server: ${serverId} -> ${livekitEndpoint}`);
|
||||
Logger.info('[VoiceDataInitializer] Successfully created default voice region');
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredRegion = await repository.getRegion(defaultRegion.id);
|
||||
if (!configuredRegion) {
|
||||
Logger.info(
|
||||
`[VoiceDataInitializer] ${existingRegions.length} voice region(s) already exist, skipping default region creation`,
|
||||
`[VoiceDataInitializer] ${existingRegions.length} voice region(s) already exist and configured default region (${defaultRegion.id}) is absent, skipping config sync`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.info('[VoiceDataInitializer] Creating default voice region from config...');
|
||||
const configuredServer = await repository.getServer(defaultRegion.id, serverId);
|
||||
if (!configuredServer) {
|
||||
Logger.info(
|
||||
`[VoiceDataInitializer] Region ${defaultRegion.id} exists but server ${serverId} is absent, skipping config sync`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const livekitEndpoint =
|
||||
Config.voice.url ||
|
||||
(() => {
|
||||
const protocol = new URL(Config.endpoints.apiPublic).protocol.slice(0, -1) === 'https' ? 'wss' : 'ws';
|
||||
return `${protocol}://${new URL(Config.endpoints.apiPublic).hostname}/livekit`;
|
||||
})();
|
||||
const endpointChanged = configuredServer.endpoint !== livekitEndpoint;
|
||||
const apiKeyChanged = configuredServer.apiKey !== livekitApiKey;
|
||||
const apiSecretChanged = configuredServer.apiSecret !== livekitApiSecret;
|
||||
if (!endpointChanged && !apiKeyChanged && !apiSecretChanged) {
|
||||
Logger.info(`[VoiceDataInitializer] Default voice server ${serverId} already matches config credentials`);
|
||||
return;
|
||||
}
|
||||
|
||||
await repository.createRegion({
|
||||
id: defaultRegion.id,
|
||||
name: defaultRegion.name,
|
||||
emoji: defaultRegion.emoji,
|
||||
latitude: defaultRegion.latitude,
|
||||
longitude: defaultRegion.longitude,
|
||||
isDefault: true,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
});
|
||||
Logger.info(`[VoiceDataInitializer] Created region: ${defaultRegion.name} (${defaultRegion.id})`);
|
||||
|
||||
const serverId = `${defaultRegion.id}-server-1`;
|
||||
|
||||
await repository.createServer({
|
||||
regionId: defaultRegion.id,
|
||||
serverId,
|
||||
await repository.upsertServer({
|
||||
...configuredServer,
|
||||
endpoint: livekitEndpoint,
|
||||
apiKey: livekitApiKey,
|
||||
apiSecret: livekitApiSecret,
|
||||
isActive: true,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
Logger.info(`[VoiceDataInitializer] Created server: ${serverId} -> ${livekitEndpoint}`);
|
||||
Logger.info('[VoiceDataInitializer] Successfully created default voice region');
|
||||
Logger.info(`[VoiceDataInitializer] Synced default voice server ${serverId} credentials from config`);
|
||||
} catch (error) {
|
||||
Logger.error({error}, '[VoiceDataInitializer] Failed to create default voice region');
|
||||
Logger.error({error}, '[VoiceDataInitializer] Failed to initialise config-managed voice topology');
|
||||
}
|
||||
}
|
||||
|
||||
private resolveLivekitEndpoint(): string {
|
||||
if (Config.voice.url) {
|
||||
return Config.voice.url;
|
||||
}
|
||||
|
||||
const protocol = new URL(Config.endpoints.apiPublic).protocol.slice(0, -1) === 'https' ? 'wss' : 'ws';
|
||||
return `${protocol}://${new URL(Config.endpoints.apiPublic).hostname}/livekit`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ import {
|
||||
createGuildEmoji,
|
||||
createWebhook,
|
||||
deleteWebhook,
|
||||
enableExpressionPacksForGuild,
|
||||
executeWebhook,
|
||||
getChannelMessage,
|
||||
grantCreateExpressionsPermission,
|
||||
grantStaffAccess,
|
||||
sendChannelMessage,
|
||||
} from '@fluxer/api/src/webhook/tests/WebhookTestUtils';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
@@ -54,7 +54,7 @@ describe('Webhook compare to regular user', () => {
|
||||
const emojiGuild = await createGuild(harness, user.token, 'Emoji Source Guild');
|
||||
const emojiGuildId = emojiGuild.id;
|
||||
|
||||
await enableExpressionPacksForGuild(harness, emojiGuildId);
|
||||
await grantStaffAccess(harness, user.userId);
|
||||
await grantCreateExpressionsPermission(harness, user.token, emojiGuildId);
|
||||
|
||||
const emoji = await createGuildEmoji(harness, user.token, emojiGuildId, 'compare');
|
||||
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
createGuildEmojiWithFile,
|
||||
createWebhook,
|
||||
deleteWebhook,
|
||||
enableExpressionPacksForGuild,
|
||||
executeWebhook,
|
||||
grantCreateExpressionsPermission,
|
||||
grantStaffAccess,
|
||||
} from '@fluxer/api/src/webhook/tests/WebhookTestUtils';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Webhook emoji bypass', () => {
|
||||
const guildId = guild.id;
|
||||
const channelId = guild.system_channel_id!;
|
||||
|
||||
await enableExpressionPacksForGuild(harness, guildId);
|
||||
await grantStaffAccess(harness, user.userId);
|
||||
await grantCreateExpressionsPermission(harness, user.token, guildId);
|
||||
|
||||
const emoji = await createGuildEmoji(harness, user.token, guildId, 'external');
|
||||
@@ -112,7 +112,7 @@ describe('Webhook emoji bypass', () => {
|
||||
const guildId = guild.id;
|
||||
const channelId = guild.system_channel_id!;
|
||||
|
||||
await enableExpressionPacksForGuild(harness, guildId);
|
||||
await grantStaffAccess(harness, user.userId);
|
||||
await grantCreateExpressionsPermission(harness, user.token, guildId);
|
||||
|
||||
const emoji = await createGuildEmoji(harness, user.token, guildId, 'wait_emoji');
|
||||
|
||||
@@ -21,7 +21,6 @@ import {createMultipartFormData} from '@fluxer/api/src/channel/tests/AttachmentT
|
||||
import {getPngDataUrl, VALID_PNG_BASE64} from '@fluxer/api/src/emoji/tests/EmojiTestUtils';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {ManagedTraits} from '@fluxer/constants/src/ManagedTraits';
|
||||
import type {GuildEmojiWithUserResponse} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {WebhookResponse, WebhookTokenResponse} from '@fluxer/schema/src/domains/webhook/WebhookSchemas';
|
||||
@@ -215,13 +214,8 @@ export async function getChannelMessage(
|
||||
return createBuilder<MessageResponse>(harness, token).get(`/channels/${channelId}/messages/${messageId}`).execute();
|
||||
}
|
||||
|
||||
export async function enableExpressionPacksForGuild(harness: ApiTestHarness, guildId: string): Promise<void> {
|
||||
return createBuilderWithoutAuth<void>(harness)
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({
|
||||
add_features: [ManagedTraits.EXPRESSION_PACKS],
|
||||
})
|
||||
.execute();
|
||||
export async function grantStaffAccess(harness: ApiTestHarness, userId: string): Promise<void> {
|
||||
await createBuilderWithoutAuth(harness).patch(`/test/users/${userId}/flags`).body({flags: 1}).execute();
|
||||
}
|
||||
|
||||
const CREATE_EXPRESSIONS = 0x08000000n;
|
||||
|
||||
@@ -322,7 +322,6 @@ export async function initializeWorkerDependencies(snowflakeService: SnowflakeSe
|
||||
webhookRepository,
|
||||
guildAuditLogService,
|
||||
limitConfigService,
|
||||
undefined,
|
||||
);
|
||||
const inviteService = new InviteService(
|
||||
inviteRepository,
|
||||
|
||||
@@ -679,6 +679,55 @@
|
||||
"description": "Internal Valkey/Redis URL for key-value operations.",
|
||||
"default": "redis://localhost:6379/0"
|
||||
},
|
||||
"kv_mode": {
|
||||
"type": "string",
|
||||
"description": "Valkey/Redis connection mode. Use 'standalone' for a single node or 'cluster' for a Valkey/Redis cluster.",
|
||||
"enum": [
|
||||
"standalone",
|
||||
"cluster"
|
||||
],
|
||||
"default": "standalone"
|
||||
},
|
||||
"kv_cluster_nodes": {
|
||||
"type": "array",
|
||||
"description": "List of cluster node URLs when kv_mode is 'cluster'. Each entry should be a host:port string.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"port"
|
||||
]
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"kv_cluster_nat_map": {
|
||||
"type": "object",
|
||||
"description": "NAT mapping for Valkey/Redis cluster nodes. Maps internal addresses to external addresses for NAT traversal.",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"port"
|
||||
]
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"queue": {
|
||||
"type": "string",
|
||||
"description": "Internal URL for the Queue service.",
|
||||
@@ -1265,16 +1314,36 @@
|
||||
},
|
||||
"search_integration": {
|
||||
"type": "object",
|
||||
"description": "Search engine integration (Meilisearch). Fluxer always uses Meilisearch for indexing and querying.",
|
||||
"description": "Search engine integration. Supports Meilisearch and Elasticsearch backends.",
|
||||
"properties": {
|
||||
"engine": {
|
||||
"type": "string",
|
||||
"description": "Search engine backend to use.",
|
||||
"enum": [
|
||||
"meilisearch",
|
||||
"elasticsearch"
|
||||
],
|
||||
"default": "meilisearch"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Meilisearch HTTP API URL.",
|
||||
"description": "Search engine HTTP API URL. Used by both Meilisearch and Elasticsearch.",
|
||||
"default": "http://127.0.0.1:7700"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "Meilisearch API key used by the API for index management and writes. Use a key with access to documents and settings."
|
||||
"description": "API key for authenticating with the search engine. For Meilisearch, this is the master or admin key. For Elasticsearch, this is an API key.",
|
||||
"default": ""
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Username for Elasticsearch basic authentication. Only used when engine is elasticsearch and api_key is not set.",
|
||||
"default": ""
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Password for Elasticsearch basic authentication. Only used when engine is elasticsearch and api_key is not set.",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -308,6 +308,7 @@ function generateZodObject(schema: JsonSchema, defs: Record<string, JsonSchema>,
|
||||
!Array.isArray(propSchema.default) &&
|
||||
Object.keys(propSchema.default).length === 0
|
||||
) {
|
||||
propType += '.default(() => ({}))';
|
||||
} else {
|
||||
propType += `.default(${JSON.stringify(propSchema.default)})`;
|
||||
}
|
||||
@@ -428,6 +429,7 @@ function generateRootSchema(schema: JsonSchema, defs: Record<string, JsonSchema>
|
||||
!Array.isArray(propSchema.default) &&
|
||||
Object.keys(propSchema.default).length === 0
|
||||
) {
|
||||
propType += '.default(() => ({}))';
|
||||
} else {
|
||||
propType += `.default(${JSON.stringify(propSchema.default)})`;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,38 @@
|
||||
"description": "Internal Valkey/Redis URL for key-value operations.",
|
||||
"default": "redis://localhost:6379/0"
|
||||
},
|
||||
"kv_mode": {
|
||||
"type": "string",
|
||||
"description": "Valkey/Redis connection mode. Use 'standalone' for a single node or 'cluster' for a Valkey/Redis cluster.",
|
||||
"enum": ["standalone", "cluster"],
|
||||
"default": "standalone"
|
||||
},
|
||||
"kv_cluster_nodes": {
|
||||
"type": "array",
|
||||
"description": "List of cluster node URLs when kv_mode is 'cluster'. Each entry should be a host:port string.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string"},
|
||||
"port": {"type": "integer"}
|
||||
},
|
||||
"required": ["host", "port"]
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"kv_cluster_nat_map": {
|
||||
"type": "object",
|
||||
"description": "NAT mapping for Valkey/Redis cluster nodes. Maps internal addresses to external addresses for NAT traversal.",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string"},
|
||||
"port": {"type": "integer"}
|
||||
},
|
||||
"required": ["host", "port"]
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"queue": {
|
||||
"type": "string",
|
||||
"description": "Internal URL for the Queue service.",
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
{
|
||||
"search_integration": {
|
||||
"type": "object",
|
||||
"description": "Search engine integration (Meilisearch). Fluxer always uses Meilisearch for indexing and querying.",
|
||||
"description": "Search engine integration. Supports Meilisearch and Elasticsearch backends.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"engine": {
|
||||
"type": "string",
|
||||
"description": "Search engine backend to use.",
|
||||
"enum": ["meilisearch", "elasticsearch"],
|
||||
"default": "meilisearch"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Meilisearch HTTP API URL.",
|
||||
"description": "Search engine HTTP API URL. Used by both Meilisearch and Elasticsearch.",
|
||||
"default": "http://127.0.0.1:7700"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "Meilisearch API key used by the API for index management and writes. Use a key with access to documents and settings."
|
||||
"description": "API key for authenticating with the search engine. For Meilisearch, this is the master or admin key. For Elasticsearch, this is an API key.",
|
||||
"default": ""
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Username for Elasticsearch basic authentication. Only used when engine is elasticsearch and api_key is not set.",
|
||||
"default": ""
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Password for Elasticsearch basic authentication. Only used when engine is elasticsearch and api_key is not set.",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"required": ["url", "api_key"]
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ManagedTraits} from '@fluxer/constants/src/ManagedTraits';
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
|
||||
export const GuildVerificationLevel = {
|
||||
@@ -123,8 +122,6 @@ export const GuildFeatures = {
|
||||
OPERATOR: 'OPERATOR',
|
||||
LARGE_GUILD_OVERRIDE: 'LARGE_GUILD_OVERRIDE',
|
||||
VERY_LARGE_GUILD: 'VERY_LARGE_GUILD',
|
||||
MANAGED_MESSAGE_SCHEDULING: ManagedTraits.MESSAGE_SCHEDULING,
|
||||
MANAGED_EXPRESSION_PACKS: ManagedTraits.EXPRESSION_PACKS,
|
||||
} as const;
|
||||
|
||||
export type GuildFeature = ValueOf<typeof GuildFeatures>;
|
||||
|
||||
20
packages/elasticsearch_search/package.json
Normal file
20
packages/elasticsearch_search/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@fluxer/elasticsearch_search",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elastic/elasticsearch": "catalog:",
|
||||
"@fluxer/schema": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -17,19 +17,26 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
import {Client} from '@elastic/elasticsearch';
|
||||
|
||||
export const MANAGED_TRAIT_PREFIX = 'MT_';
|
||||
|
||||
export const ManagedTraits = {
|
||||
MESSAGE_SCHEDULING: 'MT_MESSAGE_SCHEDULING',
|
||||
EXPRESSION_PACKS: 'MT_EXPRESSION_PACKS',
|
||||
} as const;
|
||||
|
||||
export type ManagedTrait = ValueOf<typeof ManagedTraits>;
|
||||
|
||||
export const ALL_MANAGED_TRAITS: Array<ManagedTrait> = Object.values(ManagedTraits);
|
||||
|
||||
export function isManagedTrait(value: string): value is ManagedTrait {
|
||||
return value.startsWith(MANAGED_TRAIT_PREFIX);
|
||||
export interface ElasticsearchClientConfig {
|
||||
node: string;
|
||||
auth?: {
|
||||
apiKey?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
requestTimeoutMs: number;
|
||||
}
|
||||
|
||||
export function createElasticsearchClient(config: ElasticsearchClientConfig): Client {
|
||||
return new Client({
|
||||
node: config.node,
|
||||
auth: config.auth?.apiKey
|
||||
? {apiKey: config.auth.apiKey}
|
||||
: config.auth?.username
|
||||
? {username: config.auth.username, password: config.auth.password ?? ''}
|
||||
: undefined,
|
||||
requestTimeout: config.requestTimeoutMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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 type ElasticsearchFilter = Record<string, unknown>;
|
||||
|
||||
export interface ElasticsearchRangeOptions {
|
||||
gte?: number;
|
||||
lte?: number;
|
||||
gt?: number;
|
||||
lt?: number;
|
||||
}
|
||||
|
||||
export function esTermFilter(field: string, value: string | number | boolean): ElasticsearchFilter {
|
||||
return {term: {[field]: value}};
|
||||
}
|
||||
|
||||
export function esTermsFilter(field: string, values: Array<string | number | boolean>): ElasticsearchFilter {
|
||||
return {terms: {[field]: values}};
|
||||
}
|
||||
|
||||
export function esRangeFilter(field: string, opts: ElasticsearchRangeOptions): ElasticsearchFilter {
|
||||
return {range: {[field]: opts}};
|
||||
}
|
||||
|
||||
export function esExistsFilter(field: string): ElasticsearchFilter {
|
||||
return {exists: {field}};
|
||||
}
|
||||
|
||||
export function esNotExistsFilter(field: string): ElasticsearchFilter {
|
||||
return {bool: {must_not: [{exists: {field}}]}};
|
||||
}
|
||||
|
||||
export function esMustNotTerm(field: string, value: string | number | boolean): ElasticsearchFilter {
|
||||
return {bool: {must_not: [{term: {[field]: value}}]}};
|
||||
}
|
||||
|
||||
export function esMustNotTerms(field: string, values: Array<string | number | boolean>): ElasticsearchFilter {
|
||||
return {bool: {must_not: [{terms: {[field]: values}}]}};
|
||||
}
|
||||
|
||||
export function esAndTerms(field: string, values: Array<string | number | boolean>): Array<ElasticsearchFilter> {
|
||||
return values.map((v) => esTermFilter(field, v));
|
||||
}
|
||||
|
||||
export function esExcludeAny(field: string, values: Array<string | number | boolean>): Array<ElasticsearchFilter> {
|
||||
return values.map((v) => esMustNotTerm(field, v));
|
||||
}
|
||||
|
||||
export function compactFilters(filters: Array<ElasticsearchFilter | undefined>): Array<ElasticsearchFilter> {
|
||||
return filters.filter((f): f is ElasticsearchFilter => f !== undefined);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
type ElasticsearchFieldType = 'text' | 'keyword' | 'boolean' | 'long' | 'integer' | 'date' | 'float';
|
||||
|
||||
export type FluxerSearchIndexName = 'messages' | 'guilds' | 'users' | 'reports' | 'audit_logs' | 'guild_members';
|
||||
|
||||
export interface ElasticsearchFieldMapping {
|
||||
type: ElasticsearchFieldType;
|
||||
index?: boolean;
|
||||
fields?: Record<string, ElasticsearchFieldMapping>;
|
||||
}
|
||||
|
||||
export interface ElasticsearchIndexSettings {
|
||||
number_of_shards?: number;
|
||||
number_of_replicas?: number;
|
||||
}
|
||||
|
||||
export interface ElasticsearchIndexDefinition {
|
||||
indexName: FluxerSearchIndexName;
|
||||
mappings: {
|
||||
properties: Record<string, ElasticsearchFieldMapping>;
|
||||
};
|
||||
settings?: ElasticsearchIndexSettings;
|
||||
}
|
||||
|
||||
function textWithKeyword(): ElasticsearchFieldMapping {
|
||||
return {type: 'text', fields: {keyword: {type: 'keyword'}}};
|
||||
}
|
||||
|
||||
function keyword(): ElasticsearchFieldMapping {
|
||||
return {type: 'keyword'};
|
||||
}
|
||||
|
||||
function bool(): ElasticsearchFieldMapping {
|
||||
return {type: 'boolean'};
|
||||
}
|
||||
|
||||
function long(): ElasticsearchFieldMapping {
|
||||
return {type: 'long'};
|
||||
}
|
||||
|
||||
function integer(): ElasticsearchFieldMapping {
|
||||
return {type: 'integer'};
|
||||
}
|
||||
|
||||
export const ELASTICSEARCH_INDEX_DEFINITIONS: Record<FluxerSearchIndexName, ElasticsearchIndexDefinition> = {
|
||||
messages: {
|
||||
indexName: 'messages',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: keyword(),
|
||||
channelId: keyword(),
|
||||
guildId: keyword(),
|
||||
authorId: keyword(),
|
||||
authorType: keyword(),
|
||||
content: textWithKeyword(),
|
||||
createdAt: long(),
|
||||
editedAt: long(),
|
||||
isPinned: bool(),
|
||||
mentionedUserIds: keyword(),
|
||||
mentionEveryone: bool(),
|
||||
hasLink: bool(),
|
||||
hasEmbed: bool(),
|
||||
hasPoll: bool(),
|
||||
hasFile: bool(),
|
||||
hasVideo: bool(),
|
||||
hasImage: bool(),
|
||||
hasSound: bool(),
|
||||
hasSticker: bool(),
|
||||
hasForward: bool(),
|
||||
embedTypes: keyword(),
|
||||
embedProviders: keyword(),
|
||||
linkHostnames: keyword(),
|
||||
attachmentFilenames: keyword(),
|
||||
attachmentExtensions: keyword(),
|
||||
},
|
||||
},
|
||||
},
|
||||
guilds: {
|
||||
indexName: 'guilds',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: keyword(),
|
||||
ownerId: keyword(),
|
||||
name: textWithKeyword(),
|
||||
vanityUrlCode: textWithKeyword(),
|
||||
discoveryDescription: textWithKeyword(),
|
||||
iconHash: keyword(),
|
||||
bannerHash: keyword(),
|
||||
splashHash: keyword(),
|
||||
features: keyword(),
|
||||
verificationLevel: integer(),
|
||||
mfaLevel: integer(),
|
||||
nsfwLevel: integer(),
|
||||
createdAt: long(),
|
||||
discoveryCategory: integer(),
|
||||
isDiscoverable: bool(),
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
indexName: 'users',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: textWithKeyword(),
|
||||
username: textWithKeyword(),
|
||||
email: textWithKeyword(),
|
||||
phone: textWithKeyword(),
|
||||
discriminator: integer(),
|
||||
isBot: bool(),
|
||||
isSystem: bool(),
|
||||
flags: keyword(),
|
||||
premiumType: integer(),
|
||||
emailVerified: bool(),
|
||||
emailBounced: bool(),
|
||||
suspiciousActivityFlags: integer(),
|
||||
acls: keyword(),
|
||||
createdAt: long(),
|
||||
lastActiveAt: long(),
|
||||
tempBannedUntil: long(),
|
||||
pendingDeletionAt: long(),
|
||||
stripeSubscriptionId: keyword(),
|
||||
stripeCustomerId: keyword(),
|
||||
},
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
indexName: 'reports',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: keyword(),
|
||||
reporterId: keyword(),
|
||||
reportedAt: long(),
|
||||
status: integer(),
|
||||
reportType: integer(),
|
||||
category: textWithKeyword(),
|
||||
additionalInfo: textWithKeyword(),
|
||||
reportedUserId: keyword(),
|
||||
reportedGuildId: keyword(),
|
||||
reportedGuildName: textWithKeyword(),
|
||||
reportedMessageId: keyword(),
|
||||
reportedChannelId: keyword(),
|
||||
reportedChannelName: textWithKeyword(),
|
||||
guildContextId: keyword(),
|
||||
resolvedAt: long(),
|
||||
resolvedByAdminId: keyword(),
|
||||
publicComment: keyword(),
|
||||
createdAt: long(),
|
||||
},
|
||||
},
|
||||
},
|
||||
audit_logs: {
|
||||
indexName: 'audit_logs',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: keyword(),
|
||||
logId: keyword(),
|
||||
adminUserId: keyword(),
|
||||
targetType: textWithKeyword(),
|
||||
targetId: textWithKeyword(),
|
||||
action: textWithKeyword(),
|
||||
auditLogReason: textWithKeyword(),
|
||||
createdAt: long(),
|
||||
},
|
||||
},
|
||||
},
|
||||
guild_members: {
|
||||
indexName: 'guild_members',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: keyword(),
|
||||
guildId: keyword(),
|
||||
userId: textWithKeyword(),
|
||||
username: textWithKeyword(),
|
||||
discriminator: textWithKeyword(),
|
||||
globalName: textWithKeyword(),
|
||||
nickname: textWithKeyword(),
|
||||
roleIds: keyword(),
|
||||
joinedAt: long(),
|
||||
joinSourceType: integer(),
|
||||
sourceInviteCode: keyword(),
|
||||
inviterId: keyword(),
|
||||
userCreatedAt: long(),
|
||||
isBot: bool(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import {ElasticsearchIndexAdapter} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {compactFilters, esTermFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {AuditLogSearchFilters, SearchableAuditLog} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
function buildAuditLogFilters(filters: AuditLogSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
if (filters.adminUserId) clauses.push(esTermFilter('adminUserId', filters.adminUserId));
|
||||
if (filters.targetType) clauses.push(esTermFilter('targetType', filters.targetType));
|
||||
if (filters.targetId) clauses.push(esTermFilter('targetId', filters.targetId));
|
||||
if (filters.action) clauses.push(esTermFilter('action', filters.action));
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildAuditLogSort(filters: AuditLogSearchFilters): Array<Record<string, unknown>> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'createdAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [{createdAt: {order: sortOrder}}];
|
||||
}
|
||||
|
||||
export interface ElasticsearchAuditLogAdapterOptions {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export class ElasticsearchAuditLogAdapter extends ElasticsearchIndexAdapter<AuditLogSearchFilters, SearchableAuditLog> {
|
||||
constructor(options: ElasticsearchAuditLogAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.audit_logs,
|
||||
searchableFields: ['action', 'targetType', 'targetId', 'auditLogReason'],
|
||||
buildFilters: buildAuditLogFilters,
|
||||
buildSort: buildAuditLogSort,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import {ElasticsearchIndexAdapter} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {compactFilters, esAndTerms, esTermFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
function buildGuildFilters(filters: GuildSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
if (filters.ownerId) clauses.push(esTermFilter('ownerId', filters.ownerId));
|
||||
if (filters.verificationLevel !== undefined)
|
||||
clauses.push(esTermFilter('verificationLevel', filters.verificationLevel));
|
||||
if (filters.mfaLevel !== undefined) clauses.push(esTermFilter('mfaLevel', filters.mfaLevel));
|
||||
if (filters.nsfwLevel !== undefined) clauses.push(esTermFilter('nsfwLevel', filters.nsfwLevel));
|
||||
|
||||
if (filters.hasFeature && filters.hasFeature.length > 0) {
|
||||
clauses.push(...esAndTerms('features', filters.hasFeature));
|
||||
}
|
||||
|
||||
if (filters.isDiscoverable !== undefined) clauses.push(esTermFilter('isDiscoverable', filters.isDiscoverable));
|
||||
if (filters.discoveryCategory !== undefined)
|
||||
clauses.push(esTermFilter('discoveryCategory', filters.discoveryCategory));
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildGuildSort(filters: GuildSearchFilters): Array<Record<string, unknown>> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'createdAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [{[sortBy]: {order: sortOrder}}];
|
||||
}
|
||||
|
||||
export interface ElasticsearchGuildAdapterOptions {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export class ElasticsearchGuildAdapter extends ElasticsearchIndexAdapter<GuildSearchFilters, SearchableGuild> {
|
||||
constructor(options: ElasticsearchGuildAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.guilds,
|
||||
searchableFields: ['name', 'vanityUrlCode', 'discoveryDescription'],
|
||||
buildFilters: buildGuildFilters,
|
||||
buildSort: buildGuildSort,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import {ElasticsearchIndexAdapter} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esAndTerms,
|
||||
esRangeFilter,
|
||||
esTermFilter,
|
||||
esTermsFilter,
|
||||
} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {
|
||||
GuildMemberSearchFilters,
|
||||
SearchableGuildMember,
|
||||
} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
function buildGuildMemberFilters(filters: GuildMemberSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
clauses.push(esTermFilter('guildId', filters.guildId));
|
||||
|
||||
if (filters.roleIds && filters.roleIds.length > 0) {
|
||||
clauses.push(...esAndTerms('roleIds', filters.roleIds));
|
||||
}
|
||||
|
||||
if (filters.joinedAtGte !== undefined) clauses.push(esRangeFilter('joinedAt', {gte: filters.joinedAtGte}));
|
||||
if (filters.joinedAtLte !== undefined) clauses.push(esRangeFilter('joinedAt', {lte: filters.joinedAtLte}));
|
||||
|
||||
if (filters.joinSourceType && filters.joinSourceType.length > 0) {
|
||||
clauses.push(esTermsFilter('joinSourceType', filters.joinSourceType));
|
||||
}
|
||||
|
||||
if (filters.sourceInviteCode && filters.sourceInviteCode.length > 0) {
|
||||
clauses.push(esTermsFilter('sourceInviteCode', filters.sourceInviteCode));
|
||||
}
|
||||
|
||||
if (filters.userCreatedAtGte !== undefined)
|
||||
clauses.push(esRangeFilter('userCreatedAt', {gte: filters.userCreatedAtGte}));
|
||||
if (filters.userCreatedAtLte !== undefined)
|
||||
clauses.push(esRangeFilter('userCreatedAt', {lte: filters.userCreatedAtLte}));
|
||||
|
||||
if (filters.isBot !== undefined) clauses.push(esTermFilter('isBot', filters.isBot));
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildGuildMemberSort(filters: GuildMemberSearchFilters): Array<Record<string, unknown>> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'joinedAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [{[sortBy]: {order: sortOrder}}];
|
||||
}
|
||||
|
||||
export interface ElasticsearchGuildMemberAdapterOptions {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export class ElasticsearchGuildMemberAdapter extends ElasticsearchIndexAdapter<
|
||||
GuildMemberSearchFilters,
|
||||
SearchableGuildMember
|
||||
> {
|
||||
constructor(options: ElasticsearchGuildMemberAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.guild_members,
|
||||
searchableFields: ['username', 'discriminator', 'globalName', 'nickname', 'userId'],
|
||||
buildFilters: buildGuildMemberFilters,
|
||||
buildSort: buildGuildMemberSort,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {compactFilters} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import type {ElasticsearchIndexDefinition} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {ISearchAdapter, SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
|
||||
export interface ElasticsearchIndexAdapterOptions<TFilters> {
|
||||
client: Client;
|
||||
index: ElasticsearchIndexDefinition;
|
||||
searchableFields: Array<string>;
|
||||
buildFilters: (filters: TFilters) => Array<ElasticsearchFilter | undefined>;
|
||||
buildSort?: (filters: TFilters) => Array<Record<string, unknown>> | undefined;
|
||||
}
|
||||
|
||||
export class ElasticsearchIndexAdapter<TFilters, TResult extends {id: string}>
|
||||
implements ISearchAdapter<TFilters, TResult>
|
||||
{
|
||||
protected readonly client: Client;
|
||||
protected readonly indexDefinition: ElasticsearchIndexDefinition;
|
||||
protected readonly searchableFields: Array<string>;
|
||||
protected readonly buildFilters: (filters: TFilters) => Array<ElasticsearchFilter | undefined>;
|
||||
protected readonly buildSort: ((filters: TFilters) => Array<Record<string, unknown>> | undefined) | undefined;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
constructor(options: ElasticsearchIndexAdapterOptions<TFilters>) {
|
||||
this.client = options.client;
|
||||
this.indexDefinition = options.index;
|
||||
this.searchableFields = options.searchableFields;
|
||||
this.buildFilters = options.buildFilters;
|
||||
this.buildSort = options.buildSort;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const indexName = this.indexDefinition.indexName;
|
||||
|
||||
const exists = await this.client.indices.exists({index: indexName});
|
||||
if (!exists) {
|
||||
try {
|
||||
await this.client.indices.create({
|
||||
index: indexName,
|
||||
settings: this.indexDefinition.settings ?? {},
|
||||
mappings: this.indexDefinition.mappings,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isResourceAlreadyExistsError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.client.indices.putMapping({
|
||||
index: indexName,
|
||||
...this.indexDefinition.mappings,
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
async indexDocument(doc: TResult): Promise<void> {
|
||||
await this.indexDocuments([doc]);
|
||||
}
|
||||
|
||||
async indexDocuments(docs: Array<TResult>): Promise<void> {
|
||||
if (docs.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.assertInitialised();
|
||||
|
||||
const operations = docs.flatMap((doc) => [{index: {_index: this.indexDefinition.indexName, _id: doc.id}}, doc]);
|
||||
await this.client.bulk({operations, refresh: 'wait_for'});
|
||||
}
|
||||
|
||||
async updateDocument(doc: TResult): Promise<void> {
|
||||
this.assertInitialised();
|
||||
|
||||
await this.client.index({
|
||||
index: this.indexDefinition.indexName,
|
||||
id: doc.id,
|
||||
document: doc,
|
||||
refresh: 'wait_for',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDocument(id: string): Promise<void> {
|
||||
await this.deleteDocuments([id]);
|
||||
}
|
||||
|
||||
async deleteDocuments(ids: Array<string>): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.assertInitialised();
|
||||
|
||||
const operations = ids.map((id) => ({delete: {_index: this.indexDefinition.indexName, _id: id}}));
|
||||
await this.client.bulk({operations, refresh: 'wait_for'});
|
||||
}
|
||||
|
||||
async deleteAllDocuments(): Promise<void> {
|
||||
this.assertInitialised();
|
||||
|
||||
await this.client.deleteByQuery({
|
||||
index: this.indexDefinition.indexName,
|
||||
query: {match_all: {}},
|
||||
refresh: true,
|
||||
});
|
||||
}
|
||||
|
||||
async search(query: string, filters: TFilters, options?: SearchOptions): Promise<SearchResult<TResult>> {
|
||||
this.assertInitialised();
|
||||
|
||||
const limit = options?.limit ?? options?.hitsPerPage ?? 25;
|
||||
const offset = options?.offset ?? (options?.page ? (options.page - 1) * (options.hitsPerPage ?? 25) : 0);
|
||||
|
||||
const filterClauses = compactFilters(this.buildFilters(filters));
|
||||
const sort = this.buildSort?.(filters);
|
||||
|
||||
const must: Array<Record<string, unknown>> = query
|
||||
? [{multi_match: {query, fields: this.searchableFields, type: 'best_fields'}}]
|
||||
: [{match_all: {}}];
|
||||
|
||||
const searchParams: Record<string, unknown> = {
|
||||
index: this.indexDefinition.indexName,
|
||||
query: {
|
||||
bool: {
|
||||
must,
|
||||
filter: filterClauses.length > 0 ? filterClauses : undefined,
|
||||
},
|
||||
},
|
||||
from: offset,
|
||||
size: limit,
|
||||
};
|
||||
|
||||
if (sort && sort.length > 0) {
|
||||
searchParams.sort = sort;
|
||||
}
|
||||
|
||||
const result = await this.client.search<TResult>(searchParams);
|
||||
|
||||
const totalValue = result.hits.total;
|
||||
const total = typeof totalValue === 'number' ? totalValue : (totalValue?.value ?? 0);
|
||||
const hits = result.hits.hits.map((hit) => ({...hit._source!, id: hit._id!}));
|
||||
|
||||
return {hits, total};
|
||||
}
|
||||
|
||||
private assertInitialised(): void {
|
||||
if (!this.initialized) {
|
||||
throw new Error('Elasticsearch adapter not initialised');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isResourceAlreadyExistsError(error: unknown): boolean {
|
||||
if (error == null || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const meta = (error as {meta?: {body?: {error?: {type?: string}}}}).meta;
|
||||
if (meta?.body?.error?.type === 'resource_already_exists_exception') {
|
||||
return true;
|
||||
}
|
||||
const message = (error as {message?: string}).message ?? '';
|
||||
return message.includes('resource_already_exists_exception');
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import {ElasticsearchIndexAdapter} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esAndTerms,
|
||||
esExcludeAny,
|
||||
esTermFilter,
|
||||
esTermsFilter,
|
||||
} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {MessageSearchFilters, SearchableMessage} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
const DEFAULT_HITS_PER_PAGE = 25;
|
||||
const FETCH_MULTIPLIER = 3;
|
||||
|
||||
const HAS_FIELD_MAP: Record<string, string> = {
|
||||
image: 'hasImage',
|
||||
sound: 'hasSound',
|
||||
video: 'hasVideo',
|
||||
file: 'hasFile',
|
||||
sticker: 'hasSticker',
|
||||
embed: 'hasEmbed',
|
||||
link: 'hasLink',
|
||||
poll: 'hasPoll',
|
||||
snapshot: 'hasForward',
|
||||
};
|
||||
|
||||
function buildMessageFilters(filters: MessageSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
if (filters.guildId) {
|
||||
clauses.push(esTermFilter('guildId', filters.guildId));
|
||||
}
|
||||
|
||||
if (filters.channelId) {
|
||||
clauses.push(esTermFilter('channelId', filters.channelId));
|
||||
}
|
||||
|
||||
if (filters.channelIds && filters.channelIds.length > 0) {
|
||||
clauses.push(esTermsFilter('channelId', filters.channelIds));
|
||||
}
|
||||
|
||||
if (filters.excludeChannelIds && filters.excludeChannelIds.length > 0) {
|
||||
clauses.push(...esExcludeAny('channelId', filters.excludeChannelIds));
|
||||
}
|
||||
|
||||
if (filters.authorId && filters.authorId.length > 0) {
|
||||
clauses.push(esTermsFilter('authorId', filters.authorId));
|
||||
}
|
||||
|
||||
if (filters.excludeAuthorIds && filters.excludeAuthorIds.length > 0) {
|
||||
clauses.push(...esExcludeAny('authorId', filters.excludeAuthorIds));
|
||||
}
|
||||
|
||||
if (filters.authorType && filters.authorType.length > 0) {
|
||||
clauses.push(esTermsFilter('authorType', filters.authorType));
|
||||
}
|
||||
|
||||
if (filters.excludeAuthorType && filters.excludeAuthorType.length > 0) {
|
||||
clauses.push(...esExcludeAny('authorType', filters.excludeAuthorType));
|
||||
}
|
||||
|
||||
if (filters.mentions && filters.mentions.length > 0) {
|
||||
clauses.push(...esAndTerms('mentionedUserIds', filters.mentions));
|
||||
}
|
||||
|
||||
if (filters.excludeMentions && filters.excludeMentions.length > 0) {
|
||||
clauses.push(...esExcludeAny('mentionedUserIds', filters.excludeMentions));
|
||||
}
|
||||
|
||||
if (filters.mentionEveryone !== undefined) {
|
||||
clauses.push(esTermFilter('mentionEveryone', filters.mentionEveryone));
|
||||
}
|
||||
|
||||
if (filters.pinned !== undefined) {
|
||||
clauses.push(esTermFilter('isPinned', filters.pinned));
|
||||
}
|
||||
|
||||
if (filters.has && filters.has.length > 0) {
|
||||
for (const hasType of filters.has) {
|
||||
const field = HAS_FIELD_MAP[hasType];
|
||||
if (field) {
|
||||
clauses.push(esTermFilter(field, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.excludeHas && filters.excludeHas.length > 0) {
|
||||
for (const hasType of filters.excludeHas) {
|
||||
const field = HAS_FIELD_MAP[hasType];
|
||||
if (field) {
|
||||
clauses.push(esTermFilter(field, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.embedType && filters.embedType.length > 0) {
|
||||
clauses.push(...esAndTerms('embedTypes', filters.embedType));
|
||||
}
|
||||
|
||||
if (filters.excludeEmbedTypes && filters.excludeEmbedTypes.length > 0) {
|
||||
clauses.push(...esExcludeAny('embedTypes', filters.excludeEmbedTypes));
|
||||
}
|
||||
|
||||
if (filters.embedProvider && filters.embedProvider.length > 0) {
|
||||
clauses.push(...esAndTerms('embedProviders', filters.embedProvider));
|
||||
}
|
||||
|
||||
if (filters.excludeEmbedProviders && filters.excludeEmbedProviders.length > 0) {
|
||||
clauses.push(...esExcludeAny('embedProviders', filters.excludeEmbedProviders));
|
||||
}
|
||||
|
||||
if (filters.linkHostname && filters.linkHostname.length > 0) {
|
||||
clauses.push(...esAndTerms('linkHostnames', filters.linkHostname));
|
||||
}
|
||||
|
||||
if (filters.excludeLinkHostnames && filters.excludeLinkHostnames.length > 0) {
|
||||
clauses.push(...esExcludeAny('linkHostnames', filters.excludeLinkHostnames));
|
||||
}
|
||||
|
||||
if (filters.attachmentFilename && filters.attachmentFilename.length > 0) {
|
||||
clauses.push(...esAndTerms('attachmentFilenames', filters.attachmentFilename));
|
||||
}
|
||||
|
||||
if (filters.excludeAttachmentFilenames && filters.excludeAttachmentFilenames.length > 0) {
|
||||
clauses.push(...esExcludeAny('attachmentFilenames', filters.excludeAttachmentFilenames));
|
||||
}
|
||||
|
||||
if (filters.attachmentExtension && filters.attachmentExtension.length > 0) {
|
||||
clauses.push(...esAndTerms('attachmentExtensions', filters.attachmentExtension));
|
||||
}
|
||||
|
||||
if (filters.excludeAttachmentExtensions && filters.excludeAttachmentExtensions.length > 0) {
|
||||
clauses.push(...esExcludeAny('attachmentExtensions', filters.excludeAttachmentExtensions));
|
||||
}
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildMessageSort(filters: MessageSearchFilters): Array<Record<string, unknown>> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'timestamp';
|
||||
if (sortBy === 'relevance') {
|
||||
return undefined;
|
||||
}
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [{createdAt: {order: sortOrder}}];
|
||||
}
|
||||
|
||||
function getLimit(options?: SearchOptions): number {
|
||||
return options?.limit ?? options?.hitsPerPage ?? DEFAULT_HITS_PER_PAGE;
|
||||
}
|
||||
|
||||
function getOffset(options?: SearchOptions): number {
|
||||
return options?.offset ?? (options?.page ? (options.page - 1) * (options.hitsPerPage ?? DEFAULT_HITS_PER_PAGE) : 0);
|
||||
}
|
||||
|
||||
function applyMaxMinIdFilters(hits: Array<SearchableMessage>, filters: MessageSearchFilters): Array<SearchableMessage> {
|
||||
let filtered = hits;
|
||||
if (filters.maxId != null) {
|
||||
const maxId = BigInt(filters.maxId);
|
||||
filtered = filtered.filter((message) => BigInt(message.id) < maxId);
|
||||
}
|
||||
if (filters.minId != null) {
|
||||
const minId = BigInt(filters.minId);
|
||||
filtered = filtered.filter((message) => BigInt(message.id) > minId);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function applyExactPhraseFilter(hits: Array<SearchableMessage>, phrases: Array<string>): Array<SearchableMessage> {
|
||||
return hits.filter((hit) => {
|
||||
if (!hit.content) return false;
|
||||
return phrases.every((phrase) => hit.content!.includes(phrase));
|
||||
});
|
||||
}
|
||||
|
||||
function applySortByIdTiebreaker(
|
||||
hits: Array<SearchableMessage>,
|
||||
filters: MessageSearchFilters,
|
||||
): Array<SearchableMessage> {
|
||||
const sortBy = filters.sortBy ?? 'timestamp';
|
||||
if (sortBy === 'relevance') {
|
||||
return hits;
|
||||
}
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [...hits].sort((messageA, messageB) => {
|
||||
if (messageA.createdAt !== messageB.createdAt) {
|
||||
return sortOrder === 'asc' ? messageA.createdAt - messageB.createdAt : messageB.createdAt - messageA.createdAt;
|
||||
}
|
||||
const messageAId = BigInt(messageA.id);
|
||||
const messageBId = BigInt(messageB.id);
|
||||
if (sortOrder === 'asc') {
|
||||
return messageAId < messageBId ? -1 : messageAId > messageBId ? 1 : 0;
|
||||
}
|
||||
return messageBId < messageAId ? -1 : messageBId > messageAId ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
export interface ElasticsearchMessageAdapterOptions {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export class ElasticsearchMessageAdapter extends ElasticsearchIndexAdapter<MessageSearchFilters, SearchableMessage> {
|
||||
constructor(options: ElasticsearchMessageAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.messages,
|
||||
searchableFields: ['content'],
|
||||
buildFilters: buildMessageFilters,
|
||||
buildSort: buildMessageSort,
|
||||
});
|
||||
}
|
||||
|
||||
override async search(
|
||||
query: string,
|
||||
filters: MessageSearchFilters,
|
||||
options?: SearchOptions,
|
||||
): Promise<SearchResult<SearchableMessage>> {
|
||||
const limit = getLimit(options);
|
||||
const offset = getOffset(options);
|
||||
|
||||
const fetchLimit = Math.max((limit + offset) * FETCH_MULTIPLIER, limit);
|
||||
|
||||
const exactPhrases = filters.exactPhrases ?? [];
|
||||
const contents = filters.contents ?? [];
|
||||
|
||||
if (contents.length > 0) {
|
||||
const resultMap = new Map<string, SearchableMessage>();
|
||||
const searchResults = await Promise.all(
|
||||
contents.map((term) =>
|
||||
super.search(
|
||||
term,
|
||||
{...filters, contents: undefined, exactPhrases: undefined},
|
||||
{...options, limit: fetchLimit, offset: 0},
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const result of searchResults) {
|
||||
for (const hit of result.hits) {
|
||||
if (!resultMap.has(hit.id)) {
|
||||
resultMap.set(hit.id, hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mergedHits = Array.from(resultMap.values());
|
||||
mergedHits = applyMaxMinIdFilters(mergedHits, filters);
|
||||
if (exactPhrases.length > 0) {
|
||||
mergedHits = applyExactPhraseFilter(mergedHits, exactPhrases);
|
||||
}
|
||||
const sorted = applySortByIdTiebreaker(mergedHits, filters);
|
||||
return {
|
||||
hits: sorted.slice(offset, offset + limit),
|
||||
total: mergedHits.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (exactPhrases.length > 0) {
|
||||
const result = await this.searchWithPhrases(query, exactPhrases, filters, {
|
||||
...options,
|
||||
limit: fetchLimit,
|
||||
offset: 0,
|
||||
});
|
||||
const filteredHits = applyMaxMinIdFilters(result.hits, filters);
|
||||
const sorted = applySortByIdTiebreaker(filteredHits, filters);
|
||||
return {
|
||||
hits: sorted.slice(offset, offset + limit),
|
||||
total: filteredHits.length,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await super.search(query, filters, {...options, limit: fetchLimit, offset: 0});
|
||||
const filtered = applyMaxMinIdFilters(result.hits, filters);
|
||||
const sorted = applySortByIdTiebreaker(filtered, filters);
|
||||
return {
|
||||
hits: sorted.slice(offset, offset + limit),
|
||||
total: filtered.length,
|
||||
};
|
||||
}
|
||||
|
||||
private async searchWithPhrases(
|
||||
query: string,
|
||||
exactPhrases: Array<string>,
|
||||
filters: MessageSearchFilters,
|
||||
options?: SearchOptions,
|
||||
): Promise<SearchResult<SearchableMessage>> {
|
||||
const limit = options?.limit ?? DEFAULT_HITS_PER_PAGE;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
const filterClauses = compactFilters(buildMessageFilters({...filters, exactPhrases: undefined}));
|
||||
const sort = buildMessageSort(filters);
|
||||
|
||||
const must: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (query) {
|
||||
must.push({multi_match: {query, fields: ['content'], type: 'best_fields'}});
|
||||
}
|
||||
|
||||
for (const phrase of exactPhrases) {
|
||||
must.push({match_phrase: {content: phrase}});
|
||||
}
|
||||
|
||||
if (must.length === 0) {
|
||||
must.push({match_all: {}});
|
||||
}
|
||||
|
||||
const searchParams: Record<string, unknown> = {
|
||||
index: 'messages',
|
||||
query: {
|
||||
bool: {
|
||||
must,
|
||||
filter: filterClauses.length > 0 ? filterClauses : undefined,
|
||||
},
|
||||
},
|
||||
from: offset,
|
||||
size: limit,
|
||||
};
|
||||
|
||||
if (sort && sort.length > 0) {
|
||||
searchParams.sort = sort;
|
||||
}
|
||||
|
||||
const result = await this.client.search<SearchableMessage>(searchParams);
|
||||
|
||||
const totalValue = result.hits.total;
|
||||
const total = typeof totalValue === 'number' ? totalValue : (totalValue?.value ?? 0);
|
||||
const hits = result.hits.hits.map((hit) => ({...hit._source!, id: hit._id!}));
|
||||
|
||||
return {hits, total};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import {ElasticsearchIndexAdapter} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esExistsFilter,
|
||||
esNotExistsFilter,
|
||||
esTermFilter,
|
||||
} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {ReportSearchFilters, SearchableReport} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
function buildReportFilters(filters: ReportSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
if (filters.reporterId) clauses.push(esTermFilter('reporterId', filters.reporterId));
|
||||
if (filters.status !== undefined) clauses.push(esTermFilter('status', filters.status));
|
||||
if (filters.reportType !== undefined) clauses.push(esTermFilter('reportType', filters.reportType));
|
||||
if (filters.category) clauses.push(esTermFilter('category', filters.category));
|
||||
if (filters.reportedUserId) clauses.push(esTermFilter('reportedUserId', filters.reportedUserId));
|
||||
if (filters.reportedGuildId) clauses.push(esTermFilter('reportedGuildId', filters.reportedGuildId));
|
||||
if (filters.reportedMessageId) clauses.push(esTermFilter('reportedMessageId', filters.reportedMessageId));
|
||||
if (filters.guildContextId) clauses.push(esTermFilter('guildContextId', filters.guildContextId));
|
||||
if (filters.resolvedByAdminId) clauses.push(esTermFilter('resolvedByAdminId', filters.resolvedByAdminId));
|
||||
|
||||
if (filters.isResolved !== undefined) {
|
||||
clauses.push(filters.isResolved ? esExistsFilter('resolvedAt') : esNotExistsFilter('resolvedAt'));
|
||||
}
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildReportSort(filters: ReportSearchFilters): Array<Record<string, unknown>> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'reportedAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [{[sortBy]: {order: sortOrder}}];
|
||||
}
|
||||
|
||||
export interface ElasticsearchReportAdapterOptions {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export class ElasticsearchReportAdapter extends ElasticsearchIndexAdapter<ReportSearchFilters, SearchableReport> {
|
||||
constructor(options: ElasticsearchReportAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.reports,
|
||||
searchableFields: ['category', 'additionalInfo', 'reportedGuildName', 'reportedChannelName'],
|
||||
buildFilters: buildReportFilters,
|
||||
buildSort: buildReportSort,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import {ElasticsearchIndexAdapter} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esAndTerms,
|
||||
esExistsFilter,
|
||||
esNotExistsFilter,
|
||||
esRangeFilter,
|
||||
esTermFilter,
|
||||
} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {SearchableUser, UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
function buildUserFilters(filters: UserSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
if (filters.isBot !== undefined) clauses.push(esTermFilter('isBot', filters.isBot));
|
||||
if (filters.isSystem !== undefined) clauses.push(esTermFilter('isSystem', filters.isSystem));
|
||||
if (filters.emailVerified !== undefined) clauses.push(esTermFilter('emailVerified', filters.emailVerified));
|
||||
if (filters.emailBounced !== undefined) clauses.push(esTermFilter('emailBounced', filters.emailBounced));
|
||||
|
||||
if (filters.hasPremium !== undefined) {
|
||||
clauses.push(filters.hasPremium ? esExistsFilter('premiumType') : esNotExistsFilter('premiumType'));
|
||||
}
|
||||
if (filters.isTempBanned !== undefined) {
|
||||
clauses.push(filters.isTempBanned ? esExistsFilter('tempBannedUntil') : esNotExistsFilter('tempBannedUntil'));
|
||||
}
|
||||
if (filters.isPendingDeletion !== undefined) {
|
||||
clauses.push(
|
||||
filters.isPendingDeletion ? esExistsFilter('pendingDeletionAt') : esNotExistsFilter('pendingDeletionAt'),
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.hasAcl && filters.hasAcl.length > 0) {
|
||||
clauses.push(...esAndTerms('acls', filters.hasAcl));
|
||||
}
|
||||
|
||||
if (filters.minSuspiciousActivityFlags !== undefined) {
|
||||
clauses.push(esRangeFilter('suspiciousActivityFlags', {gte: filters.minSuspiciousActivityFlags}));
|
||||
}
|
||||
|
||||
if (filters.createdAtGreaterThanOrEqual !== undefined) {
|
||||
clauses.push(esRangeFilter('createdAt', {gte: filters.createdAtGreaterThanOrEqual}));
|
||||
}
|
||||
if (filters.createdAtLessThanOrEqual !== undefined) {
|
||||
clauses.push(esRangeFilter('createdAt', {lte: filters.createdAtLessThanOrEqual}));
|
||||
}
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildUserSort(filters: UserSearchFilters): Array<Record<string, unknown>> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'createdAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [{[sortBy]: {order: sortOrder}}];
|
||||
}
|
||||
|
||||
export interface ElasticsearchUserAdapterOptions {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export class ElasticsearchUserAdapter extends ElasticsearchIndexAdapter<UserSearchFilters, SearchableUser> {
|
||||
constructor(options: ElasticsearchUserAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.users,
|
||||
searchableFields: ['username', 'email', 'phone', 'id'],
|
||||
buildFilters: buildUserFilters,
|
||||
buildSort: buildUserSort,
|
||||
});
|
||||
}
|
||||
}
|
||||
5
packages/elasticsearch_search/tsconfig.json
Normal file
5
packages/elasticsearch_search/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfigs/package.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -18,7 +18,12 @@
|
||||
*/
|
||||
|
||||
import type {IKVPipeline, IKVProvider, IKVSubscription} from '@fluxer/kv_client/src/IKVProvider';
|
||||
import {type IKVLogger, type KVClientConfig, resolveKVClientConfig} from '@fluxer/kv_client/src/KVClientConfig';
|
||||
import {
|
||||
type IKVLogger,
|
||||
type KVClientConfig,
|
||||
type ResolvedKVClientConfig,
|
||||
resolveKVClientConfig,
|
||||
} from '@fluxer/kv_client/src/KVClientConfig';
|
||||
import {KVClientError, KVClientErrorCode} from '@fluxer/kv_client/src/KVClientError';
|
||||
import {
|
||||
createStringEntriesFromPairs,
|
||||
@@ -29,7 +34,7 @@ import {
|
||||
} from '@fluxer/kv_client/src/KVCommandArguments';
|
||||
import {KVPipeline} from '@fluxer/kv_client/src/KVPipeline';
|
||||
import {KVSubscription} from '@fluxer/kv_client/src/KVSubscription';
|
||||
import Redis from 'ioredis';
|
||||
import Redis, {Cluster} from 'ioredis';
|
||||
|
||||
const RELEASE_LOCK_SCRIPT = `
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
@@ -106,21 +111,47 @@ return 1
|
||||
`;
|
||||
|
||||
export class KVClient implements IKVProvider {
|
||||
private readonly client: Redis;
|
||||
private readonly client: Redis | Cluster;
|
||||
private readonly config: ResolvedKVClientConfig;
|
||||
private readonly logger: IKVLogger;
|
||||
private readonly url: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(config: KVClientConfig | string) {
|
||||
const resolvedConfig = resolveKVClientConfig(config);
|
||||
this.config = resolvedConfig;
|
||||
this.url = resolvedConfig.url;
|
||||
this.timeoutMs = resolvedConfig.timeoutMs;
|
||||
this.logger = resolvedConfig.logger;
|
||||
this.client = new Redis(this.url, {
|
||||
connectTimeout: this.timeoutMs,
|
||||
commandTimeout: this.timeoutMs,
|
||||
maxRetriesPerRequest: 1,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
|
||||
if (resolvedConfig.mode === 'cluster') {
|
||||
this.client = this.createClusterClient(resolvedConfig);
|
||||
} else {
|
||||
this.client = new Redis(this.url, {
|
||||
connectTimeout: this.timeoutMs,
|
||||
commandTimeout: this.timeoutMs,
|
||||
maxRetriesPerRequest: 1,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private createClusterClient(clusterConfig: ResolvedKVClientConfig): Cluster {
|
||||
const nodes =
|
||||
clusterConfig.clusterNodes.length > 0 ? clusterConfig.clusterNodes : parseClusterNodesFromUrl(clusterConfig.url);
|
||||
|
||||
const natMap = clusterConfig.clusterNatMap;
|
||||
const hasNatMap = Object.keys(natMap).length > 0;
|
||||
|
||||
return new Cluster(nodes, {
|
||||
clusterRetryStrategy: createRetryStrategy(),
|
||||
redisOptions: {
|
||||
connectTimeout: clusterConfig.timeoutMs,
|
||||
commandTimeout: clusterConfig.timeoutMs,
|
||||
maxRetriesPerRequest: 1,
|
||||
},
|
||||
scaleReads: 'master',
|
||||
...(hasNatMap ? {natMap} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -374,6 +405,8 @@ export class KVClient implements IKVProvider {
|
||||
duplicate(): IKVSubscription {
|
||||
return new KVSubscription({
|
||||
url: this.url,
|
||||
mode: this.config.mode,
|
||||
clusterNodes: this.config.clusterNodes,
|
||||
timeoutMs: this.timeoutMs,
|
||||
logger: this.logger,
|
||||
});
|
||||
@@ -480,6 +513,15 @@ export class KVClient implements IKVProvider {
|
||||
}
|
||||
}
|
||||
|
||||
function parseClusterNodesFromUrl(url: string): Array<{host: string; port: number}> {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return [{host: parsed.hostname, port: Number.parseInt(parsed.port || '6379', 10)}];
|
||||
} catch {
|
||||
return [{host: '127.0.0.1', port: 6379}];
|
||||
}
|
||||
}
|
||||
|
||||
function createRetryStrategy(): (times: number) => number {
|
||||
return (times: number) => {
|
||||
const backoffMs = Math.min(times * 100, 2000);
|
||||
|
||||
@@ -24,14 +24,27 @@ export interface IKVLogger {
|
||||
error(obj: object, msg?: string): void;
|
||||
}
|
||||
|
||||
export type KVClientMode = 'standalone' | 'cluster';
|
||||
|
||||
export interface KVClusterNode {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface KVClientConfig {
|
||||
url: string;
|
||||
mode?: KVClientMode;
|
||||
clusterNodes?: Array<KVClusterNode>;
|
||||
clusterNatMap?: Record<string, KVClusterNode>;
|
||||
timeoutMs?: number;
|
||||
logger?: IKVLogger;
|
||||
}
|
||||
|
||||
export interface ResolvedKVClientConfig {
|
||||
url: string;
|
||||
mode: KVClientMode;
|
||||
clusterNodes: Array<KVClusterNode>;
|
||||
clusterNatMap: Record<string, KVClusterNode>;
|
||||
timeoutMs: number;
|
||||
logger: IKVLogger;
|
||||
}
|
||||
@@ -45,6 +58,9 @@ export function resolveKVClientConfig(config: KVClientConfig | string): Resolved
|
||||
if (typeof config === 'string') {
|
||||
return {
|
||||
url: normalizeUrl(config),
|
||||
mode: 'standalone' as const,
|
||||
clusterNodes: [],
|
||||
clusterNatMap: {},
|
||||
timeoutMs: DEFAULT_KV_TIMEOUT_MS,
|
||||
logger: noopLogger,
|
||||
};
|
||||
@@ -52,6 +68,9 @@ export function resolveKVClientConfig(config: KVClientConfig | string): Resolved
|
||||
|
||||
return {
|
||||
url: normalizeUrl(config.url),
|
||||
mode: config.mode ?? 'standalone',
|
||||
clusterNodes: config.clusterNodes ?? [],
|
||||
clusterNatMap: config.clusterNatMap ?? {},
|
||||
timeoutMs: config.timeoutMs ?? DEFAULT_KV_TIMEOUT_MS,
|
||||
logger: config.logger ?? noopLogger,
|
||||
};
|
||||
|
||||
@@ -18,17 +18,21 @@
|
||||
*/
|
||||
|
||||
import type {IKVSubscription} from '@fluxer/kv_client/src/IKVProvider';
|
||||
import type {IKVLogger} from '@fluxer/kv_client/src/KVClientConfig';
|
||||
import type {IKVLogger, KVClientMode, KVClusterNode} from '@fluxer/kv_client/src/KVClientConfig';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
interface KVSubscriptionConfig {
|
||||
url: string;
|
||||
mode?: KVClientMode;
|
||||
clusterNodes?: Array<KVClusterNode>;
|
||||
timeoutMs: number;
|
||||
logger: IKVLogger;
|
||||
}
|
||||
|
||||
export class KVSubscription implements IKVSubscription {
|
||||
private readonly url: string;
|
||||
private readonly mode: KVClientMode;
|
||||
private readonly clusterNodes: Array<KVClusterNode>;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly logger: IKVLogger;
|
||||
private readonly channels: Set<string> = new Set();
|
||||
@@ -38,6 +42,8 @@ export class KVSubscription implements IKVSubscription {
|
||||
|
||||
constructor(config: KVSubscriptionConfig) {
|
||||
this.url = config.url;
|
||||
this.mode = config.mode ?? 'standalone';
|
||||
this.clusterNodes = config.clusterNodes ?? [];
|
||||
this.timeoutMs = config.timeoutMs;
|
||||
this.logger = config.logger;
|
||||
}
|
||||
@@ -47,7 +53,8 @@ export class KVSubscription implements IKVSubscription {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new Redis(this.url, {
|
||||
const connectionUrl = this.resolveSubscriptionUrl();
|
||||
const client = new Redis(connectionUrl, {
|
||||
autoResubscribe: true,
|
||||
connectTimeout: this.timeoutMs,
|
||||
commandTimeout: this.timeoutMs,
|
||||
@@ -144,6 +151,15 @@ export class KVSubscription implements IKVSubscription {
|
||||
this.errorCallbacks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSubscriptionUrl(): string {
|
||||
if (this.mode !== 'cluster' || this.clusterNodes.length === 0) {
|
||||
return this.url;
|
||||
}
|
||||
|
||||
const node = this.clusterNodes[0];
|
||||
return `redis://${node.host}:${node.port}`;
|
||||
}
|
||||
}
|
||||
|
||||
function createRetryStrategy(): (times: number) => number {
|
||||
|
||||
@@ -50,7 +50,7 @@ export const POLICY_METADATA: ReadonlyArray<PolicyMetadata> = [
|
||||
description:
|
||||
'The rules and expectations for participating in the Fluxer community. Help us keep Fluxer safe and welcoming.',
|
||||
category: 'Community',
|
||||
lastUpdated: '2026-02-13',
|
||||
lastUpdated: '2026-02-21',
|
||||
},
|
||||
{
|
||||
slug: 'security',
|
||||
|
||||
@@ -99,9 +99,13 @@ We have a zero-tolerance stance on child sexual exploitation.
|
||||
|
||||
- **Users under 18:** If you are under 18, you must not engage with, share, or distribute any sexual or sexually suggestive content.
|
||||
|
||||
- **Content involving minors (real or fictional):** No user may share, distribute, request, or possess sexual or sexually suggestive content involving minors (whether real, fictional, or simulated). This includes "age play" or any portrayal that sexualizes minors.
|
||||
- **Child sexual abuse material (CSAM):** CSAM – sexual or sexually suggestive imagery depicting real children – is strictly prohibited and will be reported to law enforcement authorities as required by law. We use automated tools and safety systems to detect and prevent CSAM in media where technically feasible and take immediate action when we identify it. This includes realistic AI-generated or digitally manipulated imagery that is indistinguishable from photographs of real children.
|
||||
|
||||
- **Child sexual abuse material (CSAM):** CSAM is strictly prohibited and will be reported to law enforcement authorities as required by law. We use automated tools and safety systems to detect and prevent CSAM in media where technically feasible and take immediate action when we identify it.
|
||||
- **Sexualisation of real minors:** No user may share, distribute, request, or create sexual or sexually suggestive content depicting a real, identified minor in any medium, including text, imagery, or audio.
|
||||
|
||||
- **Fictional depictions:** Sexual or sexually suggestive content featuring fictional characters who are explicitly described as minors, or who are unambiguously depicted as prepubescent, is prohibited in all spaces. This includes drawn, animated, AI-generated, and written content where the character is clearly a child. We assess fictional content based on the totality of context – including stated age, narrative framing, visual presentation, and the setting in which the character appears. This rule does not apply to non-sexual coming-of-age narratives, survivor stories, educational content, or literary works that depict difficult subject matter without sexualizing it.
|
||||
|
||||
- **Grooming:** Using the platform to build a relationship with a minor for the purpose of sexual exploitation is strictly prohibited, regardless of whether explicit content is involved.
|
||||
|
||||
- **Adult content restrictions:** Adult content is only permitted in clearly marked 18+ spaces. Communities must apply an age restriction to the Community as a whole, to individual channels, or both. We may restrict or remove Communities that fail to enforce these requirements.
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: مجاني
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'ملاحظة: مزايا Plutonium وVisionary تنطبق فقط على نسخة Fluxer.app الرسمية، وليس على النسخ الخارجية أو المستضافة ذاتياً.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: رفع 500 MB، رسائل حتى 4,000 حرف، 300 علامة مرجعية، 50 حزمة إيموجي، وأكثر بكثير.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: حد أحرف النبذة
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: اختر وسم 4 أرقام مخصص مثل #0001 أو #1337 أو #9999 لاسم مستخدم فريد حقاً.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: اختر أي وسم 4 أرقام متاح من #0001 إلى #9999 لاسم مستخدم فريد حقاً.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: وسم اسم مستخدم 4 أرقام مخصص
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: وسم اسم مستخدم مخصص
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: خلفيات فيديو
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Безплатно
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Забележка: Plutonium и Visionary важат само за официалната инстанция Fluxer.app, не и за външни или самостоятелно хоствани инстанции.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Качвания до 500 MB, съобщения до 4 000 знака, 300 отметки, 50 пакета с емоджита и още много.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Лимит на знаците в биото
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Избери си собствен таг с 4 цифри като #0001, #1337 или #9999, за да направиш потребителското си име наистина уникално.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Избери си наличен таг с 4 цифри от #0001 до #9999, за да направиш потребителското си име наистина уникално.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Таг с 4 цифри по избор
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Таг по избор към потребителското име
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Видео фонове по избор
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Zdarma
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Poznámka: výhody Plutonia a Visionary platí jen pro oficiální instanci Fluxer.app, ne pro third-party nebo self-hostované instance.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Nahrávání do 500 MB, zprávy do 4 000 znaků, 300 záložek, 50 balíčků emoji a mnohem víc.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Limit znaků v bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Vyber si vlastní 4místný tag jako #0001, #1337 nebo #9999, aby byla tvoje uživatelské jméno opravdu jedinečné.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Vyber si jakýkoli dostupný 4místný tag od #0001 do #9999, aby tvoje uživatelské jméno bylo opravdu jedinečné.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Vlastní 4místný tag uživatelského jména
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Vlastní tag u uživatelského jména
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Video pozadí
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Gratis
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Bemærk: Plutonium- og Visionary-fordele gælder kun for den officielle Fluxer.app-instans, ikke tredjeparts- eller selvhostede instanser.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500 MB uploads, beskeder på 4.000 tegn, 300 bogmærker, 50 emojipakker og meget mere.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Tegnbegrænsning for bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Vælg dine egne 4-cifre tag som #0001, #1337 eller #9999 for at gøre dit brugernavn virkelig unikt.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Vælg et tilgængeligt 4-cifret tag fra #0001 til #9999 for at gøre dit brugernavn virkelig unikt.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Tilpasset 4-cifre brugernavnstag
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Tilpasset brugernavnstag
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Tilpassede videobaggrunde
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Free
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Hinweis: Plutonium- und Visionary-Vorteile gelten nur für die offizielle Fluxer.app-Instanz, nicht für Drittanbieter- oder selbst gehostete Instanzen.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500-MB-Uploads, 4.000-Zeichen-Nachrichten, 300 Lesezeichen, 50 Emoji-Pakete und vieles mehr.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Zeichenlimit für Bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Wähle deinen eigenen 4-stelligen Tag wie #0001, #1337 oder #9999, um deinen Benutzernamen wirklich einzigartig zu machen.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Wähle einen verfügbaren 4-stelligen Tag von #0001 bis #9999, um deinen Benutzernamen wirklich einzigartig zu machen.
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Eigenes Username-Tag
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Benutzerdefiniertes 4-stelliges Username-Tag
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Eigene Video-Hintergründe
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Δωρεάν
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Σημείωση: τα οφέλη Plutonium και Visionary ισχύουν μόνο στο επίσημο instance Fluxer.app, όχι σε τρίτα ή self-hosted instances.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Uploads 500 MB, μηνύματα 4.000 χαρακτήρων, 300 σελιδοδείκτες, 50 πακέτα emoji και πολλά ακόμη.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Όριο χαρακτήρων bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Διάλεξε το δικό σου 4-digit tag όπως #0001, #1337 ή #9999 για να κάνεις το username σου πραγματικά μοναδικό.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Διάλεξε οποιοδήποτε διαθέσιμο 4-digit tag από #0001 έως #9999 για να κάνεις το username σου πραγματικά μοναδικό.
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Προσαρμοσμένο tag χρήστη
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Προσαρμοσμένο 4-digit username tag
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Προσαρμοσμένα φόντα βίντεο
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Free
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Note: Plutonium and Visionary benefits only apply to the official Fluxer.app instance, not third-party or self-hosted instances.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500 MB uploads, 4,000-character messages, 300 bookmarks, 50 emoji packs, and much more.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Bio character limit
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Choose your own 4-digit tag like #0001, #1337, or #9999 to make your username truly unique.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Choose any available 4-digit tag from #0001 to #9999 to make your username truly unique.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Custom 4-digit username tag
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Custom username tag
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Video backgrounds
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Gratis
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Nota: los beneficios de Plutonium y Visionary solo aplican en la instancia oficial de Fluxer.app, no en instancias de terceros o autoalojadas.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Subidas de 500 MB, mensajes de 4,000 caracteres, 300 marcadores, 50 paquetes de emojis y mucho más.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Límite de caracteres de la bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Elige tu propio tag de 4 dígitos como #0001, #1337 o #9999 para que tu nombre de usuario sea realmente único.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Elegí cualquier tag de 4 dígitos disponible del #0001 al #9999 para que tu nombre de usuario sea realmente único.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Tag de usuario personalizado de 4 dígitos
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Tag de usuario personalizado
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Fondos de video
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Gratis
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Nota: los beneficios de Plutonium y Visionary solo se aplican a la instancia oficial de Fluxer.app, no a instancias de terceros ni autoalojadas.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Subidas de 500 MB, mensajes de 4.000 caracteres, 300 marcadores, 50 packs de emojis y mucho más.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Límite de caracteres en la bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Elige tu propia etiqueta de 4 dígitos como #0001, #1337 o #9999 para que tu nombre de usuario sea verdaderamente único.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Elige cualquier etiqueta de 4 dígitos disponible del #0001 al #9999 para que tu nombre de usuario sea verdaderamente único.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Etiqueta de nombre de usuario de 4 dígitos personalizada
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Etiqueta de usuario personalizada
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Fondos de vídeo
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Ilmainen
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Huom: Plutonium- ja Visionary-edut koskevat vain virallista Fluxer.app-instanssia, eivät kolmannen osapuolen tai itsehostattuja instansseja.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500 Mt lataukset, 4 000 merkin viestit, 300 kirjanmerkkiä, 50 emojipakettia ja paljon muuta.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Bion merkkiraja
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Valitse oma 4-numeroinen tunniste, kuten #0001, #1337 tai #9999, ja tee käyttäjänimi todella ainutlaatuiseksi.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Valitse mikä tahansa saatavilla oleva 4-numeroinen tunniste väliltä #0001–#9999 ja tee käyttäjänimestä todella ainutlaatuinen.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Oma 4-numeroinen käyttäjätunniste
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Oma käyttäjätunniste
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Videotaustat
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Gratuit
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: '"Note : les avantages Plutonium et Visionary ne s''appliquent que sur l''instance officielle Fluxer.app, pas sur les instances tierces ou auto-hébergées."'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Envois jusqu'à 500 Mo, messages de 4 000 caractères, 300 favoris, 50 packs d'emoji, et bien plus.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Limite de caractères de bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Choisis ton propre tag 4 chiffres comme #0001, #1337 ou #9999 pour te rendre vraiment unique.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Choisis un tag 4 chiffres disponible de #0001 à #9999 pour te rendre vraiment unique.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Tag de pseudo 4 chiffres personnalisé
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Tag de pseudo perso
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Arrière-plans vidéo
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: חינם
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'שימו לב: ההטבות של Plutonium ושל Visionary חלות רק על המופע הרשמי ב-Fluxer.app, לא על מופעים צד שלישי או כאלה באירוח עצמי.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: העלאות של 500 MB, הודעות עד 4,000 תווים, 300 סימניות, 50 חבילות אימוג'י ועוד הרבה.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: מגבלת תווים לביו
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: בחרו את התג שלכם כמו #0001, #1337, או #9999 כדי לעשות את שם המשתמש שלכם באמת ייחודי.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: בחרו כל תג 4 ספרות זמין מ-#0001 עד #9999 כדי לעשות את שם המשתמש שלכם באמת ייחודי.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: תג שם משתמש בעל 4 ספרות מותאם
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: תג שם משתמש מותאם
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: רקעים מותאמים לוידאו
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: फ्री
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'नोट: प्लूटोनियम और विजनरी के फायदे सिर्फ आधिकारिक Fluxer.app इंस्टेंस पर लागू होते हैं, थर्ड-पार्टी या सेल्फ-होस्टेड इंस्टेंस पर नहीं.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500 MB अपलोड्स, 4,000 कैरेक्टर के मैसेज, 300 बुकमार्क्स, 50 इमोजी पैक्स, और बहुत कुछ.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: बायो कैरेक्टर लिमिट
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: 'अपना यूनिक यूज़रनेम बनाने के लिए #0001, #1337, या #9999 जैसा अपना खुद का 4-अंकीय टैग चुनें।'
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: '#0001 से #9999 तक कोई भी उपलब्ध 4-अंकीय टैग चुनें और अपना यूज़रनेम सच में यूनिक बनाएं।'
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: कस्टम 4-अंकीय यूज़रनेम टैग
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: कस्टम यूज़रनेम टैग
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: वीडियो बैकग्राउंड्स
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Besplatno
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Napomena: Plutonium i Visionary pogodnosti vrijede samo na službenoj instanci Fluxer.app, ne na trećim stranama ili self-hostanim instancama.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Uploadovi do 500 MB, poruke do 4,000 znakova, 300 oznaka, 50 paketa emojija i još puno toga.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Limit znakova u biografiji
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Odaberi vlastitu 4-znamenkastu oznaku kao #0001, #1337, ili #9999 kako bi svoje korisničko ime bio doista jedinstveno.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Odaberi bilo koju dostupnu 4-znamenkastu oznaku od #0001 do #9999 kako bi svoje korisničko ime učinio doista jedinstvenim.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Prilagođena 4-znamenkasta oznaka korisničkog imena
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Prilagođena oznaka korisničkog imena
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Video pozadine
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Ingyenes
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Megjegyzés: a Plutonium és a Visionary előnyök csak a hivatalos Fluxer.app példányra érvényesek, nem harmadik fél vagy saját hosztolt példányokra.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500 MB feltöltések, 4 000 karakteres üzenetek, 300 könyvjelző, 50 emojicsomag, és még sok más.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Bio karakterlimit
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Válassza ki saját 4 jegyű címkéjét, mint a #0001, #1337, vagy #9999, hogy valóban egyedivé tegyük felhasználónevét.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Válasszon bármilyen elérhető 4 jegyű címkét #0001-től #9999-ig, hogy valóban egyedivé tegye felhasználónevét.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Egyedi 4 jegyű felhasználónév címke
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Egyedi felhasználónévcímke
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Egyedi videóháttér
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Gratis
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Catatan: manfaat Plutonium dan Visionary hanya berlaku untuk instance resmi Fluxer.app, bukan instance pihak ketiga atau self-hosted.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Unggahan 500 MB, pesan 4.000 karakter, 300 bookmark, 50 paket emoji, dan masih banyak lagi.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Batas karakter bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Pilih tag 4 digit kamu sendiri seperti #0001, #1337, atau #9999 untuk membuat username kamu benar-benar unik.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Pilih tag 4 digit yang tersedia dari #0001 sampai #9999 untuk membuat username kamu benar-benar unik.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Tag nama pengguna 4 digit kustom
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Tag nama pengguna kustom
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Latar belakang video
|
||||
|
||||
@@ -372,7 +372,7 @@ pricing_and_tiers.plutonium.benefits_note_official_instance_only: '"Nota: i vant
|
||||
pricing_and_tiers.plutonium.feature_highlights: Upload da 500 MB, messaggi da 4.000 caratteri, 300 segnalibri, 50 pacchetti emoji e molto altro.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Limite caratteri bio
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Tag utente personalizzato
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Scegli il tuo tag a 4 cifre personalizzato come #0001, #1337, o #9999 per rendere il tuo nome utente veramente unico.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Scegli un tag a 4 cifre disponibile da #0001 a #9999 per rendere il tuo nome utente veramente unico.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Tag utente personalizzato a 4 cifre
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Sfondi video
|
||||
pricing_and_tiers.plutonium.features.emoji_sticker_packs: Pacchetti di emoji e sticker
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: 無料
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: '注: PlutoniumとVisionaryの特典は公式のFluxer.appインスタンスのみ対象です。第三者やセルフホストのインスタンスには適用されません。'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500MBアップロード、4,000文字メッセージ、ブックマーク300件、絵文字パック50個など、まだまだ。
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: 自己紹介の文字数上限
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: #0001、#1337、#9999みたいに自分だけの4桁タグを選んで、ユーザー名をもっとユニークに。
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: '#0001から#9999の間で好きな4桁タグを選んで、ユーザー名をもっとユニークに。'
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: カスタム4桁ユーザータグ
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: カスタムユーザータグ
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: ビデオ背景
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: 무료
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: '참고: 플루토늄과 비저너리 혜택은 공식 Fluxer.app 인스턴스에서만 적용돼요.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500MB 업로드, 4,000자 메시지, 300개 북마크, 50개 이모지 팩 등 더 많은 기능이 있어요.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: 소개 글자 수 제한
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: '#0001, #1337, #9999 같은 나만의 4자리 태그를 선택해 사용자 이름을 정말 독특하게 만들어보세요.'
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: '#0001부터 #9999까지 사용 가능한 4자리 태그를 선택해 사용자 이름을 정말 독특하게 만들어보세요.'
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: 커스텀 4자리 사용자명 태그
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: 커스텀 사용자 태그
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: 커스텀 영상 배경
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Nemokama
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Pastaba: Plutonium ir Visionary naudos galioja tik oficialioje Fluxer.app instancijoje, o ne trečiųjų šalių ar savihostinamose instancijose.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500 MB įkėlimai, 4 000 simbolių žinutės, 300 žymių, 50 emoji paketų ir dar daug daugiau.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Aprašymo simbolių limitas
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: 'Pasirink savą 4 skaitmenų žymę, pavyzdžiui #0001, #1337 arba #9999, kad tavo naudotojo vardas būtų iš tiesų unikalus.'
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: 'Pasirink bet kurią laisvą 4 skaitmenų žymę nuo #0001 iki #9999, kad tavo naudotojo vardas būtų iš tiesų unikalus.'
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Pasirinktinis 4 skaitmenų naudotojo vardas žymuo
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Pasirinktinis naudotojo žymuo
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Pasirinktiniai vaizdo fonai
|
||||
|
||||
@@ -373,7 +373,7 @@ pricing_and_tiers.free.label: Free
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Note: Plutonium and Visionary benefits only apply to the official Fluxer.app instance, not third-party or self-hosted instances.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500 MB uploads, 4,000-character messages, 300 bookmarks, 50 emoji packs, and much more.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Bio character limit
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: 'Choose your own 4-digit tag like #0001, #1337, or #9999 to make your username truly unique.'
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: 'Choose any available 4-digit tag from #0001 to #9999 to make your username truly unique.'
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Custom 4-digit username tag
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Custom username tag
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Video backgrounds
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Gratis
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Let op: Plutonium- en Visionary-voordelen gelden alleen op de officiële Fluxer.app-instance, niet op instances van derden of zelf gehoste instances.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Uploads tot 500 MB, berichten tot 4.000 tekens, 300 bladwijzers, 50 emojipakketten en nog veel meer.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Bio-limiet (tekens)
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Kies je eigen 4-cijferige tag zoals #0001, #1337 of #9999 om je gebruikersnaam echt uniek te maken.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Kies een beschikbare 4-cijferige tag van #0001 tot #9999 om je gebruikersnaam echt uniek te maken.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Eigen 4-cijferige gebruikers-tag
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Eigen gebruikers-tag
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Eigen videobackgrounds
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Gratis
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Merk: Plutonium- og Visionary-fordeler gjelder bare på den offisielle Fluxer.app-instansen, ikke tredjeparts- eller selvhostede instanser.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500 MB opplastinger, 4 000-tegns meldinger, 300 bokmerker, 50 emojipakker og mye mer.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Tegnbegrensning i bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Velg din egen 4-sifret tag som #0001, #1337 eller #9999 for å gjøre brukernavnet ditt virkelig unikt.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Velg en tilgjengelig 4-sifret tag fra #0001 til #9999 for å gjøre brukernavnet ditt virkelig unikt.
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Egendefinert brukernavn-tag
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Egne videobakgrunner
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Egendefinert 4-sifret brukernavn-tag
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Za darmo
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Uwaga: benefity Plutonium i Visionary działają tylko na oficjalnej instancji Fluxer.app, nie na instancjach zewnętrznych ani self-hostowanych.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Uploady 500 MB, wiadomości do 4 000 znaków, 300 zakładek, 50 paczek emoji i dużo więcej.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Limit znaków w bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Wybierz własny tag czterocyfrowy, taki jak #0001, #1337 lub #9999, aby unikatowość twojej nazwy użytkownika była niezaprzeczalna.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Wybierz dowolny dostępny tag czterocyfrowy od #0001 do #9999, aby unikatowość twojej nazwy użytkownika była niezaprzeczalna.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Własny czterocyfrowy tag użytkownika
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Własny tag użytkownika
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Własne tła wideo
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Grátis
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Observação: os benefícios de Plutonium e Visionary só valem na instância oficial Fluxer.app, não em instâncias de terceiros ou auto-hospedadas.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Uploads de 500 MB, mensagens de 4.000 caracteres, 300 favoritos, 50 pacotes de emojis e muito mais.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Limite de caracteres da bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Escolha sua própria tag de 4 dígitos tipo #0001, #1337, ou #9999 pra tornar seu nome de usuário verdadeiramente único.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Escolha qualquer tag de 4 dígitos disponível de #0001 a #9999 pra tornar seu nome de usuário verdadeiramente único.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Tag de nome de usuário personalizada com 4 dígitos
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Tag de usuário personalizada
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Fundos de vídeo
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Gratuit
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Notă: beneficiile Plutonium și Visionary se aplică doar instanței oficiale Fluxer.app, nu instanțelor terțe sau self-hosted.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Upload-uri de 500 MB, mesaje de 4.000 de caractere, 300 de bookmark-uri, 50 de pachete de emoji și multe altele.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Limită de caractere pentru bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Alege propriul tău tag cu 4 cifre ca #0001, #1337 sau #9999 pentru a-ți face numele de utilizator cu adevărat unic.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Alege orice tag disponibil cu 4 cifre de la #0001 la #9999 pentru a-ți face numele de utilizator cu adevărat unic.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Tag personalizat cu 4 cifre pentru nume de utilizator
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Etichetă personalizată a numelui de utilizator
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Fundaluri video
|
||||
|
||||
@@ -372,7 +372,7 @@ pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Важно:
|
||||
pricing_and_tiers.plutonium.feature_highlights: Загрузки до 500 МБ, сообщения до 4 000 символов, 300 закладок, 50 паков эмодзи и многое другое.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Лимит символов в био
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Кастомный тег к нику
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Выбери свой 4-значный тег, вроде #0001, #1337 или #9999, чтобы сделать своё имя пользователя по-настоящему уникальным.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Выбери любой доступный 4-значный тег от #0001 до #9999, чтобы сделать своё имя пользователя по-настоящему уникальным.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Кастомный 4-значный тег к нику
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Свои видеофоны
|
||||
pricing_and_tiers.plutonium.features.emoji_sticker_packs: Паки эмодзи и стикеров
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Gratis
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Obs: Plutonium- och Visionary-förmåner gäller bara på den officiella Fluxer.app-instansen, inte på tredjeparts- eller självhostade instanser.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500 MB uppladdningar, 4 000 tecken per meddelande, 300 bokmärken, 50 emojipaket och mycket mer.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Teckengräns för bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Välj din egen 4-siffrig tagg som #0001, #1337 eller #9999 för att göra ditt användarnamn helt unikt.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Välj en tillgänglig 4-siffrig tagg från #0001 till #9999 för att göra ditt användarnamn helt unikt.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Egen 4-siffrig användartagg
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Egen användartagg
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Egna videobakgrunder
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: ฟรี
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'หมายเหตุ: สิทธิประโยชน์ Plutonium และ Visionary ใช้ได้เฉพาะบนอินสแตนซ์ทางการ Fluxer.app เท่านั้น ไม่รวมอินสแตนซ์ของบุคคลที่สามหรือแบบโฮสต์เอง'
|
||||
pricing_and_tiers.plutonium.feature_highlights: อัปโหลด 500 MB ข้อความยาว 4,000 ตัวอักษร บุ๊กมาร์ก 300 รายการ แพ็กอีโมจิ 50 แพ็ก และอีกมากมาย
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: ลิมิตตัวอักษรใน bio
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: เลือกแท็ก 4 หลักของคุณเองเช่น #0001, #1337, หรือ #9999 เพื่อให้ชื่อผู้ใช้ของคุณไม่ซ้ำใคร
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: เลือกแท็ก 4 หลักที่ว่างอยู่ตั้งแต่ #0001 ถึง #9999 เพื่อให้ชื่อผู้ใช้ของคุณไม่ซ้ำใคร
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: แท็กชื่อผู้ใช้ 4 หลักแบบกำหนดเอง
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: แท็กชื่อผู้ใช้แบบกำหนดเอง
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: พื้นหลังวิดีโอ
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Ücretsiz
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: '"Not: Plutonium ve Visionary avantajları yalnızca resmi Fluxer.app instance''ı için geçerli, üçüncü taraf veya self-hosted instance''lar için değil."'
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500 MB yükleme, 4.000 karakter mesaj, 300 yer imi, 50 emoji paketi ve çok daha fazlası.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Bio karakter limiti
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Kullanıcı adını gerçekten benzersiz kılmak için #0001, #1337 ya da #9999 gibi kendi 4 haneli etiketini seç.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Kullanıcı adını gerçekten benzersiz kılmak için #0001'den #9999'a kadar uygun bir 4 haneli etiket seç.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Özel 4 haneli kullanıcı adı etiketi
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Özel kullanıcı adı etiketi
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Video arka planları
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Безкоштовно
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Примітка: переваги Plutonium і Visionary діють лише на офіційному інстансі Fluxer.app, а не на сторонніх чи самохостингових.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Завантаження до 500 МБ, повідомлення до 4 000 символів, 300 закладок, 50 паків емодзі та багато іншого.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Ліміт символів у біо
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Обери власний 4-цифровий тег типу #0001, #1337 або #9999, щоб зробити свій нікнейм дійсно унікальним.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Обери будь-який доступний 4-цифровий тег від #0001 до #9999, щоб зробити свій нікнейм дійсно унікальним.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Кастомний 4-цифровий тег імені
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Кастомний тег імені
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Кастомні відеофони
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: Miễn phí
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 'Lưu ý: quyền lợi Plutonium và Visionary chỉ áp dụng cho instance chính thức Fluxer.app, không áp dụng cho instance bên thứ ba hoặc tự host.'
|
||||
pricing_and_tiers.plutonium.feature_highlights: Tải lên 500 MB, tin nhắn 4.000 ký tự, 300 bookmark, 50 gói emoji, và còn nhiều nữa.
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: Giới hạn ký tự phần giới thiệu
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Chọn tag 4 chữ số riêng của mình như #0001, #1337, hoặc #9999 để làm tên người dùng của bạn thực sự độc đáo.
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: Chọn bất kỳ tag 4 chữ số nào còn trống từ #0001 đến #9999 để làm tên người dùng của bạn thực sự độc đáo.
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: Tag tên người dùng 4 chữ số tuỳ chỉnh
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: Tag tên người dùng tuỳ chỉnh
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: Nền video tuỳ chỉnh
|
||||
|
||||
@@ -373,7 +373,7 @@ pricing_and_tiers.plutonium.feature_highlights: 500 MB 上传、4,000 字消息
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: 简介字符上限
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: 自定义用户名标签
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: 自定义 4 位数用户名标签
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: 选择你自己的 4 位数标签,如 #0001、#1337 或 #9999,让你的用户名真正独一无二。
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: 选择 #0001 到 #9999 之间任何可用的 4 位数标签,让你的用户名真正独一无二。
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: 视频背景
|
||||
pricing_and_tiers.plutonium.features.emoji_sticker_packs: 表情和贴纸包
|
||||
pricing_and_tiers.plutonium.features.file_upload_size: 文件上传大小
|
||||
|
||||
@@ -371,7 +371,7 @@ pricing_and_tiers.free.label: 免费
|
||||
pricing_and_tiers.plutonium.benefits_note_official_instance_only: 注意:Plutonium 和 Visionary 福利仅适用于官方 Fluxer.app 实例,不适用于第三方或自建实例。
|
||||
pricing_and_tiers.plutonium.feature_highlights: 500 MB 上传、4,000 字消息、300 条收藏、50 套表情包,还有更多。
|
||||
pricing_and_tiers.plutonium.features.bio_character_limit: 簡介字元上限
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: 選擇你自己的 4 位數標籤,例如 #0001、#1337 或 #9999,讓你的使用者名稱真正獨一無二。
|
||||
pricing_and_tiers.plutonium.features.choose_custom_4_digit_tag: 選擇 #0001 到 #9999 之間任何可用的 4 位數標籤,讓你的使用者名稱真正獨一無二。
|
||||
pricing_and_tiers.plutonium.features.custom_4_digit_username_tag: 自訂 4 位數使用者名稱標籤
|
||||
pricing_and_tiers.plutonium.features.custom_username_tag: 自訂使用者名稱標籤
|
||||
pricing_and_tiers.plutonium.features.custom_video_backgrounds: 自訂視訊背景
|
||||
|
||||
@@ -80,8 +80,6 @@ export const GuildFeatureSchema = withOpenApiType(
|
||||
[GuildFeatures.OPERATOR, 'OPERATOR', 'Guild is an operator guild'],
|
||||
[GuildFeatures.LARGE_GUILD_OVERRIDE, 'LARGE_GUILD_OVERRIDE', 'Guild has large guild overrides enabled'],
|
||||
[GuildFeatures.VERY_LARGE_GUILD, 'VERY_LARGE_GUILD', 'Guild has increased member capacity enabled'],
|
||||
[GuildFeatures.MANAGED_MESSAGE_SCHEDULING, 'MT_MESSAGE_SCHEDULING', 'Guild has managed message scheduling'],
|
||||
[GuildFeatures.MANAGED_EXPRESSION_PACKS, 'MT_EXPRESSION_PACKS', 'Guild has managed expression packs'],
|
||||
],
|
||||
'A guild feature flag',
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user