notion-agent-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- notion_agent_cli/__init__.py +28 -0
- notion_agent_cli/account.py +141 -0
- notion_agent_cli/agents.py +129 -0
- notion_agent_cli/bootstrap.py +253 -0
- notion_agent_cli/cli/__init__.py +6 -0
- notion_agent_cli/cli/__main__.py +764 -0
- notion_agent_cli/exceptions.py +47 -0
- notion_agent_cli/models.py +167 -0
- notion_agent_cli/ndjson.py +301 -0
- notion_agent_cli/profile.py +196 -0
- notion_agent_cli/provider.py +550 -0
- notion_agent_cli/serve.py +318 -0
- notion_agent_cli/thread_state.py +73 -0
- notion_agent_cli/transcript.py +310 -0
- notion_agent_cli/types.py +27 -0
- notion_agent_cli-0.1.1.dist-info/METADATA +245 -0
- notion_agent_cli-0.1.1.dist-info/RECORD +20 -0
- notion_agent_cli-0.1.1.dist-info/WHEEL +4 -0
- notion_agent_cli-0.1.1.dist-info/entry_points.txt +2 -0
- notion_agent_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""notion-agent-cli — call Notion's ✦ AI endpoint from Python / the shell.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
- :class:`NotionAgentClient` — async client for one round-trip to
|
|
6
|
+
``/api/v3/runInferenceTranscript``. Loads a credential file, builds
|
|
7
|
+
the chat-panel-equivalent payload, streams the NDJSON response.
|
|
8
|
+
- :class:`ChatResponse` / :class:`TokenUsage` — response dataclasses.
|
|
9
|
+
- :class:`NotionAgentError` — single error type for transport / auth /
|
|
10
|
+
Notion-side failures. CLI translates this to a non-zero exit code.
|
|
11
|
+
|
|
12
|
+
Library callers will spend most of their time on those four names; the
|
|
13
|
+
sub-modules (``transcript``, ``ndjson``, ``account``, ``models``) are
|
|
14
|
+
useful for tests + bootstrap helpers and remain importable.
|
|
15
|
+
"""
|
|
16
|
+
from notion_agent_cli.exceptions import ErrorCode, NotionAgentError
|
|
17
|
+
from notion_agent_cli.provider import NotionAgentClient
|
|
18
|
+
from notion_agent_cli.types import ChatResponse, TokenUsage
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ChatResponse",
|
|
22
|
+
"ErrorCode",
|
|
23
|
+
"NotionAgentClient",
|
|
24
|
+
"NotionAgentError",
|
|
25
|
+
"TokenUsage",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Account credentials + workspace metadata.
|
|
2
|
+
|
|
3
|
+
The credential file (default ``~/.notionagents/notion_account.json``)
|
|
4
|
+
holds a long-lived ``token_v2`` cookie + workspace UUIDs + an optional
|
|
5
|
+
Custom Agent persona binding. Schema details + bootstrap walkthrough
|
|
6
|
+
live in ``docs/01-notion-chat-protocol.md §4``.
|
|
7
|
+
|
|
8
|
+
We prefer ``full_cookie`` (the raw ``document.cookie`` string copied
|
|
9
|
+
from the browser) when present — that's the operator's escape hatch
|
|
10
|
+
when individual-field extraction is too fiddly. Otherwise we stitch
|
|
11
|
+
together the minimum set Notion's edge expects for a logged-in session.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from notion_agent_cli.exceptions import ErrorCode, NotionAgentError
|
|
21
|
+
|
|
22
|
+
_REQUIRED_FIELDS: tuple[str, ...] = (
|
|
23
|
+
"token_v2",
|
|
24
|
+
"user_id",
|
|
25
|
+
"space_id",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True, frozen=True)
|
|
30
|
+
class NotionAccount:
|
|
31
|
+
# --- Credentials ---
|
|
32
|
+
token_v2: str
|
|
33
|
+
full_cookie: str = ""
|
|
34
|
+
|
|
35
|
+
# --- Identity ---
|
|
36
|
+
user_id: str = ""
|
|
37
|
+
user_name: str = ""
|
|
38
|
+
user_email: str = ""
|
|
39
|
+
|
|
40
|
+
# --- Workspace ---
|
|
41
|
+
space_id: str = ""
|
|
42
|
+
space_name: str = ""
|
|
43
|
+
space_view_id: str = ""
|
|
44
|
+
|
|
45
|
+
# --- Browser fingerprint ---
|
|
46
|
+
browser_id: str = ""
|
|
47
|
+
device_id: str = ""
|
|
48
|
+
client_version: str = "23.13.20260516.0113"
|
|
49
|
+
user_agent: str = (
|
|
50
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
51
|
+
"(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
|
52
|
+
)
|
|
53
|
+
timezone: str = "America/Los_Angeles"
|
|
54
|
+
|
|
55
|
+
# --- Custom agent persona (Jarvis-style binding) ---
|
|
56
|
+
# Setting these makes threads surface in Notion's ✦ AI chat panel
|
|
57
|
+
# under the named persona instead of the default chat.
|
|
58
|
+
agent_name: str = ""
|
|
59
|
+
agent_accessory: str = ""
|
|
60
|
+
agent_context_page_id: str = ""
|
|
61
|
+
|
|
62
|
+
# --- Default model alias ---
|
|
63
|
+
default_model: str = "opus-4.7"
|
|
64
|
+
|
|
65
|
+
# --- Forward-compat ---
|
|
66
|
+
extras: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def has_jarvis_binding(self) -> bool:
|
|
70
|
+
return bool(self.agent_name and self.agent_context_page_id)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_notion_account(path: Path | str) -> NotionAccount:
|
|
74
|
+
"""Read ``notion_account.json`` and return a validated NotionAccount.
|
|
75
|
+
|
|
76
|
+
Raises :class:`NotionAgentError` on missing file, malformed JSON, or
|
|
77
|
+
missing required fields so misconfiguration fails at startup rather
|
|
78
|
+
than mid-stream.
|
|
79
|
+
"""
|
|
80
|
+
p = Path(path).expanduser()
|
|
81
|
+
if not p.exists():
|
|
82
|
+
raise NotionAgentError(
|
|
83
|
+
f"notion_account.json not found at {p}; run "
|
|
84
|
+
"`notion-agent init --cookie <value>` to bootstrap one.",
|
|
85
|
+
code=ErrorCode.ACCOUNT_MISSING,
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
data: dict[str, Any] = json.loads(p.read_text(encoding="utf-8"))
|
|
89
|
+
except json.JSONDecodeError as e:
|
|
90
|
+
raise NotionAgentError(
|
|
91
|
+
f"notion_account.json malformed: {e}",
|
|
92
|
+
code=ErrorCode.ACCOUNT_MALFORMED,
|
|
93
|
+
) from e
|
|
94
|
+
|
|
95
|
+
missing = [f for f in _REQUIRED_FIELDS if not data.get(f)]
|
|
96
|
+
if missing:
|
|
97
|
+
raise NotionAgentError(
|
|
98
|
+
f"notion_account.json missing required fields: {missing}. "
|
|
99
|
+
"Run `notion-agent init` to regenerate.",
|
|
100
|
+
code=ErrorCode.ACCOUNT_INVALID,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
known = {f.name for f in NotionAccount.__dataclass_fields__.values()} - {"extras"}
|
|
104
|
+
kwargs = {k: data[k] for k in known if k in data}
|
|
105
|
+
extras = {k: v for k, v in data.items() if k not in known}
|
|
106
|
+
return NotionAccount(**kwargs, extras=extras)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_notion_account(acc: NotionAccount, path: Path | str) -> None:
|
|
110
|
+
"""Serialize a NotionAccount to JSON. Used by the `init` subcommand."""
|
|
111
|
+
p = Path(path).expanduser()
|
|
112
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
data: dict[str, Any] = {}
|
|
114
|
+
for f in NotionAccount.__dataclass_fields__.values():
|
|
115
|
+
if f.name == "extras":
|
|
116
|
+
continue
|
|
117
|
+
data[f.name] = getattr(acc, f.name)
|
|
118
|
+
data.update(acc.extras) # extras round-trip
|
|
119
|
+
p.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_cookie_header(acc: NotionAccount) -> str:
|
|
123
|
+
"""Cookie header value — prefers full_cookie when set, else stitches
|
|
124
|
+
together the minimum logged-in subset."""
|
|
125
|
+
if acc.full_cookie:
|
|
126
|
+
return acc.full_cookie
|
|
127
|
+
|
|
128
|
+
user_id = acc.user_id
|
|
129
|
+
user_id_nodash = user_id.replace("-", "")
|
|
130
|
+
parts = [
|
|
131
|
+
f"notion_browser_id={acc.browser_id}",
|
|
132
|
+
f"device_id={acc.device_id}",
|
|
133
|
+
f"notion_user_id={user_id}",
|
|
134
|
+
f'notion_users=[%22{user_id}%22]',
|
|
135
|
+
"notion_check_cookie_consent=false",
|
|
136
|
+
"notion_locale=en-US/legacy",
|
|
137
|
+
"notion_cookie_sync_completed=%7B%22completed%22%3Atrue%2C%22version%22%3A4%7D",
|
|
138
|
+
f"_cioid={user_id_nodash}",
|
|
139
|
+
f"token_v2={acc.token_v2}",
|
|
140
|
+
]
|
|
141
|
+
return "; ".join(parts)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Parse ``/api/v3/getCustomAgents`` into agent + thread summaries.
|
|
2
|
+
|
|
3
|
+
A single round-trip to ``getCustomAgents`` answers both "what custom
|
|
4
|
+
agents do I have here?" and "what threads have I run recently?" —
|
|
5
|
+
:func:`parse_custom_agents` splits the response into two ranked lists
|
|
6
|
+
the ``agents list`` / ``threads list`` CLI subcommands print.
|
|
7
|
+
|
|
8
|
+
The endpoint *does not* return agent display names — only their
|
|
9
|
+
``persistent_instructions_page`` UUIDs. To find the human name of an
|
|
10
|
+
agent you still need :func:`notion_agent_cli.bootstrap.fetch_user_content`
|
|
11
|
+
or a page-chunk lookup; for the MVP we surface the page id + activity
|
|
12
|
+
score + the title of the agent's most recent thread as a breadcrumb.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True, frozen=True)
|
|
21
|
+
class ThreadSummary:
|
|
22
|
+
"""One entry from ``mostRecentTranscripts``."""
|
|
23
|
+
thread_id: str
|
|
24
|
+
title: str | None
|
|
25
|
+
parent_agent_id: str | None
|
|
26
|
+
created_at_ms: int | None
|
|
27
|
+
updated_at_ms: int | None
|
|
28
|
+
created_by_id: str | None
|
|
29
|
+
created_source: str | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True, frozen=True)
|
|
33
|
+
class AgentSummary:
|
|
34
|
+
"""One custom agent + its most recent activity breadcrumb."""
|
|
35
|
+
agent_id: str
|
|
36
|
+
activity_score: int | None # epoch ms, None if no activity recorded
|
|
37
|
+
most_recent_thread_id: str | None
|
|
38
|
+
most_recent_thread_title: str | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _to_int(value: Any) -> int | None:
|
|
42
|
+
if value is None:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
return int(value)
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_threads(response: dict[str, Any]) -> list[ThreadSummary]:
|
|
51
|
+
"""Pull ``mostRecentTranscripts`` into a list sorted updated→created→id desc.
|
|
52
|
+
|
|
53
|
+
Missing fields land as ``None`` rather than raising — Notion returns
|
|
54
|
+
nulls for transcripts that never got past creation.
|
|
55
|
+
"""
|
|
56
|
+
out: list[ThreadSummary] = []
|
|
57
|
+
for entry in response.get("mostRecentTranscripts") or []:
|
|
58
|
+
if not isinstance(entry, dict):
|
|
59
|
+
continue
|
|
60
|
+
tid = entry.get("id")
|
|
61
|
+
if not isinstance(tid, str):
|
|
62
|
+
continue
|
|
63
|
+
out.append(ThreadSummary(
|
|
64
|
+
thread_id= tid,
|
|
65
|
+
title= entry.get("title") if isinstance(entry.get("title"), str) else None,
|
|
66
|
+
parent_agent_id= entry.get("parent_id") if isinstance(entry.get("parent_id"), str) else None,
|
|
67
|
+
created_at_ms= _to_int(entry.get("created_time")),
|
|
68
|
+
updated_at_ms= _to_int(entry.get("updated_time")),
|
|
69
|
+
created_by_id= entry.get("created_by_id") if isinstance(entry.get("created_by_id"), str) else None,
|
|
70
|
+
created_source= entry.get("created_source") if isinstance(entry.get("created_source"), str) else None,
|
|
71
|
+
))
|
|
72
|
+
# Sort newest-first by max(updated, created) so transcripts that
|
|
73
|
+
# never got an updated_at still slot in chronologically by their
|
|
74
|
+
# creation time — matches what the CLI prints in the timestamp
|
|
75
|
+
# column. Tiebreak on thread_id for stable output.
|
|
76
|
+
def _sort_key(t: ThreadSummary) -> tuple[int, str]:
|
|
77
|
+
newest = max(t.updated_at_ms or 0, t.created_at_ms or 0)
|
|
78
|
+
return (-newest, t.thread_id)
|
|
79
|
+
out.sort(key=_sort_key)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_agents(response: dict[str, Any]) -> list[AgentSummary]:
|
|
84
|
+
"""Combine ``agentIds`` + ``activityScores`` + most-recent thread title.
|
|
85
|
+
|
|
86
|
+
Sorted by activity_score desc (most-recently-used first). Agents
|
|
87
|
+
with no recorded activity sink to the bottom with ``activity_score
|
|
88
|
+
= None``.
|
|
89
|
+
"""
|
|
90
|
+
agent_ids: list[str] = [
|
|
91
|
+
x for x in (response.get("agentIds") or []) if isinstance(x, str)
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
activity: dict[str, int] = {}
|
|
95
|
+
for entry in response.get("activityScores") or []:
|
|
96
|
+
if not isinstance(entry, dict):
|
|
97
|
+
continue
|
|
98
|
+
pid = entry.get("parent_id")
|
|
99
|
+
score = _to_int(entry.get("activity_score"))
|
|
100
|
+
if (
|
|
101
|
+
isinstance(pid, str)
|
|
102
|
+
and score is not None
|
|
103
|
+
and (pid not in activity or activity[pid] < score)
|
|
104
|
+
):
|
|
105
|
+
# If duplicates, keep the highest score (= most recent activity).
|
|
106
|
+
activity[pid] = score
|
|
107
|
+
|
|
108
|
+
# Best-effort breadcrumb: agent's most recent thread (title + id).
|
|
109
|
+
threads = parse_threads(response) # already sorted newest-first
|
|
110
|
+
recent_by_agent: dict[str, ThreadSummary] = {}
|
|
111
|
+
for t in threads:
|
|
112
|
+
if t.parent_agent_id and t.parent_agent_id not in recent_by_agent:
|
|
113
|
+
recent_by_agent[t.parent_agent_id] = t
|
|
114
|
+
|
|
115
|
+
out: list[AgentSummary] = []
|
|
116
|
+
for aid in agent_ids:
|
|
117
|
+
recent = recent_by_agent.get(aid)
|
|
118
|
+
out.append(AgentSummary(
|
|
119
|
+
agent_id= aid,
|
|
120
|
+
activity_score= activity.get(aid),
|
|
121
|
+
most_recent_thread_id= recent.thread_id if recent else None,
|
|
122
|
+
most_recent_thread_title=recent.title if recent else None,
|
|
123
|
+
))
|
|
124
|
+
|
|
125
|
+
def _sort_key(a: AgentSummary) -> tuple[int, str]:
|
|
126
|
+
# None scores sink to the bottom; tiebreak on agent_id for stable order.
|
|
127
|
+
return (-(a.activity_score or 0), a.agent_id)
|
|
128
|
+
out.sort(key=_sort_key)
|
|
129
|
+
return out
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Workspace metadata bootstrap.
|
|
2
|
+
|
|
3
|
+
Library function used by the ``notion-agent init`` CLI subcommand.
|
|
4
|
+
Takes a ``token_v2`` cookie (the only thing the operator can copy from
|
|
5
|
+
a browser in <30s) plus optional fingerprint UUIDs, then calls
|
|
6
|
+
``/api/v3/loadUserContent`` to enumerate workspaces + extract user
|
|
7
|
+
identity. Returns a populated :class:`NotionAccount` ready to save.
|
|
8
|
+
|
|
9
|
+
If the user has multiple workspaces and the caller didn't pre-select
|
|
10
|
+
one, :func:`bootstrap_account` raises :class:`AmbiguousWorkspaceError`
|
|
11
|
+
carrying the available choices so the CLI can prompt.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from notion_agent_cli.account import NotionAccount
|
|
22
|
+
from notion_agent_cli.exceptions import ErrorCode, NotionAgentError
|
|
23
|
+
|
|
24
|
+
BASE_URL = "https://www.notion.so/api/v3"
|
|
25
|
+
DEFAULT_UA = (
|
|
26
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
27
|
+
"(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True, frozen=True)
|
|
32
|
+
class Workspace:
|
|
33
|
+
space_id: str
|
|
34
|
+
space_view_id: str
|
|
35
|
+
space_name: str
|
|
36
|
+
domain: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True, frozen=True)
|
|
40
|
+
class UserInfo:
|
|
41
|
+
user_id: str
|
|
42
|
+
user_name: str
|
|
43
|
+
user_email: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AmbiguousWorkspaceError(NotionAgentError):
|
|
47
|
+
"""Raised when bootstrap finds >1 workspace and caller didn't pick one."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, workspaces: list[Workspace]):
|
|
50
|
+
super().__init__(
|
|
51
|
+
f"{len(workspaces)} workspaces available — caller must specify which: "
|
|
52
|
+
+ ", ".join(f"{w.space_name!r}" for w in workspaces),
|
|
53
|
+
code=ErrorCode.WORKSPACE_AMBIGUOUS,
|
|
54
|
+
)
|
|
55
|
+
self.workspaces = workspaces
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_browser_cookie(cookie: str) -> dict[str, str]:
|
|
59
|
+
"""Parse a ``document.cookie`` string into ``{name: value}`` pairs.
|
|
60
|
+
|
|
61
|
+
Values are kept verbatim — ``token_v2`` in particular is left in its
|
|
62
|
+
URL-encoded form (``v03%3A...``), which is what Notion's edge accepts
|
|
63
|
+
in subsequent requests. Whitespace around names and values is trimmed.
|
|
64
|
+
Entries without ``=`` are dropped silently.
|
|
65
|
+
"""
|
|
66
|
+
out: dict[str, str] = {}
|
|
67
|
+
for part in cookie.split(";"):
|
|
68
|
+
part = part.strip()
|
|
69
|
+
if not part or "=" not in part:
|
|
70
|
+
continue
|
|
71
|
+
name, _, value = part.partition("=")
|
|
72
|
+
out[name.strip()] = value.strip()
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _build_cookie(token_v2: str, browser_id: str, user_id: str) -> str:
|
|
77
|
+
return "; ".join([
|
|
78
|
+
f"notion_browser_id={browser_id}",
|
|
79
|
+
f"notion_user_id={user_id}",
|
|
80
|
+
f'notion_users=[%22{user_id}%22]',
|
|
81
|
+
"notion_check_cookie_consent=false",
|
|
82
|
+
"notion_locale=en-US/legacy",
|
|
83
|
+
f"token_v2={token_v2}",
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _bootstrap_headers(token_v2: str, browser_id: str, user_id: str) -> dict[str, str]:
|
|
88
|
+
return {
|
|
89
|
+
"accept": "application/json",
|
|
90
|
+
"accept-language": "en-US,en;q=0.9",
|
|
91
|
+
"content-type": "application/json",
|
|
92
|
+
"notion-audit-log-platform": "web",
|
|
93
|
+
"notion-client-version": "23.13.20260516.0113",
|
|
94
|
+
"origin": "https://www.notion.so",
|
|
95
|
+
"referer": "https://www.notion.so/",
|
|
96
|
+
"user-agent": DEFAULT_UA,
|
|
97
|
+
"x-notion-active-user-header": user_id,
|
|
98
|
+
"sec-ch-ua": '"Not-A.Brand";v="24", "Chromium";v="146"',
|
|
99
|
+
"sec-ch-ua-mobile": "?0",
|
|
100
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
101
|
+
"sec-fetch-dest": "empty",
|
|
102
|
+
"sec-fetch-mode": "cors",
|
|
103
|
+
"sec-fetch-site": "same-origin",
|
|
104
|
+
"cookie": _build_cookie(token_v2, browser_id, user_id),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _walk_record(record: dict[str, Any]) -> dict[str, Any]:
|
|
109
|
+
"""Unwrap Notion's ``{'role': '...', 'value': {...}}`` records."""
|
|
110
|
+
if not isinstance(record, dict):
|
|
111
|
+
return {}
|
|
112
|
+
val = record.get("value")
|
|
113
|
+
if isinstance(val, dict):
|
|
114
|
+
inner = val.get("value")
|
|
115
|
+
if isinstance(inner, dict):
|
|
116
|
+
return inner
|
|
117
|
+
return val
|
|
118
|
+
return record
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract_workspaces(load_data: dict[str, Any]) -> list[Workspace]:
|
|
122
|
+
rm = load_data.get("recordMap") or {}
|
|
123
|
+
spaces_raw = rm.get("space") or {}
|
|
124
|
+
space_views_raw = rm.get("space_view") or {}
|
|
125
|
+
|
|
126
|
+
sv_by_space: dict[str, str] = {}
|
|
127
|
+
for sv_id, sv_rec in space_views_raw.items():
|
|
128
|
+
sv = _walk_record(sv_rec)
|
|
129
|
+
sp = sv.get("space_id")
|
|
130
|
+
if isinstance(sp, str) and sp not in sv_by_space:
|
|
131
|
+
sv_by_space[sp] = sv_id
|
|
132
|
+
|
|
133
|
+
out: list[Workspace] = []
|
|
134
|
+
for space_id, space_rec in spaces_raw.items():
|
|
135
|
+
sp = _walk_record(space_rec)
|
|
136
|
+
out.append(Workspace(
|
|
137
|
+
space_id=space_id,
|
|
138
|
+
space_view_id=sv_by_space.get(space_id, ""),
|
|
139
|
+
space_name=sp.get("name") or "",
|
|
140
|
+
domain=sp.get("domain") or "",
|
|
141
|
+
))
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _extract_user(load_data: dict[str, Any], user_id: str) -> UserInfo:
|
|
146
|
+
rm = load_data.get("recordMap") or {}
|
|
147
|
+
users_raw = rm.get("notion_user") or {}
|
|
148
|
+
rec = users_raw.get(user_id)
|
|
149
|
+
if not rec:
|
|
150
|
+
return UserInfo(user_id=user_id, user_name="", user_email="")
|
|
151
|
+
u = _walk_record(rec)
|
|
152
|
+
name_parts = [u.get("given_name") or "", u.get("family_name") or ""]
|
|
153
|
+
name = " ".join(p for p in name_parts if p).strip() or u.get("name") or ""
|
|
154
|
+
return UserInfo(user_id=user_id, user_name=name, user_email=u.get("email") or "")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def fetch_user_content(
|
|
158
|
+
*, token_v2: str, user_id: str, browser_id: str,
|
|
159
|
+
http_client: httpx.AsyncClient | None = None,
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
"""Call /api/v3/loadUserContent and return the parsed response."""
|
|
162
|
+
headers = _bootstrap_headers(token_v2, browser_id, user_id)
|
|
163
|
+
owns_client = http_client is None
|
|
164
|
+
client = http_client or httpx.AsyncClient(timeout=30.0)
|
|
165
|
+
try:
|
|
166
|
+
resp = await client.post(f"{BASE_URL}/loadUserContent", json={}, headers=headers)
|
|
167
|
+
if resp.status_code != 200:
|
|
168
|
+
code = (
|
|
169
|
+
ErrorCode.AUTH_INVALID
|
|
170
|
+
if resp.status_code in (401, 403)
|
|
171
|
+
else ErrorCode.HTTP_ERROR
|
|
172
|
+
)
|
|
173
|
+
raise NotionAgentError(
|
|
174
|
+
f"loadUserContent failed: HTTP {resp.status_code} body={resp.text[:500]!r}",
|
|
175
|
+
code=code,
|
|
176
|
+
)
|
|
177
|
+
return resp.json()
|
|
178
|
+
finally:
|
|
179
|
+
if owns_client:
|
|
180
|
+
await client.aclose()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _ensure_uuid(value: str | None) -> str:
|
|
184
|
+
return value or str(uuid.uuid4())
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def bootstrap_account(
|
|
188
|
+
*,
|
|
189
|
+
token_v2: str,
|
|
190
|
+
user_id: str,
|
|
191
|
+
browser_id: str | None = None,
|
|
192
|
+
space_name: str | None = None,
|
|
193
|
+
space_domain: str | None = None,
|
|
194
|
+
agent_name: str = "",
|
|
195
|
+
agent_accessory: str = "",
|
|
196
|
+
agent_context_page_id: str = "",
|
|
197
|
+
default_model: str = "opus-4.7",
|
|
198
|
+
timezone: str = "America/Los_Angeles",
|
|
199
|
+
http_client: httpx.AsyncClient | None = None,
|
|
200
|
+
) -> NotionAccount:
|
|
201
|
+
"""Probe ``/api/v3/loadUserContent`` and return a populated NotionAccount.
|
|
202
|
+
|
|
203
|
+
Raises :class:`AmbiguousWorkspaceError` when multiple workspaces are
|
|
204
|
+
available and ``space_name`` / ``space_domain`` didn't disambiguate.
|
|
205
|
+
"""
|
|
206
|
+
browser_id = _ensure_uuid(browser_id)
|
|
207
|
+
data = await fetch_user_content(
|
|
208
|
+
token_v2=token_v2, user_id=user_id, browser_id=browser_id,
|
|
209
|
+
http_client=http_client,
|
|
210
|
+
)
|
|
211
|
+
workspaces = _extract_workspaces(data)
|
|
212
|
+
if not workspaces:
|
|
213
|
+
raise NotionAgentError(
|
|
214
|
+
"loadUserContent returned no workspaces — token invalid?",
|
|
215
|
+
code=ErrorCode.WORKSPACE_EMPTY,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if len(workspaces) == 1:
|
|
219
|
+
chosen = workspaces[0]
|
|
220
|
+
elif space_domain:
|
|
221
|
+
match = next((w for w in workspaces if w.domain == space_domain), None)
|
|
222
|
+
if match is None:
|
|
223
|
+
raise AmbiguousWorkspaceError(workspaces)
|
|
224
|
+
chosen = match
|
|
225
|
+
elif space_name:
|
|
226
|
+
match = next(
|
|
227
|
+
(w for w in workspaces if w.space_name.lower() == space_name.lower()),
|
|
228
|
+
None,
|
|
229
|
+
)
|
|
230
|
+
if match is None:
|
|
231
|
+
raise AmbiguousWorkspaceError(workspaces)
|
|
232
|
+
chosen = match
|
|
233
|
+
else:
|
|
234
|
+
raise AmbiguousWorkspaceError(workspaces)
|
|
235
|
+
|
|
236
|
+
user = _extract_user(data, user_id)
|
|
237
|
+
|
|
238
|
+
return NotionAccount(
|
|
239
|
+
token_v2=token_v2,
|
|
240
|
+
user_id=user_id,
|
|
241
|
+
user_name=user.user_name,
|
|
242
|
+
user_email=user.user_email,
|
|
243
|
+
space_id=chosen.space_id,
|
|
244
|
+
space_view_id=chosen.space_view_id,
|
|
245
|
+
space_name=chosen.space_name,
|
|
246
|
+
browser_id=browser_id,
|
|
247
|
+
device_id=_ensure_uuid(None),
|
|
248
|
+
timezone=timezone,
|
|
249
|
+
agent_name=agent_name,
|
|
250
|
+
agent_accessory=agent_accessory,
|
|
251
|
+
agent_context_page_id=agent_context_page_id,
|
|
252
|
+
default_model=default_model,
|
|
253
|
+
)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""CLI surface — entry point is :mod:`notion_agent_cli.cli.__main__`.
|
|
2
|
+
|
|
3
|
+
The package exposes a single ``notion-agent`` console-script registered
|
|
4
|
+
in pyproject.toml. Subcommands live in this module; for now everything
|
|
5
|
+
fits in ``__main__.py``, but we'll split per-command files as they grow.
|
|
6
|
+
"""
|