fix(api)!: correct pagination and locking, drop toggle routes (#2505)

This commit is contained in:
Hampus
2026-09-06 15:31:13 +02:00
committed by GitHub
parent 632f4c7b6c
commit d07f520b13
10 changed files with 302 additions and 626 deletions
@@ -171,6 +171,10 @@ export function MessageInteractionController(app: HonoApp) {
const result = await ctx
.get('channelService')
.interactions.getUsersForReaction({userId, channelId, messageId, emoji, limit, after: afterUserId});
ctx.header('X-Has-More', result.has_more ? 'true' : 'false');
if (result.next_after !== null) {
ctx.header('X-Next-After', result.next_after);
}
return ctx.json(result.users, 200);
},
);
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {createSnowflakeFromTimestamp} from '@fluxer/snowflake/src/Snowflake';
import {describe, expect, it} from 'vitest';
import type {ChannelID, MessageID} from '../../../BrandedTypes';
import {createChannelID, createGuildID, createMessageID, createUserID} from '../../../BrandedTypes';
import type {GuildAuditLogService} from '../../../guild/GuildAuditLogService';
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {ISnowflakeService} from '../../../infrastructure/ISnowflakeService';
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
import {Message} from '../../../models/Message';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {AuthenticatedChannel} from '../AuthenticatedChannel';
import type {MessagePersistenceService} from '../message/MessagePersistenceService';
import {MessagePinService} from './MessagePinService';
const CHANNEL_ID = createChannelID(10n);
const GUILD_ID = createGuildID(20n);
const VIEWER_ID = createUserID(7n);
const CUTOFF = new Date('2026-01-10T00:00:00.000Z');
const CUTOFF_ISO: string | null = CUTOFF.toISOString();
const HIDDEN_CREATED_AT = new Date('2026-01-01T00:00:00.000Z');
const VISIBLE_CREATED_AT = new Date('2026-01-20T00:00:00.000Z');
const PIN_EPOCH = new Date('2026-02-01T00:00:00.000Z');
function makeMessageId(timestamp: Date, sequence: number): MessageID {
return createMessageID(createSnowflakeFromTimestamp(timestamp.getTime()) + BigInt(sequence));
}
function makePin(createdAt: Date, sequence: number, pinnedAtOffsetMs: number): Message {
return new Message({
channel_id: CHANNEL_ID,
bucket: 0,
message_id: makeMessageId(createdAt, sequence),
author_id: null,
type: MessageTypes.DEFAULT,
webhook_id: null,
webhook_name: null,
webhook_avatar_hash: null,
content: '',
edited_timestamp: null,
pinned_timestamp: new Date(PIN_EPOCH.getTime() + pinnedAtOffsetMs),
flags: 0,
mention_everyone: false,
mention_users: null,
mention_roles: null,
mention_channels: null,
attachments: null,
embeds: null,
sticker_items: null,
message_reference: null,
message_snapshots: null,
call: null,
has_reaction: null,
version: 1,
});
}
function createPinService({canReadHistory, pins}: {canReadHistory: boolean; pins: Array<Message>}) {
const calls: Array<{before: Date; limit: number}> = [];
const channelRepository = {
messageInteractions: {
listChannelPins: async (_channelId: ChannelID, before: Date, limit: number) => {
calls.push({before, limit});
return pins
.filter((pin) => pin.pinnedTimestamp!.getTime() < before.getTime())
.sort((a, b) => b.pinnedTimestamp!.getTime() - a.pinnedTimestamp!.getTime())
.slice(0, limit);
},
},
} as unknown as IChannelRepositoryAggregate;
const channel = {id: CHANNEL_ID, type: ChannelTypes.GUILD_TEXT, guildId: GUILD_ID};
const authChannel: AuthenticatedChannel = {
channel: channel as AuthenticatedChannel['channel'],
guild: {message_history_cutoff: CUTOFF_ISO} as AuthenticatedChannel['guild'],
member: null,
hasPermission: async () => canReadHistory,
checkPermission: async () => {},
};
const service = new MessagePinService(
{} as unknown as IGatewayService,
channelRepository,
{} as unknown as ISnowflakeService,
{} as unknown as MessagePersistenceService,
{} as unknown as GuildAuditLogService,
);
return {
authChannel,
calls,
listPins: (limit: number) =>
service.getChannelPins({
authChannel,
userId: VIEWER_ID,
requestCache: {} as unknown as RequestCache,
limit,
}),
};
}
describe('MessagePinService.getChannelPins', () => {
it('walks past a full page of pins hidden by the history cutoff', async () => {
const hidden = Array.from({length: 12}, (_value, index) => makePin(HIDDEN_CREATED_AT, index, 1_000 + index));
const visible = Array.from({length: 5}, (_value, index) => makePin(VISIBLE_CREATED_AT, index, index));
const {calls, listPins} = createPinService({canReadHistory: false, pins: [...hidden, ...visible]});
const page = await listPins(10);
expect(page.items.map((item) => item.message.id)).toEqual(
[...visible].reverse().map((message) => message.id.toString()),
);
expect(page.has_more).toBe(false);
expect(calls).toHaveLength(2);
});
it('reports has_more when visible pins behind the hidden run outnumber the page', async () => {
const hidden = Array.from({length: 12}, (_value, index) => makePin(HIDDEN_CREATED_AT, index, 1_000 + index));
const visible = Array.from({length: 11}, (_value, index) => makePin(VISIBLE_CREATED_AT, index, index));
const {listPins} = createPinService({canReadHistory: false, pins: [...hidden, ...visible]});
const page = await listPins(10);
expect(page.items).toHaveLength(10);
expect(page.has_more).toBe(true);
expect(page.items.map((item) => item.message.id)).toEqual(
[...visible]
.reverse()
.slice(0, 10)
.map((message) => message.id.toString()),
);
});
it('reads the pin index once for a caller that can read message history', async () => {
const pins = Array.from({length: 11}, (_value, index) => makePin(HIDDEN_CREATED_AT, index, index));
const {calls, listPins} = createPinService({canReadHistory: true, pins});
const page = await listPins(10);
expect(page.items).toHaveLength(10);
expect(page.has_more).toBe(true);
expect(calls).toHaveLength(1);
});
});
@@ -79,21 +79,31 @@ export class MessagePinService extends MessageInteractionBase {
}
}
const pageSize = Math.min(limit ?? 50, 50);
const effectiveBefore = beforeTimestamp ?? new Date();
const messages = await this.channelRepository.messageInteractions.listChannelPins(
channel.id,
effectiveBefore,
pageSize + 1,
);
const sorted = messages.sort((a, b) => (b.pinnedTimestamp?.getTime() ?? 0) - (a.pinnedTimestamp?.getTime() ?? 0));
let filtered = sorted;
if (!hasReadHistory) {
const cutoff = authChannel.guild!.message_history_cutoff!;
const cutoffTimestamp = new Date(cutoff).getTime();
filtered = sorted.filter((message) => snowflakeToDate(message.id).getTime() >= cutoffTimestamp);
const cutoffTimestamp = hasReadHistory ? null : new Date(authChannel.guild!.message_history_cutoff!).getTime();
const filtered: Array<Message> = [];
let before = beforeTimestamp ?? new Date();
let exhausted = false;
while (filtered.length <= pageSize && !exhausted) {
const messages = await this.channelRepository.messageInteractions.listChannelPins(
channel.id,
before,
pageSize + 1,
);
const sorted = messages.sort((a, b) => (b.pinnedTimestamp?.getTime() ?? 0) - (a.pinnedTimestamp?.getTime() ?? 0));
exhausted = messages.length <= pageSize;
for (const message of sorted) {
if (cutoffTimestamp === null || snowflakeToDate(message.id).getTime() >= cutoffTimestamp) {
filtered.push(message);
}
}
const last = sorted[sorted.length - 1];
if (!last?.pinnedTimestamp) {
break;
}
before = last.pinnedTimestamp;
}
const hasMore = filtered.length > pageSize;
const trimmed = hasMore ? filtered.slice(0, pageSize) : filtered;
const trimmed = filtered.slice(0, pageSize);
const access = {
sourceGuildId: channel.guildId,
messageHistoryCutoff: hasReadHistory ? null : (authChannel.guild?.message_history_cutoff ?? null),
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
import {sendChannelMessage, setupTestGuildWithMembers} from './ChannelTestUtils';
const EMOJI = encodeURIComponent('👍');
interface ReactionUsersPage {
items: Array<{id: string}>;
has_more: boolean;
next_after: string | null;
}
describe('Reaction users pagination', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function setupReactedMessage(): Promise<{token: string; channelId: string; messageId: string}> {
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
const message = await sendChannelMessage(harness, owner.token, systemChannel.id, 'react to me');
for (const account of [owner, ...members]) {
await createBuilder(harness, account.token)
.put(`/channels/${systemChannel.id}/messages/${message.id}/reactions/${EMOJI}/@me`)
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
return {token: owner.token, channelId: systemChannel.id, messageId: message.id};
}
it('carries the pagination signal of the page in headers', async () => {
const {token, channelId, messageId} = await setupReactedMessage();
const legacy = await createBuilder<Array<{id: string}>>(harness, token)
.get(`/channels/${channelId}/messages/${messageId}/reactions/${EMOJI}?limit=2`)
.executeWithResponse();
const page = await createBuilder<ReactionUsersPage>(harness, token)
.get(`/channels/${channelId}/messages/${messageId}/reactions/${EMOJI}/users?limit=2`)
.execute();
expect(legacy.json.map((user) => user.id)).toEqual(page.items.map((user) => user.id));
expect(page.has_more).toBe(true);
expect(legacy.response.headers.get('X-Has-More')).toBe('true');
expect(legacy.response.headers.get('X-Next-After')).toBe(page.next_after);
});
it('omits the cursor header on the final page', async () => {
const {token, channelId, messageId} = await setupReactedMessage();
const legacy = await createBuilder<Array<{id: string}>>(harness, token)
.get(`/channels/${channelId}/messages/${messageId}/reactions/${EMOJI}?limit=3`)
.executeWithResponse();
expect(legacy.json).toHaveLength(3);
expect(legacy.response.headers.get('X-Has-More')).toBe('false');
expect(legacy.response.headers.get('X-Next-After')).toBeNull();
});
});
@@ -1,126 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {
DisabledToggleRequest,
EnabledToggleRequest,
GuildIdParam,
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {createGuildID} from '../../BrandedTypes';
import {LoginRequired} from '../../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
type ToggleMode = 'enabled' | 'disabled';
type GuildFeatureToggleConfig = {
path: string;
operationId: string;
summary: string;
description: string;
feature: string;
mode: ToggleMode;
};
const TOGGLES: ReadonlyArray<GuildFeatureToggleConfig> = [
{
path: '/guilds/:guild_id/text-channel-flexible-names',
operationId: 'toggle_text_channel_flexible_names',
summary: 'Toggle text channel flexible names',
description: 'Requires manage_guild permission. Allows or disables flexible naming for text channels.',
feature: GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES,
mode: 'enabled',
},
{
path: '/guilds/:guild_id/detached-banner',
operationId: 'toggle_detached_banner',
summary: 'Toggle detached banner',
description: 'Requires manage_guild permission. Enables or disables independent banner display configuration.',
feature: GuildFeatures.DETACHED_BANNER,
mode: 'enabled',
},
{
path: '/guilds/:guild_id/invites-disabled',
operationId: 'toggle_invites_disabled',
summary: 'Toggle invites disabled',
description: 'Requires manage_guild permission. Pauses or resumes invite-link joins for this guild.',
feature: GuildFeatures.INVITES_DISABLED,
mode: 'disabled',
},
{
path: '/guilds/:guild_id/clone-emoji-disabled',
operationId: 'toggle_clone_emoji_disabled',
summary: 'Toggle emoji cloning disabled',
description:
"Requires manage_guild permission. When disabled, members of other guilds cannot use the in-app one-click clone shortcut for this guild's emojis. Note that this does not prevent users from saving and re-uploading the image manually.",
feature: GuildFeatures.CLONE_EMOJI_DISABLED,
mode: 'disabled',
},
{
path: '/guilds/:guild_id/hide-owner-crown',
operationId: 'toggle_hide_owner_crown',
summary: 'Toggle hide community owner crown',
description:
'Requires manage_guild permission. When enabled, the community owner crown icon is hidden across the UI for this guild.',
feature: GuildFeatures.HIDE_OWNER_CROWN,
mode: 'enabled',
},
{
path: '/guilds/:guild_id/clone-sticker-disabled',
operationId: 'toggle_clone_sticker_disabled',
summary: 'Toggle sticker cloning disabled',
description:
"Requires manage_guild permission. When disabled, members of other guilds cannot use the in-app one-click clone shortcut for this guild's stickers. Note that this does not prevent users from saving and re-uploading the image manually.",
feature: GuildFeatures.CLONE_STICKER_DISABLED,
mode: 'disabled',
},
];
function registerGuildFeatureToggle(app: HonoApp, config: GuildFeatureToggleConfig) {
const bodySchema = config.mode === 'enabled' ? EnabledToggleRequest : DisabledToggleRequest;
app.patch(
config.path,
RateLimitMiddleware(RateLimitConfigs.GUILD_UPDATE),
LoginRequired,
Validator('param', GuildIdParam),
Validator('json', bodySchema),
OpenAPI({
operationId: config.operationId,
summary: config.summary,
description: config.description,
responseSchema: GuildResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Guilds'],
}),
async (ctx) => {
const userId = ctx.get('user').id;
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const body = ctx.req.valid('json');
const present =
config.mode === 'enabled' ? (body as EnabledToggleRequest).enabled : (body as DisabledToggleRequest).disabled;
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const guildService = ctx.get('guildService');
const currentFeatures = await guildService.getGuildFeaturesForToggle(guildId);
const features = new Set(currentFeatures);
if (present) {
features.add(config.feature);
} else {
features.delete(config.feature);
}
const data: GuildUpdateRequest = {features: Array.from(features)};
return ctx.json(await guildService.updateGuild({userId, guildId, data, requestCache}, auditLogReason));
},
);
}
export function GuildFeatureToggleController(app: HonoApp) {
for (const config of TOGGLES) {
registerGuildFeatureToggle(app, config);
}
}
@@ -6,7 +6,6 @@ import {GuildBaseController} from './GuildBaseController';
import {GuildChannelController} from './GuildChannelController';
import {GuildDiscoveryController} from './GuildDiscoveryController';
import {GuildEmojiController} from './GuildEmojiController';
import {GuildFeatureToggleController} from './GuildFeatureToggleController';
import {GuildMemberController} from './GuildMemberController';
import {GuildMemberSearchController} from './GuildMemberSearchController';
import {GuildRoleController} from './GuildRoleController';
@@ -14,7 +13,6 @@ import {GuildStickerController} from './GuildStickerController';
export function registerGuildControllers(app: HonoApp) {
GuildBaseController(app);
GuildFeatureToggleController(app);
GuildMemberController(app);
GuildMemberSearchController(app);
GuildRoleController(app);
@@ -7,6 +7,7 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {ResourceLockedError} from '@fluxer/errors/src/domains/core/ResourceLockedError';
import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError';
@@ -17,6 +18,7 @@ import type {
import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type {ApiContext} from '../../ApiContext';
import type {EmojiID, GuildID, RoleID, StickerID, UserID} from '../../BrandedTypes';
@@ -95,6 +97,10 @@ interface GuildAuth {
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}
const GUILD_UPDATE_LOCK_TTL_SECONDS = 10;
const GUILD_UPDATE_LOCK_RETRY_DELAY_MS = 50;
const GUILD_UPDATE_LOCK_MAX_WAIT_MS = 5000;
export class GuildService {
public readonly data: GuildDataService;
public readonly members: GuildMemberService;
@@ -104,6 +110,7 @@ export class GuildService {
public readonly channels: GuildChannelService;
public readonly search: GuildSearchService;
private readonly guildRepository: IGuildRepositoryAggregate;
private readonly cacheService: ICacheService;
private readonly userCacheService: UserCacheService;
private readonly webhookRepository: IWebhookRepository;
private readonly guildAuditLogService: GuildAuditLogService;
@@ -135,6 +142,7 @@ export class GuildService {
} = apiContext.services;
this.gatewayService = gatewayService;
this.guildRepository = guildRepository;
this.cacheService = cacheService;
this.userCacheService = userCacheService;
this.webhookRepository = webhookRepository;
this.guildAuditLogService = guildAuditLogService;
@@ -212,12 +220,6 @@ export class GuildService {
);
}
async getGuildFeaturesForToggle(guildId: GuildID): Promise<Set<string>> {
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) throw new UnknownGuildError();
return new Set(guild.features);
}
async updateGuild(
params: {
userId: UserID;
@@ -228,7 +230,9 @@ export class GuildService {
auditLogReason?: string | null,
): Promise<GuildResponse> {
const {guildId, requestCache} = params;
const {guild, previousFeatures, updatedFeatures} = await this.data.updateGuild(params, auditLogReason);
const {guild, previousFeatures, updatedFeatures} = await this.withGuildUpdateLock(guildId, requestCache, () =>
this.data.updateGuild(params, auditLogReason),
);
if (
previousFeatures.has(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) &&
!updatedFeatures.has(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES)
@@ -238,6 +242,32 @@ export class GuildService {
return guild;
}
private async withGuildUpdateLock<T>(guildId: GuildID, requestCache: RequestCache, fn: () => Promise<T>): Promise<T> {
const lockKey = `guild:${guildId}:update`;
const lockToken = await this.acquireGuildUpdateLock(lockKey);
if (!lockToken) {
throw new ResourceLockedError();
}
try {
requestCache.guilds.delete(guildId);
return await fn();
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
}
}
private async acquireGuildUpdateLock(lockKey: string): Promise<string | null> {
const startTime = Date.now();
while (Date.now() - startTime < GUILD_UPDATE_LOCK_MAX_WAIT_MS) {
const token = await this.cacheService.acquireLock(lockKey, GUILD_UPDATE_LOCK_TTL_SECONDS);
if (token) {
return token;
}
await new Promise((resolve) => setTimeout(resolve, GUILD_UPDATE_LOCK_RETRY_DELAY_MS));
}
return null;
}
async getEmojiMetadata(emojiId: EmojiID): Promise<GuildEmojiMetadataResponse> {
const emoji = await this.guildRepository.getEmojiById(emojiId);
if (!emoji) throw new UnknownGuildEmojiError();
@@ -318,10 +348,7 @@ export class GuildService {
let processedLogs: Array<GuildAuditLog> = [];
let currentBeforeLogId = beforeLogId;
let currentAfterLogId = afterLogId;
const maxIterations = 5;
let iterations = 0;
while (processedLogs.length < effectiveLimit && iterations < maxIterations) {
iterations++;
while (processedLogs.length < effectiveLimit) {
const fetchLimit = Math.min(effectiveLimit * 2, 200);
const logs = await this.guildRepository.listAuditLogs({
guildId,
@@ -5,6 +5,7 @@ import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {authorizeBot, createTestBotAccount} from '../../bot/tests/BotTestUtils';
import {deleteMessage, sendMessage} from '../../message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
@@ -218,6 +219,27 @@ describe('Guild audit log endpoint', () => {
expect(userIds).toContain(owner.userId);
expect(userIds).toContain(member.userId);
});
test('fills a page from behind a long run of batched message deletes', async () => {
const {owner, guild, channels} = await setupTestGuildWithMembers(harness, 0);
const channel = channels[0];
await createRole(harness, owner.token, guild.id, {
name: 'Older Role',
permissions: Permissions.VIEW_CHANNEL.toString(),
});
for (let index = 0; index < 60; index++) {
const message = await sendMessage(harness, owner.token, channel.id, `delete me ${index}`);
await deleteMessage(harness, owner.token, channel.id, message.id);
}
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
.get(`/guilds/${guild.id}/audit-logs?limit=6`)
.expect(HTTP_STATUS.OK)
.execute();
expect(response.audit_log_entries).toHaveLength(6);
expect(response.audit_log_entries.slice(0, 5).map((entry) => entry.action_type)).toEqual(
Array.from({length: 5}, () => AuditLogActionType.MESSAGE_BULK_DELETE),
);
expect(response.audit_log_entries[5]?.action_type).toBe(AuditLogActionType.ROLE_CREATE);
});
test('rejects specifying before and after together', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
await createRole(harness, owner.token, guild.id, {
-462
View File
@@ -7850,160 +7850,6 @@
}
}
},
"/guilds/{guild_id}/clone-emoji-disabled": {
"patch": {
"operationId": "toggle_clone_emoji_disabled",
"summary": "Toggle emoji cloning disabled",
"tags": ["Guilds"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Toggle emoji cloning disabled"}},
"description": "Requires manage_guild permission. When disabled, members of other guilds cannot use the in-app one-click clone shortcut for this guild's emojis. Note that this does not prevent users from saving and re-uploading the image manually.",
"security": [{"botToken": []}, {"sessionToken": []}],
"parameters": [
{
"name": "guild_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the guild"
}
],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}}
}
},
"/guilds/{guild_id}/clone-sticker-disabled": {
"patch": {
"operationId": "toggle_clone_sticker_disabled",
"summary": "Toggle sticker cloning disabled",
"tags": ["Guilds"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Toggle sticker cloning disabled"}},
"description": "Requires manage_guild permission. When disabled, members of other guilds cannot use the in-app one-click clone shortcut for this guild's stickers. Note that this does not prevent users from saving and re-uploading the image manually.",
"security": [{"botToken": []}, {"sessionToken": []}],
"parameters": [
{
"name": "guild_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the guild"
}
],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}}
}
},
"/guilds/{guild_id}/delete": {
"post": {
"operationId": "delete_guild",
@@ -8081,83 +7927,6 @@
}
}
},
"/guilds/{guild_id}/detached-banner": {
"patch": {
"operationId": "toggle_detached_banner",
"summary": "Toggle detached banner",
"tags": ["Guilds"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Toggle detached banner"}},
"description": "Requires manage_guild permission. Enables or disables independent banner display configuration.",
"security": [{"botToken": []}, {"sessionToken": []}],
"parameters": [
{
"name": "guild_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the guild"
}
],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}}
}
},
"/guilds/{guild_id}/discovery": {
"post": {
"operationId": "apply_for_discovery",
@@ -8946,83 +8715,6 @@
]
}
},
"/guilds/{guild_id}/hide-owner-crown": {
"patch": {
"operationId": "toggle_hide_owner_crown",
"summary": "Toggle hide community owner crown",
"tags": ["Guilds"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Toggle hide community owner crown"}},
"description": "Requires manage_guild permission. When enabled, the community owner crown icon is hidden across the UI for this guild.",
"security": [{"botToken": []}, {"sessionToken": []}],
"parameters": [
{
"name": "guild_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the guild"
}
],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}}
}
},
"/guilds/{guild_id}/invites": {
"get": {
"operationId": "list_guild_invites",
@@ -9103,83 +8795,6 @@
]
}
},
"/guilds/{guild_id}/invites-disabled": {
"patch": {
"operationId": "toggle_invites_disabled",
"summary": "Toggle invites disabled",
"tags": ["Guilds"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Toggle invites disabled"}},
"description": "Requires manage_guild permission. Pauses or resumes invite-link joins for this guild.",
"security": [{"botToken": []}, {"sessionToken": []}],
"parameters": [
{
"name": "guild_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the guild"
}
],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}}
}
},
"/guilds/{guild_id}/members": {
"get": {
"operationId": "list_guild_members",
@@ -10958,83 +10573,6 @@
]
}
},
"/guilds/{guild_id}/text-channel-flexible-names": {
"patch": {
"operationId": "toggle_text_channel_flexible_names",
"summary": "Toggle text channel flexible names",
"tags": ["Guilds"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Toggle text channel flexible names"}},
"description": "Requires manage_guild permission. Allows or disables flexible naming for text channels.",
"security": [{"botToken": []}, {"sessionToken": []}],
"parameters": [
{
"name": "guild_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the guild"
}
],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}}
}
},
"/guilds/{guild_id}/transfer-ownership": {
"post": {
"operationId": "transfer_guild_ownership",