r"""Nextcloud 도구 핸들러 — 자연어 → NC 도구 자동 라우팅. Gemini unified prompt의 'nextcloud' 모드 분류 결과를 받아 적절한 NC 모듈 함수를 호출하고 Discord embed로 응답. """ import logging import re from datetime import datetime, timedelta from typing import Optional import discord from tools.nc_files import NCFilesClient from tools.nc_calendar import NCCalendarClient from tools.nc_mail import NCMailClient logger = logging.getLogger("variet.handlers.nc") class NCHandler: """Nextcloud 도구 라우터.""" def __init__(self): self.files = NCFilesClient() self.calendar = NCCalendarClient() self.mail = NCMailClient() # Contacts는 EasyOCR 무거움 → 필요 시 지연 로드 self._contacts = None @property def contacts(self): if self._contacts is None: from tools.nc_contacts import NCContactsClient self._contacts = NCContactsClient() return self._contacts # ────────────────────────────────────── # 메인 라우터 # ────────────────────────────────────── async def handle(self, action: dict, channel) -> None: """unified prompt 결과를 받아 NC 도구 실행 + 응답. Args: action: {"tool": "files|calendar|mail|contacts", "op": ..., "params": {...}} channel: Discord 채널/스레드 """ tool = action.get("tool", "") op = action.get("op", "") params = action.get("params", {}) try: if tool == "files": await self._handle_files(op, params, channel) elif tool == "calendar": await self._handle_calendar(op, params, channel) elif tool == "mail": await self._handle_mail(op, params, channel) elif tool == "contacts": await self._handle_contacts(op, params, channel) else: await channel.send( embed=discord.Embed( title="❌ 알 수 없는 도구", description=f"tool: `{tool}`은 지원하지 않습니다.", color=0xE74C3C, ) ) except Exception as e: logger.error(f"NC 핸들러 오류: {tool}.{op} — {e}", exc_info=True) await channel.send( embed=discord.Embed( title="❌ 오류 발생", description=f"`{tool}.{op}` 실행 중 오류:\n```{str(e)[:300]}```", color=0xE74C3C, ) ) # ────────────────────────────────────── # Files # ────────────────────────────────────── async def _handle_files(self, op: str, params: dict, channel): if op == "search": query = params.get("query", "") files = await self.files.search(query) if not files: await channel.send(f"🔍 `{query}` 검색 결과가 없습니다.") return lines = [f"{i}. {f.icon} **{f.name}** ({f.size_human})\n 📂 `{f.path}`" for i, f in enumerate(files[:15], 1)] embed = discord.Embed( title=f"🔍 파일 검색: {query}", description="\n".join(lines), color=0x3498DB, ) embed.set_footer(text=f"{len(files)}건 중 최대 15건 표시") await channel.send(embed=embed) elif op == "list": path = params.get("path", "") files = await self.files.list_dir(path) lines = [f"{f.icon} **{f.name}**" + (f" ({f.size_human})" if not f.is_dir else "") for f in files[:20]] embed = discord.Embed( title=f"📂 /{path or '(루트)'}", description="\n".join(lines) or "빈 디렉토리", color=0x3498DB, ) await channel.send(embed=embed) elif op == "link": path = params.get("path", "") url = await self.files.create_link(path) if url: await channel.send(f"🔗 공유 링크: {url}") else: await channel.send("❌ 공유 링크 생성 실패") elif op == "recent": limit = params.get("limit", 10) files = await self.files.list_recent(limit) lines = [f"{i}. {f.icon} **{f.name}** ({f.size_human})\n 📅 {f.lastmod}" for i, f in enumerate(files, 1)] embed = discord.Embed( title="🕐 최근 파일", description="\n".join(lines) or "파일 없음", color=0x3498DB, ) await channel.send(embed=embed) # ────────────────────────────────────── # Calendar # ────────────────────────────────────── async def _handle_calendar(self, op: str, params: dict, channel): if op == "today": events = await self.calendar.get_today() embed = discord.Embed( title=f"📅 오늘 일정 ({datetime.now().strftime('%Y-%m-%d')})", description="\n".join(e.to_display() for e in events) or "일정 없음 ☀️", color=0x2ECC71, ) await channel.send(embed=embed) elif op == "week": events = await self.calendar.get_week() lines = [] current_date = "" for e in events: if e.date_str != current_date: current_date = e.date_str lines.append(f"\n**📆 {current_date}**") lines.append(f" {e.to_display()}") embed = discord.Embed( title="📅 이번주 일정", description="\n".join(lines) or "일정 없음 ☀️", color=0x2ECC71, ) await channel.send(embed=embed) elif op == "add": summary = params.get("summary", "") date_str = params.get("date", "") time_str = params.get("time", "09:00") duration = params.get("duration", "1h") location = params.get("location", "") dtstart = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M") dur_match = re.match(r"(\d+)([hm])", duration) if dur_match: val, unit = int(dur_match.group(1)), dur_match.group(2) delta = timedelta(hours=val) if unit == "h" else timedelta(minutes=val) else: delta = timedelta(hours=1) dtend = dtstart + delta event = await self.calendar.create_event( summary, dtstart, dtend, location=location, ) embed = discord.Embed( title="✅ 일정 생성", description=event.to_display(), color=0x2ECC71, ) await channel.send(embed=embed) elif op == "delete": uid = params.get("uid", "") ok = await self.calendar.delete_event(uid) if ok: await channel.send(f"✅ 일정 삭제 완료: `{uid}`") else: await channel.send(f"❌ 삭제 실패: `{uid}`") elif op == "list_calendars": cals = await self.calendar.list_calendars() embed = discord.Embed( title="📅 캘린더 목록", description="\n".join(f"• {c}" for c in cals), color=0x2ECC71, ) await channel.send(embed=embed) # ────────────────────────────────────── # Mail # ────────────────────────────────────── async def _handle_mail(self, op: str, params: dict, channel): if op == "unread": limit = params.get("limit", 5) messages = await self.mail.get_unread(limit) if not messages: await channel.send("📭 미확인 메일이 없습니다.") return lines = [] for i, m in enumerate(messages, 1): lines.append( f"{i}. 📬 **{m.sender_name}**\n" f" 제목: {m.subject}\n" f" 📅 {m.date}" ) embed = discord.Embed( title=f"📬 미확인 메일 {len(messages)}건", description="\n\n".join(lines), color=0xE67E22, ) await channel.send(embed=embed) elif op == "search": query = params.get("query", "") messages = await self.mail.search(query) if not messages: await channel.send(f"🔍 `{query}` 검색 결과가 없습니다.") return lines = [f"{i}. {'📬' if not m.is_read else '📭'} {m.sender_name} — {m.subject}" for i, m in enumerate(messages[:10], 1)] embed = discord.Embed( title=f"🔍 메일 검색: {query}", description="\n".join(lines), color=0xE67E22, ) await channel.send(embed=embed) elif op == "get": uid = params.get("uid", "") msg = await self.mail.get_message(uid) if msg: body_preview = msg.body[:1500].replace("```", "") embed = discord.Embed( title=f"📧 {msg.subject}", description=f"**From:** {msg.sender}\n**Date:** {msg.date}\n\n{body_preview}", color=0xE67E22, ) await channel.send(embed=embed) else: await channel.send(f"❌ UID `{uid}` 메일을 찾을 수 없습니다.") # ────────────────────────────────────── # Contacts # ────────────────────────────────────── async def _handle_contacts(self, op: str, params: dict, channel): if op == "search": query = params.get("query", "") contacts = await self.contacts.search_contacts(query) if not contacts: await channel.send(f"🔍 `{query}` 연락처를 찾을 수 없습니다.") return lines = [c.to_display() for c in contacts[:10]] embed = discord.Embed( title=f"🔍 연락처 검색: {query}", description="\n\n".join(lines), color=0x9B59B6, ) await channel.send(embed=embed) elif op == "list": contacts = await self.contacts.list_contacts() lines = [f"👤 **{c.name}** — {', '.join(c.phone) or '전화 없음'}" for c in contacts[:20]] embed = discord.Embed( title=f"📇 연락처 {len(contacts)}건", description="\n".join(lines) or "연락처 없음", color=0x9B59B6, ) await channel.send(embed=embed) elif op == "scan": image_url = params.get("image_url", "") if not image_url: await channel.send("❌ 스캔할 이미지가 없습니다.") return contact = await self.contacts.scan_card(image_url) embed = discord.Embed( title="📇 명함 스캔 결과", description=contact.to_display(), color=0x9B59B6, ) await channel.send(embed=embed)