feat: add Discord Bot Gateway handler with mention parsing
Implements discord.py Bot Gateway to receive @agent mentions and enqueue tasks. Includes deterministic thread ID generation and message parsing for issue numbers, repo names, and freeform requests. Supports message queuing for running threads. Files: - agent/integrations/discord_handler.py: DiscordHandler class with event handling - tests/test_discord_handler.py: 4 tests for parsing and thread ID generation All tests pass (95 total).
This commit is contained in:
parent
8c274b4be2
commit
5a471907fa
120
agent/integrations/discord_handler.py
Normal file
120
agent/integrations/discord_handler.py
Normal file
@ -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()
|
||||
43
tests/test_discord_handler.py
Normal file
43
tests/test_discord_handler.py
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user