Files
variet-agent/tools/nextcloud_client.py
Variet Agent d22493125c feat(nextcloud): Nextcloud 4모듈 + NC핸들러 + AI Foreman v0.1
- tools/nextcloud_client.py: WebDAV/OCS/CalDAV/CardDAV 공통 클라이언트
- tools/nc_files.py: 파일 검색/목록/최근/공유링크
- tools/nc_calendar.py: CalDAV 일정 CRUD + ICS 빌더
- tools/nc_mail.py: IMAP 메일 조회 (PLAIN auth for Mailcow)
- tools/nc_contacts.py: CardDAV 연락처 + EasyOCR 명함 스캔
- handlers/nc_handler.py: 자연어→NC도구 자동 라우팅
- core/foreman.py: 목표 분해 + 상담 세션 + Vikunja 등록
- prompts/foreman.md: Foreman 시스템 프롬프트
- prompts/unified.md: nextcloud 모드 분류 추가
- config.py: .env 따옴표 파싱 버그 수정
- api/discord_bot.py: /goal 커맨드 + Foreman 스레드 라우팅
2026-03-18 17:26:03 +09:00

431 lines
15 KiB
Python

r"""Nextcloud 공통 클라이언트 — WebDAV / OCS / CalDAV / CardDAV.
모든 Nextcloud 연동 모듈(nc_files, nc_calendar, nc_mail, nc_contacts)의 기반.
인증: App Password + Basic Auth
API:
- WebDAV: remote.php/dav/files/{user}/
- OCS: ocs/v2.php/apps/...
- CalDAV: remote.php/dav/calendars/{user}/
- CardDAV: remote.php/dav/addressbooks/users/{user}/
"""
import asyncio
import logging
import sys
import os
from typing import Optional
from xml.etree import ElementTree as ET
import httpx
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import config
logger = logging.getLogger("variet.tools.nextcloud")
# XML 네임스페이스
DAV_NS = "DAV:"
OC_NS = "http://owncloud.org/ns"
NC_NS = "http://nextcloud.org/ns"
CAL_NS = "urn:ietf:params:xml:ns:caldav"
CARD_NS = "urn:ietf:params:xml:ns:carddav"
NS_MAP = {
"d": DAV_NS,
"oc": OC_NS,
"nc": NC_NS,
"cal": CAL_NS,
"card": CARD_NS,
}
class NextcloudClient:
"""Nextcloud 공통 HTTP 클라이언트.
WebDAV, OCS, CalDAV, CardDAV 공통 기능 제공.
"""
def __init__(
self,
base_url: str = None,
username: str = None,
app_password: str = None,
timeout: float = 30.0,
):
self.base_url = (base_url or config.NEXTCLOUD_URL).rstrip("/")
self.username = username or config.NEXTCLOUD_USER
self.app_password = app_password or config.NEXTCLOUD_APP_PASSWORD
self.timeout = timeout
self._auth = (self.username, self.app_password)
def _client(self) -> httpx.AsyncClient:
"""새 httpx 클라이언트 생성."""
return httpx.AsyncClient(
base_url=self.base_url,
auth=self._auth,
timeout=self.timeout,
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
},
)
# ──────────────────────────────────────
# OCS API (JSON)
# ──────────────────────────────────────
async def ocs_get(self, endpoint: str, params: dict = None) -> dict:
"""OCS GET 요청 (JSON 응답)."""
url = f"/ocs/v2.php/{endpoint}"
if "format=json" not in url:
params = params or {}
params["format"] = "json"
async with self._client() as client:
resp = await client.get(url, params=params)
resp.raise_for_status()
return resp.json().get("ocs", {}).get("data", {})
async def ocs_post(self, endpoint: str, data: dict = None) -> dict:
"""OCS POST 요청."""
url = f"/ocs/v2.php/{endpoint}"
async with self._client() as client:
resp = await client.post(
url,
data={**(data or {}), "format": "json"},
)
resp.raise_for_status()
return resp.json().get("ocs", {}).get("data", {})
async def ocs_delete(self, endpoint: str) -> bool:
"""OCS DELETE 요청."""
url = f"/ocs/v2.php/{endpoint}"
async with self._client() as client:
resp = await client.delete(url, params={"format": "json"})
return resp.status_code in (200, 204)
# ──────────────────────────────────────
# WebDAV (XML)
# ──────────────────────────────────────
async def webdav_request(
self,
method: str,
path: str,
body: str = None,
headers: dict = None,
) -> httpx.Response:
"""WebDAV 요청 (PROPFIND, REPORT, SEARCH, PUT, DELETE 등)."""
url = f"/remote.php/dav/{path}"
req_headers = {"Content-Type": "application/xml; charset=utf-8"}
if headers:
req_headers.update(headers)
async with self._client() as client:
resp = await client.request(
method, url,
content=body.encode("utf-8") if body else None,
headers=req_headers,
)
return resp
async def webdav_propfind(
self, path: str, props: list[str] = None, depth: int = 1,
) -> list[dict]:
"""PROPFIND — 파일/디렉토리 속성 조회.
Returns:
[{href, name, size, lastmod, content_type, is_dir}, ...]
"""
if props is None:
props = [
"d:getlastmodified",
"d:getcontentlength",
"d:getcontenttype",
"d:resourcetype",
"d:displayname",
"oc:fileid",
]
props_xml = "\n".join(f"<{p}/>" for p in props)
body = f"""<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
{props_xml}
</d:prop>
</d:propfind>"""
resp = await self.webdav_request(
"PROPFIND", path, body=body,
headers={"Depth": str(depth)},
)
resp.raise_for_status()
return self._parse_propfind(resp.text)
async def webdav_search(
self, query: str, path: str = None,
) -> list[dict]:
"""WebDAV SEARCH — 파일 검색.
Args:
query: 검색어 (파일명 매칭)
path: 검색 시작 경로 (기본: 사용자 루트)
"""
search_path = path or f"files/{self.username}"
body = f"""<?xml version="1.0" encoding="UTF-8"?>
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:basicsearch>
<d:select>
<d:prop>
<d:displayname/>
<d:getcontentlength/>
<d:getlastmodified/>
<d:getcontenttype/>
<d:resourcetype/>
<oc:fileid/>
</d:prop>
</d:select>
<d:from>
<d:scope>
<d:href>/remote.php/dav/{search_path}</d:href>
<d:depth>infinity</d:depth>
</d:scope>
</d:from>
<d:where>
<d:like>
<d:prop><d:displayname/></d:prop>
<d:literal>%{query}%</d:literal>
</d:like>
</d:where>
<d:limit>
<d:nresults>50</d:nresults>
</d:limit>
</d:basicsearch>
</d:searchrequest>"""
resp = await self.webdav_request("SEARCH", search_path, body=body)
if resp.status_code == 207:
return self._parse_propfind(resp.text)
logger.warning(f"WebDAV SEARCH 실패: {resp.status_code}")
return []
# ──────────────────────────────────────
# 파일 조작
# ──────────────────────────────────────
async def upload_file(
self, remote_path: str, data: bytes, content_type: str = "application/octet-stream",
) -> bool:
"""WebDAV PUT — 파일 업로드."""
url = f"files/{self.username}/{remote_path.lstrip('/')}"
async with self._client() as client:
resp = await client.put(
f"/remote.php/dav/{url}",
content=data,
headers={"Content-Type": content_type},
)
ok = resp.status_code in (200, 201, 204)
if ok:
logger.info(f"파일 업로드: {remote_path}")
else:
logger.error(f"업로드 실패: {resp.status_code} {resp.text[:200]}")
return ok
async def download_file(self, remote_path: str) -> Optional[bytes]:
"""WebDAV GET — 파일 다운로드."""
url = f"files/{self.username}/{remote_path.lstrip('/')}"
async with self._client() as client:
resp = await client.get(f"/remote.php/dav/{url}")
if resp.status_code == 200:
return resp.content
logger.warning(f"다운로드 실패: {resp.status_code}")
return None
# ──────────────────────────────────────
# 공유 링크 (OCS Share API)
# ──────────────────────────────────────
async def create_share_link(
self, path: str, expire_days: int = 7,
) -> Optional[str]:
"""OCS 공유 링크 생성.
Returns:
공유 URL 또는 None
"""
data = {
"shareType": "3", # 3 = 공개 링크
"path": path,
}
if expire_days:
from datetime import datetime, timedelta
expire = (datetime.now() + timedelta(days=expire_days)).strftime("%Y-%m-%d")
data["expireDate"] = expire
result = await self.ocs_post("apps/files_sharing/api/v1/shares", data)
url = result.get("url")
if url:
logger.info(f"공유 링크 생성: {path}{url}")
return url
async def delete_share(self, share_id: int) -> bool:
"""OCS 공유 링크 삭제."""
return await self.ocs_delete(f"apps/files_sharing/api/v1/shares/{share_id}")
# ──────────────────────────────────────
# DAV (CalDAV / CardDAV 공통)
# ──────────────────────────────────────
async def dav_request(
self, method: str, path: str, body: str = None, headers: dict = None,
) -> httpx.Response:
"""CalDAV/CardDAV 요청 래퍼."""
return await self.webdav_request(method, path, body=body, headers=headers)
async def dav_put(
self, path: str, data: str, content_type: str,
) -> httpx.Response:
"""CalDAV/CardDAV PUT — 리소스 생성/수정."""
async with self._client() as client:
resp = await client.put(
f"/remote.php/dav/{path}",
content=data.encode("utf-8"),
headers={"Content-Type": content_type},
)
return resp
async def dav_delete(self, path: str) -> bool:
"""CalDAV/CardDAV DELETE."""
async with self._client() as client:
resp = await client.delete(f"/remote.php/dav/{path}")
return resp.status_code in (200, 204)
# ──────────────────────────────────────
# XML 파싱 유틸
# ──────────────────────────────────────
@staticmethod
def _parse_propfind(xml_text: str) -> list[dict]:
"""PROPFIND 응답 XML → dict 리스트."""
results = []
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
logger.error("PROPFIND XML 파싱 실패")
return results
for response in root.findall(f"{{{DAV_NS}}}response"):
href_el = response.find(f"{{{DAV_NS}}}href")
if href_el is None:
continue
href = href_el.text or ""
propstat = response.find(f"{{{DAV_NS}}}propstat")
if propstat is None:
continue
prop = propstat.find(f"{{{DAV_NS}}}prop")
if prop is None:
continue
# 디렉토리 여부
rtype = prop.find(f"{{{DAV_NS}}}resourcetype")
is_dir = rtype is not None and rtype.find(f"{{{DAV_NS}}}collection") is not None
# 이름 추출
displayname = prop.find(f"{{{DAV_NS}}}displayname")
name = displayname.text if displayname is not None and displayname.text else href.rstrip("/").split("/")[-1]
# 크기
size_el = prop.find(f"{{{DAV_NS}}}getcontentlength")
size = int(size_el.text) if size_el is not None and size_el.text else 0
# 수정시간
lastmod_el = prop.find(f"{{{DAV_NS}}}getlastmodified")
lastmod = lastmod_el.text if lastmod_el is not None else ""
# 콘텐츠 타입
ctype_el = prop.find(f"{{{DAV_NS}}}getcontenttype")
content_type = ctype_el.text if ctype_el is not None else ""
results.append({
"href": href,
"name": name,
"size": size,
"lastmod": lastmod,
"content_type": content_type,
"is_dir": is_dir,
})
return results
# ──────────────────────────────────────
# 헬스체크
# ──────────────────────────────────────
async def health_check(self) -> bool:
"""Nextcloud 연결 확인."""
try:
path = f"files/{self.username}"
results = await self.webdav_propfind(path, depth=0)
ok = len(results) > 0
if ok:
logger.info("Nextcloud 연결 확인 OK")
return ok
except Exception as e:
logger.error(f"Nextcloud 연결 실패: {e}")
return False
# ──────────────────────────────────────
# CLI
# ──────────────────────────────────────
async def _cli():
import io
if sys.stdout.encoding != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
args = sys.argv[1:]
client = NextcloudClient()
if not args or args[0] == "check":
ok = await client.health_check()
print(f"{'✅ 연결 성공' if ok else '❌ 연결 실패'}")
elif args[0] == "ls" and len(args) > 1:
path = f"files/{client.username}/{args[1].lstrip('/')}"
items = await client.webdav_propfind(path)
print(f"📂 {args[1]}{len(items)}건:")
for item in items[1:]: # 첫 번째는 자기 자신
icon = "📁" if item["is_dir"] else "📄"
size = f" ({item['size']:,}B)" if not item["is_dir"] else ""
print(f" {icon} {item['name']}{size}")
elif args[0] == "search" and len(args) > 1:
query = " ".join(args[1:])
results = await client.webdav_search(query)
print(f"🔍 '{query}' 검색 결과: {len(results)}")
for item in results:
icon = "📁" if item["is_dir"] else "📄"
size = f" ({item['size']:,}B)" if not item["is_dir"] else ""
print(f" {icon} {item['name']}{size}")
print(f" {item['href']}")
elif args[0] == "share" and len(args) > 1:
path = args[1]
url = await client.create_share_link(path)
if url:
print(f"🔗 {url}")
else:
print("❌ 공유 링크 생성 실패")
else:
print("사용법:")
print(" nextcloud_client.py check")
print(" nextcloud_client.py ls <path>")
print(" nextcloud_client.py search <query>")
print(" nextcloud_client.py share <path>")
if __name__ == "__main__":
asyncio.run(_cli())