diff --git a/agent/integrations/discord_handler.py b/agent/integrations/discord_handler.py new file mode 100644 index 0000000..83665e3 --- /dev/null +++ b/agent/integrations/discord_handler.py @@ -0,0 +1,120 @@ +# agent/integrations/discord_handler.py +"""Discord Bot Gateway 수신 핸들러. + +discord.py를 사용하여 @agent 멘션을 수신하고 작업을 큐에 추가한다. +""" +from __future__ import annotations + +import hashlib +import logging +import os +import re + +import discord +from discord.ext import commands + +logger = logging.getLogger(__name__) + + +def parse_discord_message(content: str, bot_user_id: int) -> dict: + """Discord 메시지를 파싱하여 작업 정보를 추출한다.""" + # 봇 멘션 제거 (<@123456>) + cleaned = re.sub(rf"<@!?{bot_user_id}>", "", content).strip() + + # 이슈 번호 추출 + issue_match = re.search(r"#(\d+)", cleaned) + issue_number = int(issue_match.group(1)) if issue_match else 0 + + # 리포 이름 추출 + repo_name = os.environ.get("DEFAULT_REPO_NAME", "galaxis-po") + repo_match = re.search(r"\b(galaxis-\w+)\b", cleaned) + if repo_match: + repo_name = repo_match.group(1) + + # 순수 메시지 + message = re.sub(r"@agent\b", "", cleaned, flags=re.IGNORECASE) + message = re.sub(rf"\b{re.escape(repo_name)}\b", "", message) + message = re.sub(r"이슈\s*#\d+", "", message) + message = re.sub(r"#\d+", "", message) + message = message.strip() + + return { + "issue_number": issue_number, + "repo_name": repo_name, + "message": message or cleaned, + } + + +def generate_discord_thread_id(channel_id: int, message_id: int) -> str: + """Discord 메시지에서 결정론적 스레드 ID를 생성한다.""" + raw = hashlib.sha256(f"discord:{channel_id}:{message_id}".encode()).hexdigest() + return f"{raw[:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:32]}" + + +class DiscordHandler: + """Discord Bot Gateway 핸들러.""" + + def __init__(self): + intents = discord.Intents.default() + intents.message_content = True + intents.guild_messages = True + self.bot = commands.Bot(command_prefix="!", intents=intents) + self._setup_events() + + def _setup_events(self): + @self.bot.event + async def on_ready(): + logger.info("Discord bot connected as %s", self.bot.user) + + @self.bot.event + async def on_message(message: discord.Message): + if message.author == self.bot.user: + return + if not self.bot.user or not self.bot.user.mentioned_in(message): + return + await self._handle_mention(message) + + async def _handle_mention(self, message: discord.Message): + """@agent 멘션을 처리한다.""" + parsed = parse_discord_message( + message.content, self.bot.user.id if self.bot.user else 0 + ) + thread_id = generate_discord_thread_id(message.channel.id, message.id) + repo_owner = os.environ.get("DEFAULT_REPO_OWNER", "quant") + + from agent.task_queue import get_task_queue + from agent.message_store import get_message_store + + task_queue = await get_task_queue() + + if parsed["issue_number"] and await task_queue.has_running_task(thread_id): + store = await get_message_store() + await store.push_message(thread_id, { + "role": "human", + "content": parsed["message"], + }) + await message.reply("메시지를 대기열에 추가했습니다. 현재 작업이 완료되면 확인하겠습니다.") + return + + task_id = await task_queue.enqueue( + thread_id=thread_id, + source="discord", + payload={ + "issue_number": parsed["issue_number"], + "repo_owner": repo_owner, + "repo_name": parsed["repo_name"], + "message": parsed["message"], + "channel_id": str(message.channel.id), + "message_id": str(message.id), + }, + ) + await message.reply(f"작업을 대기열에 추가했습니다. (task: {task_id[:8]})") + logger.info("Discord task enqueued: %s (thread %s)", task_id, thread_id) + + async def start(self, token: str): + """Bot Gateway를 시작한다.""" + await self.bot.start(token) + + async def close(self): + """Bot Gateway를 종료한다.""" + await self.bot.close() diff --git a/tests/test_discord_handler.py b/tests/test_discord_handler.py new file mode 100644 index 0000000..a431a64 --- /dev/null +++ b/tests/test_discord_handler.py @@ -0,0 +1,43 @@ +# tests/test_discord_handler.py +import pytest +from unittest.mock import MagicMock, AsyncMock + + +def test_parse_discord_mention_with_issue(): + """이슈 번호가 포함된 Discord 멘션을 파싱한다.""" + from agent.integrations.discord_handler import parse_discord_message + + result = parse_discord_message("이슈 #42 해결해줘", bot_user_id=123) + assert result["issue_number"] == 42 + assert result["repo_name"] == "galaxis-po" + assert "해결해줘" in result["message"] + + +def test_parse_discord_mention_with_repo(): + """리포가 명시된 Discord 멘션을 파싱한다.""" + from agent.integrations.discord_handler import parse_discord_message + + result = parse_discord_message("galaxis-po 이슈 #10 수정해줘", bot_user_id=123) + assert result["issue_number"] == 10 + assert result["repo_name"] == "galaxis-po" + + +def test_parse_discord_mention_freeform(): + """이슈 번호 없는 자유형 요청을 파싱한다.""" + from agent.integrations.discord_handler import parse_discord_message + + result = parse_discord_message("factor_calculator에 듀얼 모멘텀 추가해줘", bot_user_id=123) + assert result["issue_number"] == 0 + assert result["message"] == "factor_calculator에 듀얼 모멘텀 추가해줘" + + +def test_generate_discord_thread_id(): + """Discord 메시지에서 결정론적 thread_id를 생성한다.""" + from agent.integrations.discord_handler import generate_discord_thread_id + + tid1 = generate_discord_thread_id(channel_id=111, message_id=222) + tid2 = generate_discord_thread_id(channel_id=111, message_id=222) + tid3 = generate_discord_thread_id(channel_id=111, message_id=333) + assert tid1 == tid2 + assert tid1 != tid3 + assert len(tid1) == 36