refactor: squash branch changes

This commit is contained in:
Hampus Kraft
2026-02-21 07:15:46 +00:00
parent c2b69be17d
commit d90464c381
153 changed files with 6598 additions and 4444 deletions

View File

@@ -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(

View File

@@ -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,

View File

@@ -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();

View File

@@ -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();
}

View File

@@ -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(

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);

View File

@@ -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';

View File

@@ -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: {

View File

@@ -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);

View File

@@ -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(

View File

@@ -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,

View File

@@ -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();

View File

@@ -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});
}

View File

@@ -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');

View 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;
}
}

View File

@@ -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});

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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};
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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});
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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> {

View File

@@ -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),

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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`;
}
}

View File

@@ -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');

View File

@@ -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');

View File

@@ -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;

View File

@@ -322,7 +322,6 @@ export async function initializeWorkerDependencies(snowflakeService: SnowflakeSe
webhookRepository,
guildAuditLogService,
limitConfigService,
undefined,
);
const inviteService = new InviteService(
inviteRepository,

View File

@@ -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": [

View File

@@ -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)})`;
}

View File

@@ -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.",

View File

@@ -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"]

View File

@@ -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>;

View 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:"
}
}

View File

@@ -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,
});
}

View File

@@ -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);
}

View File

@@ -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(),
},
},
},
};

View File

@@ -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,
});
}
}

View File

@@ -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,
});
}
}

View File

@@ -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,
});
}
}

View File

@@ -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');
}

View File

@@ -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};
}
}

View File

@@ -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,
});
}
}

View File

@@ -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,
});
}
}

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfigs/package.json",
"compilerOptions": {},
"include": ["src/**/*"]
}

View File

@@ -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);

View File

@@ -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,
};

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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.

View File

@@ -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: خلفيات فيديو

View File

@@ -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: Видео фонове по избор

View File

@@ -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í

View File

@@ -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

View File

@@ -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

View File

@@ -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: Προσαρμοσμένα φόντα βίντεο

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: רקעים מותאמים לוידאו

View File

@@ -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: वीडियो बैकग्राउंड्स

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: ビデオ背景

View File

@@ -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: 커스텀 영상 배경

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: Паки эмодзи и стикеров

View File

@@ -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

View File

@@ -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: พื้นหลังวิดีโอ

View File

@@ -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ı

View File

@@ -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: Кастомні відеофони

View File

@@ -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

View File

@@ -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: 文件上传大小

View File

@@ -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: 自訂視訊背景

View File

@@ -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',
),