feat: 이드 기능 완성 — note_bridge, intent_service, CalDAV Todo

- note_bridge.py: port 8096, log-conversation 제거, message 필드 추가
- intent_service.py: 의도 분류(Ollama→Claude fallback) + 한국어 날짜 파싱 + API 사용량 추적
- caldav_bridge.py: VTODO 생성 (/calendar/create-todo) + 응답 message 필드
- LaunchAgent plist: note-bridge (8096), intent-service (8097)
- .env.example: API_MONTHLY_LIMIT, NOTE_BRIDGE_URL, INTENT_SERVICE_URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-19 13:48:10 +09:00
parent 1137754964
commit dc08d29509
7 changed files with 860 additions and 4 deletions

184
note_bridge.py Normal file
View File

@@ -0,0 +1,184 @@
"""Note Bridge — Synology Note Station REST API 래퍼 (port 8096)"""
import logging
import os
import httpx
from dotenv import load_dotenv
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
load_dotenv()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("note_bridge")
# Note Station 앱 포털 포트 (9350)
NOTE_STATION_URL = os.getenv("NOTE_STATION_URL", "http://192.168.1.227:9350")
NOTE_ACCOUNT = os.getenv("NOTE_ACCOUNT", "chatbot-api")
NOTE_PASSWORD = os.getenv("NOTE_PASSWORD", "U1Ob2L3a")
# 메모장 ID
NOTEBOOK_MEMO = os.getenv("NOTEBOOK_MEMO", "1030_3CLANHDEQH4T9DRONETDNOG2U4") # chatbot (메모용)
# 세션
_sid: str = ""
async def _login() -> str:
"""Note Station 앱 포털에 로그인하여 SID를 반환."""
global _sid
async with httpx.AsyncClient(verify=False, timeout=15) as c:
resp = await c.post(f"{NOTE_STATION_URL}/webapi/entry.cgi", data={
"api": "SYNO.API.Auth",
"version": "7",
"method": "login",
"account": NOTE_ACCOUNT,
"passwd": NOTE_PASSWORD,
})
data = resp.json()
if data.get("success"):
_sid = data["data"]["sid"]
logger.info("Note Station login successful")
return _sid
else:
logger.error(f"Note Station login failed: {data}")
raise Exception(f"Login failed: {data}")
async def _ensure_sid() -> str:
"""SID가 유효한지 확인하고, 필요하면 재로그인."""
global _sid
if not _sid:
return await _login()
# SID 유효성 간단 체크
async with httpx.AsyncClient(verify=False, timeout=10) as c:
resp = await c.get(f"{NOTE_STATION_URL}/webapi/entry.cgi", params={
"api": "SYNO.NoteStation.Info",
"version": "1",
"method": "get",
"_sid": _sid,
})
data = resp.json()
if data.get("success"):
return _sid
return await _login()
async def _api_call(api: str, method: str, version: int = 3, **kwargs) -> dict:
"""Note Station API 호출 래퍼."""
sid = await _ensure_sid()
params = {
"api": api,
"version": str(version),
"method": method,
"_sid": sid,
**kwargs,
}
async with httpx.AsyncClient(verify=False, timeout=30) as c:
resp = await c.post(f"{NOTE_STATION_URL}/webapi/entry.cgi", data=params)
data = resp.json()
if not data.get("success"):
# SID 만료일 수 있으므로 재로그인 후 재시도
sid = await _login()
params["_sid"] = sid
resp = await c.post(f"{NOTE_STATION_URL}/webapi/entry.cgi", data=params)
data = resp.json()
return data
# ==================== FastAPI App ====================
app = FastAPI(title="Note Bridge")
@app.post("/note/create")
async def create_note(request: Request):
"""노트 생성. body: {title, content}"""
body = await request.json()
title = body.get("title", "제목 없음")
content = body.get("content", "")
# HTML 래핑 (이미 HTML이 아닌 경우)
if content and not content.strip().startswith("<"):
content = f"<p>{content}</p>"
result = await _api_call(
"SYNO.NoteStation.Note",
"create",
parent_id=NOTEBOOK_MEMO,
title=title,
commit_msg='{"device":"api","listable":false}',
encrypt="false",
)
if result.get("success"):
note_id = result["data"].get("object_id", "")
# 내용 업데이트 (create는 빈 노트만 생성)
if content:
await _api_call(
"SYNO.NoteStation.Note",
"update",
object_id=note_id,
content=content,
commit_msg='{"device":"api","listable":false}',
)
logger.info(f"Note created: {note_id} '{title}'")
return {"success": True, "note_id": note_id, "title": title,
"message": f"메모 저장 완료: {title}"}
else:
logger.error(f"Note creation failed: {result}")
return JSONResponse(status_code=500, content={
"success": False, "error": str(result),
"message": f"메모 저장 실패: {title}"})
@app.post("/note/append")
async def append_to_note(request: Request):
"""기존 노트에 내용 추가. body: {note_id, content}"""
body = await request.json()
note_id = body.get("note_id")
content = body.get("content", "")
if not content:
return JSONResponse(status_code=400, content={"success": False, "error": "content required"})
if not note_id:
return JSONResponse(status_code=400, content={"success": False, "error": "note_id required"})
# 기존 내용 가져오기
existing = await _api_call("SYNO.NoteStation.Note", "get", object_id=note_id)
if not existing.get("success"):
return JSONResponse(status_code=404, content={"success": False, "error": "Note not found"})
old_content = existing["data"].get("content", "")
# HTML 래핑
if not content.strip().startswith("<"):
content = f"<p>{content}</p>"
updated_content = old_content + content
result = await _api_call(
"SYNO.NoteStation.Note",
"update",
object_id=note_id,
content=updated_content,
commit_msg='{"device":"api","listable":false}',
)
if result.get("success"):
logger.info(f"Note appended: {note_id}")
return {"success": True, "note_id": note_id, "message": f"메모 추가 완료"}
else:
return JSONResponse(status_code=500, content={"success": False, "error": str(result)})
@app.get("/health")
async def health():
"""헬스체크."""
try:
sid = await _ensure_sid()
return {"status": "ok", "sid_active": bool(sid)}
except Exception as e:
return {"status": "error", "error": str(e)}