From 9fbaed112b2a990d7a577ffe30b54bc32bc127c6 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 28 May 2026 14:13:37 -0300 Subject: [PATCH 1/2] fix: resolve discord 429 rate-limit storm and archived thread crash LUCKY-27, LUCKY-28: GuildAccessService.fetchUserGuilds had redis caching wired but only used it as a fallback in the catch block. Every authenticated request called discord's /users/@me/guilds unconditionally, causing the rate-limit storm. Add primary cache read-through at the top of fetchUserGuilds so cached guild lists are served without hitting discord at all. Update the 429-fallback test to reflect the new behavior: with a warm cache, resolveGuildContext returns from redis and never reaches discord, so no 429 can occur. LUCKY-3G: AiDevToolkitService.syncBoard posts into a discord thread that discord auto-archives when idle. Editing messages in an archived thread throws DiscordAPIError[50083]. Unarchive the thread before syncing; if unarchiving fails, skip the sync cycle rather than crash. --- .../src/services/GuildAccessService.ts | 5 ++ .../unit/services/GuildAccessService.test.ts | 71 ++++++++++++------- .../bot/src/services/AiDevToolkitService.ts | 26 +++++-- 3 files changed, 71 insertions(+), 31 deletions(-) diff --git a/packages/backend/src/services/GuildAccessService.ts b/packages/backend/src/services/GuildAccessService.ts index dffc016a5..f347fd76f 100644 --- a/packages/backend/src/services/GuildAccessService.ts +++ b/packages/backend/src/services/GuildAccessService.ts @@ -151,6 +151,11 @@ class GuildAccessService { options?: { allowCachedFallback?: boolean }, ): Promise { const allowCachedFallback = options?.allowCachedFallback ?? true + const cached = await this.getCachedGuilds(session) + if (cached) { + return cached + } + const cacheKey = this.getCacheKey(session) const inFlight = this.userGuildsInFlight.get(cacheKey) if (inFlight) { diff --git a/packages/backend/tests/unit/services/GuildAccessService.test.ts b/packages/backend/tests/unit/services/GuildAccessService.test.ts index 923e8b988..43a5e2f88 100644 --- a/packages/backend/tests/unit/services/GuildAccessService.test.ts +++ b/packages/backend/tests/unit/services/GuildAccessService.test.ts @@ -9,7 +9,10 @@ const mockHasAdminPermission = jest.fn< >() class MockDiscordApiError extends Error { - constructor(public readonly statusCode: number, message = 'Discord API error') { + constructor( + public readonly statusCode: number, + message = 'Discord API error', + ) { super(message) this.name = 'DiscordApiError' } @@ -40,8 +43,7 @@ jest.mock('../../../src/services/DiscordOAuthService', () => ({ getUserGuilds: (...args: [string]) => mockGetUserGuilds(...args), hasAdminPermission: ( ...args: [string | null | undefined, string | null | undefined] - ) => - mockHasAdminPermission(...args), + ) => mockHasAdminPermission(...args), }, })) @@ -93,9 +95,7 @@ const MANAGE_ALL_ACCESS = { integrations: 'manage', } -async function expectRbacStorageUnavailable( - operation: Promise, -) { +async function expectRbacStorageUnavailable(operation: Promise) { await expect(operation).rejects.toMatchObject({ statusCode: 503, message: @@ -244,7 +244,9 @@ describe('GuildAccessService', () => { new DiscordApiError(401, 'invalid token'), ) - await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toMatchObject({ + await expect( + guildAccessService.listAuthorizedGuilds(SESSION), + ).rejects.toMatchObject({ statusCode: 401, message: 'Discord session expired. Please sign in again.', }) @@ -256,9 +258,12 @@ describe('GuildAccessService', () => { message: 'missing scope', }) - await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toMatchObject({ + await expect( + guildAccessService.listAuthorizedGuilds(SESSION), + ).rejects.toMatchObject({ statusCode: 403, - message: 'Discord OAuth scope is missing. Re-authenticate and try again.', + message: + 'Discord OAuth scope is missing. Re-authenticate and try again.', }) }) @@ -288,7 +293,9 @@ describe('GuildAccessService', () => { message: 'rate limited', }) - await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toMatchObject({ + await expect( + guildAccessService.listAuthorizedGuilds(SESSION), + ).rejects.toMatchObject({ statusCode: 502, message: 'Discord API is temporarily unavailable. Please retry.', }) @@ -319,9 +326,9 @@ describe('GuildAccessService', () => { const unknownError = new Error('boom') mockGetUserGuilds.mockRejectedValue(unknownError) - await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toBe( - unknownError, - ) + await expect( + guildAccessService.listAuthorizedGuilds(SESSION), + ).rejects.toBe(unknownError) }) test('listAuthorizedGuilds skips guild when access resolution throws', async () => { @@ -330,12 +337,14 @@ describe('GuildAccessService', () => { mockGetUserGuilds.mockResolvedValue(guilds) mockHasBotInGuild.mockResolvedValue(true) - mockResolveEffectiveAccess.mockImplementation(async (guildId: string) => { - if (guildId === '202') { - throw new Error('policy lookup failed') - } - return adminAccess - }) + mockResolveEffectiveAccess.mockImplementation( + async (guildId: string) => { + if (guildId === '202') { + throw new Error('policy lookup failed') + } + return adminAccess + }, + ) const result = await guildAccessService.listAuthorizedGuilds(SESSION) @@ -370,7 +379,9 @@ describe('GuildAccessService', () => { new Error('rbac dependency unavailable'), ) - await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toMatchObject({ + await expect( + guildAccessService.listAuthorizedGuilds(SESSION), + ).rejects.toMatchObject({ statusCode: 502, message: 'Unable to resolve server access right now. Please retry.', }) @@ -460,7 +471,9 @@ describe('GuildAccessService', () => { new Error('member context unavailable'), ) - await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toMatchObject({ + await expect( + guildAccessService.listAuthorizedGuilds(SESSION), + ).rejects.toMatchObject({ statusCode: 502, message: 'Unable to resolve server access right now. Please retry.', }) @@ -479,9 +492,9 @@ describe('GuildAccessService', () => { { ...guild, id: 'unknown-guild', hasBot: true }, ]) - await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toThrow( - 'Missing authorized context for guild unknown-guild', - ) + await expect( + guildAccessService.listAuthorizedGuilds(SESSION), + ).rejects.toThrow('Missing authorized context for guild unknown-guild') }) test('resolveGuildContext returns null when guild is not in user guild list', async () => { @@ -596,13 +609,15 @@ describe('GuildAccessService', () => { const cachedGuilds = JSON.stringify([guild]) mockRedisIsHealthy.mockReturnValue(true) - mockRedisGet.mockResolvedValue(cachedGuilds) + // First call: cache miss → Discord API populates it + mockRedisGet.mockResolvedValueOnce(null) mockGetUserGuilds.mockResolvedValueOnce([guild]) mockResolveEffectiveAccess.mockResolvedValue(adminAccess) await guildAccessService.listAuthorizedGuilds(SESSION) expect(mockRedisSetex).toHaveBeenCalledTimes(1) - mockGetUserGuilds.mockRejectedValueOnce({ status: 429 }) + // Second call: cache is populated — Discord is never called, no 429 possible + mockRedisGet.mockResolvedValue(cachedGuilds) await expect( guildAccessService.resolveGuildContext(SESSION, guild.id), @@ -625,7 +640,9 @@ describe('GuildAccessService', () => { expect(mockRedisSetex).toHaveBeenCalledTimes(1) const redisKey = mockRedisSetex.mock.calls[0][0] - expect(redisKey).toContain(`guild-access:user-guilds:${SESSION.user.id}:`) + expect(redisKey).toContain( + `guild-access:user-guilds:${SESSION.user.id}:`, + ) expect(redisKey).not.toContain(SESSION.accessToken) expect(redisKey).not.toContain(SESSION.accessToken.slice(0, 24)) }) diff --git a/packages/bot/src/services/AiDevToolkitService.ts b/packages/bot/src/services/AiDevToolkitService.ts index 878b27bb3..625cb12e4 100644 --- a/packages/bot/src/services/AiDevToolkitService.ts +++ b/packages/bot/src/services/AiDevToolkitService.ts @@ -1,4 +1,9 @@ -import { type Client, type TextChannel, type Message } from 'discord.js' +import { + type Client, + type TextChannel, + type ThreadChannel, + type Message, +} from 'discord.js' import { getPrismaClient } from '@lucky/shared/utils' import { infoLog, errorLog, debugLog } from '@lucky/shared/utils' @@ -387,9 +392,10 @@ class AiDevToolkitService { const stored = await this.getStoredGuide() try { - const channel = (await client.channels.fetch( - CHANNEL_ID, - )) as TextChannel | null + const channel = (await client.channels.fetch(CHANNEL_ID)) as + | TextChannel + | ThreadChannel + | null if (!channel?.isTextBased()) { errorLog({ message: `AiDevToolkitService: channel ${CHANNEL_ID} not found or not text-based`, @@ -397,6 +403,18 @@ class AiDevToolkitService { return } + if (channel.isThread() && channel.archived) { + try { + await channel.setArchived(false) + } catch (error) { + errorLog({ + message: `AiDevToolkitService: cannot unarchive thread ${CHANNEL_ID}, skipping sync`, + error, + }) + return + } + } + const existingMessages: Message[] = [] let foundAllMessages = true if (stored?.messageIds.length) { From b3d8ed998adb875343ea79cf50ac702ab2880ce5 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 28 May 2026 14:22:37 -0300 Subject: [PATCH 2/2] test(bot): cover archived thread guard in sync board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four cases: unarchive succeeds, unarchive fails (skip cycle), non-archived thread, and regular text channel — all via mocked discord client and spy on private fetchRepoSnapshot. --- .../services/AiDevToolkitService.test.ts | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 packages/bot/tests/services/AiDevToolkitService.test.ts diff --git a/packages/bot/tests/services/AiDevToolkitService.test.ts b/packages/bot/tests/services/AiDevToolkitService.test.ts new file mode 100644 index 000000000..f628d0d26 --- /dev/null +++ b/packages/bot/tests/services/AiDevToolkitService.test.ts @@ -0,0 +1,133 @@ +import type { Client } from 'discord.js' + +const mockErrorLog = jest.fn() +const mockInfoLog = jest.fn() +const mockGetPrismaClient = jest.fn() + +jest.mock('@lucky/shared/utils', () => ({ + errorLog: (...args: unknown[]) => mockErrorLog(...args), + infoLog: (...args: unknown[]) => mockInfoLog(...args), + debugLog: jest.fn(), + getPrismaClient: () => mockGetPrismaClient(), +})) + +import { aiDevToolkitService } from '../../src/services/AiDevToolkitService' + +function makeSnapshot() { + return { + commitSha: 'abc1234', + patterns: [], + lastUpdated: new Date().toISOString(), + } +} + +function makeChannel(overrides: Record = {}) { + return { + isTextBased: () => true, + isThread: () => false, + archived: false, + setArchived: jest + .fn, [boolean]>() + .mockResolvedValue(undefined), + messages: { + fetch: jest.fn().mockRejectedValue(new Error('not found')), + }, + send: jest.fn().mockResolvedValue({ id: 'msg-1' }), + ...overrides, + } +} + +function makeClient(channel: ReturnType | null) { + return { + channels: { fetch: jest.fn().mockResolvedValue(channel) }, + } as unknown as Client +} + +describe('AiDevToolkitService', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const svc = aiDevToolkitService as any + let fetchSpy: jest.SpyInstance + + beforeEach(() => { + jest.clearAllMocks() + svc.lastCommitSha = null + fetchSpy = jest + .spyOn(svc, 'fetchRepoSnapshot') + .mockResolvedValue(makeSnapshot()) + mockGetPrismaClient.mockReturnValue({ + liveBoard: { + findUnique: jest.fn().mockResolvedValue(null), + upsert: jest.fn().mockResolvedValue({}), + }, + }) + }) + + afterEach(() => { + fetchSpy.mockRestore() + }) + + describe('syncBoard — archived thread guard', () => { + it('unarchives the thread before posting when it is archived', async () => { + const mockSetArchived = jest + .fn, [boolean]>() + .mockResolvedValue(undefined) + const channel = makeChannel({ + isThread: () => true, + archived: true, + setArchived: mockSetArchived, + }) + + await aiDevToolkitService.syncBoard(makeClient(channel)) + + expect(mockSetArchived).toHaveBeenCalledWith(false) + expect(channel.send).toHaveBeenCalled() + }) + + it('skips the sync cycle and logs error when unarchive fails', async () => { + const mockSetArchived = jest + .fn, [boolean]>() + .mockRejectedValue(new Error('Missing Permissions')) + const channel = makeChannel({ + isThread: () => true, + archived: true, + setArchived: mockSetArchived, + }) + + await aiDevToolkitService.syncBoard(makeClient(channel)) + + expect(mockSetArchived).toHaveBeenCalledWith(false) + expect(mockErrorLog).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('cannot unarchive'), + }), + ) + expect(channel.send).not.toHaveBeenCalled() + }) + + it('does not call setArchived when the thread is not archived', async () => { + const mockSetArchived = jest.fn() + const channel = makeChannel({ + isThread: () => true, + archived: false, + setArchived: mockSetArchived, + }) + + await aiDevToolkitService.syncBoard(makeClient(channel)) + + expect(mockSetArchived).not.toHaveBeenCalled() + expect(channel.send).toHaveBeenCalled() + }) + + it('does not call setArchived for regular text channels', async () => { + const mockSetArchived = jest.fn() + const channel = makeChannel({ + isThread: () => false, + setArchived: mockSetArchived, + }) + + await aiDevToolkitService.syncBoard(makeClient(channel)) + + expect(mockSetArchived).not.toHaveBeenCalled() + }) + }) +})