feat(bridge): Watcher, Parser, Bot, Main 핵심 컴포넌트 구현 #task-215 #task-216 #task-217 #task-218
This commit is contained in:
218
parser.py
Normal file
218
parser.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Markdown → Discord text parser.
|
||||
|
||||
Handles:
|
||||
- task.md checkbox progress extraction
|
||||
- MD → Discord-friendly text conversion
|
||||
- Long text splitting for Discord's 2000 char limit
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskProgress:
|
||||
"""Parsed progress from task.md."""
|
||||
total: int = 0
|
||||
done: int = 0
|
||||
in_progress: int = 0
|
||||
pending: int = 0
|
||||
current_task: str = ""
|
||||
sections: list = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.sections is None:
|
||||
self.sections = []
|
||||
|
||||
@property
|
||||
def summary_line(self) -> str:
|
||||
bar_len = 10
|
||||
filled = round(self.done / max(self.total, 1) * bar_len)
|
||||
bar = "█" * filled + "░" * (bar_len - filled)
|
||||
return f"[{bar}] {self.done}/{self.total} 완료"
|
||||
|
||||
|
||||
def parse_task_progress(content: str) -> TaskProgress:
|
||||
"""Parse task.md and extract checkbox progress."""
|
||||
progress = TaskProgress()
|
||||
current_section = ""
|
||||
|
||||
for line in content.splitlines():
|
||||
# Section headers
|
||||
header_match = re.match(r'^#{1,3}\s+(.+)', line)
|
||||
if header_match:
|
||||
current_section = header_match.group(1).strip()
|
||||
continue
|
||||
|
||||
# Checkboxes
|
||||
checkbox_match = re.match(r'^\s*-\s*\[([ x/])\]\s*(.+)', line)
|
||||
if checkbox_match:
|
||||
state, text = checkbox_match.groups()
|
||||
progress.total += 1
|
||||
|
||||
if state == 'x':
|
||||
progress.done += 1
|
||||
elif state == '/':
|
||||
progress.in_progress += 1
|
||||
progress.current_task = text.strip()
|
||||
else:
|
||||
progress.pending += 1
|
||||
|
||||
progress.sections.append({
|
||||
"section": current_section,
|
||||
"state": state,
|
||||
"text": text.strip()
|
||||
})
|
||||
|
||||
return progress
|
||||
|
||||
|
||||
def md_to_discord_text(content: str, max_length: int = 1900) -> list[str]:
|
||||
"""Convert markdown to Discord-friendly text, splitting into chunks.
|
||||
|
||||
Preserves:
|
||||
- Headers → **bold**
|
||||
- Code blocks → unchanged (Discord supports ```)
|
||||
- Checkboxes → emoji representation
|
||||
- Tables → simplified text
|
||||
|
||||
Strips:
|
||||
- Mermaid diagrams
|
||||
- HTML comments
|
||||
- Alert syntax (> [!NOTE] etc.)
|
||||
|
||||
Returns list of text chunks, each under max_length.
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
output_lines = []
|
||||
in_mermaid = False
|
||||
in_code_block = False
|
||||
|
||||
for line in lines:
|
||||
# Skip mermaid blocks
|
||||
if re.match(r'^```mermaid', line):
|
||||
in_mermaid = True
|
||||
output_lines.append("*(mermaid 다이어그램 생략)*")
|
||||
continue
|
||||
if in_mermaid:
|
||||
if line.strip() == '```':
|
||||
in_mermaid = False
|
||||
continue
|
||||
|
||||
# Track code blocks
|
||||
if re.match(r'^```', line) and not in_mermaid:
|
||||
in_code_block = not in_code_block
|
||||
output_lines.append(line)
|
||||
continue
|
||||
|
||||
if in_code_block:
|
||||
output_lines.append(line)
|
||||
continue
|
||||
|
||||
# Skip HTML comments
|
||||
if re.match(r'^\s*<!--.*-->\s*$', line):
|
||||
continue
|
||||
|
||||
# Skip alert syntax but keep content
|
||||
alert_match = re.match(r'>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]', line)
|
||||
if alert_match:
|
||||
alert_type = alert_match.group(1)
|
||||
emoji_map = {
|
||||
"NOTE": "📝", "TIP": "💡", "IMPORTANT": "❗",
|
||||
"WARNING": "⚠️", "CAUTION": "🔴"
|
||||
}
|
||||
output_lines.append(f"{emoji_map.get(alert_type, '📌')} **{alert_type}**")
|
||||
continue
|
||||
|
||||
# Convert headers to bold
|
||||
header_match = re.match(r'^(#{1,3})\s+(.+)', line)
|
||||
if header_match:
|
||||
level = len(header_match.group(1))
|
||||
text = header_match.group(2)
|
||||
if level == 1:
|
||||
output_lines.append(f"\n**━━ {text} ━━**\n")
|
||||
elif level == 2:
|
||||
output_lines.append(f"\n**▸ {text}**")
|
||||
else:
|
||||
output_lines.append(f"**{text}**")
|
||||
continue
|
||||
|
||||
# Convert checkboxes to emoji
|
||||
checkbox_match = re.match(r'^(\s*)-\s*\[([ x/])\]\s*(.+)', line)
|
||||
if checkbox_match:
|
||||
indent, state, text = checkbox_match.groups()
|
||||
emoji = {"x": "✅", "/": "🔄", " ": "⬜"}.get(state, "⬜")
|
||||
output_lines.append(f"{indent}{emoji} {text}")
|
||||
continue
|
||||
|
||||
# Convert blockquote markers
|
||||
if line.startswith("> "):
|
||||
output_lines.append(f"│ {line[2:]}")
|
||||
continue
|
||||
|
||||
# Pass through everything else
|
||||
output_lines.append(line)
|
||||
|
||||
# Join and split into chunks
|
||||
full_text = "\n".join(output_lines).strip()
|
||||
return split_text(full_text, max_length)
|
||||
|
||||
|
||||
def split_text(text: str, max_length: int = 1900) -> list[str]:
|
||||
"""Split text into chunks respecting Discord's message limit.
|
||||
|
||||
Tries to split on newlines first, then on spaces.
|
||||
"""
|
||||
if len(text) <= max_length:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
current = ""
|
||||
|
||||
for line in text.split("\n"):
|
||||
if len(current) + len(line) + 1 > max_length:
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = ""
|
||||
|
||||
# If single line is too long, split on spaces
|
||||
if len(line) > max_length:
|
||||
words = line.split(" ")
|
||||
for word in words:
|
||||
if len(current) + len(word) + 1 > max_length:
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = word
|
||||
else:
|
||||
current = f"{current} {word}" if current else word
|
||||
else:
|
||||
current = line
|
||||
else:
|
||||
current = f"{current}\n{line}" if current else line
|
||||
|
||||
if current:
|
||||
chunks.append(current)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def format_task_embed_text(progress: TaskProgress) -> str:
|
||||
"""Format task progress as a compact Discord text message."""
|
||||
lines = [
|
||||
f"📋 **진행 상황** {progress.summary_line}",
|
||||
]
|
||||
|
||||
if progress.current_task:
|
||||
lines.append(f"🔄 현재: {progress.current_task}")
|
||||
|
||||
# Group by section
|
||||
current_section = ""
|
||||
for item in progress.sections:
|
||||
if item["section"] != current_section:
|
||||
current_section = item["section"]
|
||||
lines.append(f"\n**{current_section}**")
|
||||
|
||||
emoji = {"x": "✅", "/": "🔄", " ": "⬜"}.get(item["state"], "⬜")
|
||||
lines.append(f" {emoji} {item['text']}")
|
||||
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user