initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,404 @@
/*
* 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 '~/BrandedTypes';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import type {GuildAuditLogRow} from '~/database/CassandraTypes';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {GuildAuditLog} from '~/Models';
import type {IWorkerService} from '~/worker/IWorkerService';
import type {AuditLogChange, GuildAuditLogChange} from './GuildAuditLogTypes';
import type {IGuildRepository} from './IGuildRepository';
export type {GuildAuditLogChange};
const BATCH_AUDIT_LOG_JOB_DELAY_MS = 30_000;
interface MessageDeleteBatchGroup {
logs: Array<GuildAuditLog>;
userId: UserID;
channelId: string;
}
interface BatchResult {
processedLogs: Array<GuildAuditLog>;
deletedLogIds: Array<bigint>;
createdLogs: Array<GuildAuditLog>;
}
interface CreateGuildAuditLogParams {
guildId: GuildID;
userId: UserID;
actionType: AuditLogActionType;
targetId?: string | null;
auditLogReason?: string | null;
metadata?: Map<string, string> | Record<string, string> | Array<[string, string]>;
changes?: GuildAuditLogChange | null;
createdAt?: Date;
}
const normalizeAuditLogMetadata = (metadata?: CreateGuildAuditLogParams['metadata']): Map<string, string> => {
if (!metadata) {
return new Map();
}
if (metadata instanceof Map) {
return metadata;
}
if (Array.isArray(metadata)) {
return new Map(metadata);
}
return new Map(Object.entries(metadata));
};
export class GuildAuditLogService {
constructor(
private readonly guildRepository: IGuildRepository,
private readonly snowflakeService: SnowflakeService,
private readonly workerService?: IWorkerService,
) {}
async createLog(params: CreateGuildAuditLogParams): Promise<GuildAuditLog> {
const logId = this.snowflakeService.generate();
const metadataMap = normalizeAuditLogMetadata(params.metadata);
const row: GuildAuditLogRow = {
guild_id: params.guildId,
log_id: logId,
user_id: params.userId,
target_id: params.targetId ?? null,
action_type: params.actionType,
reason: params.auditLogReason ?? null,
options: metadataMap.size > 0 ? metadataMap : null,
changes: params.changes ? JSON.stringify(params.changes) : null,
};
const log = await this.guildRepository.createAuditLog(row);
if (params.actionType === AuditLogActionType.MESSAGE_DELETE && this.workerService) {
await this.scheduleMessageDeleteBatchJob(params.guildId);
}
return log;
}
async scheduleMessageDeleteBatchJob(guildId: GuildID): Promise<void> {
if (!this.workerService) {
return;
}
const runAt = new Date(Date.now() + BATCH_AUDIT_LOG_JOB_DELAY_MS);
await this.workerService.addJob(
'batchGuildAuditLogMessageDeletes',
{guildId: guildId.toString()},
{
jobKey: `batch-audit-log-message-deletes:${guildId}`,
runAt,
maxAttempts: 3,
},
);
}
async batchConsecutiveMessageDeleteLogs(guildId: GuildID, logs: Array<GuildAuditLog>): Promise<BatchResult> {
const groups = this.findConsecutiveMessageDeleteGroups(logs);
const deletedLogIds: Array<bigint> = [];
const createdLogs: Array<GuildAuditLog> = [];
const processedLogs: Array<GuildAuditLog> = [];
for (const log of logs) {
const group = groups.find((g) => g.logs.includes(log));
if (group && group.logs.length >= 2) {
if (log === group.logs[0]) {
const newestLogId = group.logs.reduce((max, l) => (l.logId > max ? l.logId : max), group.logs[0].logId);
for (const groupLog of group.logs) {
deletedLogIds.push(groupLog.logId);
}
await this.guildRepository.deleteAuditLogs(guildId, group.logs);
const batchedLog = await this.createBatchedMessageDeleteLog(guildId, group, newestLogId);
createdLogs.push(batchedLog);
processedLogs.push(batchedLog);
}
} else {
processedLogs.push(log);
}
}
return {processedLogs, deletedLogIds, createdLogs};
}
async batchRecentMessageDeleteLogs(guildId: GuildID, limit: number = 250): Promise<BatchResult> {
const logs = await this.guildRepository.listAuditLogs({
guildId,
limit,
actionType: AuditLogActionType.MESSAGE_DELETE,
});
if (logs.length < 2) {
return {processedLogs: logs, deletedLogIds: [], createdLogs: []};
}
const allLogs = await this.guildRepository.listAuditLogs({
guildId,
limit,
});
return this.batchConsecutiveMessageDeleteLogs(guildId, allLogs);
}
private findConsecutiveMessageDeleteGroups(logs: Array<GuildAuditLog>): Array<MessageDeleteBatchGroup> {
const groups: Array<MessageDeleteBatchGroup> = [];
let currentGroup: MessageDeleteBatchGroup | null = null;
for (const log of logs) {
if (log.actionType !== AuditLogActionType.MESSAGE_DELETE) {
if (currentGroup && currentGroup.logs.length >= 2) {
groups.push(currentGroup);
}
currentGroup = null;
continue;
}
const channelId = log.options.get('channel_id');
if (!channelId) {
if (currentGroup && currentGroup.logs.length >= 2) {
groups.push(currentGroup);
}
currentGroup = null;
continue;
}
if (currentGroup && currentGroup.userId === log.userId && currentGroup.channelId === channelId) {
currentGroup.logs.push(log);
} else {
if (currentGroup && currentGroup.logs.length >= 2) {
groups.push(currentGroup);
}
currentGroup = {
logs: [log],
userId: log.userId,
channelId,
};
}
}
if (currentGroup && currentGroup.logs.length >= 2) {
groups.push(currentGroup);
}
return groups;
}
private async createBatchedMessageDeleteLog(
guildId: GuildID,
group: MessageDeleteBatchGroup,
logId: bigint,
): Promise<GuildAuditLog> {
const row: GuildAuditLogRow = {
guild_id: guildId,
log_id: logId,
user_id: group.userId,
target_id: null,
action_type: AuditLogActionType.MESSAGE_BULK_DELETE,
reason: null,
options: new Map([
['channel_id', group.channelId],
['count', group.logs.length.toString()],
]),
changes: null,
};
return this.guildRepository.createAuditLog(row);
}
createBuilder(guildId: GuildID, userId: UserID): GuildAuditLogBuilder {
return new GuildAuditLogBuilder(this, guildId, userId);
}
computeChanges(
previous: Record<string, unknown> | null | undefined,
next: Record<string, unknown> | null | undefined,
): GuildAuditLogChange {
const changes: Array<AuditLogChange> = [];
if (!previous && next) {
for (const [key, value] of Object.entries(next)) {
changes.push({key, new_value: value});
}
return changes;
}
if (previous && !next) {
for (const [key, value] of Object.entries(previous)) {
changes.push({key, old_value: value});
}
return changes;
}
if (previous && next) {
const allKeys = new Set([...Object.keys(previous), ...Object.keys(next)]);
for (const key of allKeys) {
const oldValue = previous[key];
const newValue = next[key];
if (this.areValuesEqual(oldValue, newValue)) {
continue;
}
const change: AuditLogChange = {key};
if (oldValue === undefined && newValue !== undefined) {
change.new_value = newValue;
} else if (oldValue !== undefined && newValue === undefined) {
change.old_value = oldValue;
} else {
change.old_value = oldValue;
change.new_value = newValue;
}
changes.push(change);
}
}
return changes;
}
computeArrayChange<T>(
previous: Array<T> | null | undefined,
next: Array<T> | null | undefined,
key: string,
): AuditLogChange | null {
if (!previous?.length && !next?.length) {
return null;
}
if (!this.areArraysEqual(previous, next)) {
const change: AuditLogChange = {key};
if (previous !== undefined && previous !== null) {
change.old_value = previous;
}
if (next !== undefined && next !== null) {
change.new_value = next;
}
return change;
}
return null;
}
private areValuesEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null || b == null) return false;
if (typeof a !== typeof b) return false;
if (typeof a === 'object' && typeof b === 'object') {
return JSON.stringify(a) === JSON.stringify(b);
}
return false;
}
private areArraysEqual(a: Array<unknown> | null | undefined, b: Array<unknown> | null | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
return JSON.stringify(a) === JSON.stringify(b);
}
}
export class GuildAuditLogBuilder {
private readonly params: Partial<CreateGuildAuditLogParams>;
private metadataMap: Map<string, string> | null = null;
constructor(
private readonly service: GuildAuditLogService,
guildId: GuildID,
userId: UserID,
) {
this.params = {guildId, userId};
}
withAction(actionType: AuditLogActionType, targetId?: string | null): this {
this.params.actionType = actionType;
this.params.targetId = targetId ?? null;
return this;
}
withReason(reason?: string | null): this {
this.params.auditLogReason = reason ?? null;
return this;
}
withMetadata(metadata?: CreateGuildAuditLogParams['metadata']): this {
this.metadataMap = normalizeAuditLogMetadata(metadata);
return this;
}
withMetadataEntry(key: string, value: string): this {
if (!this.metadataMap) {
this.metadataMap = new Map();
}
this.metadataMap.set(key, value);
return this;
}
withChanges(changes: GuildAuditLogChange | null): this {
this.params.changes = changes;
return this;
}
withComputedChanges(
previous: Record<string, unknown> | null | undefined,
next: Record<string, unknown> | null | undefined,
): this {
this.params.changes = this.service.computeChanges(previous, next);
return this;
}
withCreatedAt(createdAt?: Date): this {
this.params.createdAt = createdAt;
return this;
}
async commit(): Promise<GuildAuditLog> {
if (this.params.actionType === undefined) {
throw new Error('Audit log action type must be set before committing');
}
return this.service.createLog({
guildId: this.params.guildId!,
userId: this.params.userId!,
actionType: this.params.actionType,
targetId: this.params.targetId ?? null,
auditLogReason: this.params.auditLogReason ?? null,
metadata: this.metadataMap ?? undefined,
changes: this.params.changes ?? null,
createdAt: this.params.createdAt,
});
}
}

View File

@@ -0,0 +1,26 @@
/*
* 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 interface AuditLogChange<K extends string = string, D = unknown> {
key: K;
old_value?: D;
new_value?: D;
}
export type GuildAuditLogChange = Array<AuditLogChange>;

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {registerGuildControllers} from './controllers';
export const GuildController = (app: HonoApp) => {
registerGuildControllers(app);
};

View File

@@ -0,0 +1,520 @@
/*
* 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 {
AVATAR_MAX_SIZE,
EMOJI_MAX_SIZE,
GuildExplicitContentFilterTypes,
GuildMFALevel,
GuildSplashCardAlignment,
GuildVerificationLevel,
STICKER_MAX_SIZE,
VALID_TEMP_BAN_DURATIONS,
} from '~/Constants';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Guild, GuildBan, GuildEmoji, GuildMember, GuildRole, GuildSticker} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {ColorType, createBase64StringType, createStringType, Int64Type, PasswordType, z} from '~/Schema';
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '~/user/UserCacheHelpers';
import {UserPartialResponse} from '~/user/UserModel';
const SplashCardAlignmentSchema = z.union([
z.literal(GuildSplashCardAlignment.CENTER),
z.literal(GuildSplashCardAlignment.LEFT),
z.literal(GuildSplashCardAlignment.RIGHT),
]);
export const GuildResponse = z.object({
id: z.string(),
name: z.string(),
icon: z.string().nullish(),
banner: z.string().nullish(),
banner_width: z.number().int().nullish(),
banner_height: z.number().int().nullish(),
splash: z.string().nullish(),
splash_width: z.number().int().nullish(),
splash_height: z.number().int().nullish(),
splash_card_alignment: SplashCardAlignmentSchema,
embed_splash: z.string().nullish(),
embed_splash_width: z.number().int().nullish(),
embed_splash_height: z.number().int().nullish(),
vanity_url_code: z.string().nullish(),
owner_id: z.string(),
system_channel_id: z.string().nullish(),
system_channel_flags: z.number().int(),
rules_channel_id: z.string().nullish(),
afk_channel_id: z.string().nullish(),
afk_timeout: z.number().int(),
features: z.array(z.string()),
verification_level: z.number().int(),
mfa_level: z.number().int(),
nsfw_level: z.number().int(),
explicit_content_filter: z.number().int(),
default_message_notifications: z.number().int(),
disabled_operations: z.number().int(),
permissions: z.string().nullish(),
});
export type GuildResponse = z.infer<typeof GuildResponse>;
export const GuildPartialResponse = z.object({
id: z.string(),
name: z.string(),
icon: z.string().nullish(),
banner: z.string().nullish(),
banner_width: z.number().int().nullish(),
banner_height: z.number().int().nullish(),
splash: z.string().nullish(),
splash_width: z.number().int().nullish(),
splash_height: z.number().int().nullish(),
splash_card_alignment: SplashCardAlignmentSchema,
embed_splash: z.string().nullish(),
embed_splash_width: z.number().int().nullish(),
embed_splash_height: z.number().int().nullish(),
features: z.array(z.string()),
});
export type GuildPartialResponse = z.infer<typeof GuildPartialResponse>;
export const GuildCreateRequest = z.object({
name: createStringType(1, 100),
icon: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
empty_features: z.boolean().optional(),
});
export type GuildCreateRequest = z.infer<typeof GuildCreateRequest>;
export const GuildUpdateRequest = z
.object({
name: createStringType(1, 100),
icon: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
system_channel_id: Int64Type.nullish(),
system_channel_flags: z.number().int().min(0),
afk_channel_id: Int64Type.nullish(),
afk_timeout: z.number().int().min(60).max(3600),
default_message_notifications: z.number().int().min(0).max(1),
verification_level: z.union([
z.literal(GuildVerificationLevel.NONE),
z.literal(GuildVerificationLevel.LOW),
z.literal(GuildVerificationLevel.MEDIUM),
z.literal(GuildVerificationLevel.HIGH),
z.literal(GuildVerificationLevel.VERY_HIGH),
]),
mfa_level: z.union([z.literal(GuildMFALevel.NONE), z.literal(GuildMFALevel.ELEVATED)]),
explicit_content_filter: z.union([
z.literal(GuildExplicitContentFilterTypes.DISABLED),
z.literal(GuildExplicitContentFilterTypes.MEMBERS_WITHOUT_ROLES),
z.literal(GuildExplicitContentFilterTypes.ALL_MEMBERS),
]),
banner: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
splash: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
embed_splash: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
splash_card_alignment: SplashCardAlignmentSchema.optional(),
features: z.array(z.string()),
})
.partial();
export type GuildUpdateRequest = z.infer<typeof GuildUpdateRequest>;
export const GuildMemberResponse = z.object({
user: z.lazy(() => UserPartialResponse),
nick: z.string().nullish(),
avatar: z.string().nullish(),
banner: z.string().nullish(),
accent_color: z.number().int().nullish(),
roles: z.array(z.string()),
joined_at: z.iso.datetime(),
join_source_type: z.number().int().nullish(),
source_invite_code: z.string().nullish(),
inviter_id: z.string().nullish(),
mute: z.boolean(),
deaf: z.boolean(),
communication_disabled_until: z.iso.datetime().nullish(),
profile_flags: z.number().int().nullish(),
});
export type GuildMemberResponse = z.infer<typeof GuildMemberResponse>;
export const GuildMemberUpdateRequest = z.object({
nick: createStringType(1, 32).nullish(),
roles: z
.array(Int64Type)
.max(100, 'Maximum 100 roles allowed')
.optional()
.transform((ids) => (ids ? new Set(ids) : undefined)),
avatar: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
banner: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
bio: createStringType(1, 320).nullish(),
pronouns: createStringType(1, 40).nullish(),
accent_color: ColorType.nullish(),
profile_flags: z.number().int().nullish(),
mute: z.boolean().optional(),
deaf: z.boolean().optional(),
communication_disabled_until: z.iso.datetime().nullish(),
timeout_reason: createStringType(1, 512).nullish(),
channel_id: Int64Type.nullish(),
connection_id: createStringType(1, 32).nullish(),
});
export type GuildMemberUpdateRequest = z.infer<typeof GuildMemberUpdateRequest>;
export const MyGuildMemberUpdateRequest = GuildMemberUpdateRequest.omit({roles: true}).partial();
export type MyGuildMemberUpdateRequest = z.infer<typeof MyGuildMemberUpdateRequest>;
export const GuildRoleResponse = z.object({
id: z.string(),
name: z.string(),
color: z.number().int(),
position: z.number().int(),
hoist_position: z.number().int().nullish(),
permissions: z.string(),
hoist: z.boolean(),
mentionable: z.boolean(),
});
export type GuildRoleResponse = z.infer<typeof GuildRoleResponse>;
export const GuildRoleCreateRequest = z.object({
name: createStringType(1, 100),
color: ColorType.default(0x000000),
permissions: Int64Type.optional(),
});
export type GuildRoleCreateRequest = z.infer<typeof GuildRoleCreateRequest>;
export const GuildRoleUpdateRequest = z.object({
name: createStringType(1, 100).optional(),
color: ColorType.optional(),
permissions: Int64Type.optional(),
hoist: z.boolean().optional(),
hoist_position: z.number().int().nullish(),
mentionable: z.boolean().optional(),
});
export type GuildRoleUpdateRequest = z.infer<typeof GuildRoleUpdateRequest>;
export const GuildEmojiResponse = z.object({
id: z.string(),
name: z.string(),
animated: z.boolean(),
});
export type GuildEmojiResponse = z.infer<typeof GuildEmojiResponse>;
export const GuildEmojiWithUserResponse = z.object({
id: z.string(),
name: z.string(),
animated: z.boolean(),
user: z.lazy(() => UserPartialResponse),
});
export type GuildEmojiWithUserResponse = z.infer<typeof GuildEmojiWithUserResponse>;
export const GuildEmojiCreateRequest = z.object({
name: createStringType(2, 32).refine(
(value) => /^[a-zA-Z0-9_]+$/.test(value),
'Emoji name can only contain letters, numbers, and underscores',
),
image: createBase64StringType(1, EMOJI_MAX_SIZE * 1.33),
});
export type GuildEmojiCreateRequest = z.infer<typeof GuildEmojiCreateRequest>;
export const GuildEmojiUpdateRequest = GuildEmojiCreateRequest.pick({name: true});
export type GuildEmojiUpdateRequest = z.infer<typeof GuildEmojiUpdateRequest>;
export const GuildEmojiBulkCreateRequest = z.object({
emojis: z
.array(GuildEmojiCreateRequest)
.min(1, 'At least one emoji is required')
.max(50, 'Maximum 50 emojis per batch'),
});
export type GuildEmojiBulkCreateRequest = z.infer<typeof GuildEmojiBulkCreateRequest>;
export const GuildStickerResponse = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
tags: z.array(z.string()),
format_type: z.int(),
});
export type GuildStickerResponse = z.infer<typeof GuildStickerResponse>;
export const GuildStickerWithUserResponse = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
tags: z.array(z.string()),
format_type: z.int(),
user: z.lazy(() => UserPartialResponse),
});
export type GuildStickerWithUserResponse = z.infer<typeof GuildStickerWithUserResponse>;
export const GuildStickerCreateRequest = z.object({
name: createStringType(2, 30),
description: createStringType(1, 100).nullish(),
tags: z.array(createStringType(1, 30)).min(0).max(10).optional().default([]),
image: createBase64StringType(1, STICKER_MAX_SIZE * 1.33),
});
export type GuildStickerCreateRequest = z.infer<typeof GuildStickerCreateRequest>;
export const GuildStickerUpdateRequest = GuildStickerCreateRequest.pick({
name: true,
description: true,
tags: true,
});
export type GuildStickerUpdateRequest = z.infer<typeof GuildStickerUpdateRequest>;
export const GuildStickerBulkCreateRequest = z.object({
stickers: z
.array(GuildStickerCreateRequest)
.min(1, 'At least one sticker is required')
.max(50, 'Maximum 50 stickers per batch'),
});
export type GuildStickerBulkCreateRequest = z.infer<typeof GuildStickerBulkCreateRequest>;
export const GuildTransferOwnershipRequest = z.object({
new_owner_id: Int64Type,
password: PasswordType.optional(),
});
export type GuildTransferOwnershipRequest = z.infer<typeof GuildTransferOwnershipRequest>;
export const GuildBanCreateRequest = z.object({
delete_message_days: z.number().int().min(0).max(7).default(0),
reason: createStringType(0, 512).nullish(),
ban_duration_seconds: z
.number()
.int()
.refine((val) => val === 0 || VALID_TEMP_BAN_DURATIONS.has(val), {
message: `Ban duration must be 0 (permanent) or one of the valid durations: ${Array.from(VALID_TEMP_BAN_DURATIONS).join(', ')} seconds`,
})
.optional(),
});
export type GuildBanCreateRequest = z.infer<typeof GuildBanCreateRequest>;
export const GuildBanResponse = z.object({
user: z.lazy(() => UserPartialResponse),
reason: z.string().nullish(),
moderator_id: z.string(),
banned_at: z.iso.datetime(),
expires_at: z.iso.datetime().nullish(),
});
export type GuildBanResponse = z.infer<typeof GuildBanResponse>;
export const GuildVanityURLResponse = z.object({
code: z.string().nullish(),
uses: z.number().int(),
});
export type GuildVanityURLResponse = z.infer<typeof GuildVanityURLResponse>;
export const mapGuildToPartialResponse = (guild: Guild): GuildPartialResponse => ({
id: guild.id.toString(),
name: guild.name,
icon: guild.iconHash,
banner: guild.bannerHash,
banner_width: guild.bannerWidth,
banner_height: guild.bannerHeight,
splash: guild.splashHash,
splash_width: guild.splashWidth,
splash_height: guild.splashHeight,
embed_splash: guild.embedSplashHash,
embed_splash_width: guild.embedSplashWidth,
embed_splash_height: guild.embedSplashHeight,
splash_card_alignment: guild.splashCardAlignment,
features: Array.from(guild.features),
});
export const mapGuildToGuildResponse = (guild: Guild, options?: {permissions?: bigint | null}): GuildResponse => ({
id: guild.id.toString(),
name: guild.name,
icon: guild.iconHash,
banner: guild.bannerHash,
banner_width: guild.bannerWidth,
banner_height: guild.bannerHeight,
splash: guild.splashHash,
splash_width: guild.splashWidth,
splash_height: guild.splashHeight,
embed_splash: guild.embedSplashHash,
embed_splash_width: guild.embedSplashWidth,
embed_splash_height: guild.embedSplashHeight,
splash_card_alignment: guild.splashCardAlignment,
vanity_url_code: guild.vanityUrlCode,
owner_id: guild.ownerId.toString(),
system_channel_id: guild.systemChannelId ? guild.systemChannelId.toString() : null,
system_channel_flags: guild.systemChannelFlags,
rules_channel_id: guild.rulesChannelId ? guild.rulesChannelId.toString() : null,
afk_channel_id: guild.afkChannelId ? guild.afkChannelId.toString() : null,
afk_timeout: guild.afkTimeout,
features: Array.from(guild.features),
verification_level: guild.verificationLevel,
mfa_level: guild.mfaLevel,
nsfw_level: guild.nsfwLevel,
explicit_content_filter: guild.explicitContentFilter,
default_message_notifications: guild.defaultMessageNotifications,
disabled_operations: guild.disabledOperations,
permissions: options?.permissions != null ? options.permissions.toString() : undefined,
});
export const mapGuildRoleToResponse = (role: GuildRole): GuildRoleResponse => ({
id: role.id.toString(),
name: role.name,
color: role.color,
position: role.position,
hoist_position: role.hoistPosition,
permissions: role.permissions.toString(),
hoist: role.isHoisted,
mentionable: role.isMentionable,
});
export const mapGuildEmojiToResponse = (emoji: GuildEmoji): GuildEmojiResponse => ({
id: emoji.id.toString(),
name: emoji.name,
animated: emoji.isAnimated,
});
export const mapGuildStickerToResponse = (sticker: GuildSticker): GuildStickerResponse => ({
id: sticker.id.toString(),
name: sticker.name,
description: sticker.description ?? '',
tags: sticker.tags,
format_type: sticker.formatType,
});
const mapMemberWithUser = (
member: GuildMember,
userPartial: z.infer<typeof UserPartialResponse>,
): GuildMemberResponse => {
const now = Date.now();
const isTimedOut = member.communicationDisabledUntil != null && member.communicationDisabledUntil.getTime() > now;
return {
user: userPartial,
nick: member.nickname,
avatar: member.isPremiumSanitized ? null : member.avatarHash,
banner: member.isPremiumSanitized ? null : member.bannerHash,
accent_color: member.accentColor,
roles: Array.from(member.roleIds).map((id) => id.toString()),
joined_at: member.joinedAt.toISOString(),
join_source_type: member.joinSourceType,
source_invite_code: member.sourceInviteCode,
inviter_id: member.inviterId ? member.inviterId.toString() : null,
mute: isTimedOut ? true : member.isMute,
deaf: member.isDeaf,
communication_disabled_until: member.communicationDisabledUntil?.toISOString() ?? null,
profile_flags: member.profileFlags || undefined,
};
};
export const isGuildMemberTimedOut = (member?: GuildMemberResponse | null): boolean => {
if (!member?.communication_disabled_until) {
return false;
}
const timestamp = Date.parse(member.communication_disabled_until);
return !Number.isNaN(timestamp) && timestamp > Date.now();
};
export async function mapGuildMemberToResponse(
member: GuildMember,
userCacheService: UserCacheService,
requestCache: RequestCache,
): Promise<GuildMemberResponse> {
const userPartial = await getCachedUserPartialResponse({userId: member.userId, userCacheService, requestCache});
return mapMemberWithUser(member, userPartial);
}
export async function mapGuildMembersToResponse(
members: Array<GuildMember>,
userCacheService: UserCacheService,
requestCache: RequestCache,
): Promise<Array<GuildMemberResponse>> {
const userIds = [...new Set(members.map((member) => member.userId))];
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
return members.map((member) => mapMemberWithUser(member, userPartials.get(member.userId)!));
}
const mapEmojiWithUser = (
emoji: GuildEmoji,
userPartial: z.infer<typeof UserPartialResponse>,
): GuildEmojiWithUserResponse => ({
id: emoji.id.toString(),
name: emoji.name,
animated: emoji.isAnimated,
user: userPartial,
});
export async function mapGuildEmojisWithUsersToResponse(
emojis: Array<GuildEmoji>,
userCacheService: UserCacheService,
requestCache: RequestCache,
): Promise<Array<GuildEmojiWithUserResponse>> {
const userIds = [...new Set(emojis.map((emoji) => emoji.creatorId))];
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
return emojis.map((emoji) => mapEmojiWithUser(emoji, userPartials.get(emoji.creatorId)!));
}
const mapStickerWithUser = (
sticker: GuildSticker,
userPartial: z.infer<typeof UserPartialResponse>,
): GuildStickerWithUserResponse => ({
id: sticker.id.toString(),
name: sticker.name,
description: sticker.description ?? '',
tags: sticker.tags,
format_type: sticker.formatType,
user: userPartial,
});
export async function mapGuildStickersWithUsersToResponse(
stickers: Array<GuildSticker>,
userCacheService: UserCacheService,
requestCache: RequestCache,
): Promise<Array<GuildStickerWithUserResponse>> {
const userIds = [...new Set(stickers.map((emoji) => emoji.creatorId))];
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
return stickers.map((sticker) => mapStickerWithUser(sticker, userPartials.get(sticker.creatorId)!));
}
const mapBanWithUser = (ban: GuildBan, userPartial: z.infer<typeof UserPartialResponse>): GuildBanResponse => ({
user: userPartial,
reason: ban.reason,
moderator_id: ban.moderatorId.toString(),
banned_at: ban.bannedAt.toISOString(),
expires_at: ban.expiresAt ? ban.expiresAt.toISOString() : null,
});
export async function mapGuildBansToResponse(
bans: Array<GuildBan>,
userCacheService: UserCacheService,
requestCache: RequestCache,
): Promise<Array<GuildBanResponse>> {
const userIds = [...new Set(bans.map((ban) => ban.userId))];
const userPartials = await getCachedUserPartialResponses({userIds, userCacheService, requestCache});
return bans.map((ban) => mapBanWithUser(ban, userPartials.get(ban.userId)!));
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export type {IGuildRepositoryAggregate as IGuildRepository} from './repositories/IGuildRepositoryAggregate';

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 {HonoApp} from '~/App';
import {createGuildID, createUserID} from '~/BrandedTypes';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {InputValidationError} from '~/Errors';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {coerceNumberFromString, Int32Type, Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
const actionTypeSchema = coerceNumberFromString(Int32Type).pipe(z.nativeEnum(AuditLogActionType));
export const GuildAuditLogController = (app: HonoApp) => {
app.get(
'/guilds/:guild_id/audit-logs',
RateLimitMiddleware(RateLimitConfigs.GUILD_AUDIT_LOGS),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({guild_id: Int64Type})),
Validator(
'query',
z.object({
limit: coerceNumberFromString(Int32Type.max(100)).optional(),
before: Int64Type.optional(),
after: Int64Type.optional(),
user_id: Int64Type.optional(),
action_type: actionTypeSchema.optional(),
}),
),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const query = ctx.req.valid('query');
if (query.before !== undefined && query.after !== undefined) {
throw InputValidationError.create('before', 'Cannot specify both before and after');
}
const requestCache = ctx.get('requestCache');
const response = await ctx.get('guildService').listGuildAuditLogs({
userId,
guildId,
requestCache,
limit: query.limit ?? undefined,
beforeLogId: query.before ?? undefined,
afterLogId: query.after ?? undefined,
filterUserId: query.user_id ? createUserID(query.user_id) : undefined,
actionType: query.action_type,
});
return ctx.json(response);
},
);
};

View File

@@ -0,0 +1,208 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {requireSudoMode} from '~/auth/services/SudoVerificationService';
import {createGuildID} from '~/BrandedTypes';
import {AccessDeniedError} from '~/Errors';
import {GuildCreateRequest, GuildUpdateRequest} from '~/guild/GuildModel';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, PasswordType, SudoVerificationSchema, VanityURLCodeType, z} from '~/Schema';
import {Validator} from '~/Validator';
export const GuildBaseController = (app: HonoApp) => {
app.post(
'/guilds',
RateLimitMiddleware(RateLimitConfigs.GUILD_CREATE),
Validator('json', GuildCreateRequest),
LoginRequired,
async (ctx) => {
const user = ctx.get('user');
const data = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(await ctx.get('guildService').createGuild({user, data}, auditLogReason));
},
);
app.get('/users/@me/guilds', RateLimitMiddleware(RateLimitConfigs.GUILD_LIST), LoginRequired, async (ctx) => {
if (ctx.get('authTokenType') === 'bearer') {
const scopes = ctx.get('oauthBearerScopes');
if (!scopes || !scopes.has('guilds')) {
throw new AccessDeniedError();
}
}
const userId = ctx.get('user').id;
return ctx.json(await ctx.get('guildService').getUserGuilds(userId));
});
app.delete(
'/users/@me/guilds/:guild_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_LEAVE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('guildService').leaveGuild({userId, guildId}, auditLogReason);
return ctx.body(null, 204);
},
);
app.get(
'/guilds/:guild_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_GET),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
return ctx.json(await ctx.get('guildService').getGuild({userId, guildId}));
},
);
app.patch(
'/guilds/:guild_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_UPDATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', GuildUpdateRequest),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const data = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(await ctx.get('guildService').updateGuild({userId, guildId, data, requestCache}, auditLogReason));
},
);
app.post(
'/guilds/:guild_id/delete',
RateLimitMiddleware(RateLimitConfigs.GUILD_DELETE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
SudoModeMiddleware,
Validator('json', z.object({password: PasswordType.optional()}).merge(SudoVerificationSchema)),
async (ctx) => {
const user = ctx.get('user');
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('guildService').deleteGuild({user, guildId}, auditLogReason);
return ctx.body(null, 204);
},
);
app.get(
'/guilds/:guild_id/vanity-url',
RateLimitMiddleware(RateLimitConfigs.GUILD_VANITY_URL_GET),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
return ctx.json(await ctx.get('guildService').getVanityURL({userId, guildId}));
},
);
app.patch(
'/guilds/:guild_id/vanity-url',
RateLimitMiddleware(RateLimitConfigs.GUILD_VANITY_URL_PATCH),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', z.object({code: VanityURLCodeType.nullish()})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const {code} = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const {code: newCode} = await ctx
.get('guildService')
.updateVanityURL({userId, guildId, code: code ?? null, requestCache}, auditLogReason);
return ctx.json({code: newCode});
},
);
app.patch(
'/guilds/:guild_id/text-channel-flexible-names',
RateLimitMiddleware(RateLimitConfigs.GUILD_UPDATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', z.object({enabled: z.boolean()})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const {enabled} = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(
await ctx
.get('guildService')
.updateTextChannelFlexibleNamesFeature({userId, guildId, enabled, requestCache}, auditLogReason),
);
},
);
app.patch(
'/guilds/:guild_id/detached-banner',
RateLimitMiddleware(RateLimitConfigs.GUILD_UPDATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', z.object({enabled: z.boolean()})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const {enabled} = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(
await ctx
.get('guildService')
.updateDetachedBannerFeature({userId, guildId, enabled, requestCache}, auditLogReason),
);
},
);
app.patch(
'/guilds/:guild_id/disallow-unclaimed-accounts',
RateLimitMiddleware(RateLimitConfigs.GUILD_UPDATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', z.object({enabled: z.boolean()})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const {enabled} = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(
await ctx
.get('guildService')
.updateDisallowUnclaimedAccountsFeature({userId, guildId, enabled, requestCache}, auditLogReason),
);
},
);
};

View File

@@ -0,0 +1,101 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createChannelID, createGuildID} from '~/BrandedTypes';
import {ChannelCreateRequest} from '~/channel/ChannelModel';
import {LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
export const GuildChannelController = (app: HonoApp) => {
app.get(
'/guilds/:guild_id/channels',
RateLimitMiddleware(RateLimitConfigs.GUILD_CHANNELS_LIST),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const requestCache = ctx.get('requestCache');
return ctx.json(await ctx.get('guildService').getChannels({userId, guildId, requestCache}));
},
);
app.post(
'/guilds/:guild_id/channels',
RateLimitMiddleware(RateLimitConfigs.GUILD_CHANNEL_CREATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', ChannelCreateRequest),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const data = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(
await ctx.get('guildService').createChannel({userId, guildId, data, requestCache}, auditLogReason),
);
},
);
app.patch(
'/guilds/:guild_id/channels',
RateLimitMiddleware(RateLimitConfigs.GUILD_CHANNEL_POSITIONS),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator(
'json',
z.array(
z.object({
id: Int64Type,
position: z.number().int().nonnegative().optional(),
parent_id: Int64Type.nullish(),
lock_permissions: z.boolean().optional(),
}),
),
),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const payload = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('guildService').updateChannelPositions(
{
userId,
guildId,
updates: payload.map((item) => ({
channelId: createChannelID(item.id),
position: item.position,
parentId: item.parent_id == null ? item.parent_id : createChannelID(item.parent_id),
lockPermissions: item.lock_permissions ?? false,
})),
requestCache,
},
auditLogReason,
);
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,108 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createEmojiID, createGuildID} from '~/BrandedTypes';
import {GuildEmojiBulkCreateRequest, GuildEmojiCreateRequest, GuildEmojiUpdateRequest} from '~/guild/GuildModel';
import {LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, QueryBooleanType, z} from '~/Schema';
import {Validator} from '~/Validator';
export const GuildEmojiController = (app: HonoApp) => {
app.post(
'/guilds/:guild_id/emojis',
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_CREATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', GuildEmojiCreateRequest),
async (ctx) => {
const user = ctx.get('user');
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const {name, image} = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(await ctx.get('guildService').createEmoji({user, guildId, name, image}, auditLogReason));
},
);
app.post(
'/guilds/:guild_id/emojis/bulk',
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_BULK_CREATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', GuildEmojiBulkCreateRequest),
async (ctx) => {
const user = ctx.get('user');
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const {emojis} = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(await ctx.get('guildService').bulkCreateEmojis({user, guildId, emojis}, auditLogReason));
},
);
app.get(
'/guilds/:guild_id/emojis',
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJIS_LIST),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const {guild_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const guildId = createGuildID(guild_id);
const requestCache = ctx.get('requestCache');
return ctx.json(await ctx.get('guildService').getEmojis({userId, guildId, requestCache}));
},
);
app.patch(
'/guilds/:guild_id/emojis/:emoji_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_UPDATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, emoji_id: Int64Type})),
Validator('json', GuildEmojiUpdateRequest),
async (ctx) => {
const {guild_id, emoji_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const guildId = createGuildID(guild_id);
const emojiId = createEmojiID(emoji_id);
const {name} = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(await ctx.get('guildService').updateEmoji({userId, guildId, emojiId, name}, auditLogReason));
},
);
app.delete(
'/guilds/:guild_id/emojis/:emoji_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_DELETE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, emoji_id: Int64Type})),
Validator('query', z.object({purge: QueryBooleanType.optional()})),
async (ctx) => {
const {guild_id, emoji_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const guildId = createGuildID(guild_id);
const emojiId = createEmojiID(emoji_id);
const auditLogReason = ctx.get('auditLogReason') ?? null;
const {purge = false} = ctx.req.valid('query');
await ctx.get('guildService').deleteEmoji({userId, guildId, emojiId, purge}, auditLogReason);
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,246 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {requireSudoMode} from '~/auth/services/SudoVerificationService';
import {createGuildID, createRoleID, createUserID} from '~/BrandedTypes';
import {
GuildBanCreateRequest,
GuildMemberUpdateRequest,
GuildTransferOwnershipRequest,
MyGuildMemberUpdateRequest,
} from '~/guild/GuildModel';
import {LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, SudoVerificationSchema, z} from '~/Schema';
import {Validator} from '~/Validator';
export const GuildMemberController = (app: HonoApp) => {
app.get(
'/guilds/:guild_id/members',
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBERS),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const requestCache = ctx.get('requestCache');
return ctx.json(await ctx.get('guildService').getMembers({userId, guildId, requestCache}));
},
);
app.get(
'/guilds/:guild_id/members/@me',
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBERS),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const requestCache = ctx.get('requestCache');
return ctx.json(await ctx.get('guildService').getMember({userId, targetId: userId, guildId, requestCache}));
},
);
app.get(
'/guilds/:guild_id/members/:user_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBERS),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, user_id: Int64Type})),
async (ctx) => {
const {guild_id, user_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const targetId = createUserID(user_id);
const guildId = createGuildID(guild_id);
const requestCache = ctx.get('requestCache');
return ctx.json(await ctx.get('guildService').getMember({userId, targetId, guildId, requestCache}));
},
);
app.patch(
'/guilds/:guild_id/members/@me',
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_UPDATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', MyGuildMemberUpdateRequest),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const requestCache = ctx.get('requestCache');
const data = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const result = await ctx
.get('guildService')
.updateMember({userId, targetId: userId, guildId, data, requestCache}, auditLogReason);
return ctx.json(result);
},
);
app.patch(
'/guilds/:guild_id/members/:user_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_UPDATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, user_id: Int64Type})),
Validator('json', GuildMemberUpdateRequest),
async (ctx) => {
const {guild_id, user_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const targetId = createUserID(user_id);
const guildId = createGuildID(guild_id);
const data = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const result = await ctx
.get('guildService')
.updateMember({userId, targetId, guildId, data, requestCache}, auditLogReason);
return ctx.json(result);
},
);
app.delete(
'/guilds/:guild_id/members/:user_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_REMOVE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, user_id: Int64Type})),
async (ctx) => {
const {guild_id, user_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const targetId = createUserID(user_id);
const guildId = createGuildID(guild_id);
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('guildService').removeMember({userId, targetId, guildId}, auditLogReason);
return ctx.body(null, 204);
},
);
app.post(
'/guilds/:guild_id/transfer-ownership',
RateLimitMiddleware(RateLimitConfigs.GUILD_UPDATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
SudoModeMiddleware,
Validator('json', GuildTransferOwnershipRequest.merge(SudoVerificationSchema)),
async (ctx) => {
const user = ctx.get('user');
const userId = user.id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
const {new_owner_id} = body;
const newOwnerId = createUserID(new_owner_id);
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(await ctx.get('guildService').transferOwnership({userId, guildId, newOwnerId}, auditLogReason));
},
);
app.get(
'/guilds/:guild_id/bans',
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBERS),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const requestCache = ctx.get('requestCache');
return ctx.json(await ctx.get('guildService').listBans({userId, guildId, requestCache}));
},
);
app.put(
'/guilds/:guild_id/bans/:user_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_REMOVE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, user_id: Int64Type})),
Validator('json', GuildBanCreateRequest),
async (ctx) => {
const {guild_id, user_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const targetId = createUserID(user_id);
const guildId = createGuildID(guild_id);
const {delete_message_days, reason, ban_duration_seconds} = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('guildService').banMember(
{
userId,
guildId,
targetId,
deleteMessageDays: delete_message_days,
reason: reason ?? undefined,
banDurationSeconds: ban_duration_seconds,
},
auditLogReason,
);
return ctx.body(null, 204);
},
);
app.delete(
'/guilds/:guild_id/bans/:user_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_REMOVE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, user_id: Int64Type})),
async (ctx) => {
const {guild_id, user_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const targetId = createUserID(user_id);
const guildId = createGuildID(guild_id);
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('guildService').unbanMember({userId, guildId, targetId}, auditLogReason);
return ctx.body(null, 204);
},
);
app.put(
'/guilds/:guild_id/members/:user_id/roles/:role_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_ROLE_ADD),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, user_id: Int64Type, role_id: Int64Type})),
async (ctx) => {
const {guild_id, user_id, role_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const targetId = createUserID(user_id);
const guildId = createGuildID(guild_id);
const roleId = createRoleID(role_id);
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('guildService').addMemberRole({userId, targetId, guildId, roleId, requestCache}, auditLogReason);
return ctx.body(null, 204);
},
);
app.delete(
'/guilds/:guild_id/members/:user_id/roles/:role_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_MEMBER_ROLE_REMOVE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, user_id: Int64Type, role_id: Int64Type})),
async (ctx) => {
const {guild_id, user_id, role_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const targetId = createUserID(user_id);
const guildId = createGuildID(guild_id);
const roleId = createRoleID(role_id);
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('guildService').removeMemberRole({userId, targetId, guildId, roleId, requestCache}, auditLogReason);
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,165 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createGuildID, createRoleID} from '~/BrandedTypes';
import {GuildRoleCreateRequest, GuildRoleUpdateRequest} from '~/guild/GuildModel';
import {LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
export const GuildRoleController = (app: HonoApp) => {
app.get(
'/guilds/:guild_id/roles',
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_LIST),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
return ctx.json(await ctx.get('guildService').listRoles({userId, guildId}));
},
);
app.post(
'/guilds/:guild_id/roles',
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_CREATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', GuildRoleCreateRequest),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const data = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(await ctx.get('guildService').createRole({userId, guildId, data}, auditLogReason));
},
);
app.patch(
'/guilds/:guild_id/roles/hoist-positions',
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_HOIST_POSITIONS),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator(
'json',
z.array(
z.object({
id: Int64Type,
hoist_position: z.number().int(),
}),
),
),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const payload = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('guildService').updateHoistPositions(
{
userId,
guildId,
updates: payload.map((item) => ({roleId: createRoleID(item.id), hoistPosition: item.hoist_position})),
},
auditLogReason,
);
return ctx.body(null, 204);
},
);
app.delete(
'/guilds/:guild_id/roles/hoist-positions',
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_HOIST_POSITIONS_RESET),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('guildService').resetHoistPositions({userId, guildId}, auditLogReason);
return ctx.body(null, 204);
},
);
app.patch(
'/guilds/:guild_id/roles/:role_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_UPDATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, role_id: Int64Type})),
Validator('json', GuildRoleUpdateRequest),
async (ctx) => {
const {guild_id, role_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const guildId = createGuildID(guild_id);
const roleId = createRoleID(role_id);
const data = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(await ctx.get('guildService').updateRole({userId, guildId, roleId, data}, auditLogReason));
},
);
app.patch(
'/guilds/:guild_id/roles',
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_POSITIONS),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator(
'json',
z.array(
z.object({
id: Int64Type,
position: z.number().int().optional(),
}),
),
),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const payload = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('guildService').updateRolePositions(
{
userId,
guildId,
updates: payload.map((item) => ({roleId: createRoleID(item.id), position: item.position})),
},
auditLogReason,
);
return ctx.body(null, 204);
},
);
app.delete(
'/guilds/:guild_id/roles/:role_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_ROLE_DELETE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, role_id: Int64Type})),
async (ctx) => {
const {guild_id, role_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const guildId = createGuildID(guild_id);
const roleId = createRoleID(role_id);
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('guildService').deleteRole({userId, guildId, roleId}, auditLogReason);
return ctx.body(null, 204);
},
);
};

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 {HonoApp} from '~/App';
import {createGuildID, createStickerID} from '~/BrandedTypes';
import {GuildStickerBulkCreateRequest, GuildStickerCreateRequest, GuildStickerUpdateRequest} from '~/guild/GuildModel';
import {LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, QueryBooleanType, z} from '~/Schema';
import {Validator} from '~/Validator';
export const GuildStickerController = (app: HonoApp) => {
app.post(
'/guilds/:guild_id/stickers',
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_CREATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', GuildStickerCreateRequest),
async (ctx) => {
const user = ctx.get('user');
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const {name, description, tags, image} = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(
await ctx.get('guildService').createSticker({user, guildId, name, description, tags, image}, auditLogReason),
);
},
);
app.post(
'/guilds/:guild_id/stickers/bulk',
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_BULK_CREATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
Validator('json', GuildStickerBulkCreateRequest),
async (ctx) => {
const user = ctx.get('user');
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const {stickers} = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(await ctx.get('guildService').bulkCreateStickers({user, guildId, stickers}, auditLogReason));
},
);
app.get(
'/guilds/:guild_id/stickers',
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKERS_LIST),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const {guild_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const guildId = createGuildID(guild_id);
const requestCache = ctx.get('requestCache');
return ctx.json(await ctx.get('guildService').getStickers({userId, guildId, requestCache}));
},
);
app.patch(
'/guilds/:guild_id/stickers/:sticker_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_UPDATE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, sticker_id: Int64Type})),
Validator('json', GuildStickerUpdateRequest),
async (ctx) => {
const {guild_id, sticker_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const guildId = createGuildID(guild_id);
const stickerId = createStickerID(sticker_id);
const {name, description, tags} = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
return ctx.json(
await ctx
.get('guildService')
.updateSticker({userId, guildId, stickerId, name, description, tags}, auditLogReason),
);
},
);
app.delete(
'/guilds/:guild_id/stickers/:sticker_id',
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_DELETE),
LoginRequired,
Validator('param', z.object({guild_id: Int64Type, sticker_id: Int64Type})),
Validator('query', z.object({purge: QueryBooleanType.optional()})),
async (ctx) => {
const {guild_id, sticker_id} = ctx.req.valid('param');
const userId = ctx.get('user').id;
const guildId = createGuildID(guild_id);
const stickerId = createStickerID(sticker_id);
const auditLogReason = ctx.get('auditLogReason') ?? null;
const {purge = false} = ctx.req.valid('query');
await ctx.get('guildService').deleteSticker({userId, guildId, stickerId, purge}, auditLogReason);
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,37 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {GuildAuditLogController} from './GuildAuditLogController';
import {GuildBaseController} from './GuildBaseController';
import {GuildChannelController} from './GuildChannelController';
import {GuildEmojiController} from './GuildEmojiController';
import {GuildMemberController} from './GuildMemberController';
import {GuildRoleController} from './GuildRoleController';
import {GuildStickerController} from './GuildStickerController';
export const registerGuildControllers = (app: HonoApp) => {
GuildBaseController(app);
GuildMemberController(app);
GuildRoleController(app);
GuildChannelController(app);
GuildEmojiController(app);
GuildStickerController(app);
GuildAuditLogController(app);
};

View File

@@ -0,0 +1,192 @@
/*
* 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 {EmojiID, GuildID, StickerID} from '~/BrandedTypes';
import {BatchBuilder, buildPatchFromData, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import {
GUILD_EMOJI_COLUMNS,
GUILD_STICKER_COLUMNS,
type GuildEmojiRow,
type GuildStickerRow,
} from '~/database/CassandraTypes';
import {GuildEmoji, GuildSticker} from '~/Models';
import {GuildEmojis, GuildEmojisByEmojiId, GuildStickers, GuildStickersByStickerId} from '~/Tables';
import {IGuildContentRepository} from './IGuildContentRepository';
const FETCH_GUILD_EMOJIS_BY_GUILD_ID_QUERY = GuildEmojis.selectCql({
where: GuildEmojis.where.eq('guild_id'),
});
const FETCH_GUILD_EMOJI_BY_ID_QUERY = GuildEmojis.selectCql({
where: [GuildEmojis.where.eq('guild_id'), GuildEmojis.where.eq('emoji_id')],
limit: 1,
});
const FETCH_GUILD_EMOJI_BY_EMOJI_ID_ONLY_QUERY = GuildEmojisByEmojiId.selectCql({
where: GuildEmojisByEmojiId.where.eq('emoji_id'),
limit: 1,
});
const FETCH_GUILD_STICKERS_BY_GUILD_ID_QUERY = GuildStickers.selectCql({
where: GuildStickers.where.eq('guild_id'),
});
const FETCH_GUILD_STICKER_BY_ID_QUERY = GuildStickers.selectCql({
where: [GuildStickers.where.eq('guild_id'), GuildStickers.where.eq('sticker_id')],
limit: 1,
});
const FETCH_GUILD_STICKER_BY_STICKER_ID_ONLY_QUERY = GuildStickersByStickerId.selectCql({
where: GuildStickersByStickerId.where.eq('sticker_id'),
limit: 1,
});
export class GuildContentRepository extends IGuildContentRepository {
async getEmoji(emojiId: EmojiID, guildId: GuildID): Promise<GuildEmoji | null> {
const emoji = await fetchOne<GuildEmojiRow>(FETCH_GUILD_EMOJI_BY_ID_QUERY, {
guild_id: guildId,
emoji_id: emojiId,
});
return emoji ? new GuildEmoji(emoji) : null;
}
async getEmojiById(emojiId: EmojiID): Promise<GuildEmoji | null> {
const emoji = await fetchOne<GuildEmojiRow>(FETCH_GUILD_EMOJI_BY_EMOJI_ID_ONLY_QUERY, {
emoji_id: emojiId,
});
return emoji ? new GuildEmoji(emoji) : null;
}
async listEmojis(guildId: GuildID): Promise<Array<GuildEmoji>> {
const emojis = await fetchMany<GuildEmojiRow>(FETCH_GUILD_EMOJIS_BY_GUILD_ID_QUERY, {
guild_id: guildId,
});
return emojis.map((emoji) => new GuildEmoji(emoji));
}
async countEmojis(guildId: GuildID): Promise<number> {
const emojis = await fetchMany<GuildEmojiRow>(FETCH_GUILD_EMOJIS_BY_GUILD_ID_QUERY, {
guild_id: guildId,
});
return emojis.length;
}
async upsertEmoji(data: GuildEmojiRow, oldData?: GuildEmojiRow | null): Promise<GuildEmoji> {
const guildId = data.guild_id;
const emojiId = data.emoji_id;
const result = await executeVersionedUpdate<GuildEmojiRow, 'guild_id' | 'emoji_id'>(
async () => {
if (oldData !== undefined) return oldData;
return await fetchOne<GuildEmojiRow>(FETCH_GUILD_EMOJI_BY_ID_QUERY, {
guild_id: guildId,
emoji_id: emojiId,
});
},
(current) => ({
pk: {guild_id: guildId, emoji_id: emojiId},
patch: buildPatchFromData(data, current, GUILD_EMOJI_COLUMNS, ['guild_id', 'emoji_id']),
}),
GuildEmojis,
{onFailure: 'log'},
);
await fetchOne(GuildEmojisByEmojiId.insert(data));
return new GuildEmoji({...data, version: result.finalVersion ?? 1});
}
async deleteEmoji(guildId: GuildID, emojiId: EmojiID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(
GuildEmojis.deleteByPk({
guild_id: guildId,
emoji_id: emojiId,
}),
);
batch.addPrepared(GuildEmojisByEmojiId.deleteByPk({emoji_id: emojiId}));
await batch.execute();
}
async getSticker(stickerId: StickerID, guildId: GuildID): Promise<GuildSticker | null> {
const sticker = await fetchOne<GuildStickerRow>(FETCH_GUILD_STICKER_BY_ID_QUERY, {
guild_id: guildId,
sticker_id: stickerId,
});
return sticker ? new GuildSticker(sticker) : null;
}
async getStickerById(stickerId: StickerID): Promise<GuildSticker | null> {
const sticker = await fetchOne<GuildStickerRow>(FETCH_GUILD_STICKER_BY_STICKER_ID_ONLY_QUERY, {
sticker_id: stickerId,
});
return sticker ? new GuildSticker(sticker) : null;
}
async listStickers(guildId: GuildID): Promise<Array<GuildSticker>> {
const stickers = await fetchMany<GuildStickerRow>(FETCH_GUILD_STICKERS_BY_GUILD_ID_QUERY, {
guild_id: guildId,
});
return stickers.map((sticker) => new GuildSticker(sticker));
}
async countStickers(guildId: GuildID): Promise<number> {
const stickers = await fetchMany<GuildStickerRow>(FETCH_GUILD_STICKERS_BY_GUILD_ID_QUERY, {
guild_id: guildId,
});
return stickers.length;
}
async upsertSticker(data: GuildStickerRow, oldData?: GuildStickerRow | null): Promise<GuildSticker> {
const guildId = data.guild_id;
const stickerId = data.sticker_id;
const result = await executeVersionedUpdate<GuildStickerRow, 'guild_id' | 'sticker_id'>(
async () => {
if (oldData !== undefined) return oldData;
return await fetchOne<GuildStickerRow>(FETCH_GUILD_STICKER_BY_ID_QUERY, {
guild_id: guildId,
sticker_id: stickerId,
});
},
(current) => ({
pk: {guild_id: guildId, sticker_id: stickerId},
patch: buildPatchFromData(data, current, GUILD_STICKER_COLUMNS, ['guild_id', 'sticker_id']),
}),
GuildStickers,
{onFailure: 'log'},
);
await fetchOne(GuildStickersByStickerId.insert(data));
return new GuildSticker({...data, version: result.finalVersion ?? 1});
}
async deleteSticker(guildId: GuildID, stickerId: StickerID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(
GuildStickers.deleteByPk({
guild_id: guildId,
sticker_id: stickerId,
}),
);
batch.addPrepared(GuildStickersByStickerId.deleteByPk({sticker_id: stickerId}));
await batch.execute();
}
}

View File

@@ -0,0 +1,180 @@
/*
* 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 '~/BrandedTypes';
import {BatchBuilder, buildPatchFromData, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import {
GUILD_COLUMNS,
type GuildByOwnerIdRow,
type GuildMemberByUserIdRow,
type GuildRow,
} from '~/database/CassandraTypes';
import {Guild} from '~/Models';
import {GuildMembersByUserId, Guilds, GuildsByOwnerId} from '~/Tables';
import {IGuildDataRepository} from './IGuildDataRepository';
const FETCH_GUILD_BY_ID_QUERY = Guilds.selectCql({
where: Guilds.where.eq('guild_id'),
limit: 1,
});
const FETCH_GUILD_MEMBERS_BY_USER_QUERY = GuildMembersByUserId.select({
columns: ['guild_id'],
where: GuildMembersByUserId.where.eq('user_id'),
});
const FETCH_GUILDS_BY_IDS_QUERY = Guilds.selectCql({
where: Guilds.where.in('guild_id', 'guild_ids'),
});
const createFetchAllGuildsPaginatedQuery = (limit: number) =>
Guilds.selectCql({
where: Guilds.where.tokenGt('guild_id', 'last_guild_id'),
limit,
});
const createFetchAllGuildsFirstPageQuery = (limit: number) => Guilds.selectCql({limit});
const FETCH_GUILD_IDS_BY_OWNER_QUERY = GuildsByOwnerId.select({
columns: ['guild_id'],
where: GuildsByOwnerId.where.eq('owner_id'),
});
export class GuildDataRepository extends IGuildDataRepository {
async findUnique(guildId: GuildID): Promise<Guild | null> {
const guild = await fetchOne<GuildRow>(FETCH_GUILD_BY_ID_QUERY, {
guild_id: guildId,
});
return guild ? new Guild(guild) : null;
}
async listGuilds(guildIds: Array<GuildID>): Promise<Array<Guild>> {
if (guildIds.length === 0) {
return [];
}
const guilds = await fetchMany<GuildRow>(FETCH_GUILDS_BY_IDS_QUERY, {guild_ids: guildIds});
return guilds.map((guild) => new Guild(guild));
}
async listAllGuildsPaginated(limit: number, lastGuildId?: GuildID): Promise<Array<Guild>> {
let guilds: Array<GuildRow>;
if (lastGuildId) {
const query = createFetchAllGuildsPaginatedQuery(limit);
guilds = await fetchMany<GuildRow>(query, {
last_guild_id: lastGuildId,
});
} else {
const query = createFetchAllGuildsFirstPageQuery(limit);
guilds = await fetchMany<GuildRow>(query, {});
}
return guilds.map((guild) => new Guild(guild));
}
async listUserGuilds(userId: UserID): Promise<Array<Guild>> {
const guildMemberships = await fetchMany<Pick<GuildMemberByUserIdRow, 'guild_id'>>(
FETCH_GUILD_MEMBERS_BY_USER_QUERY.bind({user_id: userId}),
);
if (guildMemberships.length === 0) {
return [];
}
const guildIds = guildMemberships.map((m) => m.guild_id);
const guilds = await fetchMany<GuildRow>(FETCH_GUILDS_BY_IDS_QUERY, {guild_ids: guildIds});
return guilds.map((guild) => new Guild(guild));
}
async countUserGuilds(userId: UserID): Promise<number> {
const guildMemberships = await fetchMany<Pick<GuildMemberByUserIdRow, 'guild_id'>>(
FETCH_GUILD_MEMBERS_BY_USER_QUERY.bind({user_id: userId}),
);
return guildMemberships.length;
}
async listOwnedGuildIds(userId: UserID): Promise<Array<GuildID>> {
const guilds = await fetchMany<Pick<GuildByOwnerIdRow, 'guild_id'>>(
FETCH_GUILD_IDS_BY_OWNER_QUERY.bind({owner_id: userId}),
);
return guilds.map((g) => g.guild_id as GuildID);
}
async upsert(data: GuildRow, oldData?: GuildRow | null): Promise<Guild> {
const guildId = data.guild_id;
const result = await executeVersionedUpdate<GuildRow, 'guild_id'>(
async () => {
if (oldData !== undefined) return oldData;
return await fetchOne<GuildRow>(FETCH_GUILD_BY_ID_QUERY, {guild_id: guildId});
},
(current) => ({
pk: {guild_id: guildId},
patch: buildPatchFromData(data, current, GUILD_COLUMNS, ['guild_id']),
}),
Guilds,
{onFailure: 'log'},
);
const previousOwnerId = oldData?.owner_id ?? (await this.findUnique(guildId))?.ownerId;
if (previousOwnerId && previousOwnerId !== data.owner_id) {
const batch = new BatchBuilder();
batch.addPrepared(
GuildsByOwnerId.deleteByPk({
guild_id: guildId,
owner_id: previousOwnerId,
}),
);
batch.addPrepared(
GuildsByOwnerId.insert({
owner_id: data.owner_id,
guild_id: guildId,
}),
);
await batch.execute();
} else if (!previousOwnerId) {
await fetchOne(
GuildsByOwnerId.insert({
owner_id: data.owner_id,
guild_id: guildId,
}),
);
}
return new Guild({...data, version: result.finalVersion ?? 0});
}
async delete(guildId: GuildID): Promise<void> {
const guild = await this.findUnique(guildId);
if (!guild) {
return;
}
const finalBatch = new BatchBuilder();
finalBatch.addPrepared(Guilds.deleteByPk({guild_id: guildId}));
finalBatch.addPrepared(
GuildsByOwnerId.deleteByPk({
guild_id: guildId,
owner_id: guild.ownerId,
}),
);
await finalBatch.execute();
}
}

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, UserID} from '~/BrandedTypes';
import {BatchBuilder, buildPatchFromData, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import {GUILD_MEMBER_COLUMNS, type GuildMemberRow} from '~/database/CassandraTypes';
import {GuildMember} from '~/Models';
import {GuildMembers, GuildMembersByUserId} from '~/Tables';
import {IGuildMemberRepository} from './IGuildMemberRepository';
const FETCH_GUILD_MEMBER_BY_GUILD_AND_USER_ID_QUERY = GuildMembers.selectCql({
where: [GuildMembers.where.eq('guild_id'), GuildMembers.where.eq('user_id')],
limit: 1,
});
const FETCH_GUILD_MEMBERS_BY_GUILD_ID_QUERY = GuildMembers.selectCql({
where: GuildMembers.where.eq('guild_id'),
});
export class GuildMemberRepository extends IGuildMemberRepository {
async getMember(guildId: GuildID, userId: UserID): Promise<GuildMember | null> {
const member = await fetchOne<GuildMemberRow>(FETCH_GUILD_MEMBER_BY_GUILD_AND_USER_ID_QUERY, {
guild_id: guildId,
user_id: userId,
});
return member ? new GuildMember(member) : null;
}
async listMembers(guildId: GuildID): Promise<Array<GuildMember>> {
const members = await fetchMany<GuildMemberRow>(FETCH_GUILD_MEMBERS_BY_GUILD_ID_QUERY, {
guild_id: guildId,
});
return members.map((member) => new GuildMember(member));
}
async upsertMember(data: GuildMemberRow, oldData?: GuildMemberRow | null): Promise<GuildMember> {
const guildId = data.guild_id;
const userId = data.user_id;
const result = await executeVersionedUpdate<GuildMemberRow, 'guild_id' | 'user_id'>(
async () => {
if (oldData !== undefined) return oldData;
return await fetchOne<GuildMemberRow>(FETCH_GUILD_MEMBER_BY_GUILD_AND_USER_ID_QUERY, {
guild_id: guildId,
user_id: userId,
});
},
(current) => ({
pk: {guild_id: guildId, user_id: userId},
patch: buildPatchFromData(data, current, GUILD_MEMBER_COLUMNS, ['guild_id', 'user_id']),
}),
GuildMembers,
{onFailure: 'log'},
);
await fetchOne(
GuildMembersByUserId.insert({
user_id: userId,
guild_id: guildId,
}),
);
return new GuildMember({...data, version: result.finalVersion ?? 1});
}
async deleteMember(guildId: GuildID, userId: UserID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(
GuildMembers.deleteByPk({
guild_id: guildId,
user_id: userId,
}),
);
batch.addPrepared(
GuildMembersByUserId.deleteByPk({
user_id: userId,
guild_id: guildId,
}),
);
await batch.execute();
}
}

View File

@@ -0,0 +1,293 @@
/*
* 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 '~/BrandedTypes';
import type {AuditLogActionType} from '~/constants/AuditLogActionType';
import {
BatchBuilder,
Db,
deleteOneOrMany,
executeVersionedUpdate,
fetchMany,
fetchOne,
upsertOne,
} from '~/database/Cassandra';
import type {GuildAuditLogRow, GuildBanRow, GuildRow} from '~/database/CassandraTypes';
import {GuildAuditLog, GuildBan} from '~/Models';
import {
GuildAuditLogs,
GuildAuditLogsByAction,
GuildAuditLogsByUser,
GuildAuditLogsByUserAction,
GuildBans,
Guilds,
} from '~/Tables';
import {IGuildModerationRepository} from './IGuildModerationRepository';
const FETCH_GUILD_BAN_BY_GUILD_AND_USER_ID_QUERY = GuildBans.selectCql({
where: [GuildBans.where.eq('guild_id'), GuildBans.where.eq('user_id')],
limit: 1,
});
const FETCH_GUILD_BANS_BY_GUILD_ID_QUERY = GuildBans.selectCql({
where: GuildBans.where.eq('guild_id'),
});
const AUDIT_LOG_TTL_SECONDS = 45 * 24 * 60 * 60;
const FETCH_GUILD_BY_ID_QUERY = Guilds.selectCql({
where: Guilds.where.eq('guild_id'),
limit: 1,
});
const FETCH_GUILD_AUDIT_LOG_QUERY = GuildAuditLogs.selectCql({
where: [GuildAuditLogs.where.eq('guild_id'), GuildAuditLogs.where.eq('log_id')],
limit: 1,
});
const FETCH_GUILD_AUDIT_LOGS_BY_IDS_QUERY = GuildAuditLogs.selectCql({
where: [GuildAuditLogs.where.eq('guild_id'), GuildAuditLogs.where.in('log_id', 'log_ids')],
});
export class GuildModerationRepository extends IGuildModerationRepository {
async getBan(guildId: GuildID, userId: UserID): Promise<GuildBan | null> {
const ban = await fetchOne<GuildBanRow>(FETCH_GUILD_BAN_BY_GUILD_AND_USER_ID_QUERY, {
guild_id: guildId,
user_id: userId,
});
return ban ? new GuildBan(ban) : null;
}
async listBans(guildId: GuildID): Promise<Array<GuildBan>> {
const bans = await fetchMany<GuildBanRow>(FETCH_GUILD_BANS_BY_GUILD_ID_QUERY, {
guild_id: guildId,
});
return bans.map((ban) => new GuildBan(ban));
}
async upsertBan(data: GuildBanRow): Promise<GuildBan> {
if (data.expires_at) {
const now = Date.now();
const expiryTime = data.expires_at.getTime();
const ttl = Math.max(0, Math.ceil((expiryTime - now) / 1000));
await upsertOne(GuildBans.insertWithTtl(data, ttl));
} else {
await upsertOne(GuildBans.insert(data));
}
return new GuildBan(data);
}
async deleteBan(guildId: GuildID, userId: UserID): Promise<void> {
await deleteOneOrMany(
GuildBans.deleteByPk({
guild_id: guildId,
user_id: userId,
}),
);
}
async createAuditLog(data: GuildAuditLogRow): Promise<GuildAuditLog> {
const payload = {
...data,
options: data.options ?? null,
changes: data.changes ?? null,
};
const batch = new BatchBuilder();
batch.addPrepared(GuildAuditLogs.insertWithTtl(payload, AUDIT_LOG_TTL_SECONDS));
batch.addPrepared(GuildAuditLogsByUser.insertWithTtl(payload, AUDIT_LOG_TTL_SECONDS));
batch.addPrepared(GuildAuditLogsByAction.insertWithTtl(payload, AUDIT_LOG_TTL_SECONDS));
batch.addPrepared(GuildAuditLogsByUserAction.insertWithTtl(payload, AUDIT_LOG_TTL_SECONDS));
await batch.execute();
return this.mapRowToGuildAuditLog(data);
}
async getAuditLog(guildId: GuildID, logId: bigint): Promise<GuildAuditLog | null> {
const row = await fetchOne<GuildAuditLogRow>(FETCH_GUILD_AUDIT_LOG_QUERY, {
guild_id: guildId,
log_id: logId,
});
return row ? this.mapRowToGuildAuditLog(row) : null;
}
async listAuditLogs(params: {
guildId: GuildID;
limit: number;
afterLogId?: bigint;
beforeLogId?: bigint;
userId?: UserID;
actionType?: AuditLogActionType;
}): Promise<Array<GuildAuditLog>> {
const {guildId, limit, afterLogId, beforeLogId, userId, actionType} = params;
const table = this.selectAuditLogTable(userId, actionType);
const query = this.buildAuditLogSelectQuery(table, limit, beforeLogId, afterLogId, userId, actionType);
const values: {
guild_id: GuildID;
user_id?: UserID;
action_type?: AuditLogActionType;
before_log_id?: bigint;
after_log_id?: bigint;
} = {guild_id: guildId};
if (userId) {
values.user_id = userId;
}
if (actionType !== undefined) {
values.action_type = actionType;
}
if (beforeLogId) {
values.before_log_id = beforeLogId;
} else if (afterLogId) {
values.after_log_id = afterLogId;
}
const rows = await fetchMany<GuildAuditLogRow>(query, values);
return rows.map((row) => this.mapRowToGuildAuditLog(row));
}
async listAuditLogsByIds(guildId: GuildID, logIds: Array<bigint>): Promise<Array<GuildAuditLog>> {
if (logIds.length === 0) {
return [];
}
const rows = await fetchMany<GuildAuditLogRow>(FETCH_GUILD_AUDIT_LOGS_BY_IDS_QUERY, {
guild_id: guildId,
log_ids: logIds,
});
return rows.map((row) => this.mapRowToGuildAuditLog(row));
}
async deleteAuditLogs(guildId: GuildID, logs: Array<GuildAuditLog>): Promise<void> {
if (logs.length === 0) {
return;
}
const batch = new BatchBuilder();
for (const log of logs) {
batch.addPrepared(
GuildAuditLogs.deleteByPk({
guild_id: guildId,
log_id: log.logId,
}),
);
batch.addPrepared(
GuildAuditLogsByUser.deleteByPk({
guild_id: guildId,
user_id: log.userId,
log_id: log.logId,
}),
);
batch.addPrepared(
GuildAuditLogsByAction.deleteByPk({
guild_id: guildId,
action_type: log.actionType,
log_id: log.logId,
}),
);
batch.addPrepared(
GuildAuditLogsByUserAction.deleteByPk({
guild_id: guildId,
user_id: log.userId,
action_type: log.actionType,
log_id: log.logId,
}),
);
}
await batch.execute();
}
async updateAuditLogsIndexedAt(guildId: GuildID, indexedAt: Date | null): Promise<void> {
await executeVersionedUpdate<GuildRow, 'guild_id'>(
() => fetchOne<GuildRow>(FETCH_GUILD_BY_ID_QUERY, {guild_id: guildId}),
(current) => {
const patch: Record<string, ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>> = {};
if (indexedAt !== null) {
patch.audit_logs_indexed_at = Db.set(indexedAt);
} else if (current?.audit_logs_indexed_at !== null && current?.audit_logs_indexed_at !== undefined) {
patch.audit_logs_indexed_at = Db.clear();
}
return {
pk: {guild_id: guildId},
patch,
};
},
Guilds,
{onFailure: 'log'},
);
}
private mapRowToGuildAuditLog(row: GuildAuditLogRow): GuildAuditLog {
return new GuildAuditLog(row);
}
private buildAuditLogSelectQuery(
table:
| typeof GuildAuditLogs
| typeof GuildAuditLogsByUser
| typeof GuildAuditLogsByAction
| typeof GuildAuditLogsByUserAction,
limit: number,
beforeLogId?: bigint,
afterLogId?: bigint,
userId?: UserID,
actionType?: AuditLogActionType,
): string {
const where: Array<ReturnType<typeof table.where.eq | typeof table.where.lt | typeof table.where.gt>> = [
table.where.eq('guild_id'),
];
if (userId) {
where.push(table.where.eq('user_id'));
}
if (actionType !== undefined) {
where.push(table.where.eq('action_type'));
}
if (beforeLogId) {
where.push(table.where.lt('log_id', 'before_log_id'));
} else if (afterLogId) {
where.push(table.where.gt('log_id', 'after_log_id'));
}
return table.selectCql({where, limit});
}
private selectAuditLogTable(
userId?: UserID,
actionType?: AuditLogActionType,
):
| typeof GuildAuditLogs
| typeof GuildAuditLogsByUser
| typeof GuildAuditLogsByAction
| typeof GuildAuditLogsByUserAction {
if (userId && actionType !== undefined) {
return GuildAuditLogsByUserAction;
}
if (userId) {
return GuildAuditLogsByUser;
}
if (actionType !== undefined) {
return GuildAuditLogsByAction;
}
return GuildAuditLogs;
}
}

View File

@@ -0,0 +1,247 @@
/*
* 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 {EmojiID, GuildID, RoleID, StickerID, UserID} from '~/BrandedTypes';
import type {AuditLogActionType} from '~/constants/AuditLogActionType';
import type {
GuildAuditLogRow,
GuildBanRow,
GuildEmojiRow,
GuildMemberRow,
GuildRoleRow,
GuildRow,
GuildStickerRow,
} from '~/database/CassandraTypes';
import type {Guild, GuildAuditLog, GuildBan, GuildEmoji, GuildMember, GuildRole, GuildSticker} from '~/Models';
import {GuildContentRepository} from './GuildContentRepository';
import {GuildDataRepository} from './GuildDataRepository';
import {GuildMemberRepository} from './GuildMemberRepository';
import {GuildModerationRepository} from './GuildModerationRepository';
import {GuildRoleRepository} from './GuildRoleRepository';
import type {IGuildRepositoryAggregate} from './IGuildRepositoryAggregate';
export class GuildRepository implements IGuildRepositoryAggregate {
private dataRepo: GuildDataRepository;
private memberRepo: GuildMemberRepository;
private roleRepo: GuildRoleRepository;
private moderationRepo: GuildModerationRepository;
private contentRepo: GuildContentRepository;
constructor() {
this.dataRepo = new GuildDataRepository();
this.memberRepo = new GuildMemberRepository();
this.roleRepo = new GuildRoleRepository();
this.moderationRepo = new GuildModerationRepository();
this.contentRepo = new GuildContentRepository();
}
async findUnique(guildId: GuildID): Promise<Guild | null> {
return await this.dataRepo.findUnique(guildId);
}
async listGuilds(guildIds: Array<GuildID>): Promise<Array<Guild>> {
return await this.dataRepo.listGuilds(guildIds);
}
async listAllGuildsPaginated(limit: number, lastGuildId?: GuildID): Promise<Array<Guild>> {
return await this.dataRepo.listAllGuildsPaginated(limit, lastGuildId);
}
async listUserGuilds(userId: UserID): Promise<Array<Guild>> {
return await this.dataRepo.listUserGuilds(userId);
}
async countUserGuilds(userId: UserID): Promise<number> {
return await this.dataRepo.countUserGuilds(userId);
}
async listOwnedGuildIds(userId: UserID): Promise<Array<GuildID>> {
return await this.dataRepo.listOwnedGuildIds(userId);
}
async upsert(data: GuildRow): Promise<Guild> {
return await this.dataRepo.upsert(data);
}
async delete(guildId: GuildID): Promise<void> {
const guild = await this.findUnique(guildId);
if (!guild) {
return;
}
const [members, roles, emojis] = await Promise.all([
this.memberRepo.listMembers(guildId),
this.roleRepo.listRoles(guildId),
this.contentRepo.listEmojis(guildId),
]);
const BATCH_SIZE = 50;
for (let i = 0; i < members.length; i += BATCH_SIZE) {
const memberBatch = members.slice(i, i + BATCH_SIZE);
await Promise.all(memberBatch.map((member) => this.memberRepo.deleteMember(guildId, member.userId)));
}
for (let i = 0; i < roles.length; i += BATCH_SIZE) {
const roleBatch = roles.slice(i, i + BATCH_SIZE);
await Promise.all(roleBatch.map((role) => this.roleRepo.deleteRole(guildId, role.id)));
}
for (let i = 0; i < emojis.length; i += BATCH_SIZE) {
const emojiBatch = emojis.slice(i, i + BATCH_SIZE);
await Promise.all(emojiBatch.map((emoji) => this.contentRepo.deleteEmoji(guildId, emoji.id)));
}
await this.dataRepo.delete(guildId);
}
async getMember(guildId: GuildID, userId: UserID): Promise<GuildMember | null> {
return await this.memberRepo.getMember(guildId, userId);
}
async listMembers(guildId: GuildID): Promise<Array<GuildMember>> {
return await this.memberRepo.listMembers(guildId);
}
async upsertMember(data: GuildMemberRow): Promise<GuildMember> {
return await this.memberRepo.upsertMember(data);
}
async deleteMember(guildId: GuildID, userId: UserID): Promise<void> {
return await this.memberRepo.deleteMember(guildId, userId);
}
async getRole(roleId: RoleID, guildId: GuildID): Promise<GuildRole | null> {
return await this.roleRepo.getRole(roleId, guildId);
}
async listRoles(guildId: GuildID): Promise<Array<GuildRole>> {
return await this.roleRepo.listRoles(guildId);
}
async listRolesByIds(roleIds: Array<RoleID>, guildId: GuildID): Promise<Array<GuildRole>> {
return await this.roleRepo.listRolesByIds(roleIds, guildId);
}
async countRoles(guildId: GuildID): Promise<number> {
return await this.roleRepo.countRoles(guildId);
}
async upsertRole(data: GuildRoleRow): Promise<GuildRole> {
return await this.roleRepo.upsertRole(data);
}
async deleteRole(guildId: GuildID, roleId: RoleID): Promise<void> {
return await this.roleRepo.deleteRole(guildId, roleId);
}
async getBan(guildId: GuildID, userId: UserID): Promise<GuildBan | null> {
return await this.moderationRepo.getBan(guildId, userId);
}
async listBans(guildId: GuildID): Promise<Array<GuildBan>> {
return await this.moderationRepo.listBans(guildId);
}
async upsertBan(data: GuildBanRow): Promise<GuildBan> {
return await this.moderationRepo.upsertBan(data);
}
async deleteBan(guildId: GuildID, userId: UserID): Promise<void> {
return await this.moderationRepo.deleteBan(guildId, userId);
}
async createAuditLog(data: GuildAuditLogRow): Promise<GuildAuditLog> {
return await this.moderationRepo.createAuditLog(data);
}
async getAuditLog(guildId: GuildID, logId: bigint): Promise<GuildAuditLog | null> {
return await this.moderationRepo.getAuditLog(guildId, logId);
}
async listAuditLogs(params: {
guildId: GuildID;
limit: number;
afterLogId?: bigint;
beforeLogId?: bigint;
userId?: UserID;
actionType?: AuditLogActionType;
}): Promise<Array<GuildAuditLog>> {
return await this.moderationRepo.listAuditLogs(params);
}
async listAuditLogsByIds(guildId: GuildID, logIds: Array<bigint>): Promise<Array<GuildAuditLog>> {
return await this.moderationRepo.listAuditLogsByIds(guildId, logIds);
}
async deleteAuditLogs(guildId: GuildID, logs: Array<GuildAuditLog>): Promise<void> {
return await this.moderationRepo.deleteAuditLogs(guildId, logs);
}
async updateAuditLogsIndexedAt(guildId: GuildID, indexedAt: Date | null): Promise<void> {
return await this.moderationRepo.updateAuditLogsIndexedAt(guildId, indexedAt);
}
async getEmoji(emojiId: EmojiID, guildId: GuildID): Promise<GuildEmoji | null> {
return await this.contentRepo.getEmoji(emojiId, guildId);
}
async getEmojiById(emojiId: EmojiID): Promise<GuildEmoji | null> {
return await this.contentRepo.getEmojiById(emojiId);
}
async listEmojis(guildId: GuildID): Promise<Array<GuildEmoji>> {
return await this.contentRepo.listEmojis(guildId);
}
async countEmojis(guildId: GuildID): Promise<number> {
return await this.contentRepo.countEmojis(guildId);
}
async upsertEmoji(data: GuildEmojiRow): Promise<GuildEmoji> {
return await this.contentRepo.upsertEmoji(data);
}
async deleteEmoji(guildId: GuildID, emojiId: EmojiID): Promise<void> {
return await this.contentRepo.deleteEmoji(guildId, emojiId);
}
async getSticker(stickerId: StickerID, guildId: GuildID): Promise<GuildSticker | null> {
return await this.contentRepo.getSticker(stickerId, guildId);
}
async getStickerById(stickerId: StickerID): Promise<GuildSticker | null> {
return await this.contentRepo.getStickerById(stickerId);
}
async listStickers(guildId: GuildID): Promise<Array<GuildSticker>> {
return await this.contentRepo.listStickers(guildId);
}
async countStickers(guildId: GuildID): Promise<number> {
return await this.contentRepo.countStickers(guildId);
}
async upsertSticker(data: GuildStickerRow): Promise<GuildSticker> {
return await this.contentRepo.upsertSticker(data);
}
async deleteSticker(guildId: GuildID, stickerId: StickerID): Promise<void> {
return await this.contentRepo.deleteSticker(guildId, stickerId);
}
}

View File

@@ -0,0 +1,104 @@
/*
* 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, RoleID} from '~/BrandedTypes';
import {buildPatchFromData, deleteOneOrMany, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import {GUILD_ROLE_COLUMNS, type GuildRoleRow} from '~/database/CassandraTypes';
import {GuildRole} from '~/Models';
import {GuildRoles} from '~/Tables';
import {IGuildRoleRepository} from './IGuildRoleRepository';
const FETCH_GUILD_ROLE_BY_ID_QUERY = GuildRoles.selectCql({
where: [GuildRoles.where.eq('guild_id'), GuildRoles.where.eq('role_id')],
limit: 1,
});
const FETCH_GUILD_ROLES_BY_GUILD_ID_QUERY = GuildRoles.selectCql({
where: GuildRoles.where.eq('guild_id'),
});
const FETCH_ROLES_BY_IDS_QUERY = GuildRoles.selectCql({
where: [GuildRoles.where.eq('guild_id'), GuildRoles.where.in('role_id', 'role_ids')],
});
export class GuildRoleRepository extends IGuildRoleRepository {
async getRole(roleId: RoleID, guildId: GuildID): Promise<GuildRole | null> {
const role = await fetchOne<GuildRoleRow>(FETCH_GUILD_ROLE_BY_ID_QUERY, {
guild_id: guildId,
role_id: roleId,
});
return role ? new GuildRole(role) : null;
}
async listRoles(guildId: GuildID): Promise<Array<GuildRole>> {
const roles = await fetchMany<GuildRoleRow>(FETCH_GUILD_ROLES_BY_GUILD_ID_QUERY, {
guild_id: guildId,
});
return roles.map((role) => new GuildRole(role));
}
async listRolesByIds(roleIds: Array<RoleID>, guildId: GuildID): Promise<Array<GuildRole>> {
if (roleIds.length === 0) return [];
const roles = await fetchMany<GuildRoleRow>(FETCH_ROLES_BY_IDS_QUERY, {
guild_id: guildId,
role_ids: roleIds,
});
return roles.map((role) => new GuildRole(role));
}
async countRoles(guildId: GuildID): Promise<number> {
const roles = await fetchMany<GuildRoleRow>(FETCH_GUILD_ROLES_BY_GUILD_ID_QUERY, {
guild_id: guildId,
});
return roles.length;
}
async upsertRole(data: GuildRoleRow, oldData?: GuildRoleRow | null): Promise<GuildRole> {
const guildId = data.guild_id;
const roleId = data.role_id;
const result = await executeVersionedUpdate<GuildRoleRow, 'guild_id' | 'role_id'>(
async () => {
if (oldData !== undefined) return oldData;
return await fetchOne<GuildRoleRow>(FETCH_GUILD_ROLE_BY_ID_QUERY, {
guild_id: guildId,
role_id: roleId,
});
},
(current) => ({
pk: {guild_id: guildId, role_id: roleId},
patch: buildPatchFromData(data, current, GUILD_ROLE_COLUMNS, ['guild_id', 'role_id']),
}),
GuildRoles,
{onFailure: 'log'},
);
return new GuildRole({...data, version: result.finalVersion ?? 1});
}
async deleteRole(guildId: GuildID, roleId: RoleID): Promise<void> {
await deleteOneOrMany(
GuildRoles.deleteByPk({
guild_id: guildId,
role_id: roleId,
}),
);
}
}

View File

@@ -0,0 +1,37 @@
/*
* 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 {EmojiID, GuildID, StickerID} from '~/BrandedTypes';
import type {GuildEmojiRow, GuildStickerRow} from '~/database/CassandraTypes';
import type {GuildEmoji, GuildSticker} from '~/Models';
export abstract class IGuildContentRepository {
abstract getEmoji(emojiId: EmojiID, guildId: GuildID): Promise<GuildEmoji | null>;
abstract getEmojiById(emojiId: EmojiID): Promise<GuildEmoji | null>;
abstract listEmojis(guildId: GuildID): Promise<Array<GuildEmoji>>;
abstract countEmojis(guildId: GuildID): Promise<number>;
abstract upsertEmoji(data: GuildEmojiRow): Promise<GuildEmoji>;
abstract deleteEmoji(guildId: GuildID, emojiId: EmojiID): Promise<void>;
abstract getSticker(stickerId: StickerID, guildId: GuildID): Promise<GuildSticker | null>;
abstract getStickerById(stickerId: StickerID): Promise<GuildSticker | null>;
abstract listStickers(guildId: GuildID): Promise<Array<GuildSticker>>;
abstract countStickers(guildId: GuildID): Promise<number>;
abstract upsertSticker(data: GuildStickerRow): Promise<GuildSticker>;
abstract deleteSticker(guildId: GuildID, stickerId: StickerID): Promise<void>;
}

View File

@@ -0,0 +1,33 @@
/*
* 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 '~/BrandedTypes';
import type {GuildRow} from '~/database/CassandraTypes';
import type {Guild} from '~/Models';
export abstract class IGuildDataRepository {
abstract findUnique(guildId: GuildID): Promise<Guild | null>;
abstract listGuilds(guildIds: Array<GuildID>): Promise<Array<Guild>>;
abstract listAllGuildsPaginated(limit: number, lastGuildId?: GuildID): Promise<Array<Guild>>;
abstract listUserGuilds(userId: UserID): Promise<Array<Guild>>;
abstract countUserGuilds(userId: UserID): Promise<number>;
abstract listOwnedGuildIds(userId: UserID): Promise<Array<GuildID>>;
abstract upsert(data: GuildRow): Promise<Guild>;
abstract delete(guildId: GuildID): Promise<void>;
}

View File

@@ -0,0 +1,29 @@
/*
* 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 '~/BrandedTypes';
import type {GuildMemberRow} from '~/database/CassandraTypes';
import type {GuildMember} from '~/Models';
export abstract class IGuildMemberRepository {
abstract getMember(guildId: GuildID, userId: UserID): Promise<GuildMember | null>;
abstract listMembers(guildId: GuildID): Promise<Array<GuildMember>>;
abstract upsertMember(data: GuildMemberRow): Promise<GuildMember>;
abstract deleteMember(guildId: GuildID, userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,43 @@
/*
* 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 '~/BrandedTypes';
import type {AuditLogActionType} from '~/constants/AuditLogActionType';
import type {GuildAuditLogRow, GuildBanRow} from '~/database/CassandraTypes';
import type {GuildAuditLog, GuildBan} from '~/Models';
export abstract class IGuildModerationRepository {
abstract getBan(guildId: GuildID, userId: UserID): Promise<GuildBan | null>;
abstract listBans(guildId: GuildID): Promise<Array<GuildBan>>;
abstract upsertBan(data: GuildBanRow): Promise<GuildBan>;
abstract deleteBan(guildId: GuildID, userId: UserID): Promise<void>;
abstract createAuditLog(data: GuildAuditLogRow): Promise<GuildAuditLog>;
abstract getAuditLog(guildId: GuildID, logId: bigint): Promise<GuildAuditLog | null>;
abstract listAuditLogs(params: {
guildId: GuildID;
limit: number;
afterLogId?: bigint;
beforeLogId?: bigint;
userId?: UserID;
actionType?: AuditLogActionType;
}): Promise<Array<GuildAuditLog>>;
abstract listAuditLogsByIds(guildId: GuildID, logIds: Array<bigint>): Promise<Array<GuildAuditLog>>;
abstract deleteAuditLogs(guildId: GuildID, logs: Array<GuildAuditLog>): Promise<void>;
abstract updateAuditLogsIndexedAt(guildId: GuildID, indexedAt: Date | null): Promise<void>;
}

View File

@@ -0,0 +1,31 @@
/*
* 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 {IGuildContentRepository} from './IGuildContentRepository';
import type {IGuildDataRepository} from './IGuildDataRepository';
import type {IGuildMemberRepository} from './IGuildMemberRepository';
import type {IGuildModerationRepository} from './IGuildModerationRepository';
import type {IGuildRoleRepository} from './IGuildRoleRepository';
export interface IGuildRepositoryAggregate
extends IGuildDataRepository,
IGuildMemberRepository,
IGuildRoleRepository,
IGuildModerationRepository,
IGuildContentRepository {}

View File

@@ -0,0 +1,31 @@
/*
* 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, RoleID} from '~/BrandedTypes';
import type {GuildRoleRow} from '~/database/CassandraTypes';
import type {GuildRole} from '~/Models';
export abstract class IGuildRoleRepository {
abstract getRole(roleId: RoleID, guildId: GuildID): Promise<GuildRole | null>;
abstract listRoles(guildId: GuildID): Promise<Array<GuildRole>>;
abstract listRolesByIds(roleIds: Array<RoleID>, guildId: GuildID): Promise<Array<GuildRole>>;
abstract countRoles(guildId: GuildID): Promise<number>;
abstract upsertRole(data: GuildRoleRow): Promise<GuildRole>;
abstract deleteRole(guildId: GuildID, roleId: RoleID): Promise<void>;
}

View File

@@ -0,0 +1,132 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, UserID} from '~/BrandedTypes';
import {Permissions} from '~/Constants';
import type {ChannelCreateRequest, ChannelResponse} from '~/channel/ChannelModel';
import {mapChannelToResponse} from '~/channel/ChannelModel';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import {MissingPermissionsError} from '~/Errors';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {GuildAuditLogService} from '../GuildAuditLogService';
import {ChannelOperationsService} from './channel/ChannelOperationsService';
export class GuildChannelService {
private readonly channelOps: ChannelOperationsService;
constructor(
private readonly channelRepository: IChannelRepository,
private readonly userCacheService: UserCacheService,
private readonly gatewayService: IGatewayService,
cacheService: ICacheService,
snowflakeService: SnowflakeService,
guildAuditLogService: GuildAuditLogService,
) {
this.channelOps = new ChannelOperationsService(
channelRepository,
userCacheService,
gatewayService,
cacheService,
snowflakeService,
guildAuditLogService,
);
}
async getChannels(params: {
userId: UserID;
guildId: GuildID;
requestCache: RequestCache;
}): Promise<Array<ChannelResponse>> {
await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
const viewableChannelIds = await this.gatewayService.getViewableChannels({
guildId: params.guildId,
userId: params.userId,
});
const channels = await this.channelRepository.listGuildChannels(params.guildId);
const viewableChannels = channels.filter((channel) => viewableChannelIds.includes(channel.id));
return Promise.all(
viewableChannels.map((channel) => {
return mapChannelToResponse({
channel,
currentUserId: null,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
}),
);
}
async createChannel(
params: {userId: UserID; guildId: GuildID; data: ChannelCreateRequest; requestCache: RequestCache},
auditLogReason?: string | null,
): Promise<ChannelResponse> {
await this.checkPermission({
userId: params.userId,
guildId: params.guildId,
permission: Permissions.MANAGE_CHANNELS,
});
return this.channelOps.createChannel(params, auditLogReason);
}
async updateChannelPositions(
params: {
userId: UserID;
guildId: GuildID;
updates: Array<{
channelId: ChannelID;
position?: number;
parentId: ChannelID | null | undefined;
lockPermissions: boolean;
}>;
requestCache: RequestCache;
},
auditLogReason?: string | null,
): Promise<void> {
await this.checkPermission({
userId: params.userId,
guildId: params.guildId,
permission: Permissions.MANAGE_CHANNELS,
});
await this.channelOps.updateChannelPositionsByList({
userId: params.userId,
guildId: params.guildId,
updates: params.updates,
requestCache: params.requestCache,
auditLogReason: auditLogReason ?? null,
});
}
async sanitizeTextChannelNames(params: {guildId: GuildID; requestCache: RequestCache}): Promise<void> {
await this.channelOps.sanitizeTextChannelNames(params);
}
private async checkPermission(params: {userId: UserID; guildId: GuildID; permission: bigint}): Promise<void> {
const hasPermission = await this.gatewayService.checkPermission({
guildId: params.guildId,
userId: params.userId,
permission: params.permission,
});
if (!hasPermission) throw new MissingPermissionsError();
}
}

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 {EmojiID, GuildID, StickerID, UserID} from '~/BrandedTypes';
import type {
GuildEmojiResponse,
GuildEmojiWithUserResponse,
GuildStickerResponse,
GuildStickerWithUserResponse,
} from '~/guild/GuildModel';
import type {AvatarService} from '~/infrastructure/AvatarService';
import type {IAssetDeletionQueue} from '~/infrastructure/IAssetDeletionQueue';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {User} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {UserPartialResponse} from '~/user/UserModel';
import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {IGuildRepository} from '../IGuildRepository';
import {ContentHelpers} from './content/ContentHelpers';
import {EmojiService} from './content/EmojiService';
import {ExpressionAssetPurger} from './content/ExpressionAssetPurger';
import {StickerService} from './content/StickerService';
export class GuildContentService {
private readonly contentHelpers: ContentHelpers;
private readonly emojiService: EmojiService;
private readonly stickerService: StickerService;
constructor(
guildRepository: IGuildRepository,
userCacheService: UserCacheService,
gatewayService: IGatewayService,
avatarService: AvatarService,
snowflakeService: SnowflakeService,
guildAuditLogService: GuildAuditLogService,
assetDeletionQueue: IAssetDeletionQueue,
) {
this.contentHelpers = new ContentHelpers(gatewayService, guildAuditLogService);
const expressionAssetPurger = new ExpressionAssetPurger(assetDeletionQueue);
this.emojiService = new EmojiService(
guildRepository,
userCacheService,
gatewayService,
avatarService,
snowflakeService,
this.contentHelpers,
expressionAssetPurger,
);
this.stickerService = new StickerService(
guildRepository,
userCacheService,
gatewayService,
avatarService,
snowflakeService,
this.contentHelpers,
expressionAssetPurger,
);
}
async getEmojis(params: {
userId: UserID;
guildId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildEmojiWithUserResponse>> {
return this.emojiService.getEmojis(params);
}
async getEmojiUser(params: {
userId: UserID;
guildId: GuildID;
emojiId: EmojiID;
requestCache: RequestCache;
}): Promise<UserPartialResponse> {
return this.emojiService.getEmojiUser(params);
}
async createEmoji(
params: {user: User; guildId: GuildID; name: string; image: string},
auditLogReason?: string | null,
): Promise<GuildEmojiResponse> {
return this.emojiService.createEmoji(params, auditLogReason);
}
async bulkCreateEmojis(
params: {user: User; guildId: GuildID; emojis: Array<{name: string; image: string}>},
auditLogReason?: string | null,
): Promise<{
success: Array<GuildEmojiResponse>;
failed: Array<{name: string; error: string}>;
}> {
return this.emojiService.bulkCreateEmojis(params, auditLogReason);
}
async updateEmoji(
params: {userId: UserID; guildId: GuildID; emojiId: EmojiID; name: string},
auditLogReason?: string | null,
): Promise<GuildEmojiResponse> {
return this.emojiService.updateEmoji(params, auditLogReason);
}
async deleteEmoji(
params: {userId: UserID; guildId: GuildID; emojiId: EmojiID; purge?: boolean},
auditLogReason?: string | null,
): Promise<void> {
return this.emojiService.deleteEmoji(params, auditLogReason);
}
async getStickers(params: {
userId: UserID;
guildId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildStickerWithUserResponse>> {
return this.stickerService.getStickers(params);
}
async getStickerUser(params: {
userId: UserID;
guildId: GuildID;
stickerId: StickerID;
requestCache: RequestCache;
}): Promise<UserPartialResponse> {
return this.stickerService.getStickerUser(params);
}
async createSticker(
params: {
user: User;
guildId: GuildID;
name: string;
description?: string | null;
tags: Array<string>;
image: string;
},
auditLogReason?: string | null,
): Promise<GuildStickerResponse> {
return this.stickerService.createSticker(params, auditLogReason);
}
async bulkCreateStickers(
params: {
user: User;
guildId: GuildID;
stickers: Array<{name: string; description?: string | null; tags: Array<string>; image: string}>;
},
auditLogReason?: string | null,
): Promise<{
success: Array<GuildStickerResponse>;
failed: Array<{name: string; error: string}>;
}> {
return this.stickerService.bulkCreateStickers(params, auditLogReason);
}
async updateSticker(
params: {
userId: UserID;
guildId: GuildID;
stickerId: StickerID;
name: string;
description?: string | null;
tags: Array<string>;
},
auditLogReason?: string | null,
): Promise<GuildStickerResponse> {
return this.stickerService.updateSticker(params, auditLogReason);
}
async deleteSticker(
params: {userId: UserID; guildId: GuildID; stickerId: StickerID; purge?: boolean},
auditLogReason?: string | null,
): Promise<void> {
return this.stickerService.deleteSticker(params, auditLogReason);
}
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildID, UserID} from '~/BrandedTypes';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import type {ChannelService} from '~/channel/services/ChannelService';
import type {
GuildCreateRequest,
GuildPartialResponse,
GuildResponse,
GuildUpdateRequest,
GuildVanityURLResponse,
} from '~/guild/GuildModel';
import type {EntityAssetService} from '~/infrastructure/EntityAssetService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {InviteRepository} from '~/invite/InviteRepository';
import type {Guild, GuildMember, User} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IWebhookRepository} from '~/webhook/IWebhookRepository';
import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {IGuildRepository} from '../IGuildRepository';
import {GuildDataHelpers} from './data/GuildDataHelpers';
import {GuildOperationsService} from './data/GuildOperationsService';
import {GuildOwnershipService} from './data/GuildOwnershipService';
import {GuildVanityService} from './data/GuildVanityService';
export class GuildDataService {
private readonly helpers: GuildDataHelpers;
private readonly operationsService: GuildOperationsService;
private readonly vanityService: GuildVanityService;
private readonly ownershipService: GuildOwnershipService;
constructor(
private readonly guildRepository: IGuildRepository,
private readonly channelRepository: IChannelRepository,
private readonly inviteRepository: InviteRepository,
private readonly channelService: ChannelService,
private readonly gatewayService: IGatewayService,
private readonly entityAssetService: EntityAssetService,
private readonly userRepository: IUserRepository,
private readonly snowflakeService: SnowflakeService,
private readonly webhookRepository: IWebhookRepository,
private readonly guildAuditLogService: GuildAuditLogService,
) {
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService);
this.operationsService = new GuildOperationsService(
this.guildRepository,
this.channelRepository,
this.inviteRepository,
this.channelService,
this.gatewayService,
this.entityAssetService,
this.userRepository,
this.snowflakeService,
this.webhookRepository,
this.helpers,
);
this.vanityService = new GuildVanityService(this.guildRepository, this.inviteRepository, this.helpers);
this.ownershipService = new GuildOwnershipService(this.guildRepository, this.userRepository, this.helpers);
}
async getGuild({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildResponse> {
return this.operationsService.getGuild({userId, guildId});
}
async getUserGuilds(userId: UserID): Promise<Array<GuildResponse>> {
return this.operationsService.getUserGuilds(userId);
}
async getPublicGuildData(guildId: GuildID): Promise<GuildPartialResponse> {
return this.operationsService.getPublicGuildData(guildId);
}
async getGuildSystem(guildId: GuildID): Promise<Guild> {
return this.operationsService.getGuildSystem(guildId);
}
async createGuild(
params: {user: User; data: GuildCreateRequest},
auditLogReason?: string | null,
): Promise<GuildResponse> {
return this.operationsService.createGuild(params, auditLogReason);
}
async updateGuild(
params: {userId: UserID; guildId: GuildID; data: GuildUpdateRequest; requestCache: RequestCache},
auditLogReason?: string | null,
): Promise<GuildResponse> {
return this.operationsService.updateGuild(params, auditLogReason);
}
async getVanityURL(params: {userId: UserID; guildId: GuildID}): Promise<GuildVanityURLResponse> {
return this.vanityService.getVanityURL(params);
}
async updateVanityURL(
params: {userId: UserID; guildId: GuildID; code: string | null; requestCache: RequestCache},
auditLogReason?: string | null,
): Promise<{code: string}> {
return this.vanityService.updateVanityURL(params, auditLogReason);
}
async deleteGuild(params: {user: User; guildId: GuildID}, auditLogReason?: string | null): Promise<void> {
return this.operationsService.deleteGuild(params, auditLogReason);
}
async deleteGuildForAdmin(guildId: GuildID, _auditLogReason?: string | null): Promise<void> {
return this.operationsService.deleteGuildById(guildId);
}
async transferOwnership(
params: {userId: UserID; guildId: GuildID; newOwnerId: UserID},
auditLogReason?: string | null,
): Promise<GuildResponse> {
return this.ownershipService.transferOwnership(params, auditLogReason);
}
async checkGuildVerification(params: {user: User; guild: Guild; member: GuildMember}): Promise<void> {
return this.ownershipService.checkGuildVerification(params);
}
}

View File

@@ -0,0 +1,265 @@
/*
* 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, RoleID, UserID} from '~/BrandedTypes';
import type {ChannelService} from '~/channel/services/ChannelService';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {UnknownGuildMemberError} from '~/Errors';
import type {GuildMemberResponse, GuildMemberUpdateRequest} from '~/guild/GuildModel';
import type {EntityAssetService} from '~/infrastructure/EntityAssetService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {GuildMember} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {IGuildRepository} from '../IGuildRepository';
import {GuildMemberAuditService} from './member/GuildMemberAuditService';
import {GuildMemberAuthService} from './member/GuildMemberAuthService';
import {GuildMemberEventService} from './member/GuildMemberEventService';
import {GuildMemberOperationsService} from './member/GuildMemberOperationsService';
import {GuildMemberRoleService} from './member/GuildMemberRoleService';
import {GuildMemberValidationService} from './member/GuildMemberValidationService';
export class GuildMemberService {
private readonly authService: GuildMemberAuthService;
private readonly validationService: GuildMemberValidationService;
private readonly auditService: GuildMemberAuditService;
private readonly eventService: GuildMemberEventService;
private readonly operationsService: GuildMemberOperationsService;
private readonly roleService: GuildMemberRoleService;
constructor(
private readonly guildRepository: IGuildRepository,
channelService: ChannelService,
userCacheService: UserCacheService,
gatewayService: IGatewayService,
entityAssetService: EntityAssetService,
userRepository: IUserRepository,
rateLimitService: IRateLimitService,
private readonly guildAuditLogService: GuildAuditLogService,
) {
this.authService = new GuildMemberAuthService(gatewayService);
this.validationService = new GuildMemberValidationService(guildRepository, userRepository);
this.auditService = new GuildMemberAuditService(guildAuditLogService);
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
this.operationsService = new GuildMemberOperationsService(
guildRepository,
channelService,
userCacheService,
gatewayService,
entityAssetService,
userRepository,
rateLimitService,
this.authService,
this.validationService,
this.guildAuditLogService,
);
this.roleService = new GuildMemberRoleService(
guildRepository,
gatewayService,
this.authService,
this.validationService,
);
}
async getMembers(params: {
userId: UserID;
guildId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildMemberResponse>> {
return this.operationsService.getMembers(params);
}
async getMember(params: {
userId: UserID;
targetId: UserID;
guildId: GuildID;
requestCache: RequestCache;
}): Promise<GuildMemberResponse> {
return this.operationsService.getMember(params);
}
async updateMember(
params: {
userId: UserID;
targetId: UserID;
guildId: GuildID;
data: GuildMemberUpdateRequest | Omit<GuildMemberUpdateRequest, 'roles'>;
requestCache: RequestCache;
},
auditLogReason?: string | null,
): Promise<GuildMemberResponse> {
const {userId, targetId, guildId, data, requestCache} = params;
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
const previousSnapshot = this.auditService.serializeMemberForAudit(targetMember);
const result = await this.operationsService.updateMember({
userId,
targetId,
guildId,
data,
requestCache,
auditLogReason,
});
const updatedMember = await this.guildRepository.getMember(guildId, targetId);
if (!updatedMember) throw new UnknownGuildMemberError();
await this.eventService.dispatchGuildMemberUpdate({guildId, member: updatedMember, requestCache});
const timeoutMetadata = (() => {
if (data.communication_disabled_until === undefined) {
return undefined;
}
const metadata: Record<string, string> = {};
if (data.communication_disabled_until !== null) {
metadata.communication_disabled_until = data.communication_disabled_until;
}
const trimmedReason = data.timeout_reason?.trim();
if (trimmedReason) {
metadata.timeout_reason = trimmedReason;
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
})();
await this.auditService.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.MEMBER_UPDATE,
targetId: targetId,
auditLogReason: auditLogReason ?? null,
metadata: timeoutMetadata,
changes: this.guildAuditLogService.computeChanges(
previousSnapshot,
this.auditService.serializeMemberForAudit(updatedMember),
),
});
return result;
}
async addMemberRole(
params: {userId: UserID; targetId: UserID; guildId: GuildID; roleId: RoleID; requestCache: RequestCache},
auditLogReason?: string | null,
): Promise<void> {
const {userId, targetId, guildId, roleId, requestCache} = params;
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
const previousSnapshot = this.auditService.serializeMemberForAudit(targetMember);
await this.roleService.addMemberRole(params);
const updatedMember = await this.guildRepository.getMember(guildId, targetId);
if (updatedMember) {
await this.eventService.dispatchGuildMemberUpdate({guildId, member: updatedMember, requestCache});
await this.auditService.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.MEMBER_ROLE_UPDATE,
targetId: targetId,
auditLogReason: auditLogReason ?? null,
metadata: {role_id: roleId.toString(), action: 'add'},
changes: this.guildAuditLogService.computeChanges(
previousSnapshot,
this.auditService.serializeMemberForAudit(updatedMember),
),
});
}
}
async removeMemberRole(
params: {userId: UserID; targetId: UserID; guildId: GuildID; roleId: RoleID; requestCache: RequestCache},
auditLogReason?: string | null,
): Promise<void> {
const {userId, targetId, guildId, roleId, requestCache} = params;
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
const previousSnapshot = this.auditService.serializeMemberForAudit(targetMember);
await this.roleService.removeMemberRole(params);
const updatedMember = await this.guildRepository.getMember(guildId, targetId);
if (updatedMember) {
await this.eventService.dispatchGuildMemberUpdate({guildId, member: updatedMember, requestCache});
await this.auditService.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.MEMBER_ROLE_UPDATE,
targetId: targetId,
auditLogReason: auditLogReason ?? null,
metadata: {role_id: roleId.toString(), action: 'remove'},
changes: this.guildAuditLogService.computeChanges(
previousSnapshot,
this.auditService.serializeMemberForAudit(updatedMember),
),
});
}
}
async removeMember(
params: {userId: UserID; targetId: UserID; guildId: GuildID},
auditLogReason?: string | null,
): Promise<void> {
const {userId, targetId, guildId} = params;
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
await this.operationsService.removeMember(params);
await this.eventService.dispatchGuildMemberRemove({guildId, userId: targetId});
await this.auditService.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.MEMBER_KICK,
targetId: targetId,
auditLogReason: auditLogReason ?? null,
});
}
async addUserToGuild(params: {
userId: UserID;
guildId: GuildID;
sendJoinMessage?: boolean;
skipGuildLimitCheck?: boolean;
skipBanCheck?: boolean;
isTemporary?: boolean;
joinSourceType?: number;
requestCache: RequestCache;
initiatorId?: UserID;
}): Promise<GuildMember> {
return this.operationsService.addUserToGuild(params, this.eventService);
}
async leaveGuild(params: {userId: UserID; guildId: GuildID}, _auditLogReason?: string | null): Promise<void> {
const {userId, guildId} = params;
const member = await this.guildRepository.getMember(guildId, userId);
if (!member) throw new UnknownGuildMemberError();
await this.operationsService.leaveGuild(params);
await this.eventService.dispatchGuildMemberRemove({guildId, userId});
}
}

View File

@@ -0,0 +1,277 @@
/*
* 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 '~/BrandedTypes';
import {Permissions} from '~/Constants';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {BannedFromGuildError, InputValidationError, IpBannedFromGuildError, MissingPermissionsError} from '~/Errors';
import type {GuildBanResponse} from '~/guild/GuildModel';
import {mapGuildBansToResponse} from '~/guild/GuildModel';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {Logger} from '~/Logger';
import type {GuildBan} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IWorkerService} from '~/worker/IWorkerService';
import type {GuildAuditLogChange, GuildAuditLogService} from '../GuildAuditLogService';
import type {IGuildRepository} from '../IGuildRepository';
export class GuildModerationService {
constructor(
private readonly guildRepository: IGuildRepository,
private readonly userRepository: IUserRepository,
private readonly gatewayService: IGatewayService,
private readonly userCacheService: UserCacheService,
private readonly workerService: IWorkerService,
private readonly guildAuditLogService: GuildAuditLogService,
) {}
async banMember(
params: {
userId: UserID;
targetId: UserID;
guildId: GuildID;
deleteMessageDays?: number;
reason?: string | null;
banDurationSeconds?: number;
},
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (targetMember) {
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId: targetId});
if (!canManage) throw new MissingPermissionsError();
}
if (deleteMessageDays && deleteMessageDays > 0) {
await this.workerService.addJob('deleteUserMessagesInGuildByTime', {
guildId: guildId.toString(),
userId: targetId.toString(),
days: deleteMessageDays,
});
}
const targetUser = await this.userRepository.findUnique(targetId);
const targetIp = targetUser?.lastActiveIp || null;
let expiresAt: Date | null = null;
if (banDurationSeconds && banDurationSeconds > 0) {
expiresAt = new Date(Date.now() + banDurationSeconds * 1000);
}
const ban = await this.guildRepository.upsertBan({
guild_id: guildId,
user_id: targetId,
moderator_id: userId,
banned_at: new Date(),
expires_at: expiresAt,
reason: reason || null,
ip: targetIp,
});
const metadata: Record<string, string> | undefined =
deleteMessageDays !== undefined ? {delete_member_days: deleteMessageDays.toString()} : undefined;
await this.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.MEMBER_BAN_ADD,
targetId: targetId,
auditLogReason: auditLogReason ?? null,
metadata,
changes: this.guildAuditLogService.computeChanges(null, this.serializeBanForAudit(ban)),
});
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_BAN_ADD',
data: {
guild_id: guildId.toString(),
user: {id: targetId.toString()},
},
});
if (targetMember) {
await this.guildRepository.deleteMember(guildId, targetId);
const guild = await this.guildRepository.findUnique(guildId);
if (guild) {
const guildRow = guild.toRow();
await this.guildRepository.upsert({
...guildRow,
member_count: Math.max(0, guild.memberCount - 1),
});
}
await this.dispatchGuildMemberRemove({guildId, userId: targetId});
await this.gatewayService.leaveGuild({userId: targetId, guildId});
}
}
async listBans(params: {
userId: UserID;
guildId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildBanResponse>> {
const {userId, guildId, requestCache} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
const bans = await this.guildRepository.listBans(guildId);
return await mapGuildBansToResponse(bans, this.userCacheService, requestCache);
}
async unbanMember(
params: {userId: UserID; targetId: UserID; guildId: GuildID},
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
const ban = await this.guildRepository.getBan(guildId, targetId);
if (!ban) {
throw InputValidationError.create('user_id', 'User is not banned');
}
await this.guildRepository.deleteBan(guildId, targetId);
await this.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.MEMBER_BAN_REMOVE,
targetId: targetId,
auditLogReason: auditLogReason ?? null,
changes: this.guildAuditLogService.computeChanges(this.serializeBanForAudit(ban), null),
});
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_BAN_REMOVE',
data: {
guild_id: guildId.toString(),
user: {id: targetId.toString()},
},
});
}
async checkUserBanStatus(params: {userId: UserID; guildId: GuildID}): Promise<void> {
const {userId, guildId} = params;
const bans = await this.guildRepository.listBans(guildId);
const user = await this.userRepository.findUnique(userId);
const userIp = user?.lastActiveIp;
for (const ban of bans) {
if (ban.userId === userId) {
throw new BannedFromGuildError();
}
if (userIp && ban.ipAddress && ban.ipAddress === userIp) {
throw new IpBannedFromGuildError();
}
}
}
private serializeBanForAudit(ban: GuildBan): Record<string, unknown> {
return {
user_id: ban.userId.toString(),
moderator_id: ban.moderatorId.toString(),
banned_at: ban.bannedAt.toISOString(),
expires_at: ban.expiresAt ? ban.expiresAt.toISOString() : null,
reason: ban.reason ?? null,
};
}
private async dispatchGuildMemberRemove({guildId, userId}: {guildId: GuildID; userId: UserID}): Promise<void> {
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_MEMBER_REMOVE',
data: {user: {id: userId.toString()}},
});
}
private async recordAuditLog(params: {
guildId: GuildID;
userId: UserID;
action: AuditLogActionType;
targetId?: UserID | string | null;
auditLogReason?: string | null;
metadata?: Map<string, string> | Record<string, string>;
changes?: GuildAuditLogChange | null;
createdAt?: Date;
}): Promise<void> {
const targetId =
params.targetId === undefined || params.targetId === null
? null
: typeof params.targetId === 'string'
? params.targetId
: params.targetId.toString();
try {
const builder = this.guildAuditLogService
.createBuilder(params.guildId, params.userId)
.withAction(params.action, targetId)
.withReason(params.auditLogReason ?? null);
if (params.metadata) {
builder.withMetadata(params.metadata);
}
if (params.changes) {
builder.withChanges(params.changes);
}
if (params.createdAt) {
builder.withCreatedAt(params.createdAt);
}
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: params.guildId.toString(),
userId: params.userId.toString(),
action: params.action,
targetId,
},
'Failed to record guild audit log',
);
}
}
}

View File

@@ -0,0 +1,837 @@
/*
* 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, RoleID, UserID} from '~/BrandedTypes';
import {createRoleID, guildIdToRoleId} from '~/BrandedTypes';
import {ALL_PERMISSIONS, DEFAULT_PERMISSIONS, MAX_GUILD_ROLES, Permissions} from '~/Constants';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {
InputValidationError,
MaxGuildRolesError,
MissingPermissionsError,
ResourceLockedError,
UnknownGuildRoleError,
} from '~/Errors';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import {Logger} from '~/Logger';
import {GuildRole} from '~/Models';
import {computePermissionsDiff} from '~/utils/PermissionUtils';
import type {GuildAuditLogChange, GuildAuditLogService} from '../GuildAuditLogService';
import {
type GuildRoleCreateRequest,
type GuildRoleResponse,
type GuildRoleUpdateRequest,
mapGuildRoleToResponse,
} from '../GuildModel';
import type {IGuildMemberRepository} from '../repositories/IGuildMemberRepository';
import type {IGuildRoleRepository} from '../repositories/IGuildRoleRepository';
interface RoleReorderOperation {
roleId: RoleID;
precedingRoleId: RoleID | null;
}
interface GuildAuth {
guildData: {owner_id: string};
checkPermission: (permission: bigint) => Promise<void>;
getMyPermissions: () => Promise<bigint>;
}
type RoleUpdateData = Partial<{
name: string;
color: number;
position: number;
hoistPosition: number | null;
permissions: bigint;
iconHash: string | null;
unicodeEmoji: string | null;
hoist: boolean;
mentionable: boolean;
}>;
export class GuildRoleService {
constructor(
private readonly guildRoleRepository: IGuildRoleRepository,
private readonly guildMemberRepository: IGuildMemberRepository,
private readonly snowflakeService: SnowflakeService,
private readonly cacheService: ICacheService,
private readonly gatewayService: IGatewayService,
private readonly guildAuditLogService: GuildAuditLogService,
) {}
async createRole(
params: {userId: UserID; guildId: GuildID; data: GuildRoleCreateRequest},
auditLogReason?: string | null,
): Promise<GuildRoleResponse> {
const {userId, guildId, data} = params;
const {checkPermission, getMyPermissions, guildData} = await this.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_ROLES);
const currentRoleCount = await this.guildRoleRepository.countRoles(guildId);
if (currentRoleCount >= MAX_GUILD_ROLES) throw new MaxGuildRolesError();
const permissions =
data.permissions !== undefined
? await this.resolveRequestedPermissions({
requestedPermissions: data.permissions,
guildData,
userId,
getMyPermissions,
})
: DEFAULT_PERMISSIONS;
const roleId = createRoleID(this.snowflakeService.generate());
const position = 1;
const role = await this.guildRoleRepository.upsertRole({
guild_id: guildId,
role_id: roleId,
name: data.name,
permissions,
position,
hoist_position: null,
color: data.color || 0,
icon_hash: null,
unicode_emoji: null,
hoist: false,
mentionable: false,
version: 1,
});
await this.dispatchGuildRoleCreate({guildId, role});
await this.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.ROLE_CREATE,
targetId: role.id,
auditLogReason: auditLogReason ?? null,
changes: this.guildAuditLogService.computeChanges(null, this.serializeRoleForAudit(role)),
});
return mapGuildRoleToResponse(role);
}
private async resolveRequestedPermissions(params: {
requestedPermissions: bigint;
guildData: {owner_id: string};
userId: UserID;
getMyPermissions: () => Promise<bigint>;
}): Promise<bigint> {
const {requestedPermissions, guildData, userId, getMyPermissions} = params;
const sanitizedPermissions = requestedPermissions & ALL_PERMISSIONS;
const isOwner = guildData && guildData.owner_id === userId.toString();
if (!isOwner) {
const myPermissions = await getMyPermissions();
if ((sanitizedPermissions & ~myPermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
return sanitizedPermissions;
}
async updateRole(
params: {userId: UserID; guildId: GuildID; roleId: RoleID; data: GuildRoleUpdateRequest},
auditLogReason?: string | null,
): Promise<GuildRoleResponse> {
const {userId, guildId, roleId, data} = params;
const {guildData, checkPermission, getMyPermissions} = await this.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_ROLES);
const role = await this.guildRoleRepository.getRole(roleId, guildId);
if (!role || (role.id === guildIdToRoleId(guildId) && roleId !== guildIdToRoleId(guildId))) {
throw new UnknownGuildRoleError();
}
const isOwner = guildData && guildData.owner_id === userId.toString();
if (!isOwner) {
const canManageRole = await this.checkCanManageRole({guildId, userId, targetRole: role});
if (!canManageRole) {
throw new MissingPermissionsError();
}
}
const previousSnapshot = this.serializeRoleForAudit(role);
const updateData = await this.buildRoleUpdateData({
role,
guildId,
guildData,
userId,
data,
getMyPermissions,
});
const updatedRoleData = {
...role.toRow(),
name: updateData.name ?? role.name,
color: updateData.color ?? role.color,
position: updateData.position ?? role.position,
hoist_position: updateData.hoistPosition !== undefined ? updateData.hoistPosition : role.hoistPosition,
permissions: updateData.permissions ?? role.permissions,
icon_hash: updateData.iconHash ?? role.iconHash,
unicode_emoji: updateData.unicodeEmoji ?? role.unicodeEmoji,
hoist: updateData.hoist ?? role.isHoisted,
mentionable: updateData.mentionable ?? role.isMentionable,
};
const updatedRole = await this.guildRoleRepository.upsertRole(updatedRoleData);
await this.dispatchGuildRoleUpdate({guildId, role: updatedRole});
const changes = this.guildAuditLogService.computeChanges(previousSnapshot, this.serializeRoleForAudit(updatedRole));
if (role.permissions !== updatedRole.permissions) {
const permissionsDiff = computePermissionsDiff(role.permissions, updatedRole.permissions);
changes.push({key: 'permissions_diff', new_value: permissionsDiff});
}
await this.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.ROLE_UPDATE,
targetId: roleId,
auditLogReason: auditLogReason ?? null,
changes,
});
return mapGuildRoleToResponse(updatedRole);
}
async deleteRole(
params: {userId: UserID; guildId: GuildID; roleId: RoleID},
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, roleId} = params;
const {checkPermission, guildData} = await this.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_ROLES);
const role = await this.guildRoleRepository.getRole(roleId, guildId);
if (!role || role.id === guildIdToRoleId(guildId)) {
throw new UnknownGuildRoleError();
}
const isOwner = guildData && guildData.owner_id === userId.toString();
if (!isOwner) {
const canManageRole = await this.checkCanManageRole({guildId, userId, targetRole: role});
if (!canManageRole) {
throw new MissingPermissionsError();
}
}
const previousSnapshot = this.serializeRoleForAudit(role);
const memberIds = await this.gatewayService.getMembersWithRole({guildId, roleId});
await Promise.all(
memberIds.map(async (memberId) => {
const member = await this.guildMemberRepository.getMember(guildId, memberId);
if (member?.roleIds.has(roleId)) {
const updatedRoleIds = new Set(member.roleIds);
updatedRoleIds.delete(roleId);
await this.guildMemberRepository.upsertMember({
...member.toRow(),
role_ids: updatedRoleIds.size > 0 ? updatedRoleIds : null,
});
}
}),
);
await this.guildRoleRepository.deleteRole(guildId, role.id);
await this.dispatchGuildRoleDelete({guildId, roleId: role.id});
await this.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.ROLE_DELETE,
targetId: roleId,
auditLogReason: auditLogReason ?? null,
changes: this.guildAuditLogService.computeChanges(previousSnapshot, null),
});
}
async updateRolePositions(
params: {userId: UserID; guildId: GuildID; updates: Array<{roleId: RoleID; position?: number}>},
_auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, updates} = params;
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_ROLES);
const lockKey = `guild:${guildId}:role-positions`;
const lockToken = await this.cacheService.acquireLock(lockKey, 30);
if (!lockToken) {
throw new ResourceLockedError();
}
try {
await this.updateRolePositionsByList({userId, guildId, updates});
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
}
}
async listRoles(params: {userId: UserID; guildId: GuildID}): Promise<Array<GuildRoleResponse>> {
const {userId, guildId} = params;
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_ROLES);
const roles = await this.guildRoleRepository.listRoles(guildId);
const sortedRoles = [...roles].sort((a, b) => {
if (b.position !== a.position) return b.position - a.position;
return String(a.id).localeCompare(String(b.id));
});
return sortedRoles.map(mapGuildRoleToResponse);
}
async updateHoistPositions(
params: {userId: UserID; guildId: GuildID; updates: Array<{roleId: RoleID; hoistPosition: number}>},
_auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, updates} = params;
const {checkPermission, guildData} = await this.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_ROLES);
const lockKey = `guild:${guildId}:role-hoist-positions`;
const lockToken = await this.cacheService.acquireLock(lockKey, 30);
if (!lockToken) {
throw new ResourceLockedError();
}
try {
const allRoles = await this.guildRoleRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
const everyoneRoleId = guildIdToRoleId(guildId);
const isOwner = guildData && guildData.owner_id === userId.toString();
let myHighestRole: GuildRole | null = null;
if (!isOwner) {
const member = await this.guildMemberRepository.getMember(guildId, userId);
if (member) {
myHighestRole = this.getUserHighestRole(member, allRoles);
}
}
const canManageRole = (role: GuildRole): boolean => {
if (isOwner) return true;
if (role.id === everyoneRoleId) return false;
if (!myHighestRole) return false;
return this.isRoleHigherThan(myHighestRole, role);
};
for (const update of updates) {
if (update.roleId === everyoneRoleId) {
throw InputValidationError.create('id', 'Cannot set hoist position for the @everyone role');
}
const role = roleMap.get(update.roleId);
if (!role) {
throw InputValidationError.create('id', `Invalid role ID: ${update.roleId}`);
}
if (!canManageRole(role)) {
throw new MissingPermissionsError();
}
}
const changedRoles: Array<GuildRole> = [];
for (const update of updates) {
const role = roleMap.get(update.roleId)!;
if (role.hoistPosition === update.hoistPosition) continue;
const updatedRole = await this.guildRoleRepository.upsertRole({
...role.toRow(),
hoist_position: update.hoistPosition,
});
changedRoles.push(updatedRole);
}
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
}
}
async resetHoistPositions(
params: {userId: UserID; guildId: GuildID},
_auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId} = params;
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_ROLES);
const lockKey = `guild:${guildId}:role-hoist-positions`;
const lockToken = await this.cacheService.acquireLock(lockKey, 30);
if (!lockToken) {
throw new ResourceLockedError();
}
try {
const allRoles = await this.guildRoleRepository.listRoles(guildId);
const changedRoles: Array<GuildRole> = [];
for (const role of allRoles) {
if (role.hoistPosition === null) continue;
const updatedRole = await this.guildRoleRepository.upsertRole({
...role.toRow(),
hoist_position: null,
});
changedRoles.push(updatedRole);
}
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
}
}
private async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
};
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
return {
guildData,
checkPermission,
getMyPermissions,
};
}
private async checkCanManageRole(params: {
guildId: GuildID;
userId: UserID;
targetRole: GuildRole;
}): Promise<boolean> {
const {guildId, userId, targetRole} = params;
return this.gatewayService.canManageRole({guildId, userId, roleId: targetRole.id});
}
private getUserHighestRole(member: {roleIds: Set<RoleID>}, allRoles: Array<GuildRole>): GuildRole | null {
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
let highestRole: GuildRole | null = null;
for (const roleId of member.roleIds) {
const role = roleMap.get(roleId);
if (!role) continue;
if (!highestRole) {
highestRole = role;
} else {
if (this.isRoleHigherThan(role, highestRole)) {
highestRole = role;
}
}
}
return highestRole;
}
private isRoleHigherThan(roleA: GuildRole, roleB: GuildRole): boolean {
if (roleA.position > roleB.position) {
return true;
}
if (roleA.position === roleB.position) {
return String(roleA.id) < String(roleB.id);
}
return false;
}
private async buildRoleUpdateData(params: {
role: GuildRole;
guildId: GuildID;
guildData: {owner_id: string};
userId: UserID;
data: GuildRoleUpdateRequest;
getMyPermissions: () => Promise<bigint>;
}): Promise<RoleUpdateData> {
const {role, guildId, guildData, userId, data, getMyPermissions} = params;
const updateData: RoleUpdateData = {};
const isEveryoneRole = role.id === guildIdToRoleId(guildId);
if (data.name !== undefined && !isEveryoneRole) {
updateData.name = data.name;
}
if (data.color !== undefined) {
updateData.color = data.color;
}
if (data.hoist !== undefined && !isEveryoneRole) {
updateData.hoist = data.hoist;
}
if (data.hoist_position !== undefined && !isEveryoneRole) {
updateData.hoistPosition = data.hoist_position;
}
if (data.mentionable !== undefined && !isEveryoneRole) {
updateData.mentionable = data.mentionable;
}
if (data.permissions !== undefined) {
updateData.permissions = await this.resolveRequestedPermissions({
requestedPermissions: data.permissions,
guildData,
userId,
getMyPermissions,
});
}
return updateData;
}
private async updateRolePositionsByList(params: {
userId: UserID;
guildId: GuildID;
updates: Array<{roleId: RoleID; position?: number}>;
auditLogReason?: string | null;
}): Promise<void> {
const {userId, guildId, updates} = params;
const {guildData} = await this.getGuildAuthenticated({userId, guildId});
const allRoles = await this.guildRoleRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
for (const update of updates) {
if (update.roleId === guildIdToRoleId(guildId)) {
throw InputValidationError.create('id', 'Cannot reorder the @everyone role');
}
if (!roleMap.has(update.roleId)) {
throw InputValidationError.create('id', `Invalid role ID: ${update.roleId}`);
}
}
const everyoneRoleId = guildIdToRoleId(guildId);
const isOwner = guildData && guildData.owner_id === userId.toString();
let myHighestRole: GuildRole | null = null;
if (!isOwner) {
const member = await this.guildMemberRepository.getMember(guildId, userId);
if (member) {
myHighestRole = this.getUserHighestRole(member, allRoles);
}
}
const canManageRole = (role: GuildRole): boolean => {
if (isOwner) return true;
if (role.id === everyoneRoleId) return false;
if (!myHighestRole) return false;
return this.isRoleHigherThan(myHighestRole, role);
};
for (const update of updates) {
const role = roleMap.get(update.roleId)!;
if (!canManageRole(role)) {
throw new MissingPermissionsError();
}
}
const explicitPositions = new Map<RoleID, number>();
for (const update of updates) {
if (update.position !== undefined) {
explicitPositions.set(update.roleId, update.position);
}
}
const nonEveryoneRoles = allRoles.filter((r) => r.id !== everyoneRoleId);
const currentOrder = [...nonEveryoneRoles].sort(
(a, b) => b.position - a.position || String(a.id).localeCompare(String(b.id)),
);
const targetOrder = [...currentOrder].sort((a, b) => {
const posA = explicitPositions.has(a.id) ? explicitPositions.get(a.id)! : a.position;
const posB = explicitPositions.has(b.id) ? explicitPositions.get(b.id)! : b.position;
if (posA !== posB) return posB - posA;
return 0;
});
for (const role of currentOrder) {
if (!canManageRole(role)) {
const originalIndex = currentOrder.findIndex((r) => r.id === role.id);
const newIndex = targetOrder.findIndex((r) => r.id === role.id);
if (originalIndex !== newIndex) {
throw new MissingPermissionsError();
}
}
}
const reorderedIds = targetOrder.map((r) => r.id);
await this.updateRolePositionsLocked({
userId,
guildId,
operation: {roleId: reorderedIds[0]!, precedingRoleId: null},
customOrder: reorderedIds,
});
}
private async updateRolePositionsLocked(params: {
userId: UserID;
guildId: GuildID;
operation: RoleReorderOperation;
customOrder?: Array<RoleID>;
}): Promise<void> {
const {userId, guildId, operation, customOrder} = params;
const {guildData} = await this.getGuildAuthenticated({userId, guildId});
const allRoles = await this.guildRoleRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
const targetRole = roleMap.get(operation.roleId);
if (!targetRole) {
throw InputValidationError.create('role_id', `Invalid role ID: ${operation.roleId}`);
}
const everyoneRoleId = guildIdToRoleId(guildId);
if (targetRole.id === everyoneRoleId) {
throw InputValidationError.create('role_id', 'Cannot reorder the @everyone role');
}
let precedingRole: GuildRole | null = null;
if (!customOrder) {
if (operation.precedingRoleId) {
if (operation.precedingRoleId === targetRole.id) {
throw InputValidationError.create('preceding_role_id', 'Cannot use the same role as a preceding role');
}
precedingRole = roleMap.get(operation.precedingRoleId) ?? null;
if (!precedingRole) {
throw InputValidationError.create('preceding_role_id', `Invalid role ID: ${operation.precedingRoleId}`);
}
}
}
const sortedRoles = [...allRoles].sort((a, b) => {
if (b.position !== a.position) {
return b.position - a.position;
}
return String(a.id).localeCompare(String(b.id));
});
const originalIndex = sortedRoles.findIndex((role) => role.id === targetRole.id);
if (originalIndex === -1) {
throw new Error('Role ordering inconsistency detected');
}
const baseList = sortedRoles.filter((role) => role.id !== targetRole.id);
let insertIndex = 0;
if (!customOrder) {
if (precedingRole) {
const precedingIndex = baseList.findIndex((role) => role.id === precedingRole!.id);
if (precedingIndex === -1) {
throw InputValidationError.create('preceding_role_id', 'Preceding role is not present in the guild');
}
insertIndex = precedingIndex + 1;
}
}
const isOwner = guildData && guildData.owner_id === userId.toString();
let myHighestRole: GuildRole | null = null;
if (!isOwner) {
const member = await this.guildMemberRepository.getMember(guildId, userId);
if (member) {
myHighestRole = this.getUserHighestRole(member, allRoles);
}
}
const canManageRole = (role: GuildRole): boolean => {
if (isOwner) return true;
if (role.id === everyoneRoleId) return false;
if (!myHighestRole) return false;
return this.isRoleHigherThan(myHighestRole, role);
};
if (!canManageRole(targetRole)) {
throw new MissingPermissionsError();
}
if (insertIndex < originalIndex) {
const rolesToCross = sortedRoles.slice(insertIndex, originalIndex);
for (const role of rolesToCross) {
if (!canManageRole(role)) {
throw new MissingPermissionsError();
}
}
}
if (customOrder) {
baseList.splice(0, baseList.length, ...sortedRoles.filter((role) => role.id !== everyoneRoleId));
} else {
baseList.splice(insertIndex, 0, targetRole);
}
const finalOrder = customOrder
? customOrder.filter((roleId) => roleId !== everyoneRoleId)
: baseList.filter((role) => role.id !== everyoneRoleId).map((role) => role.id);
const reorderedRoles = this.reorderRolePositions({
allRoles,
reorderedIds: finalOrder,
guildId,
});
const updatePromises = reorderedRoles.map((role) => this.guildRoleRepository.upsertRole(role.toRow()));
await Promise.all(updatePromises);
const updatedRoles = await this.guildRoleRepository.listRoles(guildId);
const changedRoles = updatedRoles.filter((role) => {
const oldRole = roleMap.get(role.id);
return oldRole && oldRole.position !== role.position;
});
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
}
}
private reorderRolePositions({
allRoles,
reorderedIds,
guildId,
}: {
allRoles: Array<GuildRole>;
reorderedIds: Array<RoleID>;
guildId: GuildID;
}): Array<GuildRole> {
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
const everyoneRole = roleMap.get(guildIdToRoleId(guildId));
const reorderedRoleSet = new Set(reorderedIds);
const nonReorderedRoles = allRoles
.filter((role) => role.id !== guildIdToRoleId(guildId) && !reorderedRoleSet.has(role.id))
.sort((a, b) => a.position - b.position);
const newRoles: Array<GuildRole> = [];
if (everyoneRole) {
newRoles.push(new GuildRole({...everyoneRole.toRow(), position: 0}));
}
let currentPosition = reorderedIds.length + nonReorderedRoles.length;
for (const roleId of reorderedIds) {
const role = roleMap.get(roleId);
if (role && roleId !== guildIdToRoleId(guildId)) {
newRoles.push(new GuildRole({...role.toRow(), position: currentPosition}));
currentPosition--;
}
}
for (const role of nonReorderedRoles) {
newRoles.push(new GuildRole({...role.toRow(), position: currentPosition}));
currentPosition--;
}
return newRoles;
}
private serializeRoleForAudit(role: GuildRole): Record<string, unknown> {
return {
role_id: role.id.toString(),
name: role.name,
permissions: role.permissions.toString(),
position: role.position,
hoist_position: role.hoistPosition,
color: role.color,
icon_hash: role.iconHash ?? null,
unicode_emoji: role.unicodeEmoji ?? null,
hoist: role.isHoisted,
mentionable: role.isMentionable,
};
}
private async dispatchGuildRoleCreate({guildId, role}: {guildId: GuildID; role: GuildRole}): Promise<void> {
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_ROLE_CREATE',
data: {role: mapGuildRoleToResponse(role)},
});
}
private async dispatchGuildRoleUpdate({guildId, role}: {guildId: GuildID; role: GuildRole}): Promise<void> {
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_ROLE_UPDATE',
data: {role: mapGuildRoleToResponse(role)},
});
}
private async dispatchGuildRoleDelete({guildId, roleId}: {guildId: GuildID; roleId: RoleID}): Promise<void> {
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_ROLE_DELETE',
data: {role_id: roleId.toString()},
});
}
private async dispatchGuildRoleUpdateBulk({
guildId,
roles,
}: {
guildId: GuildID;
roles: Array<GuildRole>;
}): Promise<void> {
const roleResponses = roles.map((role) => mapGuildRoleToResponse(role));
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_ROLE_UPDATE_BULK',
data: {roles: roleResponses},
});
}
private async recordAuditLog(params: {
guildId: GuildID;
userId: UserID;
action: AuditLogActionType;
targetId?: RoleID | string | null;
auditLogReason?: string | null;
metadata?: Map<string, string> | Record<string, string>;
changes?: GuildAuditLogChange | null;
}): Promise<void> {
const targetId =
params.targetId === undefined || params.targetId === null
? null
: typeof params.targetId === 'string'
? params.targetId
: params.targetId.toString();
try {
const builder = this.guildAuditLogService
.createBuilder(params.guildId, params.userId)
.withAction(params.action, targetId)
.withReason(params.auditLogReason ?? null);
if (params.metadata) {
builder.withMetadata(params.metadata);
}
if (params.changes) {
builder.withChanges(params.changes);
}
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: params.guildId.toString(),
userId: params.userId.toString(),
action: params.action,
targetId,
},
'Failed to record guild audit log',
);
}
}
}

View File

@@ -0,0 +1,273 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, UserID} from '~/BrandedTypes';
import {Permissions} from '~/Constants';
import type {MessageSearchRequest, MessageSearchResponse} from '~/channel/ChannelModel';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import type {ChannelService} from '~/channel/services/ChannelService';
import {FeatureTemporarilyDisabledError, InputValidationError, MissingPermissionsError} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {getMessageSearchService} from '~/Meilisearch';
import type {Channel} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {buildMessageSearchFilters} from '~/search/buildMessageSearchFilters';
import {MessageSearchResponseMapper} from '~/search/MessageSearchResponseMapper';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IWorkerService} from '~/worker/IWorkerService';
export class GuildSearchService {
private readonly responseMapper: MessageSearchResponseMapper;
constructor(
private readonly channelRepository: IChannelRepository,
private readonly channelService: ChannelService,
private readonly userCacheService: UserCacheService,
private readonly gatewayService: IGatewayService,
private readonly userRepository: IUserRepository,
private readonly mediaService: IMediaService,
private readonly workerService: IWorkerService,
) {
this.responseMapper = new MessageSearchResponseMapper(
this.channelRepository,
this.channelService,
this.userCacheService,
this.mediaService,
);
}
async searchMessages(params: {
userId: UserID;
guildId: GuildID;
channelIds: Array<ChannelID>;
searchParams: MessageSearchRequest;
requestCache: RequestCache;
}): Promise<MessageSearchResponse | {indexing: true}> {
const {userId, guildId, searchParams, requestCache} = params;
let {channelIds} = params;
await this.gatewayService.getGuildData({guildId, userId});
const searchService = getMessageSearchService();
if (!searchService) {
throw new FeatureTemporarilyDisabledError();
}
if (channelIds.length === 0) {
const channels = await this.channelRepository.listGuildChannels(guildId);
channelIds = channels.map((c) => c.id);
}
const validChannelIds: Array<ChannelID> = [];
for (const channelId of channelIds) {
const channel = await this.channelRepository.findUnique(channelId);
if (!channel || channel.guildId !== guildId) {
throw InputValidationError.create('channel_ids', 'All channels must belong to this guild');
}
const canSearch = await this.gatewayService.checkPermission({
guildId,
userId,
channelId,
permission: Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY,
});
if (!canSearch) {
throw new MissingPermissionsError();
}
validChannelIds.push(channelId);
}
const channelsNeedingIndex = await Promise.all(
validChannelIds.map(async (channelId) => {
const channel = await this.channelRepository.findUnique(channelId);
return channel && !channel.indexedAt ? channelId : null;
}),
);
const unindexedChannels = channelsNeedingIndex.filter((id): id is ChannelID => id !== null);
if (unindexedChannels.length > 0) {
await Promise.all(
unindexedChannels.map((channelId) =>
this.workerService.addJob(
'indexChannelMessages',
{channelId: channelId.toString()},
{
jobKey: `indexChannelMessages-${channelId}`,
maxAttempts: 3,
},
),
),
);
return {indexing: true};
}
const filters = buildMessageSearchFilters(
searchParams,
validChannelIds.map((id) => id.toString()),
);
const hitsPerPage = searchParams.hits_per_page ?? 25;
const page = searchParams.page ?? 1;
const result = await searchService.searchMessages(searchParams.content ?? '', filters, {
hitsPerPage,
page,
});
const messageResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
return {
messages: messageResponses,
total: result.total,
hits_per_page: hitsPerPage,
page,
};
}
async searchAllGuilds(params: {
userId: UserID;
channelIds: Array<ChannelID>;
searchParams: MessageSearchRequest;
requestCache: RequestCache;
}): Promise<MessageSearchResponse | {indexing: true}> {
const {userId, channelIds, searchParams, requestCache} = params;
const searchService = getMessageSearchService();
if (!searchService) {
throw new FeatureTemporarilyDisabledError();
}
const {accessibleChannels, unindexedChannelIds} = await this.collectAccessibleGuildChannels(userId);
if (unindexedChannelIds.size > 0) {
await this.queueIndexingChannels(unindexedChannelIds);
return {indexing: true};
}
let searchChannelIds = Array.from(accessibleChannels.keys());
if (channelIds.length > 0) {
const requestedChannelStrings = channelIds.map((id) => id.toString());
for (const requested of requestedChannelStrings) {
if (!accessibleChannels.has(requested)) {
throw new MissingPermissionsError();
}
}
searchChannelIds = requestedChannelStrings;
}
if (searchChannelIds.length === 0) {
const hitsPerPage = searchParams.hits_per_page ?? 25;
const page = searchParams.page ?? 1;
return {
messages: [],
total: 0,
hits_per_page: hitsPerPage,
page,
};
}
const filters = buildMessageSearchFilters(searchParams, searchChannelIds);
const hitsPerPage = searchParams.hits_per_page ?? 25;
const page = searchParams.page ?? 1;
const result = await searchService.searchMessages(searchParams.content ?? '', filters, {
hitsPerPage,
page,
});
const messageResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
return {
messages: messageResponses,
total: result.total,
hits_per_page: hitsPerPage,
page,
};
}
private async queueIndexingChannels(channelIds: Iterable<string>): Promise<void> {
await Promise.all(
Array.from(channelIds).map((channelId) =>
this.workerService.addJob(
'indexChannelMessages',
{channelId},
{
jobKey: `indexChannelMessages-${channelId}`,
maxAttempts: 3,
},
),
),
);
}
async collectAccessibleGuildChannels(userId: UserID): Promise<{
accessibleChannels: Map<string, Channel>;
unindexedChannelIds: Set<string>;
}> {
const guildIds = await this.userRepository.getUserGuildIds(userId);
const accessibleChannels = new Map<string, Channel>();
const unindexedChannelIds = new Set<string>();
for (const guildId of guildIds) {
const guildChannels = await this.channelRepository.listGuildChannels(guildId);
if (guildChannels.length === 0) {
continue;
}
const viewableChannelIds = new Set(
(
await this.gatewayService.getViewableChannels({
guildId,
userId,
})
).map((channelId) => channelId.toString()),
);
for (const channel of guildChannels) {
const channelIdStr = channel.id.toString();
if (!viewableChannelIds.has(channelIdStr)) {
continue;
}
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
channelId: channel.id,
permission: Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY,
});
if (!hasPermission) {
continue;
}
accessibleChannels.set(channelIdStr, channel);
if (!channel.indexedAt) {
unindexedChannelIds.add(channelIdStr);
}
}
}
return {accessibleChannels, unindexedChannelIds};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID} from '~/BrandedTypes';
import {ChannelTypes} from '~/Constants';
import {InputValidationError} from '~/Errors';
import type {Channel} from '~/Models';
import {serializeChannelForAudit as serializeChannelForAuditUtil} from '~/utils/AuditSerializationUtils';
import {toIdString} from '~/utils/IdUtils';
export interface ChannelReorderOperation {
channelId: ChannelID;
parentId: ChannelID | null | undefined;
precedingSiblingId: ChannelID | null;
}
// biome-ignore lint/complexity/noStaticOnlyClass: Using class for namespace organization
export class ChannelHelpers {
static getNextGlobalChannelPosition(
channelType: number,
parentId: ChannelID | null | undefined,
existingChannels: Array<Channel>,
): number {
if (channelType === ChannelTypes.GUILD_CATEGORY) {
return existingChannels.reduce((max, c) => Math.max(max, c.position || 0), 0) + 1;
}
if (parentId == null || parentId === undefined) {
return existingChannels.reduce((max, c) => Math.max(max, c.position || 0), 0) + 1;
}
const parentCategory = existingChannels.find((c) => c.id === parentId);
if (!parentCategory) {
return existingChannels.reduce((max, c) => Math.max(max, c.position || 0), 0) + 1;
}
const channelsInCategory = existingChannels.filter((c) => c.parentId === parentId);
const textChannels = channelsInCategory.filter(
(c) => c.type === ChannelTypes.GUILD_TEXT || c.type === ChannelTypes.GUILD_LINK,
);
const voiceChannels = channelsInCategory.filter((c) => c.type === ChannelTypes.GUILD_VOICE);
if (channelType === ChannelTypes.GUILD_VOICE) {
if (voiceChannels.length > 0) {
return Math.max(...voiceChannels.map((c) => c.position || 0)) + 1;
} else if (textChannels.length > 0) {
return Math.max(...textChannels.map((c) => c.position || 0)) + 1;
} else {
return parentCategory.position + 1;
}
} else {
if (textChannels.length > 0) {
const maxTextPosition = Math.max(...textChannels.map((c) => c.position || 0));
if (voiceChannels.length > 0) {
return maxTextPosition + 1;
} else {
return maxTextPosition + 1;
}
} else {
return parentCategory.position + 1;
}
}
}
static validateChannelVoicePlacement(
finalChannels: Array<Channel>,
parentMap: Map<ChannelID, ChannelID | null>,
): void {
const orderedByParent = new Map<ChannelID | null, Array<Channel>>();
for (const channel of finalChannels) {
const parentId = parentMap.get(channel.id) ?? null;
if (!orderedByParent.has(parentId)) {
orderedByParent.set(parentId, []);
}
orderedByParent.get(parentId)!.push(channel);
}
for (const [parentId, siblings] of orderedByParent.entries()) {
if (parentId === null) continue;
let encounteredVoice = false;
for (const sibling of siblings) {
if (sibling.type === ChannelTypes.GUILD_VOICE) {
encounteredVoice = true;
continue;
}
const isText = sibling.type === ChannelTypes.GUILD_TEXT || sibling.type === ChannelTypes.GUILD_LINK;
if (encounteredVoice && isText) {
throw InputValidationError.create(
'preceding_sibling_id',
'Voice channels cannot be positioned above text channels within the same category',
);
}
}
}
}
static serializeChannelForAudit(channel: Channel): Record<string, unknown> {
return serializeChannelForAuditUtil(channel);
}
static serializeChannelOrdering(
channels: Array<Channel>,
): Array<{channel_id: string; parent_id: string | null; position: number}> {
return [...channels]
.sort((a, b) => {
if (a.position !== b.position) {
return a.position - b.position;
}
return a.id.toString().localeCompare(b.id.toString());
})
.map((channel) => ({
channel_id: channel.id.toString(),
parent_id: toIdString(channel.parentId),
position: channel.position,
}));
}
}

View File

@@ -0,0 +1,789 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '~/BrandedTypes';
import {createChannelID, createRoleID, createUserID} from '~/BrandedTypes';
import {ChannelTypes, GuildFeatures, MAX_CHANNELS_PER_CATEGORY, MAX_GUILD_CHANNELS, Permissions} from '~/Constants';
import type {ChannelCreateRequest, ChannelResponse} from '~/channel/ChannelModel';
import {mapChannelToResponse} from '~/channel/ChannelModel';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import type {PermissionOverwrite} from '~/database/CassandraTypes';
import {
InputValidationError,
MaxCategoryChannelsError,
MaxGuildChannelsError,
MissingPermissionsError,
ResourceLockedError,
} from '~/Errors';
import type {GuildAuditLogChange, GuildAuditLogService} from '~/guild/GuildAuditLogService';
import type {AuditLogChange} from '~/guild/GuildAuditLogTypes';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {Logger} from '~/Logger';
import {type Channel, ChannelPermissionOverwrite} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {ChannelNameType} from '~/Schema';
import {ChannelHelpers, type ChannelReorderOperation} from './ChannelHelpers';
export class ChannelOperationsService {
constructor(
private readonly channelRepository: IChannelRepository,
private readonly userCacheService: UserCacheService,
private readonly gatewayService: IGatewayService,
private readonly cacheService: ICacheService,
private readonly snowflakeService: SnowflakeService,
private readonly guildAuditLogService: GuildAuditLogService,
) {}
async createChannel(
params: {userId: UserID; guildId: GuildID; data: ChannelCreateRequest; requestCache: RequestCache},
auditLogReason?: string | null,
): Promise<ChannelResponse> {
await this.ensureGuildHasCapacity(params.guildId);
const channels = await this.channelRepository.listGuildChannels(params.guildId);
const parentId = params.data.parent_id ? createChannelID(params.data.parent_id) : null;
if (parentId) {
await this.ensureCategoryHasCapacity({guildId: params.guildId, categoryId: parentId});
}
const newPosition = ChannelHelpers.getNextGlobalChannelPosition(params.data.type, parentId, channels);
let permissionOverwrites: Map<RoleID | UserID, PermissionOverwrite> | null = null;
const requestedOverwrites = params.data.permission_overwrites ?? null;
if (requestedOverwrites) {
const basePermissions = await this.gatewayService.getUserPermissions({
guildId: params.guildId,
userId: params.userId,
channelId: parentId ?? undefined,
});
for (const overwrite of requestedOverwrites) {
const allowPerms = overwrite.allow ? BigInt(overwrite.allow) : 0n;
const denyPerms = overwrite.deny ? BigInt(overwrite.deny) : 0n;
const combined = allowPerms | denyPerms;
if ((combined & ~basePermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
permissionOverwrites = new Map(
requestedOverwrites.map((overwrite) => {
const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
return [
targetId,
new ChannelPermissionOverwrite({
type: overwrite.type,
allow_: overwrite.allow ? BigInt(overwrite.allow) : 0n,
deny_: overwrite.deny ? BigInt(overwrite.deny) : 0n,
}).toPermissionOverwrite(),
];
}),
);
} else if (parentId) {
const parentChannel = await this.channelRepository.findUnique(parentId);
if (parentChannel?.permissionOverwrites) {
permissionOverwrites = new Map(
Array.from(parentChannel.permissionOverwrites.entries()).map(([targetId, overwrite]) => [
targetId,
overwrite.toPermissionOverwrite(),
]),
);
}
}
let channelName = params.data.name;
if (params.data.type === ChannelTypes.GUILD_TEXT) {
const guildData = await this.gatewayService.getGuildData({
guildId: params.guildId,
userId: params.userId,
skipMembershipCheck: true,
});
const hasFlexibleNamesEnabled = guildData.features.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
if (!hasFlexibleNamesEnabled) {
channelName = ChannelNameType.parse(channelName);
}
}
const channelId = createChannelID(this.snowflakeService.generate());
const channel = await this.channelRepository.upsert({
channel_id: channelId,
guild_id: params.guildId,
type: params.data.type,
name: channelName,
topic: params.data.topic ?? null,
icon_hash: null,
url: params.data.url ?? null,
parent_id: parentId,
position: newPosition,
owner_id: null,
recipient_ids: null,
nsfw: false,
rate_limit_per_user: 0,
bitrate: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.bitrate ?? 64000) : null,
user_limit: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.user_limit ?? 0) : null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: permissionOverwrites,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
});
await this.dispatchChannelCreate({guildId: params.guildId, channel, requestCache: params.requestCache});
await this.recordAuditLog({
guildId: params.guildId,
userId: params.userId,
action: AuditLogActionType.CHANNEL_CREATE,
targetId: channel.id,
auditLogReason: auditLogReason ?? null,
metadata: {name: channel.name ?? '', type: channel.type.toString()},
changes: this.guildAuditLogService.computeChanges(null, ChannelHelpers.serializeChannelForAudit(channel)),
});
return await mapChannelToResponse({
channel,
currentUserId: null,
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
}
async updateChannelPositionsLocked(params: {
userId: UserID;
guildId: GuildID;
operation: ChannelReorderOperation;
requestCache: RequestCache;
}): Promise<void> {
const lockKey = `guild:${params.guildId}:channel-positions`;
const lockToken = await this.cacheService.acquireLock(lockKey, 30);
if (!lockToken) {
throw new ResourceLockedError();
}
try {
await this.executeChannelReorder(params);
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
}
}
async sanitizeTextChannelNames(params: {guildId: GuildID; requestCache: RequestCache}): Promise<void> {
const {guildId, requestCache} = params;
const channels = await this.channelRepository.listGuildChannels(guildId);
let hasChanges = false;
const updatedChannels: Array<Channel> = [];
for (const channel of channels) {
if (channel.type !== ChannelTypes.GUILD_TEXT || channel.name == null) {
updatedChannels.push(channel);
continue;
}
const normalized = ChannelNameType.parse(channel.name);
if (normalized === channel.name) {
updatedChannels.push(channel);
continue;
}
const updated = await this.channelRepository.upsert({
...channel.toRow(),
name: normalized,
});
updatedChannels.push(updated);
hasChanges = true;
}
if (hasChanges) {
await this.dispatchChannelUpdateBulk({guildId, channels: updatedChannels, requestCache});
}
}
async setChannelPermissionOverwrite(params: {
userId: UserID;
channelId: ChannelID;
overwriteId: bigint;
overwrite: {type: number; allow_: bigint; deny_: bigint};
requestCache: RequestCache;
}): Promise<void> {
const channel = await this.channelRepository.findUnique(params.channelId);
if (!channel || !channel.guildId) {
throw InputValidationError.create('channel_id', 'Invalid channel');
}
const guildId = channel.guildId;
const hasManageRoles = await this.gatewayService.checkPermission({
guildId,
userId: params.userId,
permission: Permissions.MANAGE_ROLES,
});
if (!hasManageRoles) {
throw new MissingPermissionsError();
}
const userPermissions = await this.gatewayService.getUserPermissions({
guildId,
userId: params.userId,
channelId: channel.id,
});
const combined = params.overwrite.allow_ | params.overwrite.deny_;
if ((combined & ~userPermissions) !== 0n) {
throw new MissingPermissionsError();
}
const overwrites = new Map(channel.permissionOverwrites ?? []);
const targetId = params.overwrite.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
const previousOverwrite = overwrites.get(targetId);
overwrites.set(targetId, new ChannelPermissionOverwrite(params.overwrite));
const updated = await this.channelRepository.upsert({
...channel.toRow(),
permission_overwrites: new Map(
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
),
});
await this.dispatchChannelUpdateBulk({guildId, channels: [updated], requestCache: params.requestCache});
const changeSet = this.buildOverwriteChanges(previousOverwrite, new ChannelPermissionOverwrite(params.overwrite));
if (changeSet.length > 0) {
await this.recordAuditLog({
guildId,
userId: params.userId,
action: previousOverwrite
? AuditLogActionType.CHANNEL_OVERWRITE_UPDATE
: AuditLogActionType.CHANNEL_OVERWRITE_CREATE,
targetId,
auditLogReason: null,
metadata: {
type: params.overwrite.type.toString(),
channel_id: channel.id.toString(),
},
changes: changeSet,
});
}
}
async deleteChannelPermissionOverwrite(params: {
userId: UserID;
channelId: ChannelID;
overwriteId: bigint;
requestCache: RequestCache;
}): Promise<void> {
const channel = await this.channelRepository.findUnique(params.channelId);
if (!channel || !channel.guildId) {
throw InputValidationError.create('channel_id', 'Invalid channel');
}
const guildId = channel.guildId;
const hasManageRoles = await this.gatewayService.checkPermission({
guildId,
userId: params.userId,
permission: Permissions.MANAGE_ROLES,
});
if (!hasManageRoles) {
throw new MissingPermissionsError();
}
const overwrites = new Map(channel.permissionOverwrites ?? []);
const roleKey = createRoleID(params.overwriteId);
const userKey = createUserID(params.overwriteId);
let targetId: (RoleID | UserID) | null = null;
let targetType: number | null = null;
if (overwrites.has(roleKey)) {
targetId = roleKey;
targetType = 0;
} else if (overwrites.has(userKey)) {
targetId = userKey;
targetType = 1;
}
const previousOverwrite = targetId ? overwrites.get(targetId) : undefined;
overwrites.delete(roleKey);
overwrites.delete(userKey);
const updated = await this.channelRepository.upsert({
...channel.toRow(),
permission_overwrites: new Map(
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
),
});
await this.dispatchChannelUpdateBulk({guildId, channels: [updated], requestCache: params.requestCache});
if (targetId && previousOverwrite) {
const changeSet = this.buildOverwriteChanges(previousOverwrite, undefined);
if (changeSet.length > 0) {
await this.recordAuditLog({
guildId,
userId: params.userId,
action: AuditLogActionType.CHANNEL_OVERWRITE_DELETE,
targetId,
auditLogReason: null,
metadata: {
type: targetType !== null ? targetType.toString() : '0',
channel_id: channel.id.toString(),
},
changes: changeSet,
});
}
}
}
async updateChannelPositionsByList(params: {
userId: UserID;
guildId: GuildID;
updates: Array<{
channelId: ChannelID;
position?: number;
parentId: ChannelID | null | undefined;
lockPermissions: boolean;
}>;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<void> {
const {guildId, userId, updates, requestCache} = params;
const lockKey = `guild:${guildId}:channel-positions`;
const lockToken = await this.cacheService.acquireLock(lockKey, 30);
if (!lockToken) {
throw new ResourceLockedError();
}
try {
const viewable = new Set(await this.gatewayService.getViewableChannels({guildId, userId}));
for (const update of updates) {
if (!viewable.has(update.channelId)) {
throw new MissingPermissionsError();
}
if (update.parentId && !viewable.has(update.parentId)) {
throw new MissingPermissionsError();
}
}
for (const update of updates) {
await this.applySinglePositionUpdate({
guildId,
userId,
update,
requestCache,
});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
}
}
private async applySinglePositionUpdate(params: {
guildId: GuildID;
userId: UserID;
update: {channelId: ChannelID; position?: number; parentId: ChannelID | null | undefined; lockPermissions: boolean};
requestCache: RequestCache;
}): Promise<void> {
const {guildId, update, requestCache} = params;
const allChannels = await this.channelRepository.listGuildChannels(guildId);
const channelMap = new Map(allChannels.map((ch) => [ch.id, ch]));
const target = channelMap.get(update.channelId);
if (!target) {
throw InputValidationError.create('id', 'Channel not found');
}
const desiredParent = update.parentId === undefined ? (target.parentId ?? null) : update.parentId;
if (desiredParent && !channelMap.has(desiredParent)) {
throw InputValidationError.create('parent_id', 'Invalid parent channel');
}
if (desiredParent) {
const parentChannel = channelMap.get(desiredParent)!;
if (parentChannel.type !== ChannelTypes.GUILD_CATEGORY) {
throw InputValidationError.create('parent_id', 'Parent must be a category');
}
}
if (target.type === ChannelTypes.GUILD_CATEGORY && desiredParent) {
throw InputValidationError.create('parent_id', 'Categories cannot have parents');
}
const siblings = allChannels
.filter((ch) => (ch.parentId ?? null) === desiredParent)
.sort((a, b) => (a.position === b.position ? String(a.id).localeCompare(String(b.id)) : a.position - b.position));
const blockIds = new Set<ChannelID>();
blockIds.add(target.id);
if (target.type === ChannelTypes.GUILD_CATEGORY) {
for (const ch of allChannels) {
if (ch.parentId === target.id) blockIds.add(ch.id);
}
}
const siblingsWithoutBlock = siblings.filter((ch) => !blockIds.has(ch.id));
let insertIndex = siblingsWithoutBlock.length;
if (update.position !== undefined) {
const isChangingParent = desiredParent !== (target.parentId ?? null);
const adjustedPosition = isChangingParent ? Math.max(update.position - 1, 0) : Math.max(update.position, 0);
insertIndex = Math.min(adjustedPosition, siblingsWithoutBlock.length);
} else {
const isVoice = target.type === ChannelTypes.GUILD_VOICE;
if (isVoice) {
insertIndex = siblingsWithoutBlock.length;
} else {
const firstVoice = siblingsWithoutBlock.findIndex((ch) => ch.type === ChannelTypes.GUILD_VOICE);
insertIndex = firstVoice === -1 ? siblingsWithoutBlock.length : firstVoice;
}
}
const precedingSibling = insertIndex === 0 ? null : siblingsWithoutBlock[insertIndex - 1].id;
await this.executeChannelReorder({
guildId,
operation: {
channelId: target.id,
parentId: desiredParent === undefined ? (target.parentId ?? null) : desiredParent,
precedingSiblingId: precedingSibling,
},
requestCache,
});
if (update.lockPermissions && desiredParent && desiredParent !== (target.parentId ?? null)) {
await this.syncPermissionsWithParent({guildId, channelId: target.id, parentId: desiredParent});
}
}
private async syncPermissionsWithParent(params: {
guildId: GuildID;
channelId: ChannelID;
parentId: ChannelID;
}): Promise<void> {
const parent = await this.channelRepository.findUnique(params.parentId);
if (!parent || !parent.permissionOverwrites) return;
const child = await this.channelRepository.findUnique(params.channelId);
if (!child) return;
await this.channelRepository.upsert({
...child.toRow(),
permission_overwrites: new Map(
Array.from(parent.permissionOverwrites.entries()).map(([targetId, overwrite]) => [
targetId,
overwrite.toPermissionOverwrite(),
]),
),
});
}
private async executeChannelReorder(params: {
guildId: GuildID;
operation: ChannelReorderOperation;
requestCache: RequestCache;
}): Promise<void> {
const {guildId, operation, requestCache} = params;
const allChannels = await this.channelRepository.listGuildChannels(guildId);
const channelMap = new Map(allChannels.map((c) => [c.id, c]));
const targetChannel = channelMap.get(operation.channelId);
if (!targetChannel) {
throw InputValidationError.create('channel_id', `Invalid channel ID: ${operation.channelId}`);
}
const requestedParentId = operation.parentId;
const desiredParentId =
targetChannel.type === ChannelTypes.GUILD_CATEGORY
? null
: requestedParentId !== undefined
? requestedParentId
: (targetChannel.parentId ?? null);
if (targetChannel.type === ChannelTypes.GUILD_CATEGORY && operation.parentId) {
throw InputValidationError.create('parent_id', 'Categories cannot have a parent channel');
}
if (desiredParentId) {
const parentChannel = channelMap.get(desiredParentId);
if (!parentChannel || parentChannel.type !== ChannelTypes.GUILD_CATEGORY) {
throw InputValidationError.create('parent_id', 'Invalid parent channel');
}
}
const currentParentId = targetChannel.parentId ?? null;
if (desiredParentId && desiredParentId !== currentParentId) {
await this.ensureCategoryHasCapacity({guildId, categoryId: desiredParentId});
}
const precedingId: ChannelID | null = operation.precedingSiblingId ?? null;
if (precedingId && !channelMap.has(precedingId)) {
throw InputValidationError.create('preceding_sibling_id', `Invalid channel ID: ${precedingId}`);
}
const blockIds = new Set<ChannelID>();
if (targetChannel.type === ChannelTypes.GUILD_CATEGORY) {
blockIds.add(targetChannel.id);
for (const channel of allChannels) {
if (channel.parentId === targetChannel.id) {
blockIds.add(channel.id);
}
}
} else {
blockIds.add(targetChannel.id);
}
if (precedingId && blockIds.has(precedingId)) {
throw InputValidationError.create(
'preceding_sibling_id',
'Cannot position a channel relative to itself or its descendants',
);
}
const orderedChannels = [...allChannels].sort((a, b) => {
if (a.position !== b.position) return a.position - b.position;
return String(a.id).localeCompare(String(b.id));
});
const remainingChannels = orderedChannels.filter((ch) => !blockIds.has(ch.id));
const blockChannels = orderedChannels.filter((ch) => blockIds.has(ch.id));
const expectedParent = desiredParentId ?? null;
if (precedingId) {
const precedingChannel = channelMap.get(precedingId)!;
const precedingParent = precedingChannel.parentId ?? null;
if (precedingParent !== expectedParent) {
throw InputValidationError.create(
'preceding_sibling_id',
'Preceding channel must share the same parent as the moved channel',
);
}
}
const findCategorySpan = (list: Array<Channel>, categoryId: ChannelID) => {
const start = list.findIndex((ch) => ch.id === categoryId);
if (start === -1) return {start: -1, end: -1};
let end = start + 1;
while (end < list.length && list[end].parentId === categoryId) {
end++;
}
return {start, end};
};
let insertIndex = 0;
if (precedingId) {
const precedingIndex = remainingChannels.findIndex((ch) => ch.id === precedingId);
if (precedingIndex === -1) {
throw InputValidationError.create('preceding_sibling_id', 'Preceding channel is not present in the guild');
}
const precedingChannel = channelMap.get(precedingId)!;
if (precedingChannel.type === ChannelTypes.GUILD_CATEGORY) {
const span = findCategorySpan(remainingChannels, precedingChannel.id);
insertIndex = span.end;
} else {
insertIndex = precedingIndex + 1;
}
} else if (desiredParentId) {
const parentIndex = remainingChannels.findIndex((ch) => ch.id === desiredParentId);
if (parentIndex === -1) {
throw InputValidationError.create('parent_id', 'Parent channel is not present in the guild');
}
insertIndex = parentIndex + 1;
} else {
insertIndex = 0;
}
const finalChannels = [...remainingChannels];
finalChannels.splice(insertIndex, 0, ...blockChannels);
const desiredParentMap = new Map<ChannelID, ChannelID | null>();
for (const channel of finalChannels) {
if (channel.id === targetChannel.id) {
desiredParentMap.set(channel.id, desiredParentId ?? null);
} else {
desiredParentMap.set(channel.id, channel.parentId ?? null);
}
}
ChannelHelpers.validateChannelVoicePlacement(finalChannels, desiredParentMap);
const orderUnchanged =
finalChannels.length === orderedChannels.length &&
finalChannels.every((channel, index) => channel.id === orderedChannels[index].id) &&
(targetChannel.parentId ?? null) === (desiredParentMap.get(targetChannel.id) ?? null);
if (orderUnchanged) {
return;
}
const updatePromises: Array<Promise<void>> = [];
for (let index = 0; index < finalChannels.length; index++) {
const channel = finalChannels[index];
const desiredPosition = index + 1;
const desiredParent = desiredParentMap.get(channel.id) ?? null;
const currentParent = channel.parentId ?? null;
if (channel.position !== desiredPosition || currentParent !== desiredParent) {
updatePromises.push(
this.channelRepository
.upsert({...channel.toRow(), position: desiredPosition, parent_id: desiredParent})
.then(() => {}),
);
}
}
await Promise.all(updatePromises);
const updatedChannels = await this.channelRepository.listGuildChannels(guildId);
await this.dispatchChannelUpdateBulk({guildId, channels: updatedChannels, requestCache});
}
private async recordAuditLog(params: {
guildId: GuildID;
userId: UserID;
action: AuditLogActionType;
targetId?: GuildID | ChannelID | RoleID | UserID | EmojiID | StickerID | string | null;
auditLogReason?: string | null;
metadata?: Map<string, string> | Record<string, string>;
changes?: GuildAuditLogChange | null;
createdAt?: Date;
}): Promise<void> {
const targetId =
params.targetId === undefined || params.targetId === null
? null
: typeof params.targetId === 'string'
? params.targetId
: params.targetId.toString();
try {
const builder = this.guildAuditLogService
.createBuilder(params.guildId, params.userId)
.withAction(params.action, targetId)
.withReason(params.auditLogReason ?? null);
if (params.metadata) {
builder.withMetadata(params.metadata);
}
if (params.changes) {
builder.withChanges(params.changes);
}
if (params.createdAt) {
builder.withCreatedAt(params.createdAt);
}
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: params.guildId.toString(),
userId: params.userId.toString(),
action: params.action,
targetId,
},
'Failed to record guild audit log',
);
}
}
private buildOverwriteChanges(
previous: ChannelPermissionOverwrite | undefined,
current: ChannelPermissionOverwrite | undefined,
): GuildAuditLogChange {
const changes: GuildAuditLogChange = [];
const pushChange = (key: string, before?: bigint, after?: bigint) => {
if (before === after) {
return;
}
const change: AuditLogChange = {key};
if (before !== undefined) {
change.old_value = before;
}
if (after !== undefined) {
change.new_value = after;
}
changes.push(change);
};
pushChange('allow', previous?.allow, current?.allow);
pushChange('deny', previous?.deny, current?.deny);
return changes;
}
private async dispatchChannelCreate({
guildId,
channel,
requestCache,
}: {
guildId: GuildID;
channel: Channel;
requestCache: RequestCache;
}): Promise<void> {
await this.gatewayService.dispatchGuild({
guildId,
event: 'CHANNEL_CREATE',
data: await mapChannelToResponse({
channel,
currentUserId: null,
userCacheService: this.userCacheService,
requestCache,
}),
});
}
private async dispatchChannelUpdateBulk({
guildId,
channels,
requestCache,
}: {
guildId: GuildID;
channels: Array<Channel>;
requestCache: RequestCache;
}): Promise<void> {
const channelResponses = await Promise.all(
channels.map((channel) =>
mapChannelToResponse({
channel,
currentUserId: null,
userCacheService: this.userCacheService,
requestCache,
}),
),
);
await this.gatewayService.dispatchGuild({
guildId,
event: 'CHANNEL_UPDATE_BULK',
data: {channels: channelResponses},
});
}
private async ensureCategoryHasCapacity(params: {guildId: GuildID; categoryId: ChannelID}): Promise<void> {
const count = await this.gatewayService.getCategoryChannelCount(params);
if (count >= MAX_CHANNELS_PER_CATEGORY) {
throw new MaxCategoryChannelsError();
}
}
private async ensureGuildHasCapacity(guildId: GuildID): Promise<void> {
const count = await this.gatewayService.getChannelCount({guildId});
if (count >= MAX_GUILD_CHANNELS) {
throw new MaxGuildChannelsError();
}
}
}

View File

@@ -0,0 +1,126 @@
/*
* 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 {EmojiID, GuildID, StickerID, UserID} from '~/BrandedTypes';
import {Permissions} from '~/Constants';
import type {AuditLogActionType} from '~/constants/AuditLogActionType';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import {Logger} from '~/Logger';
import type {GuildEmoji, GuildSticker} from '~/Models';
import {
serializeEmojiForAudit as serializeEmojiForAuditUtil,
serializeStickerForAudit as serializeStickerForAuditUtil,
} from '~/utils/AuditSerializationUtils';
import {hasPermission, requirePermission} from '~/utils/PermissionUtils';
import type {GuildAuditLogChange, GuildAuditLogService} from '../../GuildAuditLogService';
export class ContentHelpers {
constructor(
private readonly gatewayService: IGatewayService,
public readonly guildAuditLogService: GuildAuditLogService,
) {}
async getGuildData(params: {userId: UserID; guildId: GuildID}) {
const {userId, guildId} = params;
const guildData = await this.gatewayService.getGuildData({guildId, userId});
return guildData;
}
async checkPermission(params: {userId: UserID; guildId: GuildID; permission: bigint}) {
const {userId, guildId, permission} = params;
await requirePermission(this.gatewayService, {guildId, userId, permission});
}
async checkManageExpressionsPermission(params: {userId: UserID; guildId: GuildID}) {
return this.checkPermission({...params, permission: Permissions.MANAGE_EXPRESSIONS});
}
async checkCreateExpressionsPermission(params: {userId: UserID; guildId: GuildID}) {
return this.checkPermission({...params, permission: Permissions.CREATE_EXPRESSIONS});
}
async checkModifyExpressionPermission(params: {userId: UserID; guildId: GuildID; creatorId: UserID}) {
const {userId, guildId, creatorId} = params;
if (userId === creatorId) {
return this.checkCreateExpressionsPermission({userId, guildId});
}
return this.checkManageExpressionsPermission({userId, guildId});
}
async hasManageExpressionsPermission(params: {userId: UserID; guildId: GuildID}): Promise<boolean> {
const {userId, guildId} = params;
return hasPermission(this.gatewayService, {guildId, userId, permission: Permissions.MANAGE_EXPRESSIONS});
}
serializeEmojiForAudit(emoji: GuildEmoji): Record<string, unknown> {
return serializeEmojiForAuditUtil(emoji);
}
serializeStickerForAudit(sticker: GuildSticker): Record<string, unknown> {
return serializeStickerForAuditUtil(sticker);
}
async recordAuditLog(params: {
guildId: GuildID;
userId: UserID;
action: AuditLogActionType;
targetId?: GuildID | EmojiID | StickerID | string | null;
auditLogReason?: string | null;
metadata?: Map<string, string> | Record<string, string>;
changes?: GuildAuditLogChange | null;
createdAt?: Date;
}): Promise<void> {
const targetId =
params.targetId === undefined || params.targetId === null
? null
: typeof params.targetId === 'string'
? params.targetId
: params.targetId.toString();
try {
const builder = this.guildAuditLogService
.createBuilder(params.guildId, params.userId)
.withAction(params.action, targetId)
.withReason(params.auditLogReason ?? null);
if (params.metadata) {
builder.withMetadata(params.metadata);
}
if (params.changes) {
builder.withChanges(params.changes);
}
if (params.createdAt) {
builder.withCreatedAt(params.createdAt);
}
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: params.guildId.toString(),
userId: params.userId.toString(),
action: params.action,
targetId,
},
'Failed to record guild audit log',
);
}
}
}

View File

@@ -0,0 +1,328 @@
/*
* 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 {createEmojiID, type EmojiID, type GuildID, type UserID} from '~/BrandedTypes';
import {
GuildFeatures,
MAX_GUILD_EMOJIS_ANIMATED,
MAX_GUILD_EMOJIS_ANIMATED_MORE_EMOJI,
MAX_GUILD_EMOJIS_STATIC,
MAX_GUILD_EMOJIS_STATIC_MORE_EMOJI,
} from '~/Constants';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {
MaxGuildEmojisAnimatedError,
MaxGuildEmojisStaticError,
MissingAccessError,
UnknownGuildEmojiError,
} from '~/Errors';
import {
type GuildEmojiResponse,
type GuildEmojiWithUserResponse,
mapGuildEmojisWithUsersToResponse,
mapGuildEmojiToResponse,
} from '~/guild/GuildModel';
import type {AvatarService} from '~/infrastructure/AvatarService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {GuildEmoji, User} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers';
import type {UserPartialResponse} from '~/user/UserModel';
import type {IGuildRepository} from '../../IGuildRepository';
import type {ContentHelpers} from './ContentHelpers';
import type {ExpressionAssetPurger} from './ExpressionAssetPurger';
export class EmojiService {
constructor(
private readonly guildRepository: IGuildRepository,
private readonly userCacheService: UserCacheService,
private readonly gatewayService: IGatewayService,
private readonly avatarService: AvatarService,
private readonly snowflakeService: SnowflakeService,
private readonly contentHelpers: ContentHelpers,
private readonly assetPurger: ExpressionAssetPurger,
) {}
async getEmojis(params: {
userId: UserID;
guildId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildEmojiWithUserResponse>> {
const {userId, guildId, requestCache} = params;
await this.contentHelpers.getGuildData({userId, guildId});
const emojis = await this.guildRepository.listEmojis(guildId);
return await mapGuildEmojisWithUsersToResponse(emojis, this.userCacheService, requestCache);
}
async getEmojiUser(params: {
userId: UserID;
guildId: GuildID;
emojiId: EmojiID;
requestCache: RequestCache;
}): Promise<UserPartialResponse> {
const {userId, guildId, emojiId, requestCache} = params;
await this.contentHelpers.getGuildData({userId, guildId});
const emoji = await this.guildRepository.getEmoji(emojiId, guildId);
if (!emoji) throw new UnknownGuildEmojiError();
const userPartial = await getCachedUserPartialResponse({
userId: emoji.creatorId,
userCacheService: this.userCacheService,
requestCache,
});
return userPartial;
}
async createEmoji(
params: {user: User; guildId: GuildID; name: string; image: string},
auditLogReason?: string | null,
): Promise<GuildEmojiResponse> {
const {user, guildId, name, image} = params;
const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId});
await this.contentHelpers.checkCreateExpressionsPermission({userId: user.id, guildId});
const allEmojis = await this.guildRepository.listEmojis(guildId);
const staticCount = allEmojis.filter((e) => !e.isAnimated).length;
const animatedCount = allEmojis.filter((e) => e.isAnimated).length;
const {animated, imageBuffer} = await this.avatarService.processEmoji({errorPath: 'image', base64Image: image});
const hasUnlimitedEmoji = guildData.features.includes(GuildFeatures.UNLIMITED_EMOJI);
if (!hasUnlimitedEmoji) {
const hasMoreEmoji = guildData.features.includes(GuildFeatures.MORE_EMOJI);
const maxStatic = hasMoreEmoji ? MAX_GUILD_EMOJIS_STATIC_MORE_EMOJI : MAX_GUILD_EMOJIS_STATIC;
const maxAnimated = hasMoreEmoji ? MAX_GUILD_EMOJIS_ANIMATED_MORE_EMOJI : MAX_GUILD_EMOJIS_ANIMATED;
if (!animated && staticCount >= maxStatic) {
throw new MaxGuildEmojisStaticError();
}
if (animated && animatedCount >= maxAnimated) {
throw new MaxGuildEmojisAnimatedError();
}
}
const emojiId = createEmojiID(this.snowflakeService.generate());
await this.avatarService.uploadEmoji({prefix: 'emojis', emojiId, imageBuffer});
const emoji = await this.guildRepository.upsertEmoji({
guild_id: guildId,
emoji_id: emojiId,
name,
creator_id: user.id,
animated,
version: 1,
});
const updatedEmojis = [...allEmojis, emoji];
await this.dispatchGuildEmojisUpdate({guildId, emojis: updatedEmojis});
await this.contentHelpers.recordAuditLog({
guildId,
userId: user.id,
action: AuditLogActionType.EMOJI_CREATE,
targetId: emoji.id,
auditLogReason: auditLogReason ?? null,
changes: this.contentHelpers.guildAuditLogService.computeChanges(
null,
this.contentHelpers.serializeEmojiForAudit(emoji),
),
});
return mapGuildEmojiToResponse(emoji);
}
async bulkCreateEmojis(
params: {user: User; guildId: GuildID; emojis: Array<{name: string; image: string}>},
auditLogReason?: string | null,
): Promise<{
success: Array<GuildEmojiResponse>;
failed: Array<{name: string; error: string}>;
}> {
const {user, guildId, emojis} = params;
const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId});
await this.contentHelpers.checkCreateExpressionsPermission({userId: user.id, guildId});
const allEmojis = await this.guildRepository.listEmojis(guildId);
const hasUnlimitedEmoji = guildData.features.includes(GuildFeatures.UNLIMITED_EMOJI);
const hasMoreEmoji = guildData.features.includes(GuildFeatures.MORE_EMOJI);
const maxStatic = hasUnlimitedEmoji
? Number.POSITIVE_INFINITY
: hasMoreEmoji
? MAX_GUILD_EMOJIS_STATIC_MORE_EMOJI
: MAX_GUILD_EMOJIS_STATIC;
const maxAnimated = hasUnlimitedEmoji
? Number.POSITIVE_INFINITY
: hasMoreEmoji
? MAX_GUILD_EMOJIS_ANIMATED_MORE_EMOJI
: MAX_GUILD_EMOJIS_ANIMATED;
let staticCount = allEmojis.filter((e) => !e.isAnimated).length;
let animatedCount = allEmojis.filter((e) => e.isAnimated).length;
const success: Array<GuildEmojiResponse> = [];
const failed: Array<{name: string; error: string}> = [];
const newEmojis: Array<GuildEmoji> = [];
for (const emojiData of emojis) {
try {
const {animated, imageBuffer} = await this.avatarService.processEmoji({
errorPath: `emojis[${success.length + failed.length}].image`,
base64Image: emojiData.image,
});
if (!animated && staticCount >= maxStatic) {
failed.push({name: emojiData.name, error: 'Maximum static emojis reached'});
continue;
}
if (animated && animatedCount >= maxAnimated) {
failed.push({name: emojiData.name, error: 'Maximum animated emojis reached'});
continue;
}
const emojiId = createEmojiID(this.snowflakeService.generate());
await this.avatarService.uploadEmoji({prefix: 'emojis', emojiId, imageBuffer});
const emoji = await this.guildRepository.upsertEmoji({
guild_id: guildId,
emoji_id: emojiId,
name: emojiData.name,
animated,
creator_id: user.id,
version: 1,
});
if (animated) {
animatedCount++;
} else {
staticCount++;
}
newEmojis.push(emoji);
success.push(mapGuildEmojiToResponse(emoji));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
failed.push({name: emojiData.name, error: errorMessage});
}
}
if (newEmojis.length > 0) {
const updatedEmojis = [...allEmojis, ...newEmojis];
await this.dispatchGuildEmojisUpdate({guildId, emojis: updatedEmojis});
await Promise.all(
newEmojis.map((emoji) =>
this.contentHelpers.recordAuditLog({
guildId,
userId: user.id,
action: AuditLogActionType.EMOJI_CREATE,
targetId: emoji.id,
auditLogReason: auditLogReason ?? null,
changes: this.contentHelpers.guildAuditLogService.computeChanges(
null,
this.contentHelpers.serializeEmojiForAudit(emoji),
),
}),
),
);
}
return {success, failed};
}
async updateEmoji(
params: {userId: UserID; guildId: GuildID; emojiId: EmojiID; name: string},
auditLogReason?: string | null,
): Promise<GuildEmojiResponse> {
const {userId, guildId, emojiId, name} = params;
const allEmojis = await this.guildRepository.listEmojis(guildId);
const emoji = allEmojis.find((e) => e.id === emojiId);
if (!emoji) throw new UnknownGuildEmojiError();
await this.contentHelpers.checkModifyExpressionPermission({userId, guildId, creatorId: emoji.creatorId});
const previousSnapshot = this.contentHelpers.serializeEmojiForAudit(emoji);
const updatedEmoji = await this.guildRepository.upsertEmoji({...emoji.toRow(), name});
const updatedEmojis = allEmojis.map((e) => (e.id === emojiId ? updatedEmoji : e));
await this.dispatchGuildEmojisUpdate({guildId, emojis: updatedEmojis});
await this.contentHelpers.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.EMOJI_UPDATE,
targetId: emojiId,
auditLogReason: auditLogReason ?? null,
changes: this.contentHelpers.guildAuditLogService.computeChanges(
previousSnapshot,
this.contentHelpers.serializeEmojiForAudit(updatedEmoji),
),
});
return mapGuildEmojiToResponse(updatedEmoji);
}
async deleteEmoji(
params: {userId: UserID; guildId: GuildID; emojiId: EmojiID; purge?: boolean},
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, emojiId, purge = false} = params;
const guildData = await this.contentHelpers.getGuildData({userId, guildId});
if (purge && !guildData.features.includes(GuildFeatures.EXPRESSION_PURGE_ALLOWED)) {
throw new MissingAccessError();
}
const allEmojis = await this.guildRepository.listEmojis(guildId);
const emoji = allEmojis.find((e) => e.id === emojiId);
if (!emoji) throw new UnknownGuildEmojiError();
await this.contentHelpers.checkModifyExpressionPermission({userId, guildId, creatorId: emoji.creatorId});
const previousSnapshot = this.contentHelpers.serializeEmojiForAudit(emoji);
await this.guildRepository.deleteEmoji(guildId, emojiId);
const updatedEmojis = allEmojis.filter((e) => e.id !== emojiId);
await this.dispatchGuildEmojisUpdate({guildId, emojis: updatedEmojis});
if (purge) {
await this.assetPurger.purgeEmoji(emoji.id.toString());
}
await this.contentHelpers.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.EMOJI_DELETE,
targetId: emojiId,
auditLogReason: auditLogReason ?? null,
changes: this.contentHelpers.guildAuditLogService.computeChanges(previousSnapshot, null),
});
}
private async dispatchGuildEmojisUpdate(params: {guildId: GuildID; emojis: Array<GuildEmoji>}): Promise<void> {
const {guildId, emojis} = params;
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_EMOJIS_UPDATE',
data: {emojis: emojis.map(mapGuildEmojiToResponse)},
});
}
}

View File

@@ -0,0 +1,58 @@
/*
* 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 {Config} from '~/Config';
import type {IAssetDeletionQueue} from '~/infrastructure/IAssetDeletionQueue';
const STICKER_EXTENSIONS = ['webp', 'gif'];
export class ExpressionAssetPurger {
constructor(private readonly assetDeletionQueue: IAssetDeletionQueue) {}
async purgeEmoji(id: string): Promise<void> {
await this.queueAsset('emojis', id, this.buildEmojiCdnUrls(id));
}
async purgeSticker(id: string): Promise<void> {
await this.queueAsset('stickers', id, this.buildStickerCdnUrls(id));
}
private async queueAsset(prefix: string, id: string, cdnUrls: Array<string>): Promise<void> {
const uniqueUrls = Array.from(new Set(cdnUrls));
const [primaryUrl, ...additionalUrls] = uniqueUrls;
await this.assetDeletionQueue.queueDeletion({
s3Key: `${prefix}/${id}`,
cdnUrl: primaryUrl ?? null,
reason: 'asset_purge',
});
await Promise.all(additionalUrls.map((url) => this.assetDeletionQueue.queueCloudflarePurge(url)));
}
private buildEmojiCdnUrls(id: string): Array<string> {
const base = Config.endpoints.media;
return [`${base}/emojis/${id}.webp`, `${base}/emojis/${id}.gif`];
}
private buildStickerCdnUrls(id: string): Array<string> {
const base = Config.endpoints.media;
return STICKER_EXTENSIONS.map((ext) => `${base}/stickers/${id}.${ext}`);
}
}

View File

@@ -0,0 +1,327 @@
/*
* 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 {createStickerID, type GuildID, type StickerID, type UserID} from '~/BrandedTypes';
import {GuildFeatures, MAX_GUILD_STICKERS, MAX_GUILD_STICKERS_MORE_STICKERS} from '~/Constants';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {MaxGuildStickersStaticError, MissingAccessError, UnknownGuildStickerError} from '~/Errors';
import {
type GuildStickerResponse,
type GuildStickerWithUserResponse,
mapGuildStickersWithUsersToResponse,
mapGuildStickerToResponse,
} from '~/guild/GuildModel';
import type {AvatarService} from '~/infrastructure/AvatarService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {GuildSticker, User} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers';
import type {UserPartialResponse} from '~/user/UserModel';
import type {IGuildRepository} from '../../IGuildRepository';
import type {ContentHelpers} from './ContentHelpers';
import type {ExpressionAssetPurger} from './ExpressionAssetPurger';
export class StickerService {
constructor(
private readonly guildRepository: IGuildRepository,
private readonly userCacheService: UserCacheService,
private readonly gatewayService: IGatewayService,
private readonly avatarService: AvatarService,
private readonly snowflakeService: SnowflakeService,
private readonly contentHelpers: ContentHelpers,
private readonly assetPurger: ExpressionAssetPurger,
) {}
async getStickers(params: {
userId: UserID;
guildId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildStickerWithUserResponse>> {
const {userId, guildId, requestCache} = params;
await this.contentHelpers.getGuildData({userId, guildId});
const stickers = await this.guildRepository.listStickers(guildId);
return await mapGuildStickersWithUsersToResponse(stickers, this.userCacheService, requestCache);
}
async getStickerUser(params: {
userId: UserID;
guildId: GuildID;
stickerId: StickerID;
requestCache: RequestCache;
}): Promise<UserPartialResponse> {
const {userId, guildId, stickerId, requestCache} = params;
await this.contentHelpers.getGuildData({userId, guildId});
const sticker = await this.guildRepository.getSticker(stickerId, guildId);
if (!sticker) throw new UnknownGuildStickerError();
const userPartial = await getCachedUserPartialResponse({
userId: sticker.creatorId,
userCacheService: this.userCacheService,
requestCache,
});
return userPartial;
}
async createSticker(
params: {
user: User;
guildId: GuildID;
name: string;
description?: string | null;
tags: Array<string>;
image: string;
},
auditLogReason?: string | null,
): Promise<GuildStickerResponse> {
const {user, guildId, name, description, tags, image} = params;
const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId});
await this.contentHelpers.checkCreateExpressionsPermission({userId: user.id, guildId});
const allStickers = await this.guildRepository.listStickers(guildId);
const stickerCount = allStickers.length;
const {formatType, imageBuffer} = await this.avatarService.processSticker({errorPath: 'image', base64Image: image});
const hasUnlimitedStickers = guildData.features.includes(GuildFeatures.UNLIMITED_STICKERS);
if (!hasUnlimitedStickers) {
const hasMoreStickers = guildData.features.includes(GuildFeatures.MORE_STICKERS);
const maxStickers = hasMoreStickers ? MAX_GUILD_STICKERS_MORE_STICKERS : MAX_GUILD_STICKERS;
if (stickerCount >= maxStickers) {
throw new MaxGuildStickersStaticError(maxStickers);
}
}
const stickerId = createStickerID(this.snowflakeService.generate());
await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer});
const sticker = await this.guildRepository.upsertSticker({
guild_id: guildId,
sticker_id: stickerId,
name,
description: description ?? null,
format_type: formatType,
tags,
creator_id: user.id,
version: 1,
});
const updatedStickers = [...allStickers, sticker];
await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers});
await this.contentHelpers.recordAuditLog({
guildId,
userId: user.id,
action: AuditLogActionType.STICKER_CREATE,
targetId: sticker.id,
auditLogReason: auditLogReason ?? null,
changes: this.contentHelpers.guildAuditLogService.computeChanges(
null,
this.contentHelpers.serializeStickerForAudit(sticker),
),
});
return mapGuildStickerToResponse(sticker);
}
async bulkCreateStickers(
params: {
user: User;
guildId: GuildID;
stickers: Array<{name: string; description?: string | null; tags: Array<string>; image: string}>;
},
auditLogReason?: string | null,
): Promise<{
success: Array<GuildStickerResponse>;
failed: Array<{name: string; error: string}>;
}> {
const {user, guildId, stickers} = params;
const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId});
await this.contentHelpers.checkCreateExpressionsPermission({userId: user.id, guildId});
const allStickers = await this.guildRepository.listStickers(guildId);
const hasUnlimitedStickers = guildData.features.includes(GuildFeatures.UNLIMITED_STICKERS);
const hasMoreStickers = guildData.features.includes(GuildFeatures.MORE_STICKERS);
const maxStickers = hasUnlimitedStickers
? Infinity
: hasMoreStickers
? MAX_GUILD_STICKERS_MORE_STICKERS
: MAX_GUILD_STICKERS;
let stickerCount = allStickers.length;
const success: Array<GuildStickerResponse> = [];
const failed: Array<{name: string; error: string}> = [];
const newStickers: Array<GuildSticker> = [];
for (const stickerData of stickers) {
try {
const {formatType, imageBuffer} = await this.avatarService.processSticker({
errorPath: `stickers[${success.length + failed.length}].image`,
base64Image: stickerData.image,
});
if (stickerCount >= maxStickers) {
failed.push({name: stickerData.name, error: 'Maximum stickers reached'});
continue;
}
const stickerId = createStickerID(this.snowflakeService.generate());
await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer});
const sticker = await this.guildRepository.upsertSticker({
guild_id: guildId,
sticker_id: stickerId,
name: stickerData.name,
description: stickerData.description ?? null,
tags: stickerData.tags,
format_type: formatType,
creator_id: user.id,
version: 1,
});
stickerCount++;
newStickers.push(sticker);
success.push(mapGuildStickerToResponse(sticker));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
failed.push({name: stickerData.name, error: errorMessage});
}
}
if (newStickers.length > 0) {
const updatedStickers = [...allStickers, ...newStickers];
await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers});
await Promise.all(
newStickers.map((sticker) =>
this.contentHelpers.recordAuditLog({
guildId,
userId: user.id,
action: AuditLogActionType.STICKER_CREATE,
targetId: sticker.id,
auditLogReason: auditLogReason ?? null,
changes: this.contentHelpers.guildAuditLogService.computeChanges(
null,
this.contentHelpers.serializeStickerForAudit(sticker),
),
}),
),
);
}
return {success, failed};
}
async updateSticker(
params: {
userId: UserID;
guildId: GuildID;
stickerId: StickerID;
name: string;
description?: string | null;
tags: Array<string>;
},
auditLogReason?: string | null,
): Promise<GuildStickerResponse> {
const {userId, guildId, stickerId, name, description, tags} = params;
const allStickers = await this.guildRepository.listStickers(guildId);
const sticker = allStickers.find((e) => e.id === stickerId);
if (!sticker) throw new UnknownGuildStickerError();
await this.contentHelpers.checkModifyExpressionPermission({userId, guildId, creatorId: sticker.creatorId});
const previousSnapshot = this.contentHelpers.serializeStickerForAudit(sticker);
const updatedSticker = await this.guildRepository.upsertSticker({
...sticker.toRow(),
name,
description: description ?? null,
tags,
});
const updatedStickers = allStickers.map((e) => (e.id === stickerId ? updatedSticker : e));
await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers});
await this.contentHelpers.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.STICKER_UPDATE,
targetId: stickerId,
auditLogReason: auditLogReason ?? null,
changes: this.contentHelpers.guildAuditLogService.computeChanges(
previousSnapshot,
this.contentHelpers.serializeStickerForAudit(updatedSticker),
),
});
return mapGuildStickerToResponse(updatedSticker);
}
async deleteSticker(
params: {userId: UserID; guildId: GuildID; stickerId: StickerID; purge?: boolean},
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, stickerId, purge = false} = params;
const guildData = await this.contentHelpers.getGuildData({userId, guildId});
if (purge && !guildData.features.includes(GuildFeatures.EXPRESSION_PURGE_ALLOWED)) {
throw new MissingAccessError();
}
const allStickers = await this.guildRepository.listStickers(guildId);
const sticker = allStickers.find((e) => e.id === stickerId);
if (!sticker) throw new UnknownGuildStickerError();
await this.contentHelpers.checkModifyExpressionPermission({userId, guildId, creatorId: sticker.creatorId});
const previousSnapshot = this.contentHelpers.serializeStickerForAudit(sticker);
await this.guildRepository.deleteSticker(guildId, stickerId);
const updatedStickers = allStickers.filter((e) => e.id !== stickerId);
await this.dispatchGuildStickersUpdate({guildId, stickers: updatedStickers});
if (purge) {
await this.assetPurger.purgeSticker(sticker.id.toString());
}
await this.contentHelpers.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.STICKER_DELETE,
targetId: stickerId,
auditLogReason: auditLogReason ?? null,
changes: this.contentHelpers.guildAuditLogService.computeChanges(previousSnapshot, null),
});
}
private async dispatchGuildStickersUpdate(params: {guildId: GuildID; stickers: Array<GuildSticker>}): Promise<void> {
const {guildId, stickers} = params;
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_STICKERS_UPDATE',
data: {stickers: stickers.map(mapGuildStickerToResponse)},
});
}
}

View File

@@ -0,0 +1,129 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '~/BrandedTypes';
import type {AuditLogActionType} from '~/constants/AuditLogActionType';
import {UnknownGuildError} from '~/Errors';
import type {GuildAuditLogChange, GuildAuditLogService} from '~/guild/GuildAuditLogService';
import type {GuildResponse} from '~/guild/GuildModel';
import {mapGuildToGuildResponse} from '~/guild/GuildModel';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import {Logger} from '~/Logger';
import type {Guild} from '~/Models';
import {serializeGuildForAudit as serializeGuildForAuditUtil} from '~/utils/AuditSerializationUtils';
import {requirePermission} from '~/utils/PermissionUtils';
interface GuildAuth {
guildData: GuildResponse;
checkPermission: (permission: bigint) => Promise<void>;
}
export class GuildDataHelpers {
constructor(
private readonly gatewayService: IGatewayService,
private readonly guildAuditLogService: GuildAuditLogService,
) {}
async getGuildAuthenticated(params: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const {userId, guildId} = params;
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new UnknownGuildError();
const checkPermission = async (permission: bigint) => {
await requirePermission(this.gatewayService, {guildId, userId, permission});
};
return {
guildData,
checkPermission,
};
}
serializeGuildForAudit(guild: Guild): Record<string, unknown> {
return serializeGuildForAuditUtil(guild);
}
computeGuildChanges(
previousSnapshot: Record<string, unknown> | null,
guildOrSnapshot: Guild | Record<string, unknown> | null,
): GuildAuditLogChange {
const currentSnapshot = guildOrSnapshot
? 'id' in guildOrSnapshot
? this.serializeGuildForAudit(guildOrSnapshot as Guild)
: guildOrSnapshot
: null;
return this.guildAuditLogService.computeChanges(previousSnapshot, currentSnapshot);
}
async dispatchGuildUpdate(guild: Guild): Promise<void> {
await this.gatewayService.dispatchGuild({
guildId: guild.id,
event: 'GUILD_UPDATE',
data: mapGuildToGuildResponse(guild),
});
}
async recordAuditLog(params: {
guildId: GuildID;
userId: UserID;
action: AuditLogActionType;
targetId?: GuildID | ChannelID | RoleID | UserID | EmojiID | StickerID | string | null;
auditLogReason?: string | null;
metadata?: Map<string, string> | Record<string, string>;
changes?: GuildAuditLogChange | null;
createdAt?: Date;
}): Promise<void> {
const targetId =
params.targetId === undefined || params.targetId === null
? null
: typeof params.targetId === 'string'
? params.targetId
: params.targetId.toString();
try {
const builder = this.guildAuditLogService
.createBuilder(params.guildId, params.userId)
.withAction(params.action, targetId)
.withReason(params.auditLogReason ?? null);
if (params.metadata) {
builder.withMetadata(params.metadata);
}
if (params.changes) {
builder.withChanges(params.changes);
}
if (params.createdAt) {
builder.withCreatedAt(params.createdAt);
}
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: params.guildId.toString(),
userId: params.userId.toString(),
action: params.action,
targetId,
},
'Failed to record guild audit log',
);
}
}
}

View File

@@ -0,0 +1,743 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
type ChannelID,
createChannelID,
createGuildID,
type GuildID,
guildIdToRoleId,
type UserID,
} from '~/BrandedTypes';
import {
ChannelTypes,
DEFAULT_PERMISSIONS,
GuildFeatures,
GuildSplashCardAlignment,
type GuildSplashCardAlignmentValue,
MAX_GUILDS_NON_PREMIUM,
MAX_GUILDS_PREMIUM,
Permissions,
SystemChannelFlags,
} from '~/Constants';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import type {ChannelService} from '~/channel/services/ChannelService';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {JoinSourceTypes} from '~/constants/Guild';
import {InputValidationError, MaxGuildsError, MissingPermissionsError, UnknownGuildError} from '~/Errors';
import type {GuildCreateRequest, GuildPartialResponse, GuildResponse, GuildUpdateRequest} from '~/guild/GuildModel';
import {mapGuildToGuildResponse, mapGuildToPartialResponse} from '~/guild/GuildModel';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import {getMetricsService} from '~/infrastructure/MetricsService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {InviteRepository} from '~/invite/InviteRepository';
import {Logger} from '~/Logger';
import {getGuildSearchService} from '~/Meilisearch';
import type {Guild, User} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IWebhookRepository} from '~/webhook/IWebhookRepository';
import type {GuildDataHelpers} from './GuildDataHelpers';
interface PreparedGuildAssets {
icon: PreparedAssetUpload | null;
banner: PreparedAssetUpload | null;
splash: PreparedAssetUpload | null;
embed_splash: PreparedAssetUpload | null;
}
const BASE_GUILD_FEATURES: ReadonlyArray<string> = [
GuildFeatures.ANIMATED_ICON,
GuildFeatures.ANIMATED_BANNER,
GuildFeatures.BANNER,
GuildFeatures.INVITE_SPLASH,
];
export class GuildOperationsService {
constructor(
private readonly guildRepository: IGuildRepository,
private readonly channelRepository: IChannelRepository,
private readonly inviteRepository: InviteRepository,
private readonly channelService: ChannelService,
private readonly gatewayService: IGatewayService,
private readonly entityAssetService: EntityAssetService,
private readonly userRepository: IUserRepository,
private readonly snowflakeService: SnowflakeService,
private readonly webhookRepository: IWebhookRepository,
private readonly helpers: GuildDataHelpers,
) {}
async getGuild({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildResponse> {
const guild = await this.gatewayService.getGuildData({guildId, userId});
if (!guild) throw new UnknownGuildError();
return guild;
}
async getUserGuilds(userId: UserID): Promise<Array<GuildResponse>> {
const guilds = await this.guildRepository.listUserGuilds(userId);
const guildsWithPermissions = await Promise.all(
guilds.map(async (guild) => {
const permissions = await this.gatewayService.getUserPermissions({guildId: guild.id, userId});
return mapGuildToGuildResponse(guild, {permissions});
}),
);
guildsWithPermissions.sort((a, b) => a.id.localeCompare(b.id));
return guildsWithPermissions;
}
async getPublicGuildData(guildId: GuildID): Promise<GuildPartialResponse> {
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) throw new UnknownGuildError();
return mapGuildToPartialResponse(guild);
}
async getGuildSystem(guildId: GuildID): Promise<Guild> {
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) throw new UnknownGuildError();
return guild;
}
async createGuild(
params: {user: User; data: GuildCreateRequest},
_auditLogReason?: string | null,
): Promise<GuildResponse> {
try {
const {user, data} = params;
const currentGuildCount = await this.guildRepository.countUserGuilds(user.id);
const maxGuilds = user.isPremium() ? MAX_GUILDS_PREMIUM : MAX_GUILDS_NON_PREMIUM;
if (currentGuildCount >= maxGuilds) throw new MaxGuildsError(maxGuilds);
const guildId = createGuildID(this.snowflakeService.generate());
const textCategoryId = createChannelID(this.snowflakeService.generate());
const voiceCategoryId = createChannelID(this.snowflakeService.generate());
const generalChannelId = createChannelID(this.snowflakeService.generate());
const generalVoiceId = createChannelID(this.snowflakeService.generate());
let preparedIcon: PreparedAssetUpload | null = null;
if (data.icon) {
preparedIcon = await this.entityAssetService.prepareAssetUpload({
assetType: 'icon',
entityType: 'guild',
entityId: guildId,
previousHash: null,
base64Image: data.icon,
errorPath: 'icon',
});
}
const iconKey = preparedIcon?.newHash ?? null;
const shouldUseEmptyFeatures = data.empty_features ?? false;
const featuresSet = shouldUseEmptyFeatures ? new Set<string>() : new Set(BASE_GUILD_FEATURES);
const isUnclaimedOwner = !user.passwordHash;
if (isUnclaimedOwner) {
featuresSet.add(GuildFeatures.INVITES_DISABLED);
}
const guild = await this.guildRepository.upsert({
guild_id: guildId,
owner_id: user.id,
name: data.name,
vanity_url_code: null,
icon_hash: iconKey,
banner_hash: null,
banner_width: null,
banner_height: null,
splash_hash: null,
splash_width: null,
splash_height: null,
splash_card_alignment: GuildSplashCardAlignment.CENTER,
embed_splash_hash: null,
embed_splash_width: null,
embed_splash_height: null,
features: featuresSet,
verification_level: 0,
mfa_level: 0,
nsfw_level: 0,
explicit_content_filter: 0,
default_message_notifications: 0,
system_channel_id: generalChannelId,
system_channel_flags: 0,
rules_channel_id: null,
afk_channel_id: null,
afk_timeout: 0,
disabled_operations: 0,
member_count: 1,
audit_logs_indexed_at: null,
version: 1,
});
await Promise.all([
this.channelRepository.upsert({
channel_id: textCategoryId,
guild_id: guildId,
type: ChannelTypes.GUILD_CATEGORY,
name: 'Text Channels',
topic: null,
icon_hash: null,
url: null,
parent_id: null,
position: 0,
owner_id: null,
recipient_ids: null,
nsfw: false,
rate_limit_per_user: 0,
bitrate: null,
user_limit: null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
}),
this.channelRepository.upsert({
channel_id: voiceCategoryId,
guild_id: guildId,
type: ChannelTypes.GUILD_CATEGORY,
name: 'Voice Channels',
topic: null,
icon_hash: null,
url: null,
parent_id: null,
position: 1,
owner_id: null,
recipient_ids: null,
nsfw: false,
rate_limit_per_user: 0,
bitrate: null,
user_limit: null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
}),
this.channelRepository.upsert({
channel_id: generalChannelId,
guild_id: guildId,
type: ChannelTypes.GUILD_TEXT,
name: 'general',
topic: null,
icon_hash: null,
url: null,
parent_id: textCategoryId,
position: 0,
owner_id: null,
recipient_ids: null,
nsfw: false,
rate_limit_per_user: 0,
bitrate: null,
user_limit: null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
}),
this.channelRepository.upsert({
channel_id: generalVoiceId,
guild_id: guildId,
type: ChannelTypes.GUILD_VOICE,
name: 'General',
topic: null,
icon_hash: null,
url: null,
parent_id: voiceCategoryId,
position: 0,
owner_id: null,
recipient_ids: null,
nsfw: false,
rate_limit_per_user: 0,
bitrate: 64000,
user_limit: 0,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
}),
this.guildRepository.upsertRole({
guild_id: guildId,
role_id: guildIdToRoleId(guildId),
name: '@everyone',
permissions: DEFAULT_PERMISSIONS,
position: 0,
hoist_position: null,
color: 0,
icon_hash: null,
unicode_emoji: null,
hoist: false,
mentionable: false,
version: 1,
}),
this.guildRepository.upsertMember({
guild_id: guildId,
user_id: user.id,
joined_at: new Date(),
nick: null,
avatar_hash: null,
banner_hash: null,
bio: null,
pronouns: null,
accent_color: null,
join_source_type: JoinSourceTypes.INVITE,
source_invite_code: null,
inviter_id: null,
deaf: false,
mute: false,
communication_disabled_until: null,
role_ids: null,
is_premium_sanitized: null,
temporary: false,
profile_flags: null,
version: 1,
}),
]);
await this.gatewayService.startGuild(guildId);
await this.gatewayService.joinGuild({userId: user.id, guildId});
const guildSearchService = getGuildSearchService();
if (guildSearchService) {
await guildSearchService.indexGuild(guild).catch((error) => {
Logger.error({guildId: guild.id, error}, 'Failed to index guild in search');
});
}
getMetricsService().counter({name: 'guild.create'});
return mapGuildToGuildResponse(guild);
} catch (error) {
getMetricsService().counter({name: 'guild.create.error'});
throw error;
}
}
async updateGuild(
params: {userId: UserID; guildId: GuildID; data: GuildUpdateRequest; requestCache: RequestCache},
auditLogReason?: string | null,
): Promise<GuildResponse> {
const {userId, guildId, data} = params;
const {checkPermission, guildData} = await this.helpers.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_GUILD);
const currentGuild = await this.guildRepository.findUnique(guildId);
if (!currentGuild) throw new UnknownGuildError();
const previousSnapshot = this.helpers.serializeGuildForAudit(currentGuild);
if (data.mfa_level !== undefined) {
const isOwner = guildData.owner_id === userId.toString();
if (!isOwner) {
throw new MissingPermissionsError();
}
if (data.mfa_level === 1) {
const owner = await this.userRepository.findUniqueAssert(userId);
if (owner.authenticatorTypes.size === 0) {
throw InputValidationError.create(
'mfa_level',
'You must enable 2FA on your account before requiring it for moderators',
);
}
}
}
const preparedAssets: PreparedGuildAssets = {icon: null, banner: null, splash: null, embed_splash: null};
let iconHash = currentGuild.iconHash;
if (data.icon !== undefined) {
preparedAssets.icon = await this.entityAssetService.prepareAssetUpload({
assetType: 'icon',
entityType: 'guild',
entityId: guildId,
previousHash: currentGuild.iconHash,
base64Image: data.icon,
errorPath: 'icon',
});
iconHash = preparedAssets.icon.newHash;
}
let bannerHash = currentGuild.bannerHash;
let bannerHeight = currentGuild.bannerHeight;
let bannerWidth = currentGuild.bannerWidth;
if (data.banner !== undefined) {
if (data.banner && !currentGuild.features.has(GuildFeatures.BANNER)) {
await this.rollbackPreparedAssets(preparedAssets);
throw InputValidationError.create('banner', 'Guild banner requires BANNER feature');
}
try {
preparedAssets.banner = await this.entityAssetService.prepareAssetUpload({
assetType: 'banner',
entityType: 'guild',
entityId: guildId,
previousHash: currentGuild.bannerHash,
base64Image: data.banner,
errorPath: 'banner',
});
if (preparedAssets.banner.isAnimated && !currentGuild.features.has(GuildFeatures.ANIMATED_BANNER)) {
await this.rollbackPreparedAssets(preparedAssets);
throw InputValidationError.create('banner', 'Animated guild banner requires ANIMATED_BANNER feature');
}
bannerHash = preparedAssets.banner.newHash;
bannerHeight =
preparedAssets.banner.newHash === currentGuild.bannerHash && bannerHeight != null
? bannerHeight
: (preparedAssets.banner.height ?? null);
bannerWidth =
preparedAssets.banner.newHash === currentGuild.bannerHash && bannerWidth != null
? bannerWidth
: (preparedAssets.banner.width ?? null);
} catch (error) {
await this.rollbackPreparedAssets(preparedAssets);
throw error;
}
} else if (data.banner === null) {
bannerHeight = null;
bannerWidth = null;
}
let splashHash = currentGuild.splashHash;
let splashWidth = currentGuild.splashWidth;
let splashHeight = currentGuild.splashHeight;
if (data.splash !== undefined) {
if (data.splash && !currentGuild.features.has(GuildFeatures.INVITE_SPLASH)) {
await this.rollbackPreparedAssets(preparedAssets);
throw InputValidationError.create('splash', 'Invite splash requires INVITE_SPLASH feature');
}
try {
preparedAssets.splash = await this.entityAssetService.prepareAssetUpload({
assetType: 'splash',
entityType: 'guild',
entityId: guildId,
previousHash: currentGuild.splashHash,
base64Image: data.splash,
errorPath: 'splash',
});
splashHash = preparedAssets.splash.newHash;
splashHeight =
preparedAssets.splash.newHash === currentGuild.splashHash && splashHeight != null
? splashHeight
: (preparedAssets.splash.height ?? null);
splashWidth =
preparedAssets.splash.newHash === currentGuild.splashHash && splashWidth != null
? splashWidth
: (preparedAssets.splash.width ?? null);
} catch (error) {
await this.rollbackPreparedAssets(preparedAssets);
throw error;
}
} else if (data.splash === null) {
splashHash = null;
splashWidth = null;
splashHeight = null;
}
let embedSplashHash = currentGuild.embedSplashHash;
let embedSplashWidth = currentGuild.embedSplashWidth;
let embedSplashHeight = currentGuild.embedSplashHeight;
if (data.embed_splash !== undefined) {
if (data.embed_splash && !currentGuild.features.has(GuildFeatures.INVITE_SPLASH)) {
await this.rollbackPreparedAssets(preparedAssets);
throw InputValidationError.create('embed_splash', 'Embed splash requires INVITE_SPLASH feature');
}
try {
preparedAssets.embed_splash = await this.entityAssetService.prepareAssetUpload({
assetType: 'embed_splash',
entityType: 'guild',
entityId: guildId,
previousHash: currentGuild.embedSplashHash,
base64Image: data.embed_splash,
errorPath: 'embed_splash',
});
embedSplashHash = preparedAssets.embed_splash.newHash;
embedSplashHeight =
preparedAssets.embed_splash.newHash === currentGuild.embedSplashHash && embedSplashHeight != null
? embedSplashHeight
: (preparedAssets.embed_splash.height ?? null);
embedSplashWidth =
preparedAssets.embed_splash.newHash === currentGuild.embedSplashHash && embedSplashWidth != null
? embedSplashWidth
: (preparedAssets.embed_splash.width ?? null);
} catch (error) {
await this.rollbackPreparedAssets(preparedAssets);
throw error;
}
} else if (data.embed_splash === null) {
embedSplashHash = null;
embedSplashWidth = null;
embedSplashHeight = null;
}
let afkChannelId: ChannelID | null | undefined;
if (data.afk_channel_id !== undefined) {
if (data.afk_channel_id) {
afkChannelId = createChannelID(data.afk_channel_id);
const afkChannel = await this.channelRepository.findUnique(afkChannelId);
if (!afkChannel || afkChannel.guildId !== guildId) {
throw InputValidationError.create('afk_channel_id', 'AFK channel must be in this guild');
}
if (afkChannel.type !== ChannelTypes.GUILD_VOICE) {
throw InputValidationError.create('afk_channel_id', 'AFK channel must be a voice channel');
}
} else {
afkChannelId = null;
}
}
let systemChannelId: ChannelID | null | undefined;
if (data.system_channel_id !== undefined) {
if (data.system_channel_id) {
systemChannelId = createChannelID(data.system_channel_id);
const systemChannel = await this.channelRepository.findUnique(systemChannelId);
if (!systemChannel || systemChannel.guildId !== guildId) {
throw InputValidationError.create('system_channel_id', 'System channel must be in this guild');
}
if (systemChannel.type !== ChannelTypes.GUILD_TEXT) {
throw InputValidationError.create('system_channel_id', 'System channel must be a text channel');
}
} else {
systemChannelId = null;
}
}
let sanitizedSystemChannelFlags = currentGuild.systemChannelFlags;
if (data.system_channel_flags !== undefined) {
const SUPPORTED_SYSTEM_CHANNEL_FLAGS = SystemChannelFlags.SUPPRESS_JOIN_NOTIFICATIONS;
sanitizedSystemChannelFlags = data.system_channel_flags & SUPPORTED_SYSTEM_CHANNEL_FLAGS;
}
let updatedFeatures = currentGuild.features;
if (data.features !== undefined) {
const newFeatures = new Set(currentGuild.features);
const owner = await this.userRepository.findUnique(currentGuild.ownerId);
const isOwnerUnclaimed = owner && !owner.passwordHash;
const toggleableFeatures = [
GuildFeatures.INVITES_DISABLED,
GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES,
GuildFeatures.DETACHED_BANNER,
GuildFeatures.DISALLOW_UNCLAIMED_ACCOUNTS,
];
for (const feature of toggleableFeatures) {
if (feature === GuildFeatures.INVITES_DISABLED && isOwnerUnclaimed) {
newFeatures.add(feature);
continue;
}
if (data.features.includes(feature)) {
newFeatures.add(feature);
} else {
newFeatures.delete(feature);
}
}
updatedFeatures = newFeatures;
}
const currentGuildRow = currentGuild.toRow();
const splashCardAlignment: GuildSplashCardAlignmentValue =
data.splash_card_alignment ?? currentGuildRow.splash_card_alignment ?? GuildSplashCardAlignment.CENTER;
const upsertData = {
...currentGuildRow,
name: data.name ?? currentGuildRow.name,
icon_hash: iconHash,
banner_hash: bannerHash,
banner_width: bannerWidth,
banner_height: bannerHeight,
splash_hash: splashHash,
splash_width: splashWidth,
splash_height: splashHeight,
splash_card_alignment: splashCardAlignment,
embed_splash_hash: embedSplashHash,
embed_splash_width: embedSplashWidth,
embed_splash_height: embedSplashHeight,
features: updatedFeatures,
system_channel_id: systemChannelId !== undefined ? systemChannelId : currentGuildRow.system_channel_id,
system_channel_flags: sanitizedSystemChannelFlags,
afk_channel_id: afkChannelId !== undefined ? afkChannelId : currentGuildRow.afk_channel_id,
afk_timeout: data.afk_timeout ?? currentGuildRow.afk_timeout,
default_message_notifications:
data.default_message_notifications ?? currentGuildRow.default_message_notifications,
verification_level: data.verification_level ?? currentGuildRow.verification_level,
mfa_level: data.mfa_level ?? currentGuildRow.mfa_level,
explicit_content_filter: data.explicit_content_filter ?? currentGuildRow.explicit_content_filter,
};
let updatedGuild: Guild;
try {
updatedGuild = await this.guildRepository.upsert(upsertData);
} catch (error) {
await this.rollbackPreparedAssets(preparedAssets);
Logger.error({error, guildId}, 'Guild update failed, rolled back asset uploads');
throw error;
}
try {
await this.commitPreparedAssets(preparedAssets);
} catch (error) {
Logger.error({error, guildId}, 'Failed to commit asset changes after successful guild update');
}
await this.helpers.dispatchGuildUpdate(updatedGuild);
const guildSearchService = getGuildSearchService();
if (guildSearchService) {
await guildSearchService.updateGuild(updatedGuild).catch((error) => {
Logger.error({guildId: updatedGuild.id, error}, 'Failed to update guild in search');
});
}
await this.helpers.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.GUILD_UPDATE,
targetId: guildId,
auditLogReason: auditLogReason ?? null,
metadata: {name: updatedGuild.name},
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild),
});
return mapGuildToGuildResponse(updatedGuild);
}
private async rollbackPreparedAssets(assets: PreparedGuildAssets): Promise<void> {
const rollbackPromises: Array<Promise<void>> = [];
if (assets.icon) {
rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.icon));
}
if (assets.banner) {
rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.banner));
}
if (assets.splash) {
rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.splash));
}
if (assets.embed_splash) {
rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.embed_splash));
}
await Promise.all(rollbackPromises);
}
private async commitPreparedAssets(assets: PreparedGuildAssets): Promise<void> {
const commitPromises: Array<Promise<void>> = [];
if (assets.icon) {
commitPromises.push(this.entityAssetService.commitAssetChange({prepared: assets.icon, deferDeletion: true}));
}
if (assets.banner) {
commitPromises.push(this.entityAssetService.commitAssetChange({prepared: assets.banner, deferDeletion: true}));
}
if (assets.splash) {
commitPromises.push(this.entityAssetService.commitAssetChange({prepared: assets.splash, deferDeletion: true}));
}
if (assets.embed_splash) {
commitPromises.push(
this.entityAssetService.commitAssetChange({prepared: assets.embed_splash, deferDeletion: true}),
);
}
await Promise.all(commitPromises);
}
async deleteGuild(params: {user: User; guildId: GuildID}, _auditLogReason?: string | null): Promise<void> {
const {user, guildId} = params;
const {guildData} = await this.helpers.getGuildAuthenticated({userId: user.id, guildId});
if (!guildData || guildData.owner_id !== user.id.toString()) {
throw new MissingPermissionsError();
}
await this.performGuildDeletion(guildId);
}
async deleteGuildById(guildId: GuildID): Promise<void> {
await this.performGuildDeletion(guildId);
}
private async performGuildDeletion(guildId: GuildID): Promise<void> {
try {
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) {
throw new UnknownGuildError();
}
const members = await this.guildRepository.listMembers(guildId);
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_DELETE',
data: {id: guildId.toString()},
});
await Promise.all(
members.map(async (member) => {
await this.gatewayService.leaveGuild({userId: member.userId, guildId});
}),
);
await Promise.all(members.map((member) => this.userRepository.deleteGuildSettings(member.userId, guildId)));
const invites = await this.inviteRepository.listGuildInvites(guildId);
await Promise.all(invites.map((invite) => this.inviteRepository.delete(invite.code)));
const webhooks = await this.webhookRepository.listByGuild(guildId);
await Promise.all(webhooks.map((webhook) => this.webhookRepository.delete(webhook.id)));
const channels = await this.channelRepository.listGuildChannels(guildId);
await Promise.all(channels.map((channel) => this.channelRepository.deleteAllChannelMessages(channel.id)));
await Promise.all(channels.map((channel) => this.channelService.purgeChannelAttachments(channel)));
await this.guildRepository.delete(guildId);
await this.gatewayService.stopGuild(guildId);
const guildSearchService = getGuildSearchService();
if (guildSearchService) {
await guildSearchService.deleteGuild(guildId).catch((error) => {
Logger.error({guildId, error}, 'Failed to delete guild from search');
});
}
getMetricsService().counter({name: 'guild.delete'});
} catch (error) {
getMetricsService().counter({name: 'guild.delete.error'});
throw error;
}
}
}

View File

@@ -0,0 +1,84 @@
/*
* 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 '~/BrandedTypes';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {MissingAccessError, MissingPermissionsError, UnknownGuildError, UnknownGuildMemberError} from '~/Errors';
import type {GuildResponse} from '~/guild/GuildModel';
import {mapGuildToGuildResponse} from '~/guild/GuildModel';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {Guild, GuildMember, User} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
import {checkGuildVerificationWithGuildModel} from '~/utils/GuildVerificationUtils';
import type {GuildDataHelpers} from './GuildDataHelpers';
export class GuildOwnershipService {
constructor(
private readonly guildRepository: IGuildRepository,
private readonly userRepository: IUserRepository,
private readonly helpers: GuildDataHelpers,
) {}
async transferOwnership(
params: {userId: UserID; guildId: GuildID; newOwnerId: UserID},
auditLogReason?: string | null,
): Promise<GuildResponse> {
const {userId, guildId, newOwnerId} = params;
const {guildData} = await this.helpers.getGuildAuthenticated({userId, guildId});
if (guildData.owner_id !== userId.toString()) {
throw new MissingPermissionsError();
}
const user = await this.userRepository.findUnique(userId);
if (!user) throw new MissingAccessError();
const newOwner = await this.guildRepository.getMember(guildId, newOwnerId);
if (!newOwner) {
throw new UnknownGuildMemberError();
}
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) throw new UnknownGuildError();
const previousSnapshot = this.helpers.serializeGuildForAudit(guild);
const updatedGuild = await this.guildRepository.upsert({
...guild.toRow(),
owner_id: newOwnerId,
});
await this.helpers.dispatchGuildUpdate(updatedGuild);
await this.helpers.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.GUILD_UPDATE,
targetId: guildId,
auditLogReason: auditLogReason ?? null,
metadata: {new_owner_id: newOwnerId.toString()},
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild),
});
return mapGuildToGuildResponse(updatedGuild);
}
async checkGuildVerification(params: {user: User; guild: Guild; member: GuildMember}): Promise<void> {
const {user, guild, member} = params;
checkGuildVerificationWithGuildModel({user, guild, member});
}
}

View File

@@ -0,0 +1,147 @@
/*
* 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 {createInviteCode, createVanityURLCode, type GuildID, type UserID, vanityCodeToInviteCode} from '~/BrandedTypes';
import {GuildFeatures, InviteTypes, Permissions} from '~/Constants';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {InputValidationError, UnknownGuildError} from '~/Errors';
import type {GuildVanityURLResponse} from '~/guild/GuildModel';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {InviteRepository} from '~/invite/InviteRepository';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {GuildDataHelpers} from './GuildDataHelpers';
export class GuildVanityService {
constructor(
private readonly guildRepository: IGuildRepository,
private readonly inviteRepository: InviteRepository,
private readonly helpers: GuildDataHelpers,
) {}
async getVanityURL(params: {userId: UserID; guildId: GuildID}): Promise<GuildVanityURLResponse> {
const {userId, guildId} = params;
const {guildData, checkPermission} = await this.helpers.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_GUILD);
if (!guildData) throw new UnknownGuildError();
const vanityCodeString = guildData.vanity_url_code;
if (!vanityCodeString) {
return {code: null, uses: 0};
}
const vanityCode = createVanityURLCode(vanityCodeString);
const invite = await this.inviteRepository.findUnique(vanityCodeToInviteCode(vanityCode));
return {
code: vanityCodeString,
uses: invite?.uses ?? 0,
};
}
async updateVanityURL(
params: {userId: UserID; guildId: GuildID; code: string | null; requestCache: RequestCache},
auditLogReason?: string | null,
): Promise<{code: string}> {
const {userId, guildId, code} = params;
const {checkPermission} = await this.helpers.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_GUILD);
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) throw new UnknownGuildError();
const previousSnapshot = this.helpers.serializeGuildForAudit(guild);
if (code && !guild.features.has(GuildFeatures.VANITY_URL)) {
throw InputValidationError.create('code', 'Vanity URL requires VANITY_URL feature');
}
if (code?.includes('fluxer')) {
throw InputValidationError.create('code', 'Vanity URL code cannot contain "fluxer"');
}
if (code != null && guild.vanityUrlCode === code) {
return {code};
}
if (code == null) {
if (guild.vanityUrlCode != null) {
const oldInvite = await this.inviteRepository.findUnique(vanityCodeToInviteCode(guild.vanityUrlCode));
if (oldInvite) {
await this.inviteRepository.delete(oldInvite.code);
}
const updatedGuild = await this.guildRepository.upsert({...guild.toRow(), vanity_url_code: null});
await this.helpers.dispatchGuildUpdate(updatedGuild);
await this.helpers.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.GUILD_UPDATE,
targetId: guildId,
auditLogReason: auditLogReason ?? null,
metadata: {vanity_url_code: ''},
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild),
});
return {code: ''};
}
return {code: ''};
}
const existingInvite = await this.inviteRepository.findUnique(createInviteCode(code));
if (existingInvite != null) {
throw InputValidationError.create('code', 'Vanity URL code is already taken');
}
if (guild.vanityUrlCode != null) {
const oldInvite = await this.inviteRepository.findUnique(vanityCodeToInviteCode(guild.vanityUrlCode));
if (oldInvite) {
await this.inviteRepository.delete(oldInvite.code);
}
}
await this.inviteRepository.create({
code: createInviteCode(code),
type: InviteTypes.GUILD,
guild_id: guildId,
channel_id: null,
inviter_id: null,
uses: 0,
max_uses: 0,
max_age: 0,
});
const updatedGuild = await this.guildRepository.upsert({
...guild.toRow(),
vanity_url_code: createVanityURLCode(code),
});
await this.helpers.dispatchGuildUpdate(updatedGuild);
await this.helpers.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.GUILD_UPDATE,
targetId: guildId,
auditLogReason: auditLogReason ?? null,
metadata: {vanity_url_code: code},
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild),
});
return {code};
}
}

View File

@@ -0,0 +1,95 @@
/*
* 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 '~/BrandedTypes';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {Logger} from '~/Logger';
import type {GuildMember} from '~/Models';
import type {GuildAuditLogChange, GuildAuditLogService} from '../../GuildAuditLogService';
export class GuildMemberAuditService {
constructor(private readonly guildAuditLogService: GuildAuditLogService) {}
serializeMemberForAudit(member: GuildMember): Record<string, unknown> {
const roleIds = Array.from(member.roleIds)
.map((roleId) => roleId.toString())
.sort();
return {
user_id: member.userId.toString(),
nick: member.nickname,
roles: roleIds,
avatar_hash: member.avatarHash ?? null,
banner_hash: member.bannerHash ?? null,
bio: member.bio ?? null,
pronouns: member.pronouns ?? null,
accent_color: member.accentColor ?? null,
deaf: member.isDeaf,
mute: member.isMute,
communication_disabled_until: member.communicationDisabledUntil
? member.communicationDisabledUntil.toISOString()
: null,
temporary: member.isTemporary,
};
}
async recordAuditLog(params: {
guildId: GuildID;
userId: UserID;
action: AuditLogActionType;
targetId?: UserID | string | null;
auditLogReason?: string | null;
metadata?: Map<string, string> | Record<string, string>;
changes?: GuildAuditLogChange | null;
}): Promise<void> {
const targetId =
params.targetId === undefined || params.targetId === null
? null
: typeof params.targetId === 'string'
? params.targetId
: params.targetId.toString();
const changes = params.action === AuditLogActionType.MEMBER_KICK ? null : (params.changes ?? null);
try {
const builder = this.guildAuditLogService
.createBuilder(params.guildId, params.userId)
.withAction(params.action, targetId)
.withReason(params.auditLogReason ?? null);
if (params.metadata) {
builder.withMetadata(params.metadata);
}
if (changes) {
builder.withChanges(changes);
}
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: params.guildId.toString(),
userId: params.userId.toString(),
action: params.action,
targetId,
},
'Failed to record guild audit log',
);
}
}
}

View File

@@ -0,0 +1,66 @@
/*
* 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, RoleID, UserID} from '~/BrandedTypes';
import {MissingAccessError, MissingPermissionsError} from '~/Errors';
import type {GuildResponse} from '~/guild/GuildModel';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
interface GuildAuth {
guildData: GuildResponse;
checkPermission: (permission: bigint) => Promise<void>;
checkTargetMember: (targetUserId: UserID) => Promise<void>;
getMyPermissions: () => Promise<bigint>;
hasPermission: (permission: bigint) => Promise<boolean>;
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}
export class GuildMemberAuthService {
constructor(private readonly gatewayService: IGatewayService) {}
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new MissingAccessError();
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
};
const checkTargetMember = async (targetUserId: UserID) => {
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId});
if (!canManage) throw new MissingPermissionsError();
};
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
const hasPermission = async (permission: bigint) =>
this.gatewayService.checkPermission({guildId, userId, permission});
const canManageRoles = async (targetUserId: UserID, targetRoleId: RoleID) =>
this.gatewayService.canManageRoles({guildId, userId, targetUserId, roleId: targetRoleId});
return {
guildData,
checkPermission,
checkTargetMember,
getMyPermissions,
hasPermission,
canManageRoles,
};
}
}

View File

@@ -0,0 +1,71 @@
/*
* 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 '~/BrandedTypes';
import {mapGuildMemberToResponse} from '~/guild/GuildModel';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {GuildMember} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
export class GuildMemberEventService {
constructor(
private readonly gatewayService: IGatewayService,
private readonly userCacheService: UserCacheService,
) {}
async dispatchGuildMemberAdd({
member,
requestCache,
}: {
member: GuildMember;
requestCache: RequestCache;
}): Promise<void> {
await this.gatewayService.dispatchGuild({
guildId: member.guildId,
event: 'GUILD_MEMBER_ADD',
data: await mapGuildMemberToResponse(member, this.userCacheService, requestCache),
});
}
async dispatchGuildMemberUpdate({
guildId,
member,
requestCache,
}: {
guildId: GuildID;
member: GuildMember;
requestCache: RequestCache;
}): Promise<void> {
const memberResponse = await mapGuildMemberToResponse(member, this.userCacheService, requestCache);
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_MEMBER_UPDATE',
data: memberResponse,
});
}
async dispatchGuildMemberRemove({guildId, userId}: {guildId: GuildID; userId: UserID}): Promise<void> {
await this.gatewayService.dispatchGuild({
guildId,
event: 'GUILD_MEMBER_REMOVE',
data: {user: {id: userId.toString()}},
});
}
}

View File

@@ -0,0 +1,864 @@
/*
* 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, RoleID, UserID} from '~/BrandedTypes';
import {createChannelID, createRoleID} from '~/BrandedTypes';
import {MAX_GUILDS_NON_PREMIUM, MAX_GUILDS_PREMIUM, Permissions, SystemChannelFlags} from '~/Constants';
import type {ChannelService} from '~/channel/services/ChannelService';
import {AuditLogActionType} from '~/constants/AuditLogActionType';
import {JoinSourceTypes} from '~/constants/Guild';
import {
InputValidationError,
MaxGuildsError,
MissingPermissionsError,
UnknownGuildError,
UnknownGuildMemberError,
UserNotInVoiceError,
} from '~/Errors';
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
import type {GuildAuditLogChange} from '~/guild/GuildAuditLogTypes';
import type {GuildMemberResponse, GuildMemberUpdateRequest} from '~/guild/GuildModel';
import {mapGuildMembersToResponse, mapGuildMemberToResponse} from '~/guild/GuildModel';
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import {getMetricsService} from '~/infrastructure/MetricsService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {Logger} from '~/Logger';
import type {GuildMember, User, UserSettings} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import {mapUserSettingsToResponse} from '~/user/UserMappers';
import type {IGuildRepository} from '../../IGuildRepository';
import type {GuildMemberAuthService} from './GuildMemberAuthService';
import type {GuildMemberEventService} from './GuildMemberEventService';
import type {GuildMemberValidationService} from './GuildMemberValidationService';
const MAX_TIMEOUT_DURATION_MS = 365 * 24 * 60 * 60 * 1000;
interface MemberUpdateData {
nick?: string | null;
role_ids?: Set<RoleID>;
avatar_hash?: string | null;
banner_hash?: string | null;
bio?: string | null;
pronouns?: string | null;
accent_color?: number | null;
profile_flags?: number | null;
mute?: boolean;
deaf?: boolean;
communication_disabled_until?: Date | null;
}
interface PreparedMemberAssets {
avatar: PreparedAssetUpload | null;
banner: PreparedAssetUpload | null;
}
interface VoiceAuditLogMetadataParams {
newChannelId: bigint | null;
previousChannelId: string | null;
}
function buildVoiceAuditLogMetadata(params: VoiceAuditLogMetadataParams): Record<string, string> | null {
const channelId = params.newChannelId !== null ? params.newChannelId.toString() : (params.previousChannelId ?? null);
if (!channelId) {
return null;
}
return {
channel_id: channelId,
count: '1',
};
}
export class GuildMemberOperationsService {
constructor(
private readonly guildRepository: IGuildRepository,
private readonly channelService: ChannelService,
private readonly userCacheService: UserCacheService,
private readonly gatewayService: IGatewayService,
private readonly entityAssetService: EntityAssetService,
private readonly userRepository: IUserRepository,
private readonly rateLimitService: IRateLimitService,
private readonly authService: GuildMemberAuthService,
private readonly validationService: GuildMemberValidationService,
private readonly guildAuditLogService: GuildAuditLogService,
) {}
async getMembers(params: {
userId: UserID;
guildId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildMemberResponse>> {
const {userId, guildId, requestCache} = params;
await this.authService.getGuildAuthenticated({userId, guildId});
const members = await this.guildRepository.listMembers(guildId);
return await mapGuildMembersToResponse(members, this.userCacheService, requestCache);
}
private async recordVoiceAuditLog(params: {
guildId: GuildID;
userId: UserID;
targetId: UserID;
newChannelId: bigint | null;
previousChannelId: string | null;
connectionId: string | null;
auditLogReason?: string | null;
}): Promise<void> {
const action = params.newChannelId === null ? AuditLogActionType.MEMBER_DISCONNECT : AuditLogActionType.MEMBER_MOVE;
const previousSnapshot = params.previousChannelId !== null ? {channel_id: params.previousChannelId} : null;
const nextSnapshot = params.newChannelId !== null ? {channel_id: params.newChannelId.toString()} : null;
const voiceChanges = this.guildAuditLogService.computeChanges(previousSnapshot, nextSnapshot);
const changes = voiceChanges.length > 0 ? voiceChanges : null;
const metadata = buildVoiceAuditLogMetadata({
newChannelId: params.newChannelId,
previousChannelId: params.previousChannelId,
});
await this.recordGuildAuditLog({
guildId: params.guildId,
userId: params.userId,
action,
targetUserId: params.targetId,
auditLogReason: params.auditLogReason,
changes,
metadata: metadata ?? undefined,
});
}
private async recordGuildAuditLog(params: {
guildId: GuildID;
userId: UserID;
action: AuditLogActionType;
targetUserId: UserID;
auditLogReason?: string | null;
metadata?: Record<string, string>;
changes?: GuildAuditLogChange | null;
}): Promise<void> {
const builder = this.guildAuditLogService
.createBuilder(params.guildId, params.userId)
.withAction(params.action, params.targetUserId.toString())
.withReason(params.auditLogReason ?? null);
if (params.metadata) {
builder.withMetadata(params.metadata);
}
if (params.changes) {
builder.withChanges(params.changes);
}
try {
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: params.guildId.toString(),
userId: params.userId.toString(),
action: params.action,
targetId: params.targetUserId.toString(),
},
'Failed to record guild audit log',
);
}
}
private async fetchCurrentChannelId(guildId: GuildID, userId: UserID): Promise<string | null> {
try {
const voiceState = await this.gatewayService.getVoiceState({guildId, userId});
return voiceState?.channel_id ?? null;
} catch (error) {
Logger.warn(
{error, guildId: guildId.toString(), userId: userId.toString()},
'Failed to load voice state for audit log',
);
return null;
}
}
async getMember(params: {
userId: UserID;
targetId: UserID;
guildId: GuildID;
requestCache: RequestCache;
}): Promise<GuildMemberResponse> {
const {userId, targetId, guildId, requestCache} = params;
await this.authService.getGuildAuthenticated({userId, guildId});
const member = await this.guildRepository.getMember(guildId, targetId);
if (!member) throw new UnknownGuildMemberError();
return await mapGuildMemberToResponse(member, this.userCacheService, requestCache);
}
async updateMember(params: {
userId: UserID;
targetId: UserID;
guildId: GuildID;
data: GuildMemberUpdateRequest | Omit<GuildMemberUpdateRequest, 'roles'>;
requestCache: RequestCache;
auditLogReason?: string | null;
}): Promise<GuildMemberResponse> {
const {userId, targetId, guildId, data, requestCache} = params;
const {guildData, canManageRoles, hasPermission, checkTargetMember} = await this.authService.getGuildAuthenticated({
userId,
guildId,
});
const updateData: MemberUpdateData = {};
if (data.nick !== undefined) {
if (userId === targetId) {
const canChangeNick = await hasPermission(Permissions.CHANGE_NICKNAME);
if (!canChangeNick) throw new MissingPermissionsError();
} else {
const hasManageNicknames = await hasPermission(Permissions.MANAGE_NICKNAMES);
if (!hasManageNicknames) throw new MissingPermissionsError();
await checkTargetMember(targetId);
}
}
if (data.communication_disabled_until !== undefined) {
if (userId === targetId) {
throw new MissingPermissionsError();
}
const hasModerateMembers = await hasPermission(Permissions.MODERATE_MEMBERS);
if (!hasModerateMembers) throw new MissingPermissionsError();
await checkTargetMember(targetId);
const targetPermissions = await this.gatewayService.getUserPermissions({guildId, userId: targetId});
if ((targetPermissions & Permissions.MODERATE_MEMBERS) === Permissions.MODERATE_MEMBERS) {
throw new MissingPermissionsError();
}
const parsedTimeout =
data.communication_disabled_until !== null ? new Date(data.communication_disabled_until) : null;
if (parsedTimeout !== null && Number.isNaN(parsedTimeout.getTime())) {
throw InputValidationError.create('communication_disabled_until', 'Invalid timeout value');
}
if (parsedTimeout !== null) {
const diffMs = parsedTimeout.getTime() - Date.now();
if (diffMs > MAX_TIMEOUT_DURATION_MS) {
throw InputValidationError.create(
'communication_disabled_until',
'Timeout cannot be longer than 365 days from now.',
);
}
}
updateData.communication_disabled_until = parsedTimeout ?? null;
}
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
const targetUser = await this.userRepository.findUnique(targetId);
if (!targetUser) {
throw new UnknownGuildMemberError();
}
const preparedAssets: PreparedMemberAssets = {avatar: null, banner: null};
if (data.nick !== undefined) {
updateData.nick = data.nick;
}
if ('roles' in data && data.roles !== undefined) {
const roleIds = await this.validationService.validateAndGetRoleIds({
userId,
guildId,
guildData,
targetId,
targetMember,
newRoles: Array.from(data.roles).map(createRoleID),
hasPermission,
canManageRoles,
});
updateData.role_ids = new Set(roleIds);
}
if (userId === targetId) {
try {
await this.updateSelfProfile({
userId,
targetId,
guildId,
targetUser,
targetMember,
data,
updateData,
preparedAssets,
});
} catch (error) {
await this.rollbackPreparedAssets(preparedAssets);
throw error;
}
}
await this.updateVoiceAndChannel({
userId,
targetId,
guildId,
targetMember,
data,
updateData,
hasPermission,
auditLogReason: params.auditLogReason,
});
const isAssigningRoles = updateData.role_ids !== undefined && updateData.role_ids.size > 0;
const shouldRemoveTemporaryStatus = targetMember.isTemporary && isAssigningRoles;
const updatedMemberData = {
...targetMember.toRow(),
nick: updateData.nick !== undefined ? updateData.nick : targetMember.nickname,
role_ids: updateData.role_ids ?? targetMember.roleIds,
avatar_hash: updateData.avatar_hash !== undefined ? updateData.avatar_hash : targetMember.avatarHash,
banner_hash: updateData.banner_hash !== undefined ? updateData.banner_hash : targetMember.bannerHash,
bio: updateData.bio !== undefined ? updateData.bio : targetMember.bio,
pronouns: updateData.pronouns !== undefined ? updateData.pronouns : targetMember.pronouns,
accent_color: updateData.accent_color !== undefined ? updateData.accent_color : targetMember.accentColor,
mute: updateData.mute !== undefined ? updateData.mute : targetMember.isMute,
deaf: updateData.deaf !== undefined ? updateData.deaf : targetMember.isDeaf,
communication_disabled_until:
updateData.communication_disabled_until !== undefined
? updateData.communication_disabled_until
: targetMember.communicationDisabledUntil,
temporary: shouldRemoveTemporaryStatus ? false : targetMember.isTemporary,
};
let updatedMember: GuildMember;
try {
updatedMember = await this.guildRepository.upsertMember(updatedMemberData);
} catch (error) {
await this.rollbackPreparedAssets(preparedAssets);
throw error;
}
await this.commitPreparedAssets(preparedAssets);
if (shouldRemoveTemporaryStatus) {
await this.gatewayService.removeTemporaryGuild({userId: targetId, guildId});
}
return await mapGuildMemberToResponse(updatedMember, this.userCacheService, requestCache);
}
async removeMember(params: {userId: UserID; targetId: UserID; guildId: GuildID}): Promise<void> {
try {
const {userId, targetId, guildId} = params;
const {guildData, checkTargetMember, checkPermission} = await this.authService.getGuildAuthenticated({
userId,
guildId,
});
await checkPermission(Permissions.KICK_MEMBERS);
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
if (targetMember.userId === userId || guildData.owner_id === targetId.toString()) {
throw new UnknownGuildMemberError();
}
await checkTargetMember(targetId);
await this.guildRepository.deleteMember(guildId, targetId);
const guild = await this.guildRepository.findUnique(guildId);
if (guild) {
const guildRow = guild.toRow();
await this.guildRepository.upsert({
...guildRow,
member_count: Math.max(0, guild.memberCount - 1),
});
}
await this.gatewayService.leaveGuild({userId: targetId, guildId});
getMetricsService().counter({name: 'guild.member.leave'});
} catch (error) {
getMetricsService().counter({name: 'guild.member.leave.error'});
throw error;
}
}
async addUserToGuild(
params: {
userId: UserID;
guildId: GuildID;
sendJoinMessage?: boolean;
skipGuildLimitCheck?: boolean;
skipBanCheck?: boolean;
isTemporary?: boolean;
joinSourceType?: number;
requestCache: RequestCache;
initiatorId?: UserID;
},
eventService: GuildMemberEventService,
): Promise<GuildMember> {
try {
const {
userId,
guildId,
sendJoinMessage = true,
skipGuildLimitCheck = false,
skipBanCheck = false,
isTemporary = false,
joinSourceType = JoinSourceTypes.INVITE,
requestCache,
} = params;
const initiatorId = params.initiatorId ?? userId;
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) throw new UnknownGuildError();
const existingMember = await this.guildRepository.getMember(guildId, userId);
if (existingMember) return existingMember;
const user = await this.userRepository.findUnique(userId);
if (!user) throw new UnknownGuildError();
if (!skipBanCheck) {
await this.validationService.checkUserBanStatus({userId, guildId});
}
const userGuildsCount = await this.guildRepository.countUserGuilds(userId);
if (!skipGuildLimitCheck) {
const maxGuilds = user.isPremium() ? MAX_GUILDS_PREMIUM : MAX_GUILDS_NON_PREMIUM;
if (userGuildsCount >= maxGuilds) throw new MaxGuildsError(maxGuilds);
}
const guildMember = await this.guildRepository.upsertMember({
guild_id: guildId,
user_id: userId,
joined_at: new Date(),
nick: null,
avatar_hash: null,
banner_hash: null,
bio: null,
pronouns: null,
accent_color: null,
join_source_type: joinSourceType,
source_invite_code: null,
inviter_id: null,
deaf: false,
mute: false,
communication_disabled_until: null,
role_ids: null,
is_premium_sanitized: null,
temporary: isTemporary,
profile_flags: null,
version: 1,
});
const guildRow = guild.toRow();
await this.guildRepository.upsert({
...guildRow,
member_count: guild.memberCount + 1,
});
const newMemberCount = guild.memberCount + 1;
getMetricsService().gauge({
name: 'guild.member_count',
dimensions: {
guild_id: guildId.toString(),
guild_name: guild.name ?? 'unknown',
},
value: newMemberCount,
});
getMetricsService().gauge({
name: 'user.guild_membership_count',
dimensions: {
user_id: userId.toString(),
is_bot: user.isBot ? 'true' : 'false',
},
value: userGuildsCount + 1,
});
getMetricsService().counter({name: 'guild.member.join'});
if (user && !user.isBot) {
const userSettings = await this.userRepository.findSettings(userId);
if (userSettings?.defaultGuildsRestricted) {
const updatedRestrictedGuilds = new Set(userSettings.restrictedGuilds);
updatedRestrictedGuilds.add(guildId);
const updatedRowData = {...userSettings.toRow(), restrictedGuilds: updatedRestrictedGuilds};
const updatedSettings = await this.userRepository.upsertSettings(updatedRowData);
await this.dispatchUserSettingsUpdate({userId, settings: updatedSettings});
}
}
await eventService.dispatchGuildMemberAdd({member: guildMember, requestCache});
await this.gatewayService.joinGuild({userId, guildId});
if (sendJoinMessage && !(guild.systemChannelFlags & SystemChannelFlags.SUPPRESS_JOIN_NOTIFICATIONS)) {
await this.channelService.sendJoinSystemMessage({guildId, userId, requestCache});
}
if (user.isBot) {
await this.recordGuildAuditLog({
guildId,
userId: initiatorId,
action: AuditLogActionType.BOT_ADD,
targetUserId: userId,
metadata: {
temporary: isTemporary ? 'true' : 'false',
},
});
}
return guildMember;
} catch (error) {
getMetricsService().counter({name: 'guild.member.join.error'});
throw error;
}
}
async leaveGuild(params: {userId: UserID; guildId: GuildID}): Promise<void> {
try {
const {userId, guildId} = params;
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new UnknownGuildError();
if (guildData.owner_id === userId.toString()) {
throw InputValidationError.create(
'guild_id',
'Cannot leave guild as owner. Transfer ownership or delete the guild instead.',
);
}
await this.guildRepository.deleteMember(guildId, userId);
const guild = await this.guildRepository.findUnique(guildId);
if (guild) {
const guildRow = guild.toRow();
const newMemberCount = Math.max(0, guild.memberCount - 1);
await this.guildRepository.upsert({
...guildRow,
member_count: newMemberCount,
});
getMetricsService().gauge({
name: 'guild.member_count',
dimensions: {
guild_id: guildId.toString(),
guild_name: guild.name ?? 'unknown',
},
value: newMemberCount,
});
}
await this.gatewayService.leaveGuild({userId, guildId});
const membershipCount = await this.guildRepository.countUserGuilds(userId);
const user = await this.userRepository.findUnique(userId);
getMetricsService().gauge({
name: 'user.guild_membership_count',
dimensions: {
user_id: userId.toString(),
is_bot: user?.isBot ? 'true' : 'false',
},
value: membershipCount,
});
getMetricsService().counter({name: 'guild.member.leave'});
} catch (error) {
getMetricsService().counter({name: 'guild.member.leave.error'});
throw error;
}
}
private async updateSelfProfile(params: {
userId: UserID;
targetId: UserID;
guildId: GuildID;
targetUser: User;
targetMember: GuildMember;
data: GuildMemberUpdateRequest | Omit<GuildMemberUpdateRequest, 'roles'>;
updateData: MemberUpdateData;
preparedAssets: PreparedMemberAssets;
}): Promise<void> {
const {targetId, guildId, targetUser, targetMember, data, updateData, preparedAssets} = params;
if (!targetUser.isPremium()) {
if (data.avatar !== undefined) {
data.avatar = undefined;
}
if (data.banner !== undefined) {
data.banner = undefined;
}
if (data.bio !== undefined) {
data.bio = undefined;
}
if (data.accent_color !== undefined) {
data.accent_color = undefined;
}
}
if (data.profile_flags !== undefined) {
updateData.profile_flags = data.profile_flags;
}
if (data.avatar !== undefined) {
const avatarRateLimit = await this.rateLimitService.checkLimit({
identifier: `guild_avatar_change:${guildId}:${targetId}`,
maxAttempts: 25,
windowMs: 30 * 60 * 1000,
});
if (!avatarRateLimit.allowed) {
const minutes = Math.ceil((avatarRateLimit.retryAfter || 0) / 60);
throw InputValidationError.create(
'avatar',
`You've changed your avatar too many times recently. Please try again in ${minutes} minutes.`,
);
}
const prepared = await this.entityAssetService.prepareAssetUpload({
assetType: 'avatar',
entityType: 'guild_member',
entityId: targetId,
guildId,
previousHash: targetMember.avatarHash,
base64Image: data.avatar,
errorPath: 'avatar',
});
preparedAssets.avatar = prepared;
if (prepared.newHash !== targetMember.avatarHash) {
updateData.avatar_hash = prepared.newHash;
}
}
if (data.banner !== undefined) {
const bannerRateLimit = await this.rateLimitService.checkLimit({
identifier: `guild_banner_change:${guildId}:${targetId}`,
maxAttempts: 25,
windowMs: 30 * 60 * 1000,
});
if (!bannerRateLimit.allowed) {
const minutes = Math.ceil((bannerRateLimit.retryAfter || 0) / 60);
throw InputValidationError.create(
'banner',
`You've changed your banner too many times recently. Please try again in ${minutes} minutes.`,
);
}
const prepared = await this.entityAssetService.prepareAssetUpload({
assetType: 'banner',
entityType: 'guild_member',
entityId: targetId,
guildId,
previousHash: targetMember.bannerHash,
base64Image: data.banner,
errorPath: 'banner',
});
preparedAssets.banner = prepared;
if (prepared.newHash !== targetMember.bannerHash) {
updateData.banner_hash = prepared.newHash;
}
}
if (data.bio !== undefined) {
if (data.bio !== targetMember.bio) {
const bioRateLimit = await this.rateLimitService.checkLimit({
identifier: `guild_bio_change:${guildId}:${targetId}`,
maxAttempts: 25,
windowMs: 30 * 60 * 1000,
});
if (!bioRateLimit.allowed) {
const minutes = Math.ceil((bioRateLimit.retryAfter || 0) / 60);
throw InputValidationError.create(
'bio',
`You've changed your bio too many times recently. Please try again in ${minutes} minutes.`,
);
}
updateData.bio = data.bio;
}
}
if (data.accent_color !== undefined) {
if (data.accent_color !== targetMember.accentColor) {
const accentColorRateLimit = await this.rateLimitService.checkLimit({
identifier: `guild_accent_color_change:${guildId}:${targetId}`,
maxAttempts: 25,
windowMs: 30 * 60 * 1000,
});
if (!accentColorRateLimit.allowed) {
const minutes = Math.ceil((accentColorRateLimit.retryAfter || 0) / 60);
throw InputValidationError.create(
'accent_color',
`You've changed your accent color too many times recently. Please try again in ${minutes} minutes.`,
);
}
updateData.accent_color = data.accent_color;
}
}
if (data.pronouns !== undefined) {
if (data.pronouns !== targetMember.pronouns) {
const pronounsRateLimit = await this.rateLimitService.checkLimit({
identifier: `guild_pronouns_change:${guildId}:${targetId}`,
maxAttempts: 25,
windowMs: 30 * 60 * 1000,
});
if (!pronounsRateLimit.allowed) {
const minutes = Math.ceil((pronounsRateLimit.retryAfter || 0) / 60);
throw InputValidationError.create(
'pronouns',
`You've changed your pronouns too many times recently. Please try again in ${minutes} minutes.`,
);
}
updateData.pronouns = data.pronouns;
}
}
}
private async updateVoiceAndChannel(params: {
userId: UserID;
targetId: UserID;
guildId: GuildID;
targetMember: GuildMember;
data: GuildMemberUpdateRequest | Omit<GuildMemberUpdateRequest, 'roles'>;
updateData: MemberUpdateData;
hasPermission: (permission: bigint) => Promise<boolean>;
auditLogReason?: string | null;
}): Promise<void> {
const {userId, targetId, guildId, targetMember, data, updateData, hasPermission, auditLogReason} = params;
if (data.mute !== undefined || data.deaf !== undefined || data.channel_id !== undefined) {
if (data.mute !== undefined || data.deaf !== undefined) {
if (!(await hasPermission(Permissions.MUTE_MEMBERS))) {
throw new MissingPermissionsError();
}
}
if (data.channel_id !== undefined) {
if (!(await hasPermission(Permissions.MOVE_MEMBERS))) {
throw new MissingPermissionsError();
}
const previousChannelId = await this.fetchCurrentChannelId(guildId, targetId);
const result = await this.gatewayService.moveMember({
guildId,
moderatorId: userId,
userId: targetId,
channelId: data.channel_id !== null ? createChannelID(data.channel_id) : null,
connectionId: data.connection_id ?? null,
});
if (result.error) {
switch (result.error) {
case 'user_not_in_voice':
throw new UserNotInVoiceError();
case 'channel_not_found':
throw InputValidationError.create('channel_id', 'Channel does not exist');
case 'channel_not_voice':
throw InputValidationError.create('channel_id', 'Channel must be a voice channel');
case 'moderator_missing_connect':
throw new MissingPermissionsError();
case 'target_missing_connect':
throw new MissingPermissionsError();
default:
throw new UserNotInVoiceError();
}
} else {
await this.recordVoiceAuditLog({
guildId,
userId,
targetId,
newChannelId: data.channel_id,
previousChannelId,
connectionId: data.connection_id ?? null,
auditLogReason,
});
}
}
if (data.mute !== undefined || data.deaf !== undefined) {
try {
await this.gatewayService.updateMemberVoice({
guildId,
userId: targetId,
mute: data.mute ?? targetMember.isMute,
deaf: data.deaf ?? targetMember.isDeaf,
});
if (data.mute !== undefined) {
updateData.mute = data.mute;
}
if (data.deaf !== undefined) {
updateData.deaf = data.deaf;
}
} catch (_error) {
throw new UserNotInVoiceError();
}
}
}
}
private async dispatchUserSettingsUpdate({
userId,
settings,
}: {
userId: UserID;
settings: UserSettings;
}): Promise<void> {
await this.gatewayService.dispatchPresence({
userId,
event: 'USER_SETTINGS_UPDATE',
data: mapUserSettingsToResponse({settings}),
});
}
private async rollbackPreparedAssets(preparedAssets: PreparedMemberAssets): Promise<void> {
const rollbackPromises: Array<Promise<void>> = [];
if (preparedAssets.avatar) {
rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(preparedAssets.avatar));
}
if (preparedAssets.banner) {
rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(preparedAssets.banner));
}
await Promise.allSettled(rollbackPromises);
}
private async commitPreparedAssets(preparedAssets: PreparedMemberAssets): Promise<void> {
const commitPromises: Array<Promise<void>> = [];
if (preparedAssets.avatar) {
commitPromises.push(this.entityAssetService.commitAssetChange({prepared: preparedAssets.avatar}));
}
if (preparedAssets.banner) {
commitPromises.push(this.entityAssetService.commitAssetChange({prepared: preparedAssets.banner}));
}
await Promise.allSettled(commitPromises);
}
}

View File

@@ -0,0 +1,105 @@
/*
* 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, RoleID, UserID} from '~/BrandedTypes';
import {UnknownGuildMemberError} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IGuildRepository} from '../../IGuildRepository';
import type {GuildMemberAuthService} from './GuildMemberAuthService';
import type {GuildMemberValidationService} from './GuildMemberValidationService';
export class GuildMemberRoleService {
constructor(
private readonly guildRepository: IGuildRepository,
private readonly gatewayService: IGatewayService,
private readonly authService: GuildMemberAuthService,
private readonly validationService: GuildMemberValidationService,
) {}
async addMemberRole(params: {
userId: UserID;
targetId: UserID;
guildId: GuildID;
roleId: RoleID;
requestCache: RequestCache;
}): Promise<void> {
const {userId, targetId, guildId, roleId} = params;
const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
await this.validationService.validateRoleAssignment({
guildData,
guildId,
userId,
targetId,
roleId,
canManageRoles,
});
if (targetMember.roleIds.has(roleId)) return;
const updatedRoleIds = new Set(targetMember.roleIds);
updatedRoleIds.add(roleId);
const updatedMemberData = {
...targetMember.toRow(),
role_ids: updatedRoleIds,
temporary: targetMember.isTemporary ? false : targetMember.isTemporary,
};
await this.guildRepository.upsertMember(updatedMemberData);
if (targetMember.isTemporary) {
await this.gatewayService.removeTemporaryGuild({userId: targetId, guildId});
}
}
async removeMemberRole(params: {
userId: UserID;
targetId: UserID;
guildId: GuildID;
roleId: RoleID;
requestCache: RequestCache;
}): Promise<void> {
const {userId, targetId, guildId, roleId} = params;
const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
await this.validationService.validateRoleAssignment({
guildData,
guildId,
userId,
targetId,
roleId,
canManageRoles,
});
if (!targetMember.roleIds.has(roleId)) return;
const updatedRoleIds = new Set(targetMember.roleIds);
updatedRoleIds.delete(roleId);
const updatedMemberData = {...targetMember.toRow(), role_ids: updatedRoleIds};
await this.guildRepository.upsertMember(updatedMemberData);
}
}

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, RoleID, UserID} from '~/BrandedTypes';
import {guildIdToRoleId} from '~/BrandedTypes';
import {Permissions} from '~/Constants';
import {BannedFromGuildError, IpBannedFromGuildError, MissingPermissionsError, UnknownGuildRoleError} from '~/Errors';
import type {GuildResponse} from '~/guild/GuildModel';
import type {GuildMember} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
import type {IGuildRepository} from '../../IGuildRepository';
export class GuildMemberValidationService {
constructor(
private readonly guildRepository: IGuildRepository,
private readonly userRepository: IUserRepository,
) {}
async validateAndGetRoleIds(params: {
userId: UserID;
guildId: GuildID;
guildData: GuildResponse;
targetId: UserID;
targetMember: GuildMember;
newRoles: Array<RoleID>;
hasPermission: (permission: bigint) => Promise<boolean>;
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}): Promise<Array<RoleID>> {
const {userId, guildId, guildData, targetId, targetMember, newRoles, hasPermission, canManageRoles} = params;
if (guildData && guildData.owner_id === userId.toString()) {
const existingRoles = await this.guildRepository.listRolesByIds(newRoles, guildId);
if (existingRoles.length !== newRoles.length) {
throw new UnknownGuildRoleError();
}
return newRoles;
}
if (!(await hasPermission(Permissions.MANAGE_ROLES))) {
throw new MissingPermissionsError();
}
const currentRoles = targetMember.roleIds;
const rolesToRemove = [...currentRoles].filter((roleId) => !newRoles.includes(roleId));
const rolesToAdd = newRoles.filter((roleId) => !currentRoles.has(roleId));
for (const roleId of [...rolesToAdd, ...rolesToRemove]) {
if (roleId === guildIdToRoleId(guildId)) continue;
if (!(await canManageRoles(targetId, roleId))) {
throw new MissingPermissionsError();
}
}
const existingRoles = await this.guildRepository.listRolesByIds(newRoles, guildId);
if (existingRoles.length !== newRoles.length) {
throw new UnknownGuildRoleError();
}
return newRoles;
}
async validateRoleAssignment(params: {
guildData: GuildResponse;
guildId: GuildID;
userId: UserID;
targetId: UserID;
roleId: RoleID;
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}): Promise<void> {
const {guildData, guildId, userId, targetId, roleId, canManageRoles} = params;
if (guildData && guildData.owner_id === userId.toString()) {
const role = await this.guildRepository.getRole(roleId, guildId);
if (!role || role.id === guildIdToRoleId(guildId)) {
throw new UnknownGuildRoleError();
}
} else {
if (!(await canManageRoles(targetId, roleId))) {
throw new MissingPermissionsError();
}
}
}
async checkUserBanStatus({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<void> {
const bans = await this.guildRepository.listBans(guildId);
const user = await this.userRepository.findUnique(userId);
const userIp = user?.lastActiveIp;
for (const ban of bans) {
if (ban.userId === userId) {
throw new BannedFromGuildError();
}
if (userIp && ban.ipAddress && ban.ipAddress === userIp) {
throw new IpBannedFromGuildError();
}
}
}
}