""" backend/tools/notion_tool.py — P24-F2: Notion read/write/search/append. Permette all'agente di leggere, scrivere e cercare in Notion. Richiede NOTION_TOKEN env var (Integration Token da notion.so/my-integrations). Operazioni: search — cerca pagine/database per testo read — legge contenuto di una pagina (restituisce markdown-like) write — crea nuova pagina sotto un genitore append — aggiunge testo/markdown a pagina esistente """ from __future__ import annotations import logging import os from typing import Any import httpx _logger = logging.getLogger("agente_ai.tools.notion_tool") # Token resolution: env var → vault (fallback) → empty def _resolve_notion_token() -> str: """Legge NOTION_TOKEN da env var o dal vault crittografato del backend.""" # 1. Env var diretta (Railway secrets, HF Spaces secrets, .env locale) token = os.getenv("NOTION_TOKEN", "") if token: return token # 2. Fallback: vault crittografato (persistente tra i restart) try: import json as _j from pathlib import Path as _P import base64 as _b64, hashlib as _h, hmac as _hmac_m vault_path_str = os.getenv("VAULT_PATH", "/tmp/.vault_data/vault_secrets.json") _vp = _P(vault_path_str) if _vp.exists(): data = _j.loads(_vp.read_text()) raw_enc = data.get("notion_token", "") if raw_enc: raw_key = os.getenv("VAULT_KEY", "").encode() or b"" if raw_key: _vk = _h.sha256(raw_key).digest() raw = _b64.b64decode(raw_enc) sig, ct = raw[:32], raw[32:] if _hmac_m.compare_digest(sig, _hmac_m.new(_vk, ct, _h.sha256).digest()): ks = b"" for i in range((len(ct) // 32) + 1): ks += _h.sha256(_vk + i.to_bytes(4, "big")).digest() return bytes(a ^ b for a, b in zip(ct, ks[:len(ct)])).decode("utf-8") except Exception: # noqa: BLE001 pass return "" _NOTION_TOKEN: str = _resolve_notion_token() _BASE_URL = "https://api.notion.com/v1" _NOTION_VER = "2022-06-28" _TIMEOUT = 15.0 _MAX_TEXT = 8000 # max caratteri estratti da una pagina def _hdrs() -> dict[str, str]: return { "Authorization": f"Bearer {_NOTION_TOKEN}", "Notion-Version": _NOTION_VER, "Content-Type": "application/json", } # ── Parsing blocchi → testo ──────────────────────────────────────────────────── def _blocks_to_text(blocks: list[dict]) -> str: lines: list[str] = [] for b in blocks: btype = b.get("type", "") obj = b.get(btype, {}) rich = obj.get("rich_text", []) text = "".join(r.get("plain_text", "") for r in rich) if btype == "code": lang = obj.get("language", "") lines.append(f"```{lang}\n{text}\n```") elif btype == "heading_1": lines.append(f"# {text}") elif btype == "heading_2": lines.append(f"## {text}") elif btype == "heading_3": lines.append(f"### {text}") elif btype == "bulleted_list_item": lines.append(f"• {text}") elif btype == "numbered_list_item": lines.append(f"- {text}") elif btype == "to_do": mark = "✅" if obj.get("checked") else "☐" lines.append(f"{mark} {text}") elif btype == "divider": lines.append("---") elif text: lines.append(text) return "\n".join(lines) # ── Markdown → blocchi Notion ────────────────────────────────────────────────── def _text_to_blocks(text: str) -> list[dict[str, Any]]: blocks: list[dict[str, Any]] = [] in_code = False code_buf: list[str] = [] code_lang = "plain text" def _rt(s: str) -> list[dict]: return [{"type": "text", "text": {"content": s[:2000]}}] for line in text.split("\n"): if line.startswith("```"): if not in_code: in_code = True code_lang = line[3:].strip() or "plain text" code_buf = [] else: in_code = False blocks.append({"type": "code", "code": { "language": code_lang, "rich_text": _rt("\n".join(code_buf)), }}) continue if in_code: code_buf.append(line) continue if line.startswith("# "): blocks.append({"type": "heading_1", "heading_1": {"rich_text": _rt(line[2:])}}) elif line.startswith("## "): blocks.append({"type": "heading_2", "heading_2": {"rich_text": _rt(line[3:])}}) elif line.startswith("### "): blocks.append({"type": "heading_3", "heading_3": {"rich_text": _rt(line[4:])}}) elif line.startswith(("- ", "• ")): blocks.append({"type": "bulleted_list_item", "bulleted_list_item": {"rich_text": _rt(line[2:])}}) elif line.strip() in ("---", "***", "___"): blocks.append({"type": "divider", "divider": {}}) elif not line.strip(): blocks.append({"type": "paragraph", "paragraph": {"rich_text": []}}) else: blocks.append({"type": "paragraph", "paragraph": {"rich_text": _rt(line)}}) return blocks[:100] # Notion: max 100 blocchi per richiesta # ── Funzione principale ──────────────────────────────────────────────────────── async def notion_rw( action: str, query: str = "", page_id: str = "", parent_id: str = "", title: str = "", content: str = "", max_results: int = 5, ) -> dict[str, Any]: """ Interagisce con Notion (leggi / scrivi / cerca / appendi). action = "search" → cerca pagine per testo (query obbligatorio) action = "read" → legge contenuto pagina (page_id obbligatorio) action = "write" → crea nuova pagina (parent_id + title; content opzionale) action = "append" → aggiunge contenuto a pagina esistente (page_id + content) Richiede NOTION_TOKEN nel vault del backend. """ if not _NOTION_TOKEN: return { "ok": False, "error": ( "NOTION_TOKEN non configurato. " "Aggiungilo come NOTION_TOKEN nei secrets Railway/HF Spaces, " "oppure salvalo nel vault tramite /api/vault (chiave: notion_token)." ), } action = action.lower().strip() try: async with httpx.AsyncClient(timeout=_TIMEOUT, base_url=_BASE_URL) as c: # ── SEARCH ──────────────────────────────────────────────────────── if action == "search": if not query: return {"ok": False, "error": "query è obbligatorio per action=search"} r = await c.post("/search", headers=_hdrs(), json={"query": query, "page_size": min(max(1, max_results), 20)}) if r.status_code != 200: return {"ok": False, "error": f"Notion {r.status_code}: {r.text[:300]}"} items = [] for obj in r.json().get("results", []): otype = obj.get("object", "") t = "" if otype == "page": for v in obj.get("properties", {}).values(): if v.get("type") == "title": t = "".join(x.get("plain_text","") for x in v.get("title",[])) break elif otype == "database": t = "".join(x.get("plain_text","") for x in obj.get("title",[])) items.append({ "id": obj.get("id","").replace("-",""), "title": t or "(senza titolo)", "type": otype, "url": obj.get("url",""), "last_edited": obj.get("last_edited_time",""), }) return {"ok": True, "action": "search", "query": query, "results": items, "count": len(items)} # ── READ ────────────────────────────────────────────────────────── elif action == "read": if not page_id: return {"ok": False, "error": "page_id è obbligatorio per action=read"} pid = page_id.replace("-","") meta = await c.get(f"/pages/{pid}", headers=_hdrs()) if meta.status_code != 200: return {"ok": False, "error": f"Pagina non trovata o accesso negato ({meta.status_code})"} m = meta.json() t = "" for v in m.get("properties", {}).values(): if v.get("type") == "title": t = "".join(x.get("plain_text","") for x in v.get("title",[])) break blk_r = await c.get(f"/blocks/{pid}/children", headers=_hdrs(), params={"page_size": 100}) blocks = blk_r.json().get("results",[]) if blk_r.status_code == 200 else [] return { "ok": True, "action": "read", "page_id": pid, "title": t, "url": m.get("url",""), "last_edited": m.get("last_edited_time",""), "content": _blocks_to_text(blocks)[:_MAX_TEXT], "block_count": len(blocks), } # ── WRITE ───────────────────────────────────────────────────────── elif action == "write": if not parent_id: return {"ok": False, "error": "parent_id obbligatorio per action=write"} if not title: return {"ok": False, "error": "title obbligatorio per action=write"} pid = parent_id.replace("-","") blocks = _text_to_blocks(content) if content else [] payload: dict[str, Any] = { "parent": {"page_id": pid}, "properties": {"title": {"title": [{"type":"text","text":{"content":title[:2000]}}]}}, "children": blocks, } r = await c.post("/pages", headers=_hdrs(), json=payload) if r.status_code not in (200, 201): return {"ok": False, "error": f"Notion {r.status_code}: {r.text[:300]}"} created = r.json() return { "ok": True, "action": "write", "page_id": created.get("id","").replace("-",""), "url": created.get("url",""), "title": title, "blocks_written": len(blocks), "message": f"Pagina '{title}' creata su Notion", } # ── APPEND ──────────────────────────────────────────────────────── elif action == "append": if not page_id: return {"ok": False, "error": "page_id obbligatorio per action=append"} if not content: return {"ok": False, "error": "content obbligatorio per action=append"} pid = page_id.replace("-","") blocks = _text_to_blocks(content) r = await c.patch(f"/blocks/{pid}/children", headers=_hdrs(), json={"children": blocks}) if r.status_code != 200: return {"ok": False, "error": f"Notion {r.status_code}: {r.text[:300]}"} return { "ok": True, "action": "append", "page_id": pid, "blocks_appended": len(blocks), "message": f"{len(blocks)} blocchi aggiunti alla pagina", } else: return {"ok": False, "error": f"action non valida: '{action}'. Valori: search, read, write, append"} except httpx.TimeoutException: return {"ok": False, "error": f"Timeout ({_TIMEOUT:.0f}s) — Notion API non ha risposto"} except Exception as exc: # noqa: BLE001 _logger.warning("[notion_tool] %s", exc) return {"ok": False, "error": str(exc)[:300]} # ── Tool registry descriptor ─────────────────────────────────────────────────── TOOL_DESCRIPTOR = { "name": "notion_rw", "description": ( "Leggi, scrivi e cerca su Notion. " "action=search: cerca pagine per query testuale. " "action=read: legge contenuto pagina (page_id). " "action=write: crea nuova pagina (parent_id + title + content opzionale). " "action=append: aggiunge testo/markdown a pagina esistente (page_id + content). " "Richiede NOTION_TOKEN configurato nel backend." ), "parameters": { "type": "object", "properties": { "action": {"type": "string", "enum": ["search","read","write","append"], "description": "Operazione da eseguire"}, "query": {"type": "string", "description": "Testo di ricerca (per search)"}, "page_id": {"type": "string", "description": "ID pagina Notion (per read/append)"}, "parent_id": {"type": "string", "description": "ID pagina genitore (per write)"}, "title": {"type": "string", "description": "Titolo nuova pagina (per write)"}, "content": {"type": "string", "description": "Testo/Markdown da scrivere o appendere"}, "max_results": {"type": "integer", "description": "Max risultati search (default 5, max 20)"}, }, "required": ["action"], }, "fn": notion_rw, }