diff --git a/packages/bot/src/handlers/__tests__/messageHandler.test.ts b/packages/bot/src/handlers/__tests__/messageHandler.test.ts new file mode 100644 index 000000000..fdf94ac43 --- /dev/null +++ b/packages/bot/src/handlers/__tests__/messageHandler.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, jest } from '@jest/globals' +import type { Client, Message } from 'discord.js' +import { Events } from 'discord.js' + +jest.mock('@lucky/shared/services', () => ({ + featureToggleService: { + isEnabled: jest.fn(), + }, +})) + +jest.mock('@lucky/shared/utils', () => ({ + errorLog: jest.fn(), +})) + +const mockExecute = jest.fn() +const mockRegister = jest.fn().mockReturnThis() + +jest.mock('../message/pipeline', () => ({ + MessagePipeline: jest.fn().mockImplementation(() => ({ + register: mockRegister, + execute: mockExecute, + })), +})) + +jest.mock('../message/autoModHandler', () => ({ + autoModHandler: {}, +})) + +jest.mock('../message/spamHandler', () => ({ + spamHandler: {}, +})) + +jest.mock('../message/customCommandHandler', () => ({ + customCommandHandler: {}, +})) + +jest.mock('../message/xpHandler', () => ({ + xpHandler: {}, +})) + +import { featureToggleService } from '@lucky/shared/services' +import { errorLog } from '@lucky/shared/utils' +import { handleMessageCreate } from '../messageHandler' + +describe('messageHandler', () => { + beforeEach(() => { + jest.clearAllMocks() + mockExecute.mockClear() + mockRegister.mockClear() + }) + + describe('handleMessageCreate', () => { + it('should register Events.MessageCreate listener on client', () => { + const mockClient = { + on: jest.fn(), + } as unknown as Client + + handleMessageCreate(mockClient) + + expect(mockClient.on).toHaveBeenCalledWith( + Events.MessageCreate, + expect.any(Function), + ) + }) + + it('should early return when message.guild is falsy', async () => { + const mockClient = { + on: jest.fn(), + } as unknown as Client + + handleMessageCreate(mockClient) + + const callback = (mockClient.on as jest.Mock).mock.calls[0][1] + + const mockMessage = { + guild: null, + member: { id: 'member1' }, + author: { id: 'user1' }, + } as unknown as Message + + await callback(mockMessage) + + expect(featureToggleService.isEnabled).not.toHaveBeenCalled() + }) + + it('should early return when message.member is falsy', async () => { + const mockClient = { + on: jest.fn(), + } as unknown as Client + + handleMessageCreate(mockClient) + + const callback = (mockClient.on as jest.Mock).mock.calls[0][1] + + const mockMessage = { + guild: { id: 'guild1' }, + member: null, + author: { id: 'user1' }, + } as unknown as Message + + await callback(mockMessage) + + expect(featureToggleService.isEnabled).not.toHaveBeenCalled() + }) + + it('should fetch both feature toggles via featureToggleService', async () => { + ;(featureToggleService.isEnabled as jest.Mock) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + + const mockClient = { + on: jest.fn(), + } as unknown as Client + + handleMessageCreate(mockClient) + + const callback = (mockClient.on as jest.Mock).mock.calls[0][1] + + const mockMessage = { + guild: { id: 'guild1' }, + member: { id: 'member1' }, + author: { id: 'user1', bot: false }, + } as unknown as Message + + await callback(mockMessage) + + expect(featureToggleService.isEnabled).toHaveBeenCalledWith( + 'AUTOMOD', + { guildId: 'guild1' }, + ) + expect(featureToggleService.isEnabled).toHaveBeenCalledWith( + 'CUSTOM_COMMANDS', + { guildId: 'guild1' }, + ) + }) + + it('should handle featureToggleService failures gracefully', async () => { + ;(featureToggleService.isEnabled as jest.Mock) + .mockRejectedValueOnce(new Error('Service error')) + .mockResolvedValueOnce(true) + + mockExecute.mockResolvedValueOnce(undefined) + + const mockClient = { + on: jest.fn(), + } as unknown as Client + + handleMessageCreate(mockClient) + + const callback = (mockClient.on as jest.Mock).mock.calls[0][1] + + const mockMessage = { + guild: { id: 'guild1' }, + member: { id: 'member1' }, + author: { id: 'user1', bot: false }, + } as unknown as Message + + await callback(mockMessage) + + expect(mockExecute).toHaveBeenCalled() + }) + + it('should call pipeline.execute with correct context', async () => { + ;(featureToggleService.isEnabled as jest.Mock) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + + mockExecute.mockResolvedValueOnce(undefined) + + const mockClient = { + on: jest.fn(), + } as unknown as Client + + handleMessageCreate(mockClient) + + const callback = (mockClient.on as jest.Mock).mock.calls[0][1] + + const mockMessage = { + guild: { id: 'guild1' }, + member: { id: 'member1' }, + author: { id: 'user1', bot: false }, + } as unknown as Message + + await callback(mockMessage) + + expect(mockExecute).toHaveBeenCalledWith( + mockMessage, + expect.objectContaining({ + guild: mockMessage.guild, + member: mockMessage.member, + featureToggles: { + AUTOMOD: true, + CUSTOM_COMMANDS: false, + }, + }), + ) + }) + + it('should call errorLog when an error is thrown inside the handler', async () => { + ;(featureToggleService.isEnabled as jest.Mock) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + + const mockError = new Error('Pipeline error') + mockExecute.mockRejectedValueOnce(mockError) + + const mockClient = { + on: jest.fn(), + } as unknown as Client + + handleMessageCreate(mockClient) + + const callback = (mockClient.on as jest.Mock).mock.calls[0][1] + + const mockMessage = { + guild: { id: 'guild1' }, + member: { id: 'member1' }, + author: { id: 'user1', bot: false }, + } as unknown as Message + + await callback(mockMessage) + + expect(errorLog).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error handling message:', + error: mockError, + }), + ) + }) + }) +}) diff --git a/packages/bot/src/handlers/message/__tests__/autoModHandler.test.ts b/packages/bot/src/handlers/message/__tests__/autoModHandler.test.ts new file mode 100644 index 000000000..8a9491388 --- /dev/null +++ b/packages/bot/src/handlers/message/__tests__/autoModHandler.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it, jest } from '@jest/globals' +import type { Message, GuildMember } from 'discord.js' +import { autoModHandler } from '../autoModHandler' +import type { MessageContext } from '../types' + +jest.mock('@lucky/shared/services', () => ({ + autoModService: { + getSettings: jest.fn(), + checkCaps: jest.fn(), + checkLinks: jest.fn(), + checkInvites: jest.fn(), + checkWords: jest.fn(), + }, + moderationService: { + createCase: jest.fn(), + }, +})) + +jest.mock('@lucky/shared/utils', () => ({ + errorLog: jest.fn(), +})) + +import { autoModService, moderationService } from '@lucky/shared/services' + +describe('autoModHandler', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(moderationService.createCase as jest.Mock).mockResolvedValue( + undefined, + ) + }) + + describe('canHandle', () => { + it('should return false when AUTOMOD feature toggle is false', async () => { + const message = { + author: { bot: false }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { AUTOMOD: false }, + } + + const result = await autoModHandler.canHandle(message, context) + expect(result).toBe(false) + }) + + it('should return false when message is from a bot', async () => { + const message = { + author: { bot: true }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await autoModHandler.canHandle(message, context) + expect(result).toBe(false) + }) + + it('should return true for valid non-bot message with AUTOMOD enabled', async () => { + const message = { + author: { bot: false }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await autoModHandler.canHandle(message, context) + expect(result).toBe(true) + }) + }) + + describe('handle', () => { + it('should return stop: false when settings are null', async () => { + ;(autoModService.getSettings as jest.Mock).mockResolvedValue(null) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: { + roles: { cache: { map: jest.fn().mockReturnValue([]) } }, + } as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await autoModHandler.handle(message, context) + expect(result.stop).toBe(false) + }) + + it('should return stop: false when channel is exempt', async () => { + ;(autoModService.getSettings as jest.Mock).mockResolvedValue({ + exemptChannels: ['channel1'], + exemptRoles: [], + capsEnabled: true, + linksEnabled: true, + invitesEnabled: true, + wordsEnabled: true, + }) + + const message = { + author: { id: 'user1', bot: false, tag: 'user#1' }, + channelId: 'channel1', + content: 'HELLO', + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: { + roles: { cache: { map: jest.fn().mockReturnValue([]) } }, + } as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await autoModHandler.handle(message, context) + expect(result.stop).toBe(false) + }) + + it('should return stop: false when role is exempt', async () => { + ;(autoModService.getSettings as jest.Mock).mockResolvedValue({ + exemptChannels: [], + exemptRoles: ['role1'], + capsEnabled: true, + linksEnabled: true, + invitesEnabled: true, + wordsEnabled: true, + }) + + const message = { + author: { id: 'user1', bot: false, tag: 'user#1' }, + channelId: 'channel2', + content: 'HELLO', + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: { + roles: { + cache: { map: jest.fn().mockReturnValue(['role1']) }, + }, + } as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await autoModHandler.handle(message, context) + expect(result.stop).toBe(false) + }) + + it('should detect caps violations', async () => { + ;(autoModService.getSettings as jest.Mock).mockResolvedValue({ + exemptChannels: [], + exemptRoles: [], + capsEnabled: true, + linksEnabled: false, + invitesEnabled: false, + wordsEnabled: false, + }) + ;(autoModService.checkCaps as jest.Mock).mockResolvedValue(true) + + const message = { + author: { id: 'user1', bot: false, tag: 'user#1' }, + channelId: 'channel1', + content: 'HELLO', + delete: jest.fn().mockResolvedValue(undefined), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: { + roles: { cache: { map: jest.fn().mockReturnValue([]) } }, + } as any, + featureToggles: { AUTOMOD: true }, + } + + // Verify checkCaps was called + await autoModHandler.handle(message, context) + expect(autoModService.checkCaps).toHaveBeenCalledWith( + 'guild1', + 'HELLO', + ) + }) + + it('should detect links violations', async () => { + ;(autoModService.getSettings as jest.Mock).mockResolvedValue({ + exemptChannels: [], + exemptRoles: [], + capsEnabled: false, + linksEnabled: true, + invitesEnabled: false, + wordsEnabled: false, + }) + ;(autoModService.checkLinks as jest.Mock).mockResolvedValue(true) + + const message = { + author: { id: 'user1', bot: false, tag: 'user#1' }, + channelId: 'channel1', + content: 'http://example.com', + delete: jest.fn().mockResolvedValue(undefined), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: { + roles: { cache: { map: jest.fn().mockReturnValue([]) } }, + } as any, + featureToggles: { AUTOMOD: true }, + } + + // Verify checkLinks was called + await autoModHandler.handle(message, context) + expect(autoModService.checkLinks).toHaveBeenCalledWith( + 'guild1', + 'http://example.com', + 'channel1', + ) + }) + + it('should detect invites violations', async () => { + ;(autoModService.getSettings as jest.Mock).mockResolvedValue({ + exemptChannels: [], + exemptRoles: [], + capsEnabled: false, + linksEnabled: false, + invitesEnabled: true, + wordsEnabled: false, + }) + ;(autoModService.checkInvites as jest.Mock).mockResolvedValue(true) + + const message = { + author: { id: 'user1', bot: false, tag: 'user#1' }, + channelId: 'channel1', + content: 'discord.gg/example', + delete: jest.fn().mockResolvedValue(undefined), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: { + roles: { cache: { map: jest.fn().mockReturnValue([]) } }, + } as any, + featureToggles: { AUTOMOD: true }, + } + + // Verify checkInvites was called + await autoModHandler.handle(message, context) + expect(autoModService.checkInvites).toHaveBeenCalledWith( + 'guild1', + 'discord.gg/example', + ) + }) + + it('should detect bad words violations', async () => { + ;(autoModService.getSettings as jest.Mock).mockResolvedValue({ + exemptChannels: [], + exemptRoles: [], + capsEnabled: false, + linksEnabled: false, + invitesEnabled: false, + wordsEnabled: true, + }) + ;(autoModService.checkWords as jest.Mock).mockResolvedValue(true) + + const message = { + author: { id: 'user1', bot: false, tag: 'user#1' }, + channelId: 'channel1', + content: 'badword', + delete: jest.fn().mockResolvedValue(undefined), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: { + roles: { cache: { map: jest.fn().mockReturnValue([]) } }, + } as any, + featureToggles: { AUTOMOD: true }, + } + + // Verify checkWords was called + await autoModHandler.handle(message, context) + expect(autoModService.checkWords).toHaveBeenCalledWith( + 'guild1', + 'badword', + ) + }) + + it('should return stop: false when no violations', async () => { + ;(autoModService.getSettings as jest.Mock).mockResolvedValue({ + exemptChannels: [], + exemptRoles: [], + capsEnabled: true, + linksEnabled: true, + invitesEnabled: true, + wordsEnabled: true, + }) + ;(autoModService.checkCaps as jest.Mock).mockResolvedValue(false) + ;(autoModService.checkLinks as jest.Mock).mockResolvedValue(false) + ;(autoModService.checkInvites as jest.Mock).mockResolvedValue(false) + ;(autoModService.checkWords as jest.Mock).mockResolvedValue(false) + + const message = { + author: { id: 'user1', bot: false, tag: 'user#1' }, + channelId: 'channel1', + content: 'normal message', + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: { + roles: { cache: { map: jest.fn().mockReturnValue([]) } }, + } as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await autoModHandler.handle(message, context) + expect(result.stop).toBe(false) + }) + }) +}) diff --git a/packages/bot/src/handlers/message/__tests__/customCommandHandler.test.ts b/packages/bot/src/handlers/message/__tests__/customCommandHandler.test.ts new file mode 100644 index 000000000..13eb071c1 --- /dev/null +++ b/packages/bot/src/handlers/message/__tests__/customCommandHandler.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it, jest } from '@jest/globals' +import type { Message } from 'discord.js' +import { customCommandHandler } from '../customCommandHandler' +import type { MessageContext } from '../types' + +jest.mock('@lucky/shared/services', () => ({ + customCommandService: { + listCommands: jest.fn(), + incrementUsage: jest.fn(), + }, +})) + +jest.mock('@lucky/shared/utils', () => ({ + errorLog: jest.fn(), +})) + +import { customCommandService } from '@lucky/shared/services' + +describe('customCommandHandler', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('canHandle', () => { + it('should return false when CUSTOM_COMMANDS feature toggle is false', async () => { + const message = { + author: { bot: false }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { CUSTOM_COMMANDS: false }, + } + + const result = await customCommandHandler.canHandle( + message, + context, + ) + expect(result).toBe(false) + }) + + it('should return false when message is from a bot', async () => { + const message = { + author: { bot: true }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { CUSTOM_COMMANDS: true }, + } + + const result = await customCommandHandler.canHandle( + message, + context, + ) + expect(result).toBe(false) + }) + + it('should return true for valid non-bot message with CUSTOM_COMMANDS enabled', async () => { + const message = { + author: { bot: false }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { CUSTOM_COMMANDS: true }, + } + + const result = await customCommandHandler.canHandle( + message, + context, + ) + expect(result).toBe(true) + }) + }) + + describe('handle', () => { + it('should return stop: false when no commands found', async () => { + ;(customCommandService.listCommands as jest.Mock).mockResolvedValue( + null, + ) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + content: '!help', + reply: jest.fn(), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { CUSTOM_COMMANDS: true }, + } + + const result = await customCommandHandler.handle(message, context) + expect(result.stop).toBe(false) + expect(message.reply).not.toHaveBeenCalled() + }) + + it('should return stop: false when no command matches', async () => { + ;(customCommandService.listCommands as jest.Mock).mockResolvedValue( + [ + { + name: 'hello', + response: 'Hello there!', + }, + ], + ) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + content: 'goodbye', + reply: jest.fn(), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { CUSTOM_COMMANDS: true }, + } + + const result = await customCommandHandler.handle(message, context) + expect(result.stop).toBe(false) + expect(message.reply).not.toHaveBeenCalled() + }) + + it('should match exact trigger and reply', async () => { + ;(customCommandService.listCommands as jest.Mock).mockResolvedValue( + [ + { + name: 'hello', + response: 'Hello there!', + }, + ], + ) + ;( + customCommandService.incrementUsage as jest.Mock + ).mockResolvedValue(undefined) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + content: 'hello', + reply: jest.fn().mockResolvedValue(undefined), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { CUSTOM_COMMANDS: true }, + } + + const result = await customCommandHandler.handle(message, context) + expect(result.stop).toBe(false) + expect(message.reply).toHaveBeenCalledWith({ + content: 'Hello there!', + allowedMentions: { repliedUser: false }, + }) + expect(customCommandService.incrementUsage).toHaveBeenCalledWith( + 'guild1', + 'hello', + ) + }) + + it('should match prefix trigger (trigger + space + args) and reply', async () => { + ;(customCommandService.listCommands as jest.Mock).mockResolvedValue( + [ + { + name: 'greet', + response: 'Hi there!', + }, + ], + ) + ;( + customCommandService.incrementUsage as jest.Mock + ).mockResolvedValue(undefined) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + content: 'greet @user', + reply: jest.fn().mockResolvedValue(undefined), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { CUSTOM_COMMANDS: true }, + } + + const result = await customCommandHandler.handle(message, context) + expect(result.stop).toBe(false) + expect(message.reply).toHaveBeenCalledWith({ + content: 'Hi there!', + allowedMentions: { repliedUser: false }, + }) + expect(customCommandService.incrementUsage).toHaveBeenCalledWith( + 'guild1', + 'greet', + ) + }) + + it('should return stop: false after successful command execution', async () => { + ;(customCommandService.listCommands as jest.Mock).mockResolvedValue( + [{ name: 'help', response: 'Here is help' }], + ) + ;( + customCommandService.incrementUsage as jest.Mock + ).mockResolvedValue(undefined) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + content: 'help', + reply: jest.fn().mockResolvedValue(undefined), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { CUSTOM_COMMANDS: true }, + } + + const result = await customCommandHandler.handle(message, context) + expect(result.stop).toBe(false) + }) + + it('should handle empty command list', async () => { + ;(customCommandService.listCommands as jest.Mock).mockResolvedValue( + [], + ) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + content: 'hello', + reply: jest.fn(), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { CUSTOM_COMMANDS: true }, + } + + const result = await customCommandHandler.handle(message, context) + expect(result.stop).toBe(false) + expect(message.reply).not.toHaveBeenCalled() + }) + + it('should handle error when listCommands throws', async () => { + const error = new Error('Service error') + ;(customCommandService.listCommands as jest.Mock).mockRejectedValue( + error, + ) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + content: 'hello', + reply: jest.fn(), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { CUSTOM_COMMANDS: true }, + } + + const result = await customCommandHandler.handle(message, context) + expect(result.stop).toBe(false) + expect(message.reply).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/bot/src/handlers/message/__tests__/pipeline.test.ts b/packages/bot/src/handlers/message/__tests__/pipeline.test.ts new file mode 100644 index 000000000..3b58830e4 --- /dev/null +++ b/packages/bot/src/handlers/message/__tests__/pipeline.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, jest } from '@jest/globals' +import type { Message } from 'discord.js' +import { MessagePipeline } from '../pipeline' +import type { MessageContext, MessageHandler } from '../types' + +jest.mock('@lucky/shared/utils', () => ({ + errorLog: jest.fn(), +})) + +describe('MessagePipeline', () => { + it('should register handlers in order', () => { + const pipeline = new MessagePipeline() + const handler1: MessageHandler = { + name: 'Handler1', + canHandle: jest.fn(async () => true), + handle: jest.fn(async () => ({ stop: false })), + } + const handler2: MessageHandler = { + name: 'Handler2', + canHandle: jest.fn(async () => true), + handle: jest.fn(async () => ({ stop: false })), + } + + pipeline.register(handler1) + pipeline.register(handler2) + + const result = pipeline.register(handler1) + expect(result).toBe(pipeline) + }) + + it('should execute all handlers when none return stop:true', async () => { + const pipeline = new MessagePipeline() + const handler1: MessageHandler = { + name: 'Handler1', + canHandle: jest.fn(async () => true), + handle: jest.fn(async () => ({ stop: false })), + } + const handler2: MessageHandler = { + name: 'Handler2', + canHandle: jest.fn(async () => true), + handle: jest.fn(async () => ({ stop: false })), + } + + pipeline.register(handler1).register(handler2) + + const message = { + guild: { id: 'guild1' }, + member: { id: 'member1' }, + } as unknown as Message + const context = { + guild: message.guild, + member: message.member, + featureToggles: {}, + } as unknown as MessageContext + + await pipeline.execute(message, context) + + expect(handler1.handle).toHaveBeenCalledWith(message, context) + expect(handler2.handle).toHaveBeenCalledWith(message, context) + }) + + it('should halt pipeline when handler returns stop:true', async () => { + const pipeline = new MessagePipeline() + const handler1: MessageHandler = { + name: 'Handler1', + canHandle: jest.fn(async () => true), + handle: jest.fn(async () => ({ stop: true })), + } + const handler2: MessageHandler = { + name: 'Handler2', + canHandle: jest.fn(async () => true), + handle: jest.fn(async () => ({ stop: false })), + } + + pipeline.register(handler1).register(handler2) + + const message = { + guild: { id: 'guild1' }, + member: { id: 'member1' }, + } as unknown as Message + const context = { + guild: message.guild, + member: message.member, + featureToggles: {}, + } as unknown as MessageContext + + await pipeline.execute(message, context) + + expect(handler1.handle).toHaveBeenCalled() + expect(handler2.handle).not.toHaveBeenCalled() + }) + + it('should skip handler when canHandle returns false', async () => { + const pipeline = new MessagePipeline() + const handler1: MessageHandler = { + name: 'Handler1', + canHandle: jest.fn(async () => false), + handle: jest.fn(async () => ({ stop: false })), + } + const handler2: MessageHandler = { + name: 'Handler2', + canHandle: jest.fn(async () => true), + handle: jest.fn(async () => ({ stop: false })), + } + + pipeline.register(handler1).register(handler2) + + const message = { + guild: { id: 'guild1' }, + member: { id: 'member1' }, + } as unknown as Message + const context = { + guild: message.guild, + member: message.member, + featureToggles: {}, + } as unknown as MessageContext + + await pipeline.execute(message, context) + + expect(handler1.canHandle).toHaveBeenCalled() + expect(handler1.handle).not.toHaveBeenCalled() + expect(handler2.handle).toHaveBeenCalled() + }) + + it('should isolate errors - one handler error does not crash pipeline', async () => { + const pipeline = new MessagePipeline() + const handler1: MessageHandler = { + name: 'Handler1', + canHandle: jest.fn(async () => true), + handle: jest.fn(async () => { + throw new Error('Handler1 error') + }), + } + const handler2: MessageHandler = { + name: 'Handler2', + canHandle: jest.fn(async () => true), + handle: jest.fn(async () => ({ stop: false })), + } + + pipeline.register(handler1).register(handler2) + + const message = { + guild: { id: 'guild1' }, + member: { id: 'member1' }, + } as unknown as Message + const context = { + guild: message.guild, + member: message.member, + featureToggles: {}, + } as unknown as MessageContext + + await expect(pipeline.execute(message, context)).resolves.not.toThrow() + expect(handler2.handle).toHaveBeenCalled() + }) + + it('should catch canHandle errors and continue', async () => { + const pipeline = new MessagePipeline() + const handler1: MessageHandler = { + name: 'Handler1', + canHandle: jest.fn(async () => { + throw new Error('canHandle error') + }), + handle: jest.fn(async () => ({ stop: false })), + } + const handler2: MessageHandler = { + name: 'Handler2', + canHandle: jest.fn(async () => true), + handle: jest.fn(async () => ({ stop: false })), + } + + pipeline.register(handler1).register(handler2) + + const message = { + guild: { id: 'guild1' }, + member: { id: 'member1' }, + } as unknown as Message + const context = { + guild: message.guild, + member: message.member, + featureToggles: {}, + } as unknown as MessageContext + + await expect(pipeline.execute(message, context)).resolves.not.toThrow() + expect(handler2.handle).toHaveBeenCalled() + }) +}) diff --git a/packages/bot/src/handlers/message/__tests__/spamHandler.test.ts b/packages/bot/src/handlers/message/__tests__/spamHandler.test.ts new file mode 100644 index 000000000..b9bff5dc0 --- /dev/null +++ b/packages/bot/src/handlers/message/__tests__/spamHandler.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, jest } from '@jest/globals' +import type { Message } from 'discord.js' +import { spamHandler } from '../spamHandler' +import type { MessageContext } from '../types' + +jest.mock('@lucky/shared/services', () => ({ + autoModService: { + getSettings: jest.fn(), + trackMessageAndCheckSpam: jest.fn(), + }, +})) + +jest.mock('@lucky/shared/utils', () => ({ + errorLog: jest.fn(), +})) + +import { autoModService } from '@lucky/shared/services' + +describe('spamHandler', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('canHandle', () => { + it('should return false when AUTOMOD feature toggle is false', async () => { + const message = { + author: { bot: false }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { AUTOMOD: false }, + } + + const result = await spamHandler.canHandle(message, context) + expect(result).toBe(false) + }) + + it('should return false when message is from a bot', async () => { + ;(autoModService.getSettings as jest.Mock).mockResolvedValue({ + spamEnabled: true, + }) + + const message = { + author: { bot: true }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await spamHandler.canHandle(message, context) + expect(result).toBe(false) + }) + + it('should return true for valid non-bot message with AUTOMOD enabled and spamEnabled true', async () => { + ;(autoModService.getSettings as jest.Mock).mockResolvedValue({ + spamEnabled: true, + }) + + const message = { + author: { bot: false }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await spamHandler.canHandle(message, context) + expect(result).toBe(true) + }) + + it('should return false when spamEnabled is false in settings', async () => { + ;(autoModService.getSettings as jest.Mock).mockResolvedValue({ + spamEnabled: false, + }) + + const message = { + author: { bot: false }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await spamHandler.canHandle(message, context) + expect(result).toBe(false) + }) + }) + + describe('handle', () => { + it('should return stop: false when settings are null', async () => { + ;( + autoModService.trackMessageAndCheckSpam as jest.Mock + ).mockResolvedValue(false) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + delete: jest.fn(), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await spamHandler.handle(message, context) + expect(result.stop).toBe(false) + }) + + it('should return stop: true when spam detected', async () => { + ;( + autoModService.trackMessageAndCheckSpam as jest.Mock + ).mockResolvedValue(true) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + delete: jest.fn().mockResolvedValue(undefined), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await spamHandler.handle(message, context) + expect(result.stop).toBe(true) + expect(message.delete).toHaveBeenCalled() + }) + + it('should return stop: false when no spam', async () => { + ;( + autoModService.trackMessageAndCheckSpam as jest.Mock + ).mockResolvedValue(false) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + delete: jest.fn(), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await spamHandler.handle(message, context) + expect(result.stop).toBe(false) + expect(message.delete).not.toHaveBeenCalled() + }) + + it('should handle error when trackMessageAndCheckSpam throws', async () => { + const error = new Error('Service error') + ;( + autoModService.trackMessageAndCheckSpam as jest.Mock + ).mockRejectedValue(error) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + delete: jest.fn(), + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: { AUTOMOD: true }, + } + + const result = await spamHandler.handle(message, context) + expect(result.stop).toBe(false) + }) + }) +}) diff --git a/packages/bot/src/handlers/message/__tests__/xpHandler.test.ts b/packages/bot/src/handlers/message/__tests__/xpHandler.test.ts new file mode 100644 index 000000000..a78a66f24 --- /dev/null +++ b/packages/bot/src/handlers/message/__tests__/xpHandler.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it, jest } from '@jest/globals' +import type { Message, TextChannel } from 'discord.js' +import { xpHandler } from '../xpHandler' +import type { MessageContext } from '../types' + +jest.mock('@lucky/shared/services', () => ({ + levelService: { + getConfig: jest.fn(), + getMemberXP: jest.fn(), + addXP: jest.fn(), + getRewards: jest.fn(), + }, +})) + +jest.mock('@lucky/shared/utils', () => ({ + errorLog: jest.fn(), +})) + +import { levelService } from '@lucky/shared/services' + +describe('xpHandler', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('canHandle', () => { + it('should return false when message is from a bot', async () => { + const message = { + author: { bot: true }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: {}, + } + + const result = await xpHandler.canHandle(message, context) + expect(result).toBe(false) + }) + + it('should return true even when guild exists (canHandle does not check guild)', async () => { + const message = { + author: { bot: false }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: {}, + } + + const result = await xpHandler.canHandle(message, context) + expect(result).toBe(true) + }) + + it('should return true for valid non-bot guild message', async () => { + const message = { + author: { bot: false }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: {}, + } + + const result = await xpHandler.canHandle(message, context) + expect(result).toBe(true) + }) + }) + + describe('handle', () => { + it('should return stop: false when config is null', async () => { + ;(levelService.getConfig as jest.Mock).mockResolvedValue(null) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: {}, + } + + const result = await xpHandler.handle(message, context) + expect(result.stop).toBe(false) + }) + + it('should return stop: false when config is disabled', async () => { + ;(levelService.getConfig as jest.Mock).mockResolvedValue({ + enabled: false, + }) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: {}, + } + + const result = await xpHandler.handle(message, context) + expect(result.stop).toBe(false) + }) + + it('should return stop: false when on cooldown', async () => { + const now = Date.now() + ;(levelService.getConfig as jest.Mock).mockResolvedValue({ + enabled: true, + xpCooldownMs: 60000, + xpPerMessage: 10, + }) + ;(levelService.getMemberXP as jest.Mock).mockResolvedValue({ + lastXpAt: new Date(now - 30000), // 30 seconds ago + }) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: {}, + } + + const result = await xpHandler.handle(message, context) + expect(result.stop).toBe(false) + expect(levelService.addXP).not.toHaveBeenCalled() + }) + + it('should add XP and return stop: false on success', async () => { + const now = Date.now() + ;(levelService.getConfig as jest.Mock).mockResolvedValue({ + enabled: true, + xpCooldownMs: 60000, + xpPerMessage: 10, + }) + ;(levelService.getMemberXP as jest.Mock).mockResolvedValue({ + lastXpAt: new Date(now - 61000), // 61 seconds ago, past cooldown + }) + ;(levelService.addXP as jest.Mock).mockResolvedValue({ + leveledUp: false, + newLevel: 1, + }) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: {}, + } + + const result = await xpHandler.handle(message, context) + expect(result.stop).toBe(false) + expect(levelService.addXP).toHaveBeenCalledWith( + 'guild1', + 'user1', + 10, + ) + }) + + it('should announce level-up when leveledUp is true and announceChannel exists', async () => { + const now = Date.now() + ;(levelService.getConfig as jest.Mock).mockResolvedValue({ + enabled: true, + xpCooldownMs: 60000, + xpPerMessage: 10, + announceChannel: 'announce1', + }) + ;(levelService.getMemberXP as jest.Mock).mockResolvedValue({ + lastXpAt: new Date(now - 61000), + }) + ;(levelService.addXP as jest.Mock).mockResolvedValue({ + leveledUp: true, + newLevel: 5, + }) + ;(levelService.getRewards as jest.Mock).mockResolvedValue([]) + + const mockChannel = { + isTextBased: jest.fn().mockReturnValue(true), + send: jest.fn().mockResolvedValue(undefined), + } + + const message = { + author: { + id: 'user1', + bot: false, + toString: jest.fn(() => '<@user1>'), + }, + channelId: 'channel1', + client: { + channels: { + fetch: jest.fn().mockResolvedValue(mockChannel), + }, + }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: { roles: { add: jest.fn() } } as any, + featureToggles: {}, + } + + const result = await xpHandler.handle(message, context) + expect(result.stop).toBe(false) + expect(mockChannel.send).toHaveBeenCalledWith( + expect.stringContaining('level **5**'), + ) + }) + + it('should assign role reward when level matches', async () => { + const now = Date.now() + ;(levelService.getConfig as jest.Mock).mockResolvedValue({ + enabled: true, + xpCooldownMs: 60000, + xpPerMessage: 10, + announceChannel: 'announce1', + }) + ;(levelService.getMemberXP as jest.Mock).mockResolvedValue({ + lastXpAt: new Date(now - 61000), + }) + ;(levelService.addXP as jest.Mock).mockResolvedValue({ + leveledUp: true, + newLevel: 5, + }) + ;(levelService.getRewards as jest.Mock).mockResolvedValue([ + { level: 5, roleId: 'role5' }, + { level: 10, roleId: 'role10' }, + ]) + + const mockChannel = { + isTextBased: jest.fn().mockReturnValue(true), + send: jest.fn().mockResolvedValue(undefined), + } + + const message = { + author: { + id: 'user1', + bot: false, + toString: jest.fn(() => '<@user1>'), + }, + channelId: 'channel1', + client: { + channels: { + fetch: jest.fn().mockResolvedValue(mockChannel), + }, + }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: { + roles: { add: jest.fn().mockResolvedValue(undefined) }, + } as any, + featureToggles: {}, + } + + const result = await xpHandler.handle(message, context) + expect(result.stop).toBe(false) + expect(context.member.roles.add).toHaveBeenCalledWith('role5') + }) + + it('should handle first-time XP addition with no prior record', async () => { + ;(levelService.getConfig as jest.Mock).mockResolvedValue({ + enabled: true, + xpCooldownMs: 60000, + xpPerMessage: 10, + }) + ;(levelService.getMemberXP as jest.Mock).mockResolvedValue(null) + ;(levelService.addXP as jest.Mock).mockResolvedValue({ + leveledUp: false, + newLevel: 1, + }) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: {}, + } + + const result = await xpHandler.handle(message, context) + expect(result.stop).toBe(false) + expect(levelService.addXP).toHaveBeenCalledWith( + 'guild1', + 'user1', + 10, + ) + }) + + it('should handle error when channels.fetch fails on level up', async () => { + const now = Date.now() + ;(levelService.getConfig as jest.Mock).mockResolvedValue({ + enabled: true, + xpCooldownMs: 60000, + xpPerMessage: 10, + announceChannel: 'announce1', + }) + ;(levelService.getMemberXP as jest.Mock).mockResolvedValue({ + lastXpAt: new Date(now - 61000), + }) + ;(levelService.addXP as jest.Mock).mockResolvedValue({ + leveledUp: true, + newLevel: 5, + }) + + const message = { + author: { + id: 'user1', + bot: false, + toString: jest.fn(() => '<@user1>'), + }, + channelId: 'channel1', + client: { + channels: { + fetch: jest + .fn() + .mockRejectedValue(new Error('Channel not found')), + }, + }, + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: { roles: { add: jest.fn() } } as any, + featureToggles: {}, + } + + const result = await xpHandler.handle(message, context) + expect(result.stop).toBe(false) + }) + + it('should handle error when addXP throws', async () => { + const now = Date.now() + ;(levelService.getConfig as jest.Mock).mockResolvedValue({ + enabled: true, + xpCooldownMs: 60000, + xpPerMessage: 10, + }) + ;(levelService.getMemberXP as jest.Mock).mockResolvedValue({ + lastXpAt: new Date(now - 61000), + }) + ;(levelService.addXP as jest.Mock).mockRejectedValue( + new Error('Database error'), + ) + + const message = { + author: { id: 'user1', bot: false }, + channelId: 'channel1', + } as unknown as Message + + const context: MessageContext = { + guild: { id: 'guild1' } as any, + member: {} as any, + featureToggles: {}, + } + + const result = await xpHandler.handle(message, context) + expect(result.stop).toBe(false) + }) + }) +}) diff --git a/packages/bot/src/handlers/message/autoModHandler.ts b/packages/bot/src/handlers/message/autoModHandler.ts new file mode 100644 index 000000000..dd76e3edb --- /dev/null +++ b/packages/bot/src/handlers/message/autoModHandler.ts @@ -0,0 +1,219 @@ +import type { Message } from 'discord.js' +import { autoModService, moderationService } from '@lucky/shared/services' +import { errorLog } from '@lucky/shared/utils' +import type { + MessageContext, + MessageHandler, + MessageHandlerResult, +} from './types' + +const AUTOMOD_MUTE_DURATION = 300 + +interface Violation { + type: string + reason: string + action: string +} + +export const autoModHandler: MessageHandler = { + name: 'AutoMod', + + async canHandle( + message: Message, + context: MessageContext, + ): Promise { + return context.featureToggles['AUTOMOD'] === true && !message.author.bot + }, + + async handle( + message: Message, + context: MessageContext, + ): Promise { + try { + const guildId = context.guild.id + const userId = message.author.id + const memberRoles = context.member.roles.cache.map((r) => r.id) + + const settings = await autoModService.getSettings(guildId) + if (!settings) return { stop: false } + + const ignoredChannels = settings.exemptChannels ?? [] + const ignoredRoles = settings.exemptRoles ?? [] + const hasIgnoredRole = memberRoles.some((role) => + ignoredRoles.includes(role), + ) + + if (ignoredChannels.includes(message.channelId) || hasIgnoredRole) { + return { stop: false } + } + + const violations: Violation[] = [] + + if (settings.capsEnabled) { + const isCaps = await autoModService.checkCaps( + guildId, + message.content, + ) + if (isCaps) { + violations.push({ + type: 'caps', + reason: 'Excessive capitalization', + action: 'delete', + }) + } + } + + if (settings.linksEnabled) { + const hasLinks = await autoModService.checkLinks( + guildId, + message.content, + message.channelId, + ) + if (hasLinks) { + violations.push({ + type: 'links', + reason: 'Unauthorized link detected', + action: 'delete', + }) + } + } + + if (settings.invitesEnabled) { + const hasInvites = await autoModService.checkInvites( + guildId, + message.content, + ) + if (hasInvites) { + violations.push({ + type: 'invites', + reason: 'Invite detected', + action: 'delete', + }) + } + } + + if (settings.wordsEnabled) { + const hasBadWords = await autoModService.checkWords( + guildId, + message.content, + ) + if (hasBadWords) { + violations.push({ + type: 'badwords', + reason: 'Inappropriate language detected', + action: 'delete', + }) + } + } + + if (violations.length === 0) { + return { stop: false } + } + + await message.delete().catch(() => {}) + + const caseInput = { + guildId, + userId, + username: message.author.tag, + moderatorId: message.client.user!.id, + moderatorName: message.client.user!.tag, + reason: `[AutoMod] ${violations[0].reason}`, + channelId: message.channelId, + } + + for (const violation of violations) { + switch (violation.action) { + case 'warn': + await moderationService + .createCase({ + ...caseInput, + type: 'warn', + }) + .catch((err) => { + errorLog({ + message: 'Failed to create warn case:', + error: err, + }) + }) + break + case 'mute': + await context.member + .timeout( + AUTOMOD_MUTE_DURATION * 1000, + caseInput.reason, + ) + .catch((err) => { + errorLog({ + message: 'Failed to mute user:', + error: err, + }) + }) + await moderationService + .createCase({ + ...caseInput, + type: 'mute', + duration: AUTOMOD_MUTE_DURATION, + }) + .catch((err) => { + errorLog({ + message: 'Failed to create mute case:', + error: err, + }) + }) + break + case 'kick': + await context.member + .kick(caseInput.reason) + .catch((err) => { + errorLog({ + message: 'Failed to kick user:', + error: err, + }) + }) + await moderationService + .createCase({ + ...caseInput, + type: 'kick', + }) + .catch((err) => { + errorLog({ + message: 'Failed to create kick case:', + error: err, + }) + }) + break + case 'ban': + await context.guild.members + .ban(userId, { reason: caseInput.reason }) + .catch((err) => { + errorLog({ + message: 'Failed to ban user:', + error: err, + }) + }) + await moderationService + .createCase({ + ...caseInput, + type: 'ban', + }) + .catch((err) => { + errorLog({ + message: 'Failed to create ban case:', + error: err, + }) + }) + break + } + } + + return { stop: true } + } catch (error) { + errorLog({ + message: 'Error running automod checks:', + error, + }) + return { stop: false } + } + }, +} diff --git a/packages/bot/src/handlers/message/customCommandHandler.ts b/packages/bot/src/handlers/message/customCommandHandler.ts new file mode 100644 index 000000000..f66d0c0df --- /dev/null +++ b/packages/bot/src/handlers/message/customCommandHandler.ts @@ -0,0 +1,66 @@ +import type { Message } from 'discord.js' +import { customCommandService } from '@lucky/shared/services' +import { errorLog } from '@lucky/shared/utils' +import type { CustomCommandModel } from '@lucky/shared/generated/prisma/models/CustomCommand' +import type { + MessageContext, + MessageHandler, + MessageHandlerResult, +} from './types' + +export const customCommandHandler: MessageHandler = { + name: 'CustomCommand', + + async canHandle( + message: Message, + context: MessageContext, + ): Promise { + return ( + context.featureToggles['CUSTOM_COMMANDS'] === true && + !message.author.bot + ) + }, + + async handle( + message: Message, + context: MessageContext, + ): Promise { + try { + const commands = await customCommandService.listCommands( + context.guild.id, + ) + if (!commands || commands.length === 0) { + return { stop: false } + } + + const matchedCommand = commands.find((cmd: CustomCommandModel) => { + return ( + message.content === cmd.name || + message.content.startsWith(cmd.name + ' ') + ) + }) + + if (!matchedCommand) { + return { stop: false } + } + + await message.reply({ + content: matchedCommand.response ?? '', + allowedMentions: { repliedUser: false }, + }) + + await customCommandService.incrementUsage( + context.guild.id, + matchedCommand.name, + ) + + return { stop: false } + } catch (error) { + errorLog({ + message: 'Error handling custom command:', + error, + }) + return { stop: false } + } + }, +} diff --git a/packages/bot/src/handlers/message/index.ts b/packages/bot/src/handlers/message/index.ts new file mode 100644 index 000000000..4bbcbaac6 --- /dev/null +++ b/packages/bot/src/handlers/message/index.ts @@ -0,0 +1,10 @@ +export { MessagePipeline } from './pipeline' +export type { + MessageContext, + MessageHandler, + MessageHandlerResult, +} from './types' +export { autoModHandler } from './autoModHandler' +export { spamHandler } from './spamHandler' +export { customCommandHandler } from './customCommandHandler' +export { xpHandler } from './xpHandler' diff --git a/packages/bot/src/handlers/message/pipeline.ts b/packages/bot/src/handlers/message/pipeline.ts new file mode 100644 index 000000000..1dc6a93bc --- /dev/null +++ b/packages/bot/src/handlers/message/pipeline.ts @@ -0,0 +1,29 @@ +import type { Message } from 'discord.js' +import type { MessageContext, MessageHandler } from './types' +import { errorLog } from '@lucky/shared/utils' + +export class MessagePipeline { + private handlers: MessageHandler[] = [] + + register(handler: MessageHandler): this { + this.handlers.push(handler) + return this + } + + async execute(message: Message, context: MessageContext): Promise { + for (const handler of this.handlers) { + try { + const canHandle = await handler.canHandle(message, context) + if (!canHandle) continue + + const result = await handler.handle(message, context) + if (result.stop) break + } catch (error) { + errorLog({ + message: `Error in ${handler.name} handler:`, + error, + }) + } + } + } +} diff --git a/packages/bot/src/handlers/message/spamHandler.ts b/packages/bot/src/handlers/message/spamHandler.ts new file mode 100644 index 000000000..a95df0840 --- /dev/null +++ b/packages/bot/src/handlers/message/spamHandler.ts @@ -0,0 +1,51 @@ +import type { Message } from 'discord.js' +import { autoModService } from '@lucky/shared/services' +import { errorLog } from '@lucky/shared/utils' +import type { + MessageContext, + MessageHandler, + MessageHandlerResult, +} from './types' + +export const spamHandler: MessageHandler = { + name: 'Spam', + + async canHandle( + message: Message, + context: MessageContext, + ): Promise { + if (context.featureToggles['AUTOMOD'] !== true || message.author.bot) { + return false + } + + const settings = await autoModService.getSettings(context.guild.id) + return settings?.spamEnabled === true + }, + + async handle( + message: Message, + context: MessageContext, + ): Promise { + try { + const guildId = context.guild.id + const userId = message.author.id + + const isSpam = await autoModService.trackMessageAndCheckSpam( + guildId, + userId, + ) + if (!isSpam) { + return { stop: false } + } + + await message.delete().catch(() => {}) + return { stop: true } + } catch (error) { + errorLog({ + message: 'Error checking spam:', + error, + }) + return { stop: false } + } + }, +} diff --git a/packages/bot/src/handlers/message/types.ts b/packages/bot/src/handlers/message/types.ts new file mode 100644 index 000000000..a6c931c2d --- /dev/null +++ b/packages/bot/src/handlers/message/types.ts @@ -0,0 +1,20 @@ +import type { Guild, GuildMember, Message } from 'discord.js' + +export interface MessageHandler { + name: string + canHandle(message: Message, context: MessageContext): Promise + handle( + message: Message, + context: MessageContext, + ): Promise +} + +export interface MessageHandlerResult { + stop: boolean +} + +export interface MessageContext { + guild: Guild + member: GuildMember + featureToggles: Record +} diff --git a/packages/bot/src/handlers/message/xpHandler.ts b/packages/bot/src/handlers/message/xpHandler.ts new file mode 100644 index 000000000..3d833b7d9 --- /dev/null +++ b/packages/bot/src/handlers/message/xpHandler.ts @@ -0,0 +1,78 @@ +import type { Message, TextChannel } from 'discord.js' +import { levelService } from '@lucky/shared/services' +import { errorLog } from '@lucky/shared/utils' +import type { + MessageContext, + MessageHandler, + MessageHandlerResult, +} from './types' + +export const xpHandler: MessageHandler = { + name: 'XP', + + async canHandle( + message: Message, + context: MessageContext, + ): Promise { + return !message.author.bot + }, + + async handle( + message: Message, + context: MessageContext, + ): Promise { + try { + const guildId = context.guild.id + const userId = message.author.id + + const config = await levelService.getConfig(guildId) + if (!config || !config.enabled) { + return { stop: false } + } + + const current = await levelService.getMemberXP(guildId, userId) + const now = Date.now() + + if ( + current && + now - current.lastXpAt.getTime() < config.xpCooldownMs + ) { + return { stop: false } + } + + const result = await levelService.addXP( + guildId, + userId, + config.xpPerMessage, + ) + + if (result.leveledUp && config.announceChannel) { + const rawChannel = await message.client.channels + .fetch(config.announceChannel) + .catch(() => null) + if (rawChannel?.isTextBased()) { + await (rawChannel as TextChannel).send( + `🎉 ${message.author} reached level **${result.newLevel}**!`, + ) + } + const rewards = await levelService.getRewards(guildId) + const reward = rewards.find( + (r: { level: number }) => r.level === result.newLevel, + ) + if (reward) { + await context.member.roles + .add(reward.roleId) + .catch(() => {}) + } + } + + return { stop: false } + } catch (error) { + errorLog({ + message: 'Error handling XP:', + error, + }) + return { stop: false } + } + }, +} diff --git a/packages/bot/src/handlers/messageHandler.ts b/packages/bot/src/handlers/messageHandler.ts index 3ae026a5c..935a84cea 100644 --- a/packages/bot/src/handlers/messageHandler.ts +++ b/packages/bot/src/handlers/messageHandler.ts @@ -1,317 +1,46 @@ -import { Events, type Client, type Message, type TextChannel } from 'discord.js' -import { autoModService } from '@lucky/shared/services' -import { customCommandService } from '@lucky/shared/services' +import { Events, type Client, type Message } from 'discord.js' import { featureToggleService } from '@lucky/shared/services' -import { moderationService } from '@lucky/shared/services' -import { levelService } from '@lucky/shared/services' -import { errorLog, debugLog } from '@lucky/shared/utils' - -const AUTOMOD_MUTE_DURATION = 300 - -async function handleAutoMod(message: Message): Promise { - if (!message.guild || !message.member) return - if (message.author.bot) return - - const isEnabled = await featureToggleService.isEnabled('AUTOMOD', { - guildId: message.guild.id, - }) - if (!isEnabled) return - - try { - const guildId = message.guild.id - const userId = message.author.id - const memberRoles = message.member.roles.cache.map((r) => r.id) - - // Get automod settings to check if enabled - const settings = await autoModService.getSettings(guildId) - if (!settings) return - - // Check if this channel or user roles should be ignored - const ignoredChannels = settings.exemptChannels ?? [] - const ignoredRoles = settings.exemptRoles ?? [] - const hasIgnoredRole = memberRoles.some((role) => - ignoredRoles.includes(role), - ) - - if (ignoredChannels.includes(message.channelId) || hasIgnoredRole) { - return - } - - // Run checks based on what's enabled - const violations: Array<{ - type: string - reason: string - action: string - }> = [] - - if (settings.spamEnabled) { - const isSpam = await autoModService.trackMessageAndCheckSpam( - guildId, - userId, - ) - if (isSpam) { - violations.push({ - type: 'spam', - reason: 'Spam detected', - action: 'delete', - }) - } - } - - if (settings.capsEnabled) { - const isCaps = await autoModService.checkCaps( - guildId, - message.content, - ) - if (isCaps) { - violations.push({ - type: 'caps', - reason: 'Excessive capitalization', - action: 'delete', - }) - } - } - - if (settings.linksEnabled) { - const hasLinks = await autoModService.checkLinks( - guildId, - message.content, - message.channelId, - ) - if (hasLinks) { - violations.push({ - type: 'links', - reason: 'Unauthorized link detected', - action: 'delete', - }) - } - } - - if (settings.invitesEnabled) { - const hasInvites = await autoModService.checkInvites( - guildId, - message.content, - ) - if (hasInvites) { - violations.push({ - type: 'invites', - reason: 'Invite detected', - action: 'delete', - }) - } - } - - if (settings.wordsEnabled) { - const hasBadWords = await autoModService.checkWords( - guildId, - message.content, - ) - if (hasBadWords) { - violations.push({ - type: 'badwords', - reason: 'Inappropriate language detected', - action: 'delete', - }) - } - } - - // Process violations - for (const violation of violations) { - debugLog({ - message: `AutoMod violation in ${guildId}: ${violation.type}`, - data: violation, - }) - - await message.delete().catch(() => {}) - - const caseInput = { - guildId, - userId, - username: message.author.tag, - moderatorId: message.client.user!.id, - moderatorName: message.client.user!.tag, - reason: `[AutoMod] ${violation.reason}`, - channelId: message.channelId, - } - - switch (violation.action) { - case 'delete': - break - case 'warn': - await moderationService - .createCase({ ...caseInput, type: 'warn' }) - .catch((err) => { - errorLog({ - message: 'Failed to create warn case:', - error: err, - }) - }) - break - case 'mute': - await message.member - ?.timeout( - AUTOMOD_MUTE_DURATION * 1000, - caseInput.reason, - ) - .catch((err) => { - errorLog({ - message: 'Failed to mute user:', - error: err, - }) - }) - await moderationService - .createCase({ - ...caseInput, - type: 'mute', - duration: AUTOMOD_MUTE_DURATION, - }) - .catch((err) => { - errorLog({ - message: 'Failed to create mute case:', - error: err, - }) - }) - break - case 'kick': - await message.member - ?.kick(caseInput.reason) - .catch((err) => { - errorLog({ - message: 'Failed to kick user:', - error: err, - }) - }) - await moderationService - .createCase({ ...caseInput, type: 'kick' }) - .catch((err) => { - errorLog({ - message: 'Failed to create kick case:', - error: err, - }) - }) - break - case 'ban': - await message.guild?.members - .ban(userId, { reason: caseInput.reason }) - .catch((err) => { - errorLog({ - message: 'Failed to ban user:', - error: err, - }) - }) - await moderationService - .createCase({ ...caseInput, type: 'ban' }) - .catch((err) => { - errorLog({ - message: 'Failed to create ban case:', - error: err, - }) - }) - break - } - } - } catch (error) { - errorLog({ - message: 'Error running automod checks:', - error, - }) - } -} - -async function handleCustomCommands(message: Message): Promise { - if (!message.guild || message.author.bot) return - - const isEnabled = await featureToggleService.isEnabled('CUSTOM_COMMANDS', { - guildId: message.guild.id, - }) - if (!isEnabled) return - - try { - // Get all custom commands for the guild - const commands = await customCommandService.listCommands( - message.guild.id, - ) - if (!commands || commands.length === 0) return - - // Check if message matches any custom command trigger - const matchedCommand = commands.find((cmd: any) => { - // Check exact match or prefix match - return ( - message.content === cmd.trigger || - message.content.startsWith(cmd.trigger + ' ') - ) - }) - - if (!matchedCommand) return - - // Send response - await message.reply({ - content: matchedCommand.response ?? '', - allowedMentions: { repliedUser: false }, - }) - - // Track usage - await customCommandService.incrementUsage( - message.guild.id, - matchedCommand.name, - ) - } catch (error) { - errorLog({ - message: 'Error handling custom command:', - error, - }) - } -} - -async function handleXP(message: Message): Promise { - if (!message.guild || message.author.bot) return - const guildId = message.guild.id - const userId = message.author.id - - try { - const config = await levelService.getConfig(guildId) - if (!config || !config.enabled) return - - const current = await levelService.getMemberXP(guildId, userId) - const now = Date.now() - - if (current && now - current.lastXpAt.getTime() < config.xpCooldownMs) - return - - const result = await levelService.addXP( - guildId, - userId, - config.xpPerMessage, - ) - - if (result.leveledUp && config.announceChannel) { - const rawChannel = await message.client.channels - .fetch(config.announceChannel) - .catch(() => null) - if (rawChannel?.isTextBased()) { - await (rawChannel as TextChannel).send( - `🎉 ${message.author} reached level **${result.newLevel}**!`, - ) - } - const rewards = await levelService.getRewards(guildId) - const reward = rewards.find( - (r: { level: number }) => r.level === result.newLevel, - ) - if (reward && message.member) { - await message.member.roles.add(reward.roleId).catch(() => {}) - } - } - } catch (error) { - errorLog({ message: 'Error handling XP:', error }) - } -} +import { errorLog } from '@lucky/shared/utils' +import { MessagePipeline } from './message/pipeline' +import { autoModHandler } from './message/autoModHandler' +import { spamHandler } from './message/spamHandler' +import { customCommandHandler } from './message/customCommandHandler' +import { xpHandler } from './message/xpHandler' +import type { MessageContext } from './message/types' + +const pipeline = new MessagePipeline() + .register(spamHandler) + .register(autoModHandler) + .register(customCommandHandler) + .register(xpHandler) export function handleMessageCreate(client: Client): void { client.on(Events.MessageCreate, async (message: Message) => { try { - await handleAutoMod(message) - await handleCustomCommands(message) - await handleXP(message) + if (!message.guild || !message.member) return + + const [automod, customCommands] = await Promise.all([ + featureToggleService + .isEnabled('AUTOMOD', { guildId: message.guild.id }) + .catch(() => false), + featureToggleService + .isEnabled('CUSTOM_COMMANDS', { + guildId: message.guild.id, + }) + .catch(() => false), + ]) + const featureToggles = { + AUTOMOD: automod, + CUSTOM_COMMANDS: customCommands, + } + + const context: MessageContext = { + guild: message.guild, + member: message.member, + featureToggles, + } + + await pipeline.execute(message, context) } catch (error) { errorLog({ message: 'Error handling message:',