From 91308ac773ccf1ec8c7b7dab7e437508e7dc155c Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 5 Feb 2026 09:09:48 -0800 Subject: [PATCH] Create notion tool --- examples/wasm-tools/notion/.gitignore | 1 + examples/wasm-tools/notion/Cargo.lock | 401 ++++++++++++++++ examples/wasm-tools/notion/Cargo.toml | 21 + examples/wasm-tools/notion/README.md | 186 ++++++++ .../notion/notion_tool.capabilities.json | 26 + examples/wasm-tools/notion/src/api.rs | 445 +++++++++++++++++ examples/wasm-tools/notion/src/lib.rs | 451 ++++++++++++++++++ examples/wasm-tools/notion/src/types.rs | 267 +++++++++++ 8 files changed, 1798 insertions(+) create mode 100644 examples/wasm-tools/notion/.gitignore create mode 100644 examples/wasm-tools/notion/Cargo.lock create mode 100644 examples/wasm-tools/notion/Cargo.toml create mode 100644 examples/wasm-tools/notion/README.md create mode 100644 examples/wasm-tools/notion/notion_tool.capabilities.json create mode 100644 examples/wasm-tools/notion/src/api.rs create mode 100644 examples/wasm-tools/notion/src/lib.rs create mode 100644 examples/wasm-tools/notion/src/types.rs diff --git a/examples/wasm-tools/notion/.gitignore b/examples/wasm-tools/notion/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/examples/wasm-tools/notion/.gitignore @@ -0,0 +1 @@ +/target diff --git a/examples/wasm-tools/notion/Cargo.lock b/examples/wasm-tools/notion/Cargo.lock new file mode 100644 index 00000000..1e971b88 --- /dev/null +++ b/examples/wasm-tools/notion/Cargo.lock @@ -0,0 +1,401 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "notion-tool" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1" +dependencies = [ + "leb128", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7" +dependencies = [ + "anyhow", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25" +dependencies = [ + "ahash", + "bitflags", + "hashbrown 0.14.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/examples/wasm-tools/notion/Cargo.toml b/examples/wasm-tools/notion/Cargo.toml new file mode 100644 index 00000000..cc3d8764 --- /dev/null +++ b/examples/wasm-tools/notion/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "notion-tool" +version = "0.1.0" +edition = "2021" +description = "Notion integration tool for NEAR Agent (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 diff --git a/examples/wasm-tools/notion/README.md b/examples/wasm-tools/notion/README.md new file mode 100644 index 00000000..549d9b10 --- /dev/null +++ b/examples/wasm-tools/notion/README.md @@ -0,0 +1,186 @@ +# Notion WASM Tool + +A WASM-sandboxed Notion integration for NEAR Agent. Follows the MCP server pattern with domain-grouped operations. + +## Building + +```bash +cargo build --release --target wasm32-wasip1 +``` + +The compiled module will be at `target/wasm32-wasip1/release/notion_tool.wasm`. + +## Capabilities + +This tool requires: + +- **HTTP**: Access to `api.notion.com/v1/*` (GET, POST, PATCH, DELETE) +- **Secrets**: `notion_api_token` (injected as Bearer authorization) + +See `notion_tool.capabilities.json` for the full capability configuration. + +## Setup + +1. Create a Notion integration at https://www.notion.so/my-integrations +2. Copy the "Internal Integration Secret" +3. Add it to the agent's secrets store as `notion_api_token` +4. Share your Notion pages/databases with the integration + +## Supported Actions + +### Search + +| Action | Required Params | Optional Params | +|--------|-----------------|-----------------| +| `search` | - | `query`, `filter`, `page_size`, `start_cursor` | + +### Pages + +| Action | Required Params | Optional Params | +|--------|-----------------|-----------------| +| `get_page` | `page_id` | - | +| `create_page` | `parent`, `properties` | `children`, `icon`, `cover` | +| `update_page` | `page_id`, `properties` | `icon`, `cover` | +| `archive_page` | `page_id` | - | +| `restore_page` | `page_id` | - | + +### Blocks + +| Action | Required Params | Optional Params | +|--------|-----------------|-----------------| +| `get_blocks` | `block_id` | `page_size`, `start_cursor` | +| `append_blocks` | `block_id`, `children` | `after` | +| `get_block` | `block_id` | - | +| `update_block` | `block_id`, `content` | - | +| `delete_block` | `block_id` | - | + +### Databases + +| Action | Required Params | Optional Params | +|--------|-----------------|-----------------| +| `get_database` | `database_id` | - | +| `query_database` | `database_id` | `filter`, `sorts`, `page_size`, `start_cursor` | +| `create_database` | `parent`, `title`, `properties` | `icon`, `cover`, `is_inline` | +| `update_database` | `database_id` | `title`, `properties` | + +### Comments + +| Action | Required Params | Optional Params | +|--------|-----------------|-----------------| +| `get_comments` | `block_id` | `page_size`, `start_cursor` | +| `add_comment` | `parent`, `rich_text` | - | + +### Users + +| Action | Required Params | Optional Params | +|--------|-----------------|-----------------| +| `list_users` | - | `page_size`, `start_cursor` | +| `get_user` | `user_id` | - | +| `get_me` | - | - | + +## Examples + +### Search for pages + +```json +{ + "action": "search", + "query": "meeting notes", + "filter": { "property": "object", "value": "page" }, + "page_size": 10 +} +``` + +### Query a database with filters + +```json +{ + "action": "query_database", + "database_id": "abc123-def456-...", + "filter": { + "property": "Status", + "select": { "equals": "Done" } + }, + "sorts": [ + { "property": "Created", "direction": "descending" } + ], + "page_size": 20 +} +``` + +### Create a page in a database + +```json +{ + "action": "create_page", + "parent": { "database_id": "abc123-def456-..." }, + "properties": { + "Name": { + "title": [{ "text": { "content": "New Task" } }] + }, + "Status": { + "select": { "name": "To Do" } + } + }, + "children": [ + { + "object": "block", + "type": "paragraph", + "paragraph": { + "rich_text": [{ "text": { "content": "Task description here." } }] + } + } + ] +} +``` + +### Append content to a page + +```json +{ + "action": "append_blocks", + "block_id": "page-id-here", + "children": [ + { + "object": "block", + "type": "heading_2", + "heading_2": { + "rich_text": [{ "text": { "content": "New Section" } }] + } + }, + { + "object": "block", + "type": "paragraph", + "paragraph": { + "rich_text": [{ "text": { "content": "Some content..." } }] + } + } + ] +} +``` + +### Add a comment + +```json +{ + "action": "add_comment", + "parent": { "page_id": "page-id-here" }, + "rich_text": [ + { "text": { "content": "This is a comment from the agent." } } + ] +} +``` + +## Security + +- Runs in WASM sandbox with fuel metering and memory limits +- API token injected at host boundary (never visible to WASM) +- Only `api.notion.com/v1/*` endpoints allowed +- Responses scanned for secret leakage +- Rate limited: 50 req/min, 1000 req/hour + +## References + +- [Notion API Documentation](https://developers.notion.com/reference/intro) +- [Notion MCP Server](https://github.com/makenotion/notion-mcp-server) +- [NEAR Agent WASM Tool System](../../../src/tools/wasm/) diff --git a/examples/wasm-tools/notion/notion_tool.capabilities.json b/examples/wasm-tools/notion/notion_tool.capabilities.json new file mode 100644 index 00000000..f64eadc2 --- /dev/null +++ b/examples/wasm-tools/notion/notion_tool.capabilities.json @@ -0,0 +1,26 @@ +{ + "http": { + "allowlist": [ + { + "host": "api.notion.com", + "path_prefix": "/v1/", + "methods": ["GET", "POST", "PATCH", "DELETE"] + } + ], + "credentials": { + "notion_api_token": { + "secret_name": "notion_api_token", + "location": { "type": "bearer" }, + "host_patterns": ["api.notion.com"] + } + }, + "rate_limit": { + "requests_per_minute": 50, + "requests_per_hour": 1000 + }, + "timeout_secs": 30 + }, + "secrets": { + "allowed_names": ["notion_api_token"] + } +} diff --git a/examples/wasm-tools/notion/src/api.rs b/examples/wasm-tools/notion/src/api.rs new file mode 100644 index 00000000..fd9bc5b0 --- /dev/null +++ b/examples/wasm-tools/notion/src/api.rs @@ -0,0 +1,445 @@ +//! Notion API implementation. +//! +//! All API calls go through the host's HTTP capability, which handles +//! credential injection and rate limiting. The WASM tool never sees +//! the actual API token. + +use crate::near::agent::host; +use crate::types::*; + +const NOTION_API_BASE: &str = "https://api.notion.com/v1"; +const NOTION_VERSION: &str = "2022-06-28"; + +/// Make a Notion API GET request. +fn notion_get(path: &str) -> Result { + let url = format!("{}{}", NOTION_API_BASE, path); + + let headers = serde_json::json!({ + "Notion-Version": NOTION_VERSION + }); + let headers_str = serde_json::to_string(&headers).map_err(|e| e.to_string())?; + + host::log(host::LogLevel::Debug, &format!("Notion GET: {}", path)); + + let response = host::http_request("GET", &url, &headers_str, None)?; + + if response.status < 200 || response.status >= 300 { + let body = String::from_utf8_lossy(&response.body); + return Err(format!( + "Notion API returned status {}: {}", + response.status, body + )); + } + + let body = String::from_utf8(response.body) + .map_err(|e| format!("Invalid UTF-8 in response: {}", e))?; + serde_json::from_str(&body).map_err(|e| format!("Invalid JSON in response: {}", e)) +} + +/// Make a Notion API POST request. +fn notion_post(path: &str, body: &serde_json::Value) -> Result { + let url = format!("{}{}", NOTION_API_BASE, path); + + let headers = serde_json::json!({ + "Notion-Version": NOTION_VERSION, + "Content-Type": "application/json" + }); + let headers_str = serde_json::to_string(&headers).map_err(|e| e.to_string())?; + let body_str = serde_json::to_string(body).map_err(|e| e.to_string())?; + + host::log(host::LogLevel::Debug, &format!("Notion POST: {}", path)); + + let response = host::http_request("POST", &url, &headers_str, Some(body_str.as_bytes()))?; + + if response.status < 200 || response.status >= 300 { + let body = String::from_utf8_lossy(&response.body); + return Err(format!( + "Notion API returned status {}: {}", + response.status, body + )); + } + + let body = String::from_utf8(response.body) + .map_err(|e| format!("Invalid UTF-8 in response: {}", e))?; + serde_json::from_str(&body).map_err(|e| format!("Invalid JSON in response: {}", e)) +} + +/// Make a Notion API PATCH request. +fn notion_patch(path: &str, body: &serde_json::Value) -> Result { + let url = format!("{}{}", NOTION_API_BASE, path); + + let headers = serde_json::json!({ + "Notion-Version": NOTION_VERSION, + "Content-Type": "application/json" + }); + let headers_str = serde_json::to_string(&headers).map_err(|e| e.to_string())?; + let body_str = serde_json::to_string(body).map_err(|e| e.to_string())?; + + host::log(host::LogLevel::Debug, &format!("Notion PATCH: {}", path)); + + let response = host::http_request("PATCH", &url, &headers_str, Some(body_str.as_bytes()))?; + + if response.status < 200 || response.status >= 300 { + let body = String::from_utf8_lossy(&response.body); + return Err(format!( + "Notion API returned status {}: {}", + response.status, body + )); + } + + let body = String::from_utf8(response.body) + .map_err(|e| format!("Invalid UTF-8 in response: {}", e))?; + serde_json::from_str(&body).map_err(|e| format!("Invalid JSON in response: {}", e)) +} + +/// Make a Notion API DELETE request. +fn notion_delete(path: &str) -> Result { + let url = format!("{}{}", NOTION_API_BASE, path); + + let headers = serde_json::json!({ + "Notion-Version": NOTION_VERSION + }); + let headers_str = serde_json::to_string(&headers).map_err(|e| e.to_string())?; + + host::log(host::LogLevel::Debug, &format!("Notion DELETE: {}", path)); + + let response = host::http_request("DELETE", &url, &headers_str, None)?; + + if response.status < 200 || response.status >= 300 { + let body = String::from_utf8_lossy(&response.body); + return Err(format!( + "Notion API returned status {}: {}", + response.status, body + )); + } + + let body = String::from_utf8(response.body) + .map_err(|e| format!("Invalid UTF-8 in response: {}", e))?; + serde_json::from_str(&body).map_err(|e| format!("Invalid JSON in response: {}", e)) +} + +// ==================== Search ==================== + +pub fn search( + query: &str, + filter: Option<&SearchFilter>, + page_size: u32, + start_cursor: Option<&str>, +) -> Result { + let mut body = serde_json::json!({ + "page_size": page_size.min(100) + }); + + if !query.is_empty() { + body["query"] = serde_json::Value::String(query.to_string()); + } + + if let Some(f) = filter { + body["filter"] = serde_json::json!({ + "property": f.property, + "value": f.value + }); + } + + if let Some(cursor) = start_cursor { + body["start_cursor"] = serde_json::Value::String(cursor.to_string()); + } + + notion_post("/search", &body) +} + +// ==================== Pages ==================== + +pub fn get_page(page_id: &str) -> Result { + let page_id = normalize_uuid(page_id); + notion_get(&format!("/pages/{}", page_id)) +} + +pub fn create_page( + parent: &serde_json::Value, + properties: &serde_json::Value, + children: Option<&Vec>, + icon: Option<&serde_json::Value>, + cover: Option<&serde_json::Value>, +) -> Result { + let mut body = serde_json::json!({ + "parent": parent, + "properties": properties + }); + + if let Some(c) = children { + if !c.is_empty() { + body["children"] = serde_json::Value::Array(c.clone()); + } + } + + if let Some(i) = icon { + body["icon"] = i.clone(); + } + + if let Some(c) = cover { + body["cover"] = c.clone(); + } + + notion_post("/pages", &body) +} + +pub fn update_page( + page_id: &str, + properties: &serde_json::Value, + icon: Option<&serde_json::Value>, + cover: Option<&serde_json::Value>, +) -> Result { + let page_id = normalize_uuid(page_id); + + let mut body = serde_json::json!({ + "properties": properties + }); + + if let Some(i) = icon { + body["icon"] = i.clone(); + } + + if let Some(c) = cover { + body["cover"] = c.clone(); + } + + notion_patch(&format!("/pages/{}", page_id), &body) +} + +pub fn archive_page(page_id: &str) -> Result { + let page_id = normalize_uuid(page_id); + notion_patch( + &format!("/pages/{}", page_id), + &serde_json::json!({ "archived": true }), + ) +} + +pub fn restore_page(page_id: &str) -> Result { + let page_id = normalize_uuid(page_id); + notion_patch( + &format!("/pages/{}", page_id), + &serde_json::json!({ "archived": false }), + ) +} + +// ==================== Blocks ==================== + +pub fn get_blocks( + block_id: &str, + page_size: u32, + start_cursor: Option<&str>, +) -> Result { + let block_id = normalize_uuid(block_id); + let mut path = format!( + "/blocks/{}/children?page_size={}", + block_id, + page_size.min(100) + ); + + if let Some(cursor) = start_cursor { + path.push_str(&format!("&start_cursor={}", cursor)); + } + + notion_get(&path) +} + +pub fn append_blocks( + block_id: &str, + children: &[serde_json::Value], + after: Option<&str>, +) -> Result { + let block_id = normalize_uuid(block_id); + + let mut body = serde_json::json!({ + "children": children + }); + + if let Some(after_id) = after { + body["after"] = serde_json::Value::String(normalize_uuid(after_id)); + } + + notion_patch(&format!("/blocks/{}/children", block_id), &body) +} + +pub fn get_block(block_id: &str) -> Result { + let block_id = normalize_uuid(block_id); + notion_get(&format!("/blocks/{}", block_id)) +} + +pub fn update_block( + block_id: &str, + content: &serde_json::Value, +) -> Result { + let block_id = normalize_uuid(block_id); + notion_patch(&format!("/blocks/{}", block_id), content) +} + +pub fn delete_block(block_id: &str) -> Result { + let block_id = normalize_uuid(block_id); + notion_delete(&format!("/blocks/{}", block_id)) +} + +// ==================== Databases ==================== + +pub fn get_database(database_id: &str) -> Result { + let database_id = normalize_uuid(database_id); + notion_get(&format!("/databases/{}", database_id)) +} + +pub fn query_database( + database_id: &str, + filter: Option<&serde_json::Value>, + sorts: Option<&Vec>, + page_size: u32, + start_cursor: Option<&str>, +) -> Result { + let database_id = normalize_uuid(database_id); + + let mut body = serde_json::json!({ + "page_size": page_size.min(100) + }); + + if let Some(f) = filter { + body["filter"] = f.clone(); + } + + if let Some(s) = sorts { + if !s.is_empty() { + body["sorts"] = serde_json::Value::Array(s.clone()); + } + } + + if let Some(cursor) = start_cursor { + body["start_cursor"] = serde_json::Value::String(cursor.to_string()); + } + + notion_post(&format!("/databases/{}/query", database_id), &body) +} + +pub fn create_database( + parent: &serde_json::Value, + title: &[serde_json::Value], + properties: &serde_json::Value, + icon: Option<&serde_json::Value>, + cover: Option<&serde_json::Value>, + is_inline: bool, +) -> Result { + let mut body = serde_json::json!({ + "parent": parent, + "title": title, + "properties": properties, + "is_inline": is_inline + }); + + if let Some(i) = icon { + body["icon"] = i.clone(); + } + + if let Some(c) = cover { + body["cover"] = c.clone(); + } + + notion_post("/databases", &body) +} + +pub fn update_database( + database_id: &str, + title: Option<&Vec>, + properties: Option<&serde_json::Value>, +) -> Result { + let database_id = normalize_uuid(database_id); + + let mut body = serde_json::json!({}); + + if let Some(t) = title { + body["title"] = serde_json::Value::Array(t.clone()); + } + + if let Some(p) = properties { + body["properties"] = p.clone(); + } + + notion_patch(&format!("/databases/{}", database_id), &body) +} + +// ==================== Comments ==================== + +pub fn get_comments( + block_id: &str, + page_size: u32, + start_cursor: Option<&str>, +) -> Result { + let block_id = normalize_uuid(block_id); + let mut path = format!( + "/comments?block_id={}&page_size={}", + block_id, + page_size.min(100) + ); + + if let Some(cursor) = start_cursor { + path.push_str(&format!("&start_cursor={}", cursor)); + } + + notion_get(&path) +} + +pub fn add_comment( + parent: &serde_json::Value, + rich_text: &[serde_json::Value], +) -> Result { + let body = serde_json::json!({ + "parent": parent, + "rich_text": rich_text + }); + + notion_post("/comments", &body) +} + +// ==================== Users ==================== + +pub fn list_users(page_size: u32, start_cursor: Option<&str>) -> Result { + let mut path = format!("/users?page_size={}", page_size.min(100)); + + if let Some(cursor) = start_cursor { + path.push_str(&format!("&start_cursor={}", cursor)); + } + + notion_get(&path) +} + +pub fn get_user(user_id: &str) -> Result { + let user_id = normalize_uuid(user_id); + notion_get(&format!("/users/{}", user_id)) +} + +pub fn get_me() -> Result { + notion_get("/users/me") +} + +// ==================== Helpers ==================== + +/// Normalize a UUID by removing dashes if needed. +/// Notion accepts both formats, but we normalize for consistency. +fn normalize_uuid(id: &str) -> String { + // If it already has dashes in the right places, return as-is + if id.len() == 36 && id.chars().filter(|c| *c == '-').count() == 4 { + return id.to_string(); + } + + // If it's 32 characters without dashes, add them + let clean: String = id.chars().filter(|c| c.is_ascii_hexdigit()).collect(); + if clean.len() == 32 { + return format!( + "{}-{}-{}-{}-{}", + &clean[0..8], + &clean[8..12], + &clean[12..16], + &clean[16..20], + &clean[20..32] + ); + } + + // Otherwise return as-is (let the API handle validation) + id.to_string() +} diff --git a/examples/wasm-tools/notion/src/lib.rs b/examples/wasm-tools/notion/src/lib.rs new file mode 100644 index 00000000..2e6f1eae --- /dev/null +++ b/examples/wasm-tools/notion/src/lib.rs @@ -0,0 +1,451 @@ +//! Notion WASM Tool for NEAR Agent. +//! +//! This is a standalone WASM component that provides Notion integration. +//! It follows the MCP server pattern with domain-grouped operations. +//! +//! # Capabilities Required +//! +//! - HTTP: `api.notion.com/v1/*` (GET, POST, PATCH, DELETE) +//! - Secrets: `notion_api_token` (injected automatically as Bearer token) +//! +//! # Supported Actions +//! +//! ## Search +//! - `search`: Search across pages and databases +//! +//! ## Pages +//! - `get_page`: Retrieve a page by ID +//! - `create_page`: Create a new page in a database or as child of another page +//! - `update_page`: Update page properties +//! - `archive_page`: Archive (soft-delete) a page +//! - `restore_page`: Restore an archived page +//! +//! ## Blocks +//! - `get_blocks`: Get child blocks of a page/block +//! - `append_blocks`: Append content blocks to a page/block +//! - `get_block`: Get a single block +//! - `update_block`: Update a block's content +//! - `delete_block`: Delete a block +//! +//! ## Databases +//! - `get_database`: Get database schema +//! - `query_database`: Query with filters and sorts +//! - `create_database`: Create a new database +//! - `update_database`: Update database title/properties +//! +//! ## Comments +//! - `get_comments`: Get comments on a page/block +//! - `add_comment`: Add a comment +//! +//! ## Users +//! - `list_users`: List workspace users +//! - `get_user`: Get a specific user +//! - `get_me`: Get the bot user +//! +//! # Example Usage +//! +//! ```json +//! {"action": "search", "query": "meeting notes", "page_size": 5} +//! ``` +//! +//! ```json +//! { +//! "action": "query_database", +//! "database_id": "abc123...", +//! "filter": {"property": "Status", "select": {"equals": "Done"}}, +//! "sorts": [{"property": "Created", "direction": "descending"}] +//! } +//! ``` + +mod api; +mod types; + +use types::NotionAction; + +// Generate bindings from the WIT interface. +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../../wit/tool.wit", +}); + +/// Implementation of the tool interface. +struct NotionTool; + +impl exports::near::agent::tool::Guest for NotionTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + TOOL_SCHEMA.to_string() + } + + fn description() -> String { + "Notion workspace integration for searching, managing pages/databases/blocks, \ + comments, and users. Requires a Notion integration token with appropriate \ + capabilities (read content, update content, insert content)." + .to_string() + } +} + +/// Inner execution logic with proper error handling. +fn execute_inner(params: &str) -> Result { + // Check if the Notion token is configured + if !crate::near::agent::host::secret_exists("notion_api_token") { + return Err( + "Notion API token not configured. Please add the 'notion_api_token' secret." + .to_string(), + ); + } + + // Parse the action from JSON + let action: NotionAction = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + crate::near::agent::host::log( + crate::near::agent::host::LogLevel::Info, + &format!("Executing Notion action: {:?}", action), + ); + + // Dispatch to the appropriate handler + let result = match action { + // Search + NotionAction::Search { + query, + filter, + page_size, + start_cursor, + } => api::search(&query, filter.as_ref(), page_size, start_cursor.as_deref())?, + + // Pages + NotionAction::GetPage { page_id } => api::get_page(&page_id)?, + + NotionAction::CreatePage { + parent, + properties, + children, + icon, + cover, + } => api::create_page( + &parent, + &properties, + children.as_ref(), + icon.as_ref(), + cover.as_ref(), + )?, + + NotionAction::UpdatePage { + page_id, + properties, + icon, + cover, + } => api::update_page(&page_id, &properties, icon.as_ref(), cover.as_ref())?, + + NotionAction::ArchivePage { page_id } => api::archive_page(&page_id)?, + + NotionAction::RestorePage { page_id } => api::restore_page(&page_id)?, + + // Blocks + NotionAction::GetBlocks { + block_id, + page_size, + start_cursor, + } => api::get_blocks(&block_id, page_size, start_cursor.as_deref())?, + + NotionAction::AppendBlocks { + block_id, + children, + after, + } => api::append_blocks(&block_id, &children, after.as_deref())?, + + NotionAction::GetBlock { block_id } => api::get_block(&block_id)?, + + NotionAction::UpdateBlock { block_id, content } => api::update_block(&block_id, &content)?, + + NotionAction::DeleteBlock { block_id } => api::delete_block(&block_id)?, + + // Databases + NotionAction::GetDatabase { database_id } => api::get_database(&database_id)?, + + NotionAction::QueryDatabase { + database_id, + filter, + sorts, + page_size, + start_cursor, + } => api::query_database( + &database_id, + filter.as_ref(), + sorts.as_ref(), + page_size, + start_cursor.as_deref(), + )?, + + NotionAction::CreateDatabase { + parent, + title, + properties, + icon, + cover, + is_inline, + } => api::create_database( + &parent, + &title, + &properties, + icon.as_ref(), + cover.as_ref(), + is_inline, + )?, + + NotionAction::UpdateDatabase { + database_id, + title, + properties, + } => api::update_database(&database_id, title.as_ref(), properties.as_ref())?, + + // Comments + NotionAction::GetComments { + block_id, + page_size, + start_cursor, + } => api::get_comments(&block_id, page_size, start_cursor.as_deref())?, + + NotionAction::AddComment { parent, rich_text } => api::add_comment(&parent, &rich_text)?, + + // Users + NotionAction::ListUsers { + page_size, + start_cursor, + } => api::list_users(page_size, start_cursor.as_deref())?, + + NotionAction::GetUser { user_id } => api::get_user(&user_id)?, + + NotionAction::GetMe => api::get_me()?, + }; + + serde_json::to_string(&result).map_err(|e| format!("Failed to serialize response: {}", e)) +} + +// Export the tool implementation. +export!(NotionTool); + +/// JSON Schema for the tool's parameters. +/// +/// This schema uses `oneOf` to describe each action's specific parameters. +const TOOL_SCHEMA: &str = r#"{ + "type": "object", + "required": ["action"], + "oneOf": [ + { + "properties": { + "action": { "const": "search" }, + "query": { "type": "string", "description": "Text to search for" }, + "filter": { + "type": "object", + "properties": { + "property": { "type": "string", "const": "object" }, + "value": { "type": "string", "enum": ["page", "database"] } + }, + "description": "Filter by object type" + }, + "page_size": { "type": "integer", "minimum": 1, "maximum": 100, "default": 10 }, + "start_cursor": { "type": "string", "description": "Pagination cursor" } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "get_page" }, + "page_id": { "type": "string", "description": "Page ID (UUID)" } + }, + "required": ["action", "page_id"] + }, + { + "properties": { + "action": { "const": "create_page" }, + "parent": { + "type": "object", + "description": "Parent reference: {\"database_id\": \"...\"} or {\"page_id\": \"...\"}" + }, + "properties": { "type": "object", "description": "Page properties" }, + "children": { + "type": "array", + "items": { "type": "object" }, + "description": "Block children for page content" + }, + "icon": { "type": "object", "description": "Page icon" }, + "cover": { "type": "object", "description": "Page cover image" } + }, + "required": ["action", "parent", "properties"] + }, + { + "properties": { + "action": { "const": "update_page" }, + "page_id": { "type": "string" }, + "properties": { "type": "object", "description": "Properties to update" }, + "icon": { "type": "object" }, + "cover": { "type": "object" } + }, + "required": ["action", "page_id", "properties"] + }, + { + "properties": { + "action": { "const": "archive_page" }, + "page_id": { "type": "string" } + }, + "required": ["action", "page_id"] + }, + { + "properties": { + "action": { "const": "restore_page" }, + "page_id": { "type": "string" } + }, + "required": ["action", "page_id"] + }, + { + "properties": { + "action": { "const": "get_blocks" }, + "block_id": { "type": "string", "description": "Block or page ID" }, + "page_size": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, + "start_cursor": { "type": "string" } + }, + "required": ["action", "block_id"] + }, + { + "properties": { + "action": { "const": "append_blocks" }, + "block_id": { "type": "string", "description": "Block or page ID to append to" }, + "children": { + "type": "array", + "items": { "type": "object" }, + "description": "Block objects to append" + }, + "after": { "type": "string", "description": "Insert after this block ID" } + }, + "required": ["action", "block_id", "children"] + }, + { + "properties": { + "action": { "const": "get_block" }, + "block_id": { "type": "string" } + }, + "required": ["action", "block_id"] + }, + { + "properties": { + "action": { "const": "update_block" }, + "block_id": { "type": "string" }, + "content": { "type": "object", "description": "Block content by type" } + }, + "required": ["action", "block_id", "content"] + }, + { + "properties": { + "action": { "const": "delete_block" }, + "block_id": { "type": "string" } + }, + "required": ["action", "block_id"] + }, + { + "properties": { + "action": { "const": "get_database" }, + "database_id": { "type": "string" } + }, + "required": ["action", "database_id"] + }, + { + "properties": { + "action": { "const": "query_database" }, + "database_id": { "type": "string" }, + "filter": { "type": "object", "description": "Notion filter object" }, + "sorts": { + "type": "array", + "items": { "type": "object" }, + "description": "Sort configuration" + }, + "page_size": { "type": "integer", "minimum": 1, "maximum": 100, "default": 10 }, + "start_cursor": { "type": "string" } + }, + "required": ["action", "database_id"] + }, + { + "properties": { + "action": { "const": "create_database" }, + "parent": { "type": "object", "description": "Parent page or workspace" }, + "title": { + "type": "array", + "items": { "type": "object" }, + "description": "Database title as rich text" + }, + "properties": { "type": "object", "description": "Property schema" }, + "icon": { "type": "object" }, + "cover": { "type": "object" }, + "is_inline": { "type": "boolean", "default": false } + }, + "required": ["action", "parent", "title", "properties"] + }, + { + "properties": { + "action": { "const": "update_database" }, + "database_id": { "type": "string" }, + "title": { "type": "array", "items": { "type": "object" } }, + "properties": { "type": "object" } + }, + "required": ["action", "database_id"] + }, + { + "properties": { + "action": { "const": "get_comments" }, + "block_id": { "type": "string", "description": "Block or page ID" }, + "page_size": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, + "start_cursor": { "type": "string" } + }, + "required": ["action", "block_id"] + }, + { + "properties": { + "action": { "const": "add_comment" }, + "parent": { + "type": "object", + "description": "{\"page_id\": \"...\"} or {\"discussion_id\": \"...\"}" + }, + "rich_text": { + "type": "array", + "items": { "type": "object" }, + "description": "Comment content as rich text" + } + }, + "required": ["action", "parent", "rich_text"] + }, + { + "properties": { + "action": { "const": "list_users" }, + "page_size": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, + "start_cursor": { "type": "string" } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "get_user" }, + "user_id": { "type": "string" } + }, + "required": ["action", "user_id"] + }, + { + "properties": { + "action": { "const": "get_me" } + }, + "required": ["action"] + } + ] +}"#; diff --git a/examples/wasm-tools/notion/src/types.rs b/examples/wasm-tools/notion/src/types.rs new file mode 100644 index 00000000..58fd408a --- /dev/null +++ b/examples/wasm-tools/notion/src/types.rs @@ -0,0 +1,267 @@ +//! Types for Notion API requests and responses. + +use serde::{Deserialize, Serialize}; + +/// Input parameters for the Notion tool. +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum NotionAction { + // ==================== Search ==================== + /// Search across all pages and databases. + Search { + /// Text query to search for. + #[serde(default)] + query: String, + /// Filter by object type: "page" or "database". + #[serde(default)] + filter: Option, + /// Max results (default: 10, max: 100). + #[serde(default = "default_page_size")] + page_size: u32, + /// Pagination cursor from previous response. + #[serde(default)] + start_cursor: Option, + }, + + // ==================== Pages ==================== + /// Retrieve a page by ID. + GetPage { + /// Page ID (UUID format, with or without dashes). + page_id: String, + }, + + /// Create a new page. + CreatePage { + /// Parent reference: { "database_id": "..." } or { "page_id": "..." }. + parent: serde_json::Value, + /// Page properties matching the parent database schema. + properties: serde_json::Value, + /// Optional page content as an array of block objects. + #[serde(default)] + children: Option>, + /// Optional icon: { "emoji": "..." } or { "external": { "url": "..." } }. + #[serde(default)] + icon: Option, + /// Optional cover: { "external": { "url": "..." } }. + #[serde(default)] + cover: Option, + }, + + /// Update page properties. + UpdatePage { + /// Page ID. + page_id: String, + /// Properties to update. + properties: serde_json::Value, + /// Optional new icon. + #[serde(default)] + icon: Option, + /// Optional new cover. + #[serde(default)] + cover: Option, + }, + + /// Archive (soft-delete) a page. + ArchivePage { + /// Page ID. + page_id: String, + }, + + /// Restore an archived page. + RestorePage { + /// Page ID. + page_id: String, + }, + + // ==================== Blocks ==================== + /// Get child blocks of a block or page. + GetBlocks { + /// Block or page ID. + block_id: String, + /// Max results (default: 50, max: 100). + #[serde(default = "default_block_page_size")] + page_size: u32, + /// Pagination cursor. + #[serde(default)] + start_cursor: Option, + }, + + /// Append blocks to a page or block. + AppendBlocks { + /// Block or page ID to append to. + block_id: String, + /// Array of block objects to append. + children: Vec, + /// Append after this block ID (for ordering). + #[serde(default)] + after: Option, + }, + + /// Retrieve a single block. + GetBlock { + /// Block ID. + block_id: String, + }, + + /// Update a block's content. + UpdateBlock { + /// Block ID. + block_id: String, + /// Block content (varies by type, e.g., { "paragraph": { "rich_text": [...] } }). + content: serde_json::Value, + }, + + /// Delete a block. + DeleteBlock { + /// Block ID. + block_id: String, + }, + + // ==================== Databases ==================== + /// Retrieve a database schema. + GetDatabase { + /// Database ID. + database_id: String, + }, + + /// Query a database with filters and sorts. + QueryDatabase { + /// Database ID. + database_id: String, + /// Filter object (Notion filter format). + #[serde(default)] + filter: Option, + /// Sort configuration array. + #[serde(default)] + sorts: Option>, + /// Max results (default: 10, max: 100). + #[serde(default = "default_page_size")] + page_size: u32, + /// Pagination cursor. + #[serde(default)] + start_cursor: Option, + }, + + /// Create a new database. + CreateDatabase { + /// Parent reference: { "page_id": "..." } or { "type": "workspace", "workspace": true }. + parent: serde_json::Value, + /// Database title as rich text array. + title: Vec, + /// Property schema: { "Name": { "title": {} }, "Status": { "select": { "options": [...] } } }. + properties: serde_json::Value, + /// Optional icon. + #[serde(default)] + icon: Option, + /// Optional cover. + #[serde(default)] + cover: Option, + /// Make database inline (default: false). + #[serde(default)] + is_inline: bool, + }, + + /// Update database title or properties schema. + UpdateDatabase { + /// Database ID. + database_id: String, + /// New title (optional). + #[serde(default)] + title: Option>, + /// Properties to add or update (optional). + #[serde(default)] + properties: Option, + }, + + // ==================== Comments ==================== + /// Get comments on a block or page. + GetComments { + /// Block or page ID. + block_id: String, + /// Max results (default: 50, max: 100). + #[serde(default = "default_block_page_size")] + page_size: u32, + /// Pagination cursor. + #[serde(default)] + start_cursor: Option, + }, + + /// Add a comment to a page or discussion thread. + AddComment { + /// Parent reference: { "page_id": "..." } or { "discussion_id": "..." }. + parent: serde_json::Value, + /// Comment text as rich text array. + rich_text: Vec, + }, + + // ==================== Users ==================== + /// List all users in the workspace. + ListUsers { + /// Max results (default: 50, max: 100). + #[serde(default = "default_block_page_size")] + page_size: u32, + /// Pagination cursor. + #[serde(default)] + start_cursor: Option, + }, + + /// Get a specific user. + GetUser { + /// User ID. + user_id: String, + }, + + /// Get the bot user (the integration itself). + GetMe, +} + +/// Search filter for limiting results to pages or databases. +#[derive(Debug, Deserialize, Serialize)] +pub struct SearchFilter { + /// "object" field. + pub property: String, + /// "page" or "database". + pub value: String, +} + +fn default_page_size() -> u32 { + 10 +} + +fn default_block_page_size() -> u32 { + 50 +} + +// ==================== Response Types ==================== + +/// Generic Notion API response with pagination. +#[derive(Debug, Serialize)] +pub struct PaginatedResponse { + pub object: String, + pub results: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub has_more: bool, +} + +/// Single object response. +#[derive(Debug, Serialize)] +pub struct ObjectResponse { + pub object: String, + #[serde(flatten)] + pub data: serde_json::Value, +} + +/// Success response for mutations. +#[derive(Debug, Serialize)] +pub struct MutationResponse { + pub success: bool, + #[serde(flatten)] + pub data: serde_json::Value, +} + +/// Minimal success response. +#[derive(Debug, Serialize)] +pub struct SuccessResponse { + pub success: bool, +}