mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api)!: correct pagination and locking, drop toggle routes (#2505)
This commit is contained in:
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user