diff --git a/fluxer_api/src/api/channel/controllers/MessageInteractionController.ts b/fluxer_api/src/api/channel/controllers/MessageInteractionController.ts index c7b3d03b0..0bfe2d268 100644 --- a/fluxer_api/src/api/channel/controllers/MessageInteractionController.ts +++ b/fluxer_api/src/api/channel/controllers/MessageInteractionController.ts @@ -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); }, ); diff --git a/fluxer_api/src/api/channel/services/interaction/MessagePinService.test.ts b/fluxer_api/src/api/channel/services/interaction/MessagePinService.test.ts new file mode 100644 index 000000000..80eca921d --- /dev/null +++ b/fluxer_api/src/api/channel/services/interaction/MessagePinService.test.ts @@ -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}) { + 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); + }); +}); diff --git a/fluxer_api/src/api/channel/services/interaction/MessagePinService.ts b/fluxer_api/src/api/channel/services/interaction/MessagePinService.ts index 3b73671a9..111ef61c3 100644 --- a/fluxer_api/src/api/channel/services/interaction/MessagePinService.ts +++ b/fluxer_api/src/api/channel/services/interaction/MessagePinService.ts @@ -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 = []; + 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), diff --git a/fluxer_api/src/api/channel/tests/ReactionUsersPagination.test.ts b/fluxer_api/src/api/channel/tests/ReactionUsersPagination.test.ts new file mode 100644 index 000000000..1698a829a --- /dev/null +++ b/fluxer_api/src/api/channel/tests/ReactionUsersPagination.test.ts @@ -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>(harness, token) + .get(`/channels/${channelId}/messages/${messageId}/reactions/${EMOJI}?limit=2`) + .executeWithResponse(); + const page = await createBuilder(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>(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(); + }); +}); diff --git a/fluxer_api/src/api/guild/controllers/GuildFeatureToggleController.ts b/fluxer_api/src/api/guild/controllers/GuildFeatureToggleController.ts deleted file mode 100644 index 52da30d35..000000000 --- a/fluxer_api/src/api/guild/controllers/GuildFeatureToggleController.ts +++ /dev/null @@ -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 = [ - { - 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); - } -} diff --git a/fluxer_api/src/api/guild/controllers/index.ts b/fluxer_api/src/api/guild/controllers/index.ts index 1e66f7660..53ae21aa6 100644 --- a/fluxer_api/src/api/guild/controllers/index.ts +++ b/fluxer_api/src/api/guild/controllers/index.ts @@ -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); diff --git a/fluxer_api/src/api/guild/services/GuildService.ts b/fluxer_api/src/api/guild/services/GuildService.ts index 82a3a6d25..ea2e7d153 100644 --- a/fluxer_api/src/api/guild/services/GuildService.ts +++ b/fluxer_api/src/api/guild/services/GuildService.ts @@ -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; } +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> { - 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 { 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(guildId: GuildID, requestCache: RequestCache, fn: () => Promise): Promise { + 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 { + 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 { const emoji = await this.guildRepository.getEmojiById(emojiId); if (!emoji) throw new UnknownGuildEmojiError(); @@ -318,10 +348,7 @@ export class GuildService { let processedLogs: Array = []; 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, diff --git a/fluxer_api/src/api/guild/tests/GuildAuditLogs.test.ts b/fluxer_api/src/api/guild/tests/GuildAuditLogs.test.ts index 856b440c5..2834e8b05 100644 --- a/fluxer_api/src/api/guild/tests/GuildAuditLogs.test.ts +++ b/fluxer_api/src/api/guild/tests/GuildAuditLogs.test.ts @@ -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(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, { diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index f030f7fb6..9790811c0 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -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", diff --git a/packages/schema/src/domains/common/CommonParamSchemas.ts b/packages/schema/src/domains/common/CommonParamSchemas.ts index 0ef9b9670..bd99020be 100644 --- a/packages/schema/src/domains/common/CommonParamSchemas.ts +++ b/packages/schema/src/domains/common/CommonParamSchemas.ts @@ -129,18 +129,6 @@ export const SuccessResponse = z.object({ export type SuccessResponse = z.infer; -export const EnabledToggleRequest = z.object({ - enabled: z.boolean().describe('Whether to enable or disable the feature'), -}); - -export type EnabledToggleRequest = z.infer; - -export const DisabledToggleRequest = z.object({ - disabled: z.boolean().describe('Whether the feature should be in the disabled state'), -}); - -export type DisabledToggleRequest = z.infer; - export const EmojiIdParam = z.object({ emoji_id: SnowflakeType.describe('The ID of the emoji'), });