feat(threads): thread and forum API, admin, schemas and docs (#3251)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 15:25:12 +02:00
1 parent 5f4295e399
commit 3f4160b138
495 files changed
+39232 -1215

No files matched your search

@@ -10,14 +10,38 @@ import {
} from '@app/api/channel/services/ChannelFollowers';
import type {ChannelAuthService} from '@app/api/channel/services/channel_data/ChannelAuthService';
import type {ChannelUtilsService} from '@app/api/channel/services/channel_data/ChannelUtilsService';
import {dispatchThreadEvents} from '@app/api/channel/services/thread/ThreadDispatch';
import {
loadConvertibleParentThreads,
PARENT_CONVERSION_LOCK_TTL_SECONDS,
retypedThreadEvents,
retypeParentThreads,
} from '@app/api/channel/services/thread/ThreadParentConversion';
import {
buildThreadParentPatch,
loadThreadParentConfig,
serializeThreadParentForAudit,
type ThreadParentSettingsInput,
} from '@app/api/channel/services/thread/ThreadParentSettings';
import {enqueueDeleteChannelThreads} from '@app/api/channel/threads/ThreadJobs';
import {
everEnabled,
guildActive,
isTainted,
THREAD_FEATURE_CHANNEL_TYPES,
type ThreadViewer,
viewerActive,
} from '@app/api/experiment/ChannelThreadsGate';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {ChannelHelpers} from '@app/api/guild/services/channel/ChannelHelpers';
import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement';
import {hasThreadPermissionBits, resolveProtectedBitActor} from '@app/api/guild/services/ThreadPermissionBits';
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ILiveKitService} from '@app/api/infrastructure/ILiveKitService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
import type {IInviteRepository} from '@app/api/invite/IInviteRepository';
import {Logger} from '@app/api/Logger';
@@ -26,17 +50,17 @@ import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder'
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Channel} from '@app/api/models/Channel';
import {ChannelPermissionOverwrite} from '@app/api/models/ChannelPermissionOverwrite';
import type {ThreadState} from '@app/api/models/ThreadState';
import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {serializeChannelForAudit} from '@app/api/utils/AuditSerializationUtils';
import {applyProtectedOverwriteBits} from '@app/api/utils/featureUtils';
import {applyProtectedOverwriteBits, permissionWriteMask, protectedThreadBits} from '@app/api/utils/featureUtils';
import {overwriteGrantedBits} from '@app/api/utils/PermissionUtils';
import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
import type {VoiceRegionAvailability} from '@app/api/voice/VoiceModel';
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {
ALL_PERMISSIONS,
ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES,
ChannelTypes,
GUILD_TEXT_BASED_CHANNEL_TYPES,
@@ -45,6 +69,8 @@ import {
} from '@fluxer/constants/src/ChannelConstants';
import {ContentWarningLevel, clampVoiceChannelBitrate, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {MAX_CHANNELS_PER_CATEGORY} from '@fluxer/constants/src/LimitConstants';
import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {withImplicitThreadBits} from '@fluxer/constants/src/ThreadPermissionUtils';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {ChannelHasFollowedChannelsError} from '@fluxer/errors/src/domains/channel/ChannelHasFollowedChannelsError';
import {ChannelTypeConversionNotSupportedError} from '@fluxer/errors/src/domains/channel/ChannelTypeConversionNotSupportedError';
@@ -84,6 +110,13 @@ export interface ChannelUpdateData {
nicks?: Record<string, string | null> | null;
}
function assertOverwriteTarget(channel: Channel, viewer: ThreadViewer | undefined): void {
if (!THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) return;
const active = viewer !== undefined && channel.guildId !== null && viewerActive(viewer, channel.guildId);
if (!active) throw new UnknownChannelError();
if (channel.isThread()) throw new InvalidChannelTypeError();
}
export class ChannelOperationsService {
constructor(
private channelRepository: IChannelRepositoryAggregate,
@@ -101,20 +134,24 @@ export class ChannelOperationsService {
private limitConfigService: LimitConfigService,
private rateLimitService: IRateLimitService,
private cacheService: ICacheService,
private snowflakeService: ISnowflakeService,
) {}
async getChannel({
userId,
viewer,
channelId,
skipNsfwValidation,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
skipNsfwValidation?: boolean;
}): Promise<Channel> {
const {channel} = await this.channelAuthService.getChannelAuthenticated({
userId,
channelId,
viewer,
skipNsfwValidation,
});
return channel;
@@ -138,24 +175,29 @@ export class ChannelOperationsService {
async editChannel({
userId,
viewer,
channelId,
data,
clientFeatures,
requestCache,
auditLogReason,
typeConversion,
threadParent,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
data: ChannelUpdateData;
clientFeatures: ReadonlySet<string>;
requestCache: RequestCache;
auditLogReason: string | null;
typeConversion?: ChannelTypeConversion | null;
threadParent?: ThreadParentSettingsInput | null;
}): Promise<Channel> {
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
userId,
channelId,
viewer,
skipNsfwValidation: true,
});
if (channel.type === ChannelTypes.GROUP_DM) {
@@ -165,6 +207,18 @@ export class ChannelOperationsService {
await checkPermission(Permissions.MANAGE_CHANNELS);
const nextType = resolveNextChannelType(channel, typeConversion ?? null);
const guildIdValue = createGuildID(BigInt(guild.id));
const parentConfig = await loadThreadParentConfig(this.channelRepository.threads, channel);
const parentPatch =
threadParent && guildActive(guildIdValue)
? await buildThreadParentPatch({
channelType: channel.type,
guildId: guildIdValue,
input: threadParent,
current: parentConfig,
guildRepository: this.guildRepository,
generateId: () => this.snowflakeService.generate(),
})
: null;
contentModerationService.scanText(data.name ?? null, {
userId,
guildId: guildIdValue,
@@ -180,7 +234,7 @@ export class ChannelOperationsService {
surface: 'profile_field',
});
let channelName = data.name ?? channel.name;
if (data.name !== undefined && isTextNamedChannelType(channel.type)) {
if (data.name !== undefined && (isTextNamedChannelType(channel.type) || channel.isThreadOnly())) {
const hasFlexibleNamesEnabled = guild.features?.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) ?? false;
if (!hasFlexibleNamesEnabled) {
channelName = ChannelNameType.parse(data.name);
@@ -219,25 +273,34 @@ export class ChannelOperationsService {
const guildId = createGuildID(BigInt(guild.id));
await checkPermission(Permissions.MANAGE_ROLES);
const isOwner = guild.owner_id === userId.toString();
const channelPermissions = await this.gatewayService.getUserPermissions({
const gatewayPermissions = await this.gatewayService.getUserPermissions({
guildId,
userId,
channelId: channel.id,
});
const actor = await resolveProtectedBitActor({
guildId,
userId,
clientFeatures,
viewer,
isBot: async () => viewer.kind === 'user' && viewer.bot,
});
const writeMask = permissionWriteMask(actor);
const channelPermissions = actor.threadBits ? withImplicitThreadBits(gatewayPermissions) : gatewayPermissions;
permissionOverwrites = new Map();
for (const overwrite of data.permission_overwrites ?? []) {
const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
const existing = previousPermissionOverwrites?.get(targetId);
const protectedBits = applyProtectedOverwriteBits(
{
allow: (overwrite.allow ? BigInt(overwrite.allow) : 0n) & ALL_PERMISSIONS,
deny: (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS,
allow: (overwrite.allow ? BigInt(overwrite.allow) : 0n) & writeMask,
deny: (overwrite.deny ? BigInt(overwrite.deny) : 0n) & writeMask,
},
{
allow: existing?.allow ?? 0n,
deny: existing?.deny ?? 0n,
},
clientFeatures,
actor,
);
permissionOverwrites.set(
targetId,
@@ -248,6 +311,19 @@ export class ChannelOperationsService {
}),
);
}
const keptBits = protectedThreadBits(actor);
if (keptBits !== 0n) {
for (const [targetId, previous] of previousPermissionOverwrites ?? []) {
if (permissionOverwrites.has(targetId)) continue;
const allow = previous.allow & keptBits;
const deny = previous.deny & keptBits;
if (allow === 0n && deny === 0n) continue;
permissionOverwrites.set(
targetId,
new ChannelPermissionOverwrite({type: previous.type, allow_: allow, deny_: deny}),
);
}
}
if (!isOwner) {
const targetIds = new Set([...(previousPermissionOverwrites?.keys() ?? []), ...permissionOverwrites.keys()]);
for (const targetId of targetIds) {
@@ -255,7 +331,7 @@ export class ChannelOperationsService {
previousPermissionOverwrites?.get(targetId),
permissionOverwrites.get(targetId),
);
if ((grantedBits & ~channelPermissions) !== 0n) {
if ((grantedBits & ~keptBits & ~channelPermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
@@ -292,7 +368,7 @@ export class ChannelOperationsService {
? data.voice_connection_limit
: channel.voiceConnectionLimit,
rate_limit_per_user:
data.rate_limit_per_user !== undefined && GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type)
data.rate_limit_per_user !== undefined && acceptsRateLimit(channel.type)
? data.rate_limit_per_user
: channel.rateLimitPerUser,
nsfw: resolveNsfwOverrideWrite(channel, data),
@@ -309,26 +385,54 @@ export class ChannelOperationsService {
]),
),
};
let retypedThreads: Array<ThreadState> = [];
const toAnnouncement =
nextType === ChannelTypes.GUILD_ANNOUNCEMENT && channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT;
const updatedChannel =
nextType === ChannelTypes.GUILD_ANNOUNCEMENT && channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT
? await withChannelFollowLock(this.cacheService, channelId, async () => {
const webhooks = await this.webhookRepository.listByChannel(channelId);
if (webhooks.some((webhook) => webhook.type === WebhookTypes.CHANNEL_FOLLOWER)) {
throw new ChannelHasFollowedChannelsError();
}
return await this.channelRepository.channelData.upsert(updatedChannelData);
})
toAnnouncement || (nextType !== channel.type && everEnabled())
? await withChannelFollowLock(
this.cacheService,
channelId,
async () => {
if (toAnnouncement) {
const webhooks = await this.webhookRepository.listByChannel(channelId);
if (webhooks.some((webhook) => webhook.type === WebhookTypes.CHANNEL_FOLLOWER)) {
throw new ChannelHasFollowedChannelsError();
}
}
const threads = await loadConvertibleParentThreads(
this.channelRepository,
guildIdValue,
channel,
nextType,
);
if (threads.length === 0) return this.channelRepository.channelData.upsert(updatedChannelData);
const {parent, active} = await retypeParentThreads(this.channelRepository, threads, nextType, () =>
this.channelRepository.channelData.upsert(updatedChannelData),
);
retypedThreads = active;
return parent;
},
everEnabled() ? PARENT_CONVERSION_LOCK_TTL_SECONDS : undefined,
)
: await this.channelRepository.channelData.upsert(updatedChannelData);
if (channel.type === ChannelTypes.GUILD_ANNOUNCEMENT && nextType !== ChannelTypes.GUILD_ANNOUNCEMENT) {
await enqueueChannelFollowerRemoval({sourceChannelId: channelId, reason: 'converted'});
}
if (parentPatch) await this.channelRepository.threads.patchParentConfig(guildIdValue, channel.id, parentPatch);
const nextParentConfig = parentPatch
? await loadThreadParentConfig(this.channelRepository.threads, updatedChannel)
: parentConfig;
if (
data.rate_limit_per_user !== undefined &&
GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) &&
acceptsRateLimit(channel.type) &&
data.rate_limit_per_user !== channel.rateLimitPerUser
) {
try {
await this.rateLimitService.clearLimitsByIdentifierPrefix(`slowmode:${channelId}:`);
if (everEnabled() && (await isTainted(guildIdValue))) {
await this.rateLimitService.clearLimitsByIdentifierPrefix(`slowmode-thread:${channelId}:`);
}
} catch (error) {
Logger.error(
{error, channelId: channelId.toString()},
@@ -337,6 +441,11 @@ export class ChannelOperationsService {
}
}
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChannel, requestCache});
await dispatchThreadEvents(
this.gatewayService,
guildIdValue,
await retypedThreadEvents(this.channelRepository, updatedChannel, retypedThreads),
);
if (channel.type === ChannelTypes.GUILD_CATEGORY && data.permission_overwrites !== undefined && guild) {
await this.propagatePermissionsToSyncedChildren({
categoryChannel: updatedChannel,
@@ -356,8 +465,14 @@ export class ChannelOperationsService {
channelId,
});
}
const beforeSnapshot = serializeChannelForAudit(channel);
const afterSnapshot = serializeChannelForAudit(updatedChannel);
const beforeSnapshot = {
...serializeChannelForAudit(channel),
...serializeThreadParentForAudit(channel.type, parentConfig),
};
const afterSnapshot = {
...serializeChannelForAudit(updatedChannel),
...serializeThreadParentForAudit(updatedChannel.type, nextParentConfig),
};
const changes = this.guildAuditLogService.computeChanges(beforeSnapshot, afterSnapshot);
if (changes.length > 0) {
const builder = this.guildAuditLogService
@@ -398,11 +513,13 @@ export class ChannelOperationsService {
async deleteChannel({
userId,
viewer,
channelId,
requestCache,
auditLogReason,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
requestCache: RequestCache;
auditLogReason: string | null;
@@ -410,6 +527,7 @@ export class ChannelOperationsService {
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
userId,
channelId,
viewer,
skipNsfwValidation: true,
});
if (this.channelAuthService.isPersonalNotesChannel({userId, channelId})) {
@@ -419,13 +537,14 @@ export class ChannelOperationsService {
await checkPermission(Permissions.MANAGE_CHANNELS);
const guildId = createGuildID(BigInt(guild.id));
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId);
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId, 'maintenance');
const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === channelId);
for (const childChannel of childChannels) {
const updatedChild = await this.channelRepository.channelData.upsert({
...childChannel.toRow(),
parent_id: null,
});
if (THREAD_ONLY_CHANNEL_TYPES.has(updatedChild.type) && !guildActive(guildId)) continue;
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
}
}
@@ -470,7 +589,11 @@ export class ChannelOperationsService {
'Failed to record guild audit log',
);
}
await this.channelRepository.channelData.delete(channelId, guildId);
await this.channelRepository.channelData.delete(channelId, guildId, channel.type);
if (channel.isThreadParent() && everEnabled() && (await isTainted(guildId, {fresh: true}))) {
await enqueueDeleteChannelThreads(guildId, channelId);
await this.channelRepository.threads.deleteParentConfig(guildId, channelId);
}
const guildModel = await this.guildRepository.findUnique(guildId);
if (guildModel) {
const guildRow = guildModel.toRow();
@@ -495,9 +618,11 @@ export class ChannelOperationsService {
async getAvailableRtcRegions({
userId,
viewer,
channelId,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
}): Promise<Array<VoiceRegionAvailability>> {
if (this.voiceAvailabilityService === null) {
@@ -506,6 +631,7 @@ export class ChannelOperationsService {
const {channel, guild} = await this.channelAuthService.getChannelAuthenticated({
userId,
channelId,
viewer,
skipNsfwValidation: true,
});
if (channel.type !== ChannelTypes.GUILD_VOICE) {
@@ -535,7 +661,7 @@ export class ChannelOperationsService {
guildId: GuildID;
requestCache: RequestCache;
}): Promise<void> {
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId);
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId, 'maintenance');
const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === categoryChannel.id);
const syncedChannels: Array<Channel> = [];
for (const child of childChannels) {
@@ -555,6 +681,7 @@ export class ChannelOperationsService {
]),
),
});
if (THREAD_ONLY_CHANNEL_TYPES.has(updatedChild.type) && !guildActive(guildId)) return;
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
}),
);
@@ -617,7 +744,7 @@ export class ChannelOperationsService {
if (params.channel.type === ChannelTypes.GUILD_CATEGORY) {
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.CATEGORIES_CANNOT_HAVE_PARENTS);
}
const guildChannels = await this.channelRepository.channelData.listGuildChannels(params.guildId);
const guildChannels = await this.channelRepository.channelData.listGuildChannels(params.guildId, 'enrolled');
const parentChannel = guildChannels.find((channel) => channel.id === params.parentId);
if (!parentChannel) {
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.INVALID_PARENT_CHANNEL);
@@ -661,11 +788,13 @@ export class ChannelOperationsService {
deny_: bigint;
};
clientFeatures: ReadonlySet<string>;
viewer?: ThreadViewer;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel?.guildId) throw new UnknownChannelError();
assertOverwriteTarget(channel, params.viewer);
await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id});
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: channel.guildId,
@@ -674,22 +803,31 @@ export class ChannelOperationsService {
});
const targetId = params.overwrite.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
const existing = channel.permissionOverwrites?.get(targetId);
const actor = await resolveProtectedBitActor({
guildId: channel.guildId,
userId: params.userId,
clientFeatures: params.clientFeatures,
viewer: params.viewer,
isBot: async () => (await this.userRepository.findUnique(params.userId))?.isBot ?? false,
});
const writeMask = permissionWriteMask(actor);
const protectedBits = applyProtectedOverwriteBits(
{
allow: params.overwrite.allow_ & ALL_PERMISSIONS,
deny: params.overwrite.deny_ & ALL_PERMISSIONS,
allow: params.overwrite.allow_ & writeMask,
deny: params.overwrite.deny_ & writeMask,
},
{
allow: existing?.allow ?? 0n,
deny: existing?.deny ?? 0n,
},
params.clientFeatures,
actor,
);
const sanitizedAllow = protectedBits.allow;
const sanitizedDeny = protectedBits.deny;
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
const grantedBits = overwriteGrantedBits(existing, {allow: sanitizedAllow, deny: sanitizedDeny});
if (!hasAdministrator && (grantedBits & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const effectivePermissions = actor.threadBits ? withImplicitThreadBits(userPermissions) : userPermissions;
if (!hasAdministrator && (grantedBits & ~effectivePermissions) !== 0n) throw new MissingPermissionsError();
const previousPermissionOverwrites = channel.permissionOverwrites;
const nextOverwrite = new ChannelPermissionOverwrite({
type: params.overwrite.type,
@@ -698,12 +836,15 @@ export class ChannelOperationsService {
});
const overwrites = new Map(channel.permissionOverwrites ?? []);
overwrites.set(targetId, nextOverwrite);
const updated = await this.channelRepository.channelData.upsert({
...channel.toRow(),
permission_overwrites: new Map(
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
),
});
const updated = await this.channelRepository.channelData.upsert(
{
...channel.toRow(),
permission_overwrites: new Map(
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
),
},
channel.toRow(),
);
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
await this.propagatePermissionsToSyncedChildren({
@@ -727,17 +868,32 @@ export class ChannelOperationsService {
userId: UserID;
channelId: ChannelID;
overwriteId: bigint;
clientFeatures?: ReadonlySet<string>;
viewer?: ThreadViewer;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel?.guildId) throw new UnknownChannelError();
assertOverwriteTarget(channel, params.viewer);
await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id});
const previousPermissionOverwrites = channel.permissionOverwrites;
const overwrites = new Map(channel.permissionOverwrites ?? []);
const removedRole = overwrites.get(createRoleID(params.overwriteId));
const removedUser = overwrites.get(createUserID(params.overwriteId));
const removed = removedRole ?? removedUser;
const kept =
removed && (hasThreadPermissionBits(removed.allow) || hasThreadPermissionBits(removed.deny))
? protectedThreadBits(
await resolveProtectedBitActor({
guildId: channel.guildId,
userId: params.userId,
clientFeatures: params.clientFeatures ?? new Set(),
viewer: params.viewer,
isBot: async () => (await this.userRepository.findUnique(params.userId))?.isBot ?? false,
}),
)
: 0n;
if (removed) {
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: channel.guildId,
@@ -745,16 +901,30 @@ export class ChannelOperationsService {
channelId: channel.id,
});
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
if (!hasAdministrator && (removed.deny & ~kept & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
overwrites.delete(createRoleID(params.overwriteId));
overwrites.delete(createUserID(params.overwriteId));
const updated = await this.channelRepository.channelData.upsert({
...channel.toRow(),
permission_overwrites: new Map(
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
),
});
if (removed && kept !== 0n) {
const removedTargetId = removed.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
overwrites.set(
removedTargetId,
new ChannelPermissionOverwrite({
type: removed.type,
allow_: removed.allow & kept,
deny_: removed.deny & kept,
}),
);
}
const updated = await this.channelRepository.channelData.upsert(
{
...channel.toRow(),
permission_overwrites: new Map(
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
),
},
channel.toRow(),
);
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
await this.propagatePermissionsToSyncedChildren({
@@ -807,10 +977,15 @@ function isWritableGuildChannel(type: number): boolean {
type === ChannelTypes.GUILD_ANNOUNCEMENT ||
type === ChannelTypes.GUILD_VOICE ||
type === ChannelTypes.GUILD_LINK ||
type === ChannelTypes.GUILD_CATEGORY
type === ChannelTypes.GUILD_CATEGORY ||
THREAD_ONLY_CHANNEL_TYPES.has(type)
);
}
function acceptsRateLimit(type: number): boolean {
return GUILD_TEXT_BASED_CHANNEL_TYPES.has(type) || THREAD_ONLY_CHANNEL_TYPES.has(type);
}
function resolveNsfwOverrideWrite(channel: Channel, data: ChannelUpdateData): boolean | null {
if (!isWritableGuildChannel(channel.type)) {
return channel.nsfwOverride;