diff --git a/apps/bot/bun.lockb b/apps/bot/bun.lockb index 7634d82..da371bc 100755 Binary files a/apps/bot/bun.lockb and b/apps/bot/bun.lockb differ diff --git a/apps/bot/src/lib/channelService.ts b/apps/bot/src/lib/channelService.ts index 402ac6c..54993cd 100644 --- a/apps/bot/src/lib/channelService.ts +++ b/apps/bot/src/lib/channelService.ts @@ -142,6 +142,7 @@ export class ChannelManagementService { }); // Restrict @everyone from sending messages and adding reactions after channel inherits category permissions + // ReadMessageHistory is granted to @everyone so the bot (and all members) can read EasyPoll results try { await channel.permissionOverwrites.edit(guild.roles.everyone.id, { SendMessages: false, @@ -150,7 +151,7 @@ export class ChannelManagementService { ReadMessageHistory: true, }); logger.info( - `Restricted @everyone from sending messages and adding reactions in vote channel: ${nominee.name}` + `Set permissions for vote channel: ${nominee.name}` ); } catch (error) { logger.error( @@ -159,7 +160,7 @@ export class ChannelManagementService { channelId: channel.id, nomineeName: nominee.name, }, - 'Failed to restrict @everyone permissions' + 'Failed to set vote channel permissions' ); } diff --git a/apps/bot/src/lib/voteResultService.ts b/apps/bot/src/lib/voteResultService.ts index b567f3a..837c7d9 100644 --- a/apps/bot/src/lib/voteResultService.ts +++ b/apps/bot/src/lib/voteResultService.ts @@ -107,6 +107,26 @@ export class VoteResultService { */ private async findPollInChannel(channel: TextChannel): Promise { try { + // Check if bot has ReadMessageHistory permission + const botMember = channel.guild.members.me; + if (!botMember) { + logger.error({ channelId: channel.id }, 'Bot is not a member of this guild'); + return null; + } + + const hasReadPermission = channel.permissionsFor(botMember)?.has('ReadMessageHistory'); + if (!hasReadPermission) { + logger.error( + { + channelId: channel.id, + channelName: channel.name, + guildId: channel.guild.id + }, + 'Bot lacks ReadMessageHistory permission in vote channel. Please grant this permission to read poll results.' + ); + return null; + } + // Fetch recent messages to find the poll - force cache bypass const messages = await channel.messages.fetch({ limit: DISCORD_CONSTANTS.LIMITS.MESSAGE_FETCH_LIMIT, force: true }); diff --git a/apps/bot/src/tests/messageAccessSecurity.test.ts b/apps/bot/src/tests/messageAccessSecurity.test.ts index 235f989..7aa67d4 100644 --- a/apps/bot/src/tests/messageAccessSecurity.test.ts +++ b/apps/bot/src/tests/messageAccessSecurity.test.ts @@ -31,12 +31,33 @@ describe('Message Access Security Tests', () => { // Reset message access tracking messageAccessLog = []; + // Pre-create mockGuild so channels can reference it + mockGuild = { + id: 'test-guild-id', + members: { + me: { id: 'bot-id' }, // Bot member for permission checks + cache: new Map(), + fetch: mock(async () => new Map()) + }, + channels: { + cache: new Map(), + fetch: mock(async (id: string) => { + const channel = mockGuild.channels.cache.get(id); + if (channel) return channel; + throw new Error('Channel not found'); + }) + } + }; + // Create mock channels with tracking const createTrackedChannel = (id: string, type: string, name: string) => ({ id, name, type: 0, // GUILD_TEXT guild: mockGuild, + permissionsFor: mock(() => ({ + has: mock(() => true) // Grant all permissions in tests + })), messages: { fetch: mock(async (options?: any) => { messageAccessLog.push({ @@ -51,7 +72,7 @@ describe('Message Access Security Tests', () => { id: 'poll-message-id', author: { id: '437618149505105920' }, // EasyPoll bot ID embeds: [{ - description: 'Poll Results: ✅ 15 votes, ❌ 3 votes - Poll closed' + description: '**Question**\nShould we invite nominee?\n\n**Final Result**\n✅ **Yes, Accept** — 15 votes (83%)\n❌ **No, Reject** — 3 votes (17%)' }], createdTimestamp: Date.now() - 30000, // 30 seconds ago reactions: { @@ -62,6 +83,12 @@ describe('Message Access Security Tests', () => { } }; + // If fetching by ID (string), return the message directly + if (typeof options === 'string') { + return mockMessage; + } + + // Otherwise return a Map collection const mockCollection = new Map(); mockCollection.set('poll-message-id', mockMessage); return mockCollection; @@ -80,26 +107,10 @@ describe('Message Access Security Tests', () => { mockDiscussionChannel = createTrackedChannel('discussion-channel-456', 'discussion', 'nominee-discussion-smith'); mockGovernanceChannel = createTrackedChannel('governance-channel-789', 'governance', 'governance'); - // Mock guild - mockGuild = { - id: 'test-guild-id', - channels: { - cache: new Map([ - ['vote-channel-123', mockVoteChannel], - ['discussion-channel-456', mockDiscussionChannel], - ['governance-channel-789', mockGovernanceChannel] - ]), - fetch: mock(async (id: string) => { - const channel = mockGuild.channels.cache.get(id); - if (channel) return channel; - throw new Error('Channel not found'); - }) - }, - members: { - cache: new Map(), - fetch: mock(async () => new Map()) - } - }; + // Add channels to guild's cache + mockGuild.channels.cache.set('vote-channel-123', mockVoteChannel); + mockGuild.channels.cache.set('discussion-channel-456', mockDiscussionChannel); + mockGuild.channels.cache.set('governance-channel-789', mockGovernanceChannel); // Mock client mockClient = {