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

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