From 1fd619e32bc42ff037d183f3ac64eef016778d8a Mon Sep 17 00:00:00 2001 From: syui Date: Sun, 18 Jan 2026 12:15:43 +0900 Subject: [PATCH] add cmd lexicon --- .gitignore | 11 +- Cargo.toml | 18 + .../ai.syui.log.post/3mchqlshygs2s.json | 16 +- .../app.bsky.actor.profile/self.json | 6 +- .../describe.json | 36 +- index.html | 13 + public/favicon.svg | 67 ++++ readme.md | 99 ++++- src/commands/auth.rs | 97 +++++ src/commands/gen.rs | 192 ++++++++++ src/commands/mod.rs | 4 + src/commands/post.rs | 342 ++++++++++++++++++ src/commands/token.rs | 46 +++ src/lexicons/mod.rs | 301 +++++++++++++++ src/main.rs | 119 ++++++ 15 files changed, 1317 insertions(+), 50 deletions(-) create mode 100644 index.html create mode 100644 public/favicon.svg create mode 100644 src/commands/auth.rs create mode 100644 src/commands/gen.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/post.rs create mode 100644 src/commands/token.rs create mode 100644 src/lexicons/mod.rs create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore index 908a233..265c637 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ -dist -repos +/dist +/repos +/target +/CLAUDE.md +/.claude node_modules package-lock.json -CLAUDE.md -.claude +Cargo.lock .env -target diff --git a/Cargo.toml b/Cargo.toml index 5254d46..d4485b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,4 +2,22 @@ name = "ailog" version = "0.2.0" edition = "2021" +description = "ATProto blog CLI" +authors = ["syui"] +homepage = "https://syui.ai" +repository = "https://git.syui.ai/ai/log" +[[bin]] +name = "ailog" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +anyhow = "1.0" +dirs = "5.0" +chrono = { version = "0.4", features = ["serde"] } +rand = "0.8" diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json index 60ce66d..e38992b 100644 --- a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json +++ b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json @@ -1,18 +1,18 @@ { - "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s", "cid": "bafyreielgn743kg5xotfj5x53edl25vkbbd2d6v7s3tydyyjsvczcluyme", + "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s", "value": { - "cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e", - "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s", "$type": "ai.syui.log.post", - "title": "ailogを作り直した", + "cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e", "content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```", "createdAt": "2026-01-15T13:59:52.367Z", + "title": "ailogを作り直した", "translations": { "en": { - "title": "recreated ailog", - "content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```" + "content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```", + "title": "recreated ailog" } - } + }, + "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s" } -} +} \ No newline at end of file diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self.json b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self.json index 8a3ebf5..b62a032 100644 --- a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self.json +++ b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self.json @@ -1,18 +1,18 @@ { - "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self", "cid": "bafyreihlch2vdee6wpydo2bwap7nyzszjz6focbtxikz7zljcejxz27npy", + "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self", "value": { "$type": "app.bsky.actor.profile", "avatar": { "$type": "blob", + "mimeType": "image/jpeg", "ref": { "$link": "bafkreigta4pf5h7uvx6jpfcm3d6aeq4g3qpsiqjdoeytnutwp6vwc2yo7u" }, - "mimeType": "image/jpeg", "size": 166370 }, "createdAt": "2025-09-19T06:17:42Z", "description": "", "displayName": "syui" } -} +} \ No newline at end of file diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/describe.json b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/describe.json index 2e46361..70d3c25 100644 --- a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/describe.json +++ b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/describe.json @@ -1,39 +1,13 @@ { - "handle": "syui.syui.ai", - "did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y", - "didDoc": { - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/multikey/v1", - "https://w3id.org/security/suites/secp256k1-2019/v1" - ], - "id": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y", - "alsoKnownAs": [ - "at://syui.syui.ai" - ], - "verificationMethod": [ - { - "id": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y#atproto", - "type": "Multikey", - "controller": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y", - "publicKeyMultibase": "zQ3shZj81oA4A9CmUQgYUv97nFdd7m5qNaRMyG16XZixytTmQ" - } - ], - "service": [ - { - "id": "#atproto_pds", - "type": "AtprotoPersonalDataServer", - "serviceEndpoint": "https://syu.is" - } - ] - }, "collections": [ "ai.syui.log.post", "app.bsky.actor.profile", "app.bsky.feed.post", "app.bsky.feed.repost", "app.bsky.graph.follow", - "chat.bsky.actor.declaration" + "chat.bsky.actor.declaration", + "com.atproto.lexicon.schema" ], - "handleIsCorrect": true -} + "did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y", + "handle": "syui.syui.ai" +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..140ef9a --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + ailog + + + +
+ + + diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..5813502 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,67 @@ + + + + +syui + + + + + + + diff --git a/readme.md b/readme.md index bb09443..f9c16f1 100644 --- a/readme.md +++ b/readme.md @@ -152,13 +152,20 @@ curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collecti ### Local (Static File) ``` -public/records/ai.syui.log.post/3xxx.json +content/ +└── did:plc:xxx/ + ├── describe.json # describeRepo (special) + ├── app.bsky.actor.profile/ + │ └── self.json # {collection}/{rkey}.json + └── ai.syui.log.post/ + └── 3xxx.json # {collection}/{rkey}.json ``` ```json +// content/did:plc:xxx/ai.syui.log.post/3xxx.json { "uri": "at://did:plc:xxx/ai.syui.log.post/3xxx", - "cid": "local", + "cid": "bafyrei...", "value": { "title": "Hello World", "content": "# Hello\n\nThis is my post.", @@ -167,13 +174,23 @@ public/records/ai.syui.log.post/3xxx.json } ``` +### ATProto API Reference + +| API | Path | Description | +|-----|------|-------------| +| getRecord | `/xrpc/com.atproto.repo.getRecord` | Get single record | +| listRecords | `/xrpc/com.atproto.repo.listRecords` | List records in collection | +| describeRepo | `/xrpc/com.atproto.repo.describeRepo` | Get repo info + collections list | + +See: [com.atproto.repo.describeRepo](https://docs.bsky.app/docs/api/com-atproto-repo-describe-repo) + ### Resolution Strategy ``` at-browser │ ├── admin (config.json user) - │ ├── 1. Check local: /records/{collection}/{rkey}.json + │ ├── 1. Check local: /content/{did}/{collection}/{rkey}.json │ └── 2. Fallback to remote: PDS API │ └── user (/@handle) @@ -202,11 +219,87 @@ at-browser ## Tech Stack +- **CLI**: Rust (ailog) - **Frontend**: Vite + TypeScript - **ATProto**: @atproto/api - **OAuth**: @atproto/oauth-client-browser - **Markdown**: marked + highlight.js +## CLI (ailog) + +### Install + +```bash +cargo build --release +cp target/release/ailog ~/.local/bin/ +``` + +### Commands + +```bash +# Login to ATProto PDS +ailog login -p [-s ] + +# Post a record +ailog post -c [-r ] + +# Get records from collection +ailog get -c [-l ] + +# Delete a record +ailog delete -c -r + +# Sync PDS data to local content directory +ailog sync [-o ] + +# Generate lexicon Rust code from ATProto lexicons +ailog gen [-i ] [-o ] +``` + +### Example + +```bash +# Login +ailog login syui.syui.ai -p "app-password" -s syu.is + +# Post +echo '{"title":"Hello","content":"World","createdAt":"2025-01-01T00:00:00Z"}' > post.json +ailog post post.json -c ai.syui.log.post + +# Sync to local +ailog sync -o content +``` + +### Project Structure + +``` +src/ +├── main.rs +├── commands/ +│ ├── mod.rs +│ ├── auth.rs # login, refresh session +│ ├── token.rs # token management +│ ├── post.rs # post, get, delete, sync +│ └── gen.rs # lexicon code generation +└── lexicons/ + └── mod.rs # auto-generated from ATProto lexicons +``` + +### Lexicon Generation + +Generate Rust endpoint definitions from ATProto lexicon JSON files: + +```bash +# Clone atproto repo (if not exists) +git clone https://github.com/bluesky-social/atproto repos/atproto + +# Generate lexicons +ailog gen -i ./repos/atproto/lexicons -o ./src/lexicons + +# Rebuild +cargo build +``` + ## Collection Schema ### ai.syui.log.post diff --git a/src/commands/auth.rs b/src/commands/auth.rs new file mode 100644 index 0000000..6a93c82 --- /dev/null +++ b/src/commands/auth.rs @@ -0,0 +1,97 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use super::token::{self, Session}; +use crate::lexicons::{self, com_atproto_server}; + +#[derive(Debug, Serialize)] +struct CreateSessionRequest { + identifier: String, + password: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateSessionResponse { + did: String, + handle: String, + access_jwt: String, + refresh_jwt: String, +} + +/// Login to ATProto PDS +pub async fn login(handle: &str, password: &str, pds: &str) -> Result<()> { + let client = reqwest::Client::new(); + let url = lexicons::url(pds, &com_atproto_server::CREATE_SESSION); + + let req = CreateSessionRequest { + identifier: handle.to_string(), + password: password.to_string(), + }; + + println!("Logging in to {} as {}...", pds, handle); + + let res = client + .post(&url) + .json(&req) + .send() + .await + .context("Failed to send login request")?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + anyhow::bail!("Login failed: {} - {}", status, body); + } + + let session_res: CreateSessionResponse = res.json().await?; + + let session = Session { + did: session_res.did, + handle: session_res.handle, + access_jwt: session_res.access_jwt, + refresh_jwt: session_res.refresh_jwt, + pds: Some(pds.to_string()), + }; + + token::save_session(&session)?; + println!("Logged in as {} ({})", session.handle, session.did); + + Ok(()) +} + +/// Refresh access token +pub async fn refresh_session() -> Result { + let session = token::load_session()?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + + let client = reqwest::Client::new(); + let url = lexicons::url(pds, &com_atproto_server::REFRESH_SESSION); + + let res = client + .post(&url) + .header("Authorization", format!("Bearer {}", session.refresh_jwt)) + .send() + .await + .context("Failed to refresh session")?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + anyhow::bail!("Refresh failed: {} - {}. Try logging in again.", status, body); + } + + let new_session: CreateSessionResponse = res.json().await?; + + let session = Session { + did: new_session.did, + handle: new_session.handle, + access_jwt: new_session.access_jwt, + refresh_jwt: new_session.refresh_jwt, + pds: Some(pds.to_string()), + }; + + token::save_session(&session)?; + + Ok(session) +} diff --git a/src/commands/gen.rs b/src/commands/gen.rs new file mode 100644 index 0000000..896657d --- /dev/null +++ b/src/commands/gen.rs @@ -0,0 +1,192 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +#[derive(Debug, Deserialize)] +struct Lexicon { + id: String, + defs: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct LexiconDef { + #[serde(rename = "type")] + def_type: Option, +} + +struct EndpointInfo { + nsid: String, + method: String, // GET or POST +} + +/// Generate lexicon Rust code from ATProto lexicon JSON files +pub fn generate(input: &str, output: &str) -> Result<()> { + let input_path = Path::new(input); + + if !input_path.exists() { + anyhow::bail!("Input directory does not exist: {}", input); + } + + println!("Scanning lexicons from: {}", input); + + // Collect all endpoints grouped by namespace + let mut namespaces: BTreeMap> = BTreeMap::new(); + + // Scan com/atproto directory + let atproto_path = input_path.join("com/atproto"); + if atproto_path.exists() { + scan_namespace(&atproto_path, "com.atproto", &mut namespaces)?; + } + + // Scan app/bsky directory + let bsky_path = input_path.join("app/bsky"); + if bsky_path.exists() { + scan_namespace(&bsky_path, "app.bsky", &mut namespaces)?; + } + + // Generate Rust code + let code = generate_rust_code(&namespaces); + + // Write output + let output_path = Path::new(output).join("mod.rs"); + fs::create_dir_all(output)?; + fs::write(&output_path, &code)?; + + println!("Generated: {}", output_path.display()); + println!("Total namespaces: {}", namespaces.len()); + let total_endpoints: usize = namespaces.values().map(|v| v.len()).sum(); + println!("Total endpoints: {}", total_endpoints); + + Ok(()) +} + +fn scan_namespace( + base_path: &Path, + prefix: &str, + namespaces: &mut BTreeMap>, +) -> Result<()> { + for entry in fs::read_dir(base_path)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + let ns_name = path.file_name() + .and_then(|n| n.to_str()) + .context("Invalid directory name")?; + + let full_ns = format!("{}.{}", prefix, ns_name); + let mut endpoints = Vec::new(); + + // Scan JSON files in this namespace + for file_entry in fs::read_dir(&path)? { + let file_entry = file_entry?; + let file_path = file_entry.path(); + + if file_path.extension().map(|e| e == "json").unwrap_or(false) { + if let Some(endpoint) = parse_lexicon_file(&file_path)? { + endpoints.push(endpoint); + } + } + } + + if !endpoints.is_empty() { + endpoints.sort_by(|a, b| a.nsid.cmp(&b.nsid)); + namespaces.insert(full_ns, endpoints); + } + } + } + + Ok(()) +} + +fn parse_lexicon_file(path: &Path) -> Result> { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read: {}", path.display()))?; + + let lexicon: Lexicon = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse: {}", path.display()))?; + + // Get the main definition type + let main_def = match lexicon.defs.get("main") { + Some(def) => def, + None => return Ok(None), + }; + + let method = match main_def.def_type.as_deref() { + Some("query") => "GET", + Some("procedure") => "POST", + Some("subscription") => return Ok(None), // Skip websocket subscriptions + _ => return Ok(None), // Skip records, tokens, etc. + }; + + Ok(Some(EndpointInfo { + nsid: lexicon.id, + method: method.to_string(), + })) +} + +fn generate_rust_code(namespaces: &BTreeMap>) -> String { + let mut code = String::new(); + + // Header + code.push_str("//! Auto-generated from ATProto lexicons\n"); + code.push_str("//! Run `ailog gen` to regenerate\n"); + code.push_str("//! Do not edit manually\n\n"); + + // Endpoint struct + code.push_str("#[derive(Debug, Clone, Copy)]\n"); + code.push_str("pub struct Endpoint {\n"); + code.push_str(" pub nsid: &'static str,\n"); + code.push_str(" pub method: &'static str,\n"); + code.push_str("}\n\n"); + + // URL helper function + code.push_str("/// Build XRPC URL for an endpoint\n"); + code.push_str("pub fn url(pds: &str, endpoint: &Endpoint) -> String {\n"); + code.push_str(" format!(\"https://{}/xrpc/{}\", pds, endpoint.nsid)\n"); + code.push_str("}\n\n"); + + // Generate modules for each namespace + for (ns, endpoints) in namespaces { + // Convert namespace to module name: com.atproto.repo -> com_atproto_repo + let mod_name = ns.replace('.', "_"); + + code.push_str(&format!("pub mod {} {{\n", mod_name)); + code.push_str(" use super::Endpoint;\n\n"); + + for endpoint in endpoints { + // Extract the method name from NSID: com.atproto.repo.listRecords -> LIST_RECORDS + let method_name = endpoint.nsid + .rsplit('.') + .next() + .unwrap_or(&endpoint.nsid); + + // Convert camelCase to SCREAMING_SNAKE_CASE + let const_name = to_screaming_snake_case(method_name); + + code.push_str(&format!( + " pub const {}: Endpoint = Endpoint {{ nsid: \"{}\", method: \"{}\" }};\n", + const_name, endpoint.nsid, endpoint.method + )); + } + + code.push_str("}\n\n"); + } + + code +} + +fn to_screaming_snake_case(s: &str) -> String { + let mut result = String::new(); + + for (i, c) in s.chars().enumerate() { + if c.is_uppercase() && i > 0 { + result.push('_'); + } + result.push(c.to_ascii_uppercase()); + } + + result +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..78d475d --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,4 @@ +pub mod auth; +pub mod token; +pub mod post; +pub mod gen; diff --git a/src/commands/post.rs b/src/commands/post.rs new file mode 100644 index 0000000..378bd4f --- /dev/null +++ b/src/commands/post.rs @@ -0,0 +1,342 @@ +use anyhow::{Context, Result}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fs; + +use super::auth; +use crate::lexicons::{self, com_atproto_repo, com_atproto_identity}; + +#[derive(Debug, Serialize)] +struct PutRecordRequest { + repo: String, + collection: String, + rkey: String, + record: Value, +} + +#[derive(Debug, Serialize)] +struct DeleteRecordRequest { + repo: String, + collection: String, + rkey: String, +} + +#[derive(Debug, Deserialize)] +struct PutRecordResponse { + uri: String, + cid: String, +} + +#[derive(Debug, Deserialize)] +struct ListRecordsResponse { + records: Vec, + #[serde(default)] + cursor: Option, +} + +#[derive(Debug, Deserialize)] +struct Record { + uri: String, + cid: String, + value: Value, +} + +/// Generate TID (timestamp-based ID) +fn generate_tid() -> String { + const CHARSET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz"; + let mut rng = rand::thread_rng(); + (0..13) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +/// Put a record to ATProto +pub async fn put_record(file: &str, collection: &str, rkey: Option<&str>) -> Result<()> { + let session = auth::refresh_session().await?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + + let content = fs::read_to_string(file) + .with_context(|| format!("Failed to read file: {}", file))?; + let record: Value = serde_json::from_str(&content)?; + + let rkey = rkey.map(|s| s.to_string()).unwrap_or_else(generate_tid); + + let client = reqwest::Client::new(); + let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD); + + let req = PutRecordRequest { + repo: session.did.clone(), + collection: collection.to_string(), + rkey: rkey.clone(), + record, + }; + + println!("Posting to {} with rkey: {}", collection, rkey); + println!("{}", serde_json::to_string_pretty(&req)?); + + let res = client + .post(&url) + .header("Authorization", format!("Bearer {}", session.access_jwt)) + .json(&req) + .send() + .await?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + anyhow::bail!("Put record failed: {} - {}", status, body); + } + + let result: PutRecordResponse = res.json().await?; + println!("Success!"); + println!(" URI: {}", result.uri); + println!(" CID: {}", result.cid); + + Ok(()) +} + +/// Put a lexicon schema +pub async fn put_lexicon(file: &str) -> Result<()> { + let session = auth::refresh_session().await?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + + let content = fs::read_to_string(file) + .with_context(|| format!("Failed to read file: {}", file))?; + let lexicon: Value = serde_json::from_str(&content)?; + + let lexicon_id = lexicon["id"] + .as_str() + .context("Lexicon file must have 'id' field")? + .to_string(); + + let client = reqwest::Client::new(); + let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD); + + let req = PutRecordRequest { + repo: session.did.clone(), + collection: "com.atproto.lexicon.schema".to_string(), + rkey: lexicon_id.clone(), + record: lexicon, + }; + + println!("Putting lexicon: {}", lexicon_id); + println!("{}", serde_json::to_string_pretty(&req)?); + + let res = client + .post(&url) + .header("Authorization", format!("Bearer {}", session.access_jwt)) + .json(&req) + .send() + .await?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + anyhow::bail!("Put lexicon failed: {} - {}", status, body); + } + + let result: PutRecordResponse = res.json().await?; + println!("Success!"); + println!(" URI: {}", result.uri); + println!(" CID: {}", result.cid); + + Ok(()) +} + +/// Get records from a collection +pub async fn get_records(collection: &str, limit: u32) -> Result<()> { + let session = auth::refresh_session().await?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + + let client = reqwest::Client::new(); + let base_url = lexicons::url(pds, &com_atproto_repo::LIST_RECORDS); + let url = format!( + "{}?repo={}&collection={}&limit={}", + base_url, session.did, collection, limit + ); + + let res = client + .get(&url) + .header("Authorization", format!("Bearer {}", session.access_jwt)) + .send() + .await?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + anyhow::bail!("Get records failed: {} - {}", status, body); + } + + let result: ListRecordsResponse = res.json().await?; + + println!("Found {} records in {}", result.records.len(), collection); + for record in &result.records { + println!("---"); + println!("URI: {}", record.uri); + println!("CID: {}", record.cid); + println!("{}", serde_json::to_string_pretty(&record.value)?); + } + + Ok(()) +} + +/// Delete a record +pub async fn delete_record(collection: &str, rkey: &str) -> Result<()> { + let session = auth::refresh_session().await?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + + let client = reqwest::Client::new(); + let url = lexicons::url(pds, &com_atproto_repo::DELETE_RECORD); + + let req = DeleteRecordRequest { + repo: session.did.clone(), + collection: collection.to_string(), + rkey: rkey.to_string(), + }; + + println!("Deleting {} from {}", rkey, collection); + + let res = client + .post(&url) + .header("Authorization", format!("Bearer {}", session.access_jwt)) + .json(&req) + .send() + .await?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + anyhow::bail!("Delete failed: {} - {}", status, body); + } + + println!("Deleted successfully"); + + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct Config { + handle: String, + #[serde(default)] + collection: Option, +} + +#[derive(Debug, Deserialize)] +struct DescribeRepoResponse { + did: String, + handle: String, + collections: Vec, +} + +/// Sync PDS data to local content directory +pub async fn sync_to_local(output: &str) -> Result<()> { + let config_content = fs::read_to_string("config.json") + .context("config.json not found")?; + let config: Config = serde_json::from_str(&config_content)?; + + println!("Syncing data for {}", config.handle); + + let client = reqwest::Client::new(); + + // Resolve handle to DID + let resolve_url = format!( + "{}?handle={}", + lexicons::url("public.api.bsky.app", &com_atproto_identity::RESOLVE_HANDLE), + config.handle + ); + let res = client.get(&resolve_url).send().await?; + let resolve: serde_json::Value = res.json().await?; + let did = resolve["did"].as_str().context("Could not resolve handle")?; + + println!("DID: {}", did); + + // Get PDS from DID document + let plc_url = format!("https://plc.directory/{}", did); + let res = client.get(&plc_url).send().await?; + let did_doc: serde_json::Value = res.json().await?; + let pds = did_doc["service"] + .as_array() + .and_then(|services| { + services.iter().find(|s| s["type"] == "AtprotoPersonalDataServer") + }) + .and_then(|s| s["serviceEndpoint"].as_str()) + .context("Could not find PDS")?; + + println!("PDS: {}", pds); + + // Remove https:// prefix for lexicons::url + let pds_host = pds.trim_start_matches("https://"); + + // Create output directory + let did_dir = format!("{}/{}", output, did); + fs::create_dir_all(&did_dir)?; + + // 1. Sync describeRepo + let describe_url = format!( + "{}?repo={}", + lexicons::url(pds_host, &com_atproto_repo::DESCRIBE_REPO), + did + ); + let res = client.get(&describe_url).send().await?; + let describe: DescribeRepoResponse = res.json().await?; + + let describe_path = format!("{}/describe.json", did_dir); + let describe_json = serde_json::to_string_pretty(&serde_json::json!({ + "did": describe.did, + "handle": describe.handle, + "collections": describe.collections, + }))?; + fs::write(&describe_path, &describe_json)?; + println!("Saved: {}", describe_path); + + // 2. Sync profile + let profile_url = format!( + "{}?repo={}&collection=app.bsky.actor.profile&rkey=self", + lexicons::url(pds_host, &com_atproto_repo::GET_RECORD), + did + ); + let res = client.get(&profile_url).send().await?; + if res.status().is_success() { + let profile: serde_json::Value = res.json().await?; + let profile_dir = format!("{}/app.bsky.actor.profile", did_dir); + fs::create_dir_all(&profile_dir)?; + let profile_path = format!("{}/self.json", profile_dir); + fs::write(&profile_path, serde_json::to_string_pretty(&profile)?)?; + println!("Saved: {}", profile_path); + } + + // 3. Sync collection records + let collection = config.collection.as_deref().unwrap_or("ai.syui.log.post"); + let records_url = format!( + "{}?repo={}&collection={}&limit=100", + lexicons::url(pds_host, &com_atproto_repo::LIST_RECORDS), + did, collection + ); + let res = client.get(&records_url).send().await?; + if res.status().is_success() { + let list: ListRecordsResponse = res.json().await?; + let collection_dir = format!("{}/{}", did_dir, collection); + fs::create_dir_all(&collection_dir)?; + + for record in &list.records { + let rkey = record.uri.split('/').last().unwrap_or("unknown"); + let record_path = format!("{}/{}.json", collection_dir, rkey); + let record_json = serde_json::json!({ + "uri": record.uri, + "cid": record.cid, + "value": record.value, + }); + fs::write(&record_path, serde_json::to_string_pretty(&record_json)?)?; + println!("Saved: {}", record_path); + } + println!("Synced {} records from {}", list.records.len(), collection); + } + + println!("Sync complete!"); + + Ok(()) +} diff --git a/src/commands/token.rs b/src/commands/token.rs new file mode 100644 index 0000000..f37b5a6 --- /dev/null +++ b/src/commands/token.rs @@ -0,0 +1,46 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +/// Bundle ID for the application +pub const BUNDLE_ID: &str = "ai.syui.log"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Session { + pub did: String, + pub handle: String, + pub access_jwt: String, + pub refresh_jwt: String, + #[serde(default)] + pub pds: Option, +} + +/// Get token file path: ~/Library/Application Support/ai.syui.log/token.json +pub fn token_path() -> Result { + let config_dir = dirs::config_dir() + .context("Could not find config directory")? + .join(BUNDLE_ID); + + fs::create_dir_all(&config_dir)?; + Ok(config_dir.join("token.json")) +} + +/// Load session from token file +pub fn load_session() -> Result { + let path = token_path()?; + let content = fs::read_to_string(&path) + .with_context(|| format!("Token file not found: {:?}. Run 'ailog login' first.", path))?; + let session: Session = serde_json::from_str(&content)?; + Ok(session) +} + +/// Save session to token file +pub fn save_session(session: &Session) -> Result<()> { + let path = token_path()?; + let content = serde_json::to_string_pretty(session)?; + fs::write(&path, content)?; + println!("Token saved to {:?}", path); + Ok(()) +} diff --git a/src/lexicons/mod.rs b/src/lexicons/mod.rs new file mode 100644 index 0000000..f54e4a6 --- /dev/null +++ b/src/lexicons/mod.rs @@ -0,0 +1,301 @@ +//! Auto-generated from ATProto lexicons +//! Run `ailog gen` to regenerate +//! Do not edit manually + +#[derive(Debug, Clone, Copy)] +pub struct Endpoint { + pub nsid: &'static str, + pub method: &'static str, +} + +/// Build XRPC URL for an endpoint +pub fn url(pds: &str, endpoint: &Endpoint) -> String { + format!("https://{}/xrpc/{}", pds, endpoint.nsid) +} + +pub mod app_bsky_actor { + use super::Endpoint; + + pub const GET_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.actor.getPreferences", method: "GET" }; + pub const GET_PROFILE: Endpoint = Endpoint { nsid: "app.bsky.actor.getProfile", method: "GET" }; + pub const GET_PROFILES: Endpoint = Endpoint { nsid: "app.bsky.actor.getProfiles", method: "GET" }; + pub const GET_SUGGESTIONS: Endpoint = Endpoint { nsid: "app.bsky.actor.getSuggestions", method: "GET" }; + pub const PUT_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.actor.putPreferences", method: "POST" }; + pub const SEARCH_ACTORS: Endpoint = Endpoint { nsid: "app.bsky.actor.searchActors", method: "GET" }; + pub const SEARCH_ACTORS_TYPEAHEAD: Endpoint = Endpoint { nsid: "app.bsky.actor.searchActorsTypeahead", method: "GET" }; +} + +pub mod app_bsky_ageassurance { + use super::Endpoint; + + pub const BEGIN: Endpoint = Endpoint { nsid: "app.bsky.ageassurance.begin", method: "POST" }; + pub const GET_CONFIG: Endpoint = Endpoint { nsid: "app.bsky.ageassurance.getConfig", method: "GET" }; + pub const GET_STATE: Endpoint = Endpoint { nsid: "app.bsky.ageassurance.getState", method: "GET" }; +} + +pub mod app_bsky_bookmark { + use super::Endpoint; + + pub const CREATE_BOOKMARK: Endpoint = Endpoint { nsid: "app.bsky.bookmark.createBookmark", method: "POST" }; + pub const DELETE_BOOKMARK: Endpoint = Endpoint { nsid: "app.bsky.bookmark.deleteBookmark", method: "POST" }; + pub const GET_BOOKMARKS: Endpoint = Endpoint { nsid: "app.bsky.bookmark.getBookmarks", method: "GET" }; +} + +pub mod app_bsky_contact { + use super::Endpoint; + + pub const DISMISS_MATCH: Endpoint = Endpoint { nsid: "app.bsky.contact.dismissMatch", method: "POST" }; + pub const GET_MATCHES: Endpoint = Endpoint { nsid: "app.bsky.contact.getMatches", method: "GET" }; + pub const GET_SYNC_STATUS: Endpoint = Endpoint { nsid: "app.bsky.contact.getSyncStatus", method: "GET" }; + pub const IMPORT_CONTACTS: Endpoint = Endpoint { nsid: "app.bsky.contact.importContacts", method: "POST" }; + pub const REMOVE_DATA: Endpoint = Endpoint { nsid: "app.bsky.contact.removeData", method: "POST" }; + pub const SEND_NOTIFICATION: Endpoint = Endpoint { nsid: "app.bsky.contact.sendNotification", method: "POST" }; + pub const START_PHONE_VERIFICATION: Endpoint = Endpoint { nsid: "app.bsky.contact.startPhoneVerification", method: "POST" }; + pub const VERIFY_PHONE: Endpoint = Endpoint { nsid: "app.bsky.contact.verifyPhone", method: "POST" }; +} + +pub mod app_bsky_draft { + use super::Endpoint; + + pub const CREATE_DRAFT: Endpoint = Endpoint { nsid: "app.bsky.draft.createDraft", method: "POST" }; + pub const DELETE_DRAFT: Endpoint = Endpoint { nsid: "app.bsky.draft.deleteDraft", method: "POST" }; + pub const GET_DRAFTS: Endpoint = Endpoint { nsid: "app.bsky.draft.getDrafts", method: "GET" }; + pub const UPDATE_DRAFT: Endpoint = Endpoint { nsid: "app.bsky.draft.updateDraft", method: "POST" }; +} + +pub mod app_bsky_feed { + use super::Endpoint; + + pub const DESCRIBE_FEED_GENERATOR: Endpoint = Endpoint { nsid: "app.bsky.feed.describeFeedGenerator", method: "GET" }; + pub const GET_ACTOR_FEEDS: Endpoint = Endpoint { nsid: "app.bsky.feed.getActorFeeds", method: "GET" }; + pub const GET_ACTOR_LIKES: Endpoint = Endpoint { nsid: "app.bsky.feed.getActorLikes", method: "GET" }; + pub const GET_AUTHOR_FEED: Endpoint = Endpoint { nsid: "app.bsky.feed.getAuthorFeed", method: "GET" }; + pub const GET_FEED: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeed", method: "GET" }; + pub const GET_FEED_GENERATOR: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeedGenerator", method: "GET" }; + pub const GET_FEED_GENERATORS: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeedGenerators", method: "GET" }; + pub const GET_FEED_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeedSkeleton", method: "GET" }; + pub const GET_LIKES: Endpoint = Endpoint { nsid: "app.bsky.feed.getLikes", method: "GET" }; + pub const GET_LIST_FEED: Endpoint = Endpoint { nsid: "app.bsky.feed.getListFeed", method: "GET" }; + pub const GET_POST_THREAD: Endpoint = Endpoint { nsid: "app.bsky.feed.getPostThread", method: "GET" }; + pub const GET_POSTS: Endpoint = Endpoint { nsid: "app.bsky.feed.getPosts", method: "GET" }; + pub const GET_QUOTES: Endpoint = Endpoint { nsid: "app.bsky.feed.getQuotes", method: "GET" }; + pub const GET_REPOSTED_BY: Endpoint = Endpoint { nsid: "app.bsky.feed.getRepostedBy", method: "GET" }; + pub const GET_SUGGESTED_FEEDS: Endpoint = Endpoint { nsid: "app.bsky.feed.getSuggestedFeeds", method: "GET" }; + pub const GET_TIMELINE: Endpoint = Endpoint { nsid: "app.bsky.feed.getTimeline", method: "GET" }; + pub const SEARCH_POSTS: Endpoint = Endpoint { nsid: "app.bsky.feed.searchPosts", method: "GET" }; + pub const SEND_INTERACTIONS: Endpoint = Endpoint { nsid: "app.bsky.feed.sendInteractions", method: "POST" }; +} + +pub mod app_bsky_graph { + use super::Endpoint; + + pub const GET_ACTOR_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getActorStarterPacks", method: "GET" }; + pub const GET_BLOCKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getBlocks", method: "GET" }; + pub const GET_FOLLOWERS: Endpoint = Endpoint { nsid: "app.bsky.graph.getFollowers", method: "GET" }; + pub const GET_FOLLOWS: Endpoint = Endpoint { nsid: "app.bsky.graph.getFollows", method: "GET" }; + pub const GET_KNOWN_FOLLOWERS: Endpoint = Endpoint { nsid: "app.bsky.graph.getKnownFollowers", method: "GET" }; + pub const GET_LIST: Endpoint = Endpoint { nsid: "app.bsky.graph.getList", method: "GET" }; + pub const GET_LIST_BLOCKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getListBlocks", method: "GET" }; + pub const GET_LIST_MUTES: Endpoint = Endpoint { nsid: "app.bsky.graph.getListMutes", method: "GET" }; + pub const GET_LISTS: Endpoint = Endpoint { nsid: "app.bsky.graph.getLists", method: "GET" }; + pub const GET_LISTS_WITH_MEMBERSHIP: Endpoint = Endpoint { nsid: "app.bsky.graph.getListsWithMembership", method: "GET" }; + pub const GET_MUTES: Endpoint = Endpoint { nsid: "app.bsky.graph.getMutes", method: "GET" }; + pub const GET_RELATIONSHIPS: Endpoint = Endpoint { nsid: "app.bsky.graph.getRelationships", method: "GET" }; + pub const GET_STARTER_PACK: Endpoint = Endpoint { nsid: "app.bsky.graph.getStarterPack", method: "GET" }; + pub const GET_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getStarterPacks", method: "GET" }; + pub const GET_STARTER_PACKS_WITH_MEMBERSHIP: Endpoint = Endpoint { nsid: "app.bsky.graph.getStarterPacksWithMembership", method: "GET" }; + pub const GET_SUGGESTED_FOLLOWS_BY_ACTOR: Endpoint = Endpoint { nsid: "app.bsky.graph.getSuggestedFollowsByActor", method: "GET" }; + pub const MUTE_ACTOR: Endpoint = Endpoint { nsid: "app.bsky.graph.muteActor", method: "POST" }; + pub const MUTE_ACTOR_LIST: Endpoint = Endpoint { nsid: "app.bsky.graph.muteActorList", method: "POST" }; + pub const MUTE_THREAD: Endpoint = Endpoint { nsid: "app.bsky.graph.muteThread", method: "POST" }; + pub const SEARCH_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.graph.searchStarterPacks", method: "GET" }; + pub const UNMUTE_ACTOR: Endpoint = Endpoint { nsid: "app.bsky.graph.unmuteActor", method: "POST" }; + pub const UNMUTE_ACTOR_LIST: Endpoint = Endpoint { nsid: "app.bsky.graph.unmuteActorList", method: "POST" }; + pub const UNMUTE_THREAD: Endpoint = Endpoint { nsid: "app.bsky.graph.unmuteThread", method: "POST" }; +} + +pub mod app_bsky_labeler { + use super::Endpoint; + + pub const GET_SERVICES: Endpoint = Endpoint { nsid: "app.bsky.labeler.getServices", method: "GET" }; +} + +pub mod app_bsky_notification { + use super::Endpoint; + + pub const GET_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.notification.getPreferences", method: "GET" }; + pub const GET_UNREAD_COUNT: Endpoint = Endpoint { nsid: "app.bsky.notification.getUnreadCount", method: "GET" }; + pub const LIST_ACTIVITY_SUBSCRIPTIONS: Endpoint = Endpoint { nsid: "app.bsky.notification.listActivitySubscriptions", method: "GET" }; + pub const LIST_NOTIFICATIONS: Endpoint = Endpoint { nsid: "app.bsky.notification.listNotifications", method: "GET" }; + pub const PUT_ACTIVITY_SUBSCRIPTION: Endpoint = Endpoint { nsid: "app.bsky.notification.putActivitySubscription", method: "POST" }; + pub const PUT_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.notification.putPreferences", method: "POST" }; + pub const PUT_PREFERENCES_V2: Endpoint = Endpoint { nsid: "app.bsky.notification.putPreferencesV2", method: "POST" }; + pub const REGISTER_PUSH: Endpoint = Endpoint { nsid: "app.bsky.notification.registerPush", method: "POST" }; + pub const UNREGISTER_PUSH: Endpoint = Endpoint { nsid: "app.bsky.notification.unregisterPush", method: "POST" }; + pub const UPDATE_SEEN: Endpoint = Endpoint { nsid: "app.bsky.notification.updateSeen", method: "POST" }; +} + +pub mod app_bsky_unspecced { + use super::Endpoint; + + pub const GET_AGE_ASSURANCE_STATE: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getAgeAssuranceState", method: "GET" }; + pub const GET_CONFIG: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getConfig", method: "GET" }; + pub const GET_ONBOARDING_SUGGESTED_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getOnboardingSuggestedStarterPacks", method: "GET" }; + pub const GET_ONBOARDING_SUGGESTED_STARTER_PACKS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton", method: "GET" }; + pub const GET_POPULAR_FEED_GENERATORS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getPopularFeedGenerators", method: "GET" }; + pub const GET_POST_THREAD_OTHER_V2: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getPostThreadOtherV2", method: "GET" }; + pub const GET_POST_THREAD_V2: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getPostThreadV2", method: "GET" }; + pub const GET_SUGGESTED_FEEDS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedFeeds", method: "GET" }; + pub const GET_SUGGESTED_FEEDS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedFeedsSkeleton", method: "GET" }; + pub const GET_SUGGESTED_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedStarterPacks", method: "GET" }; + pub const GET_SUGGESTED_STARTER_PACKS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedStarterPacksSkeleton", method: "GET" }; + pub const GET_SUGGESTED_USERS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedUsers", method: "GET" }; + pub const GET_SUGGESTED_USERS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedUsersSkeleton", method: "GET" }; + pub const GET_SUGGESTIONS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestionsSkeleton", method: "GET" }; + pub const GET_TAGGED_SUGGESTIONS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTaggedSuggestions", method: "GET" }; + pub const GET_TRENDING_TOPICS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTrendingTopics", method: "GET" }; + pub const GET_TRENDS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTrends", method: "GET" }; + pub const GET_TRENDS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTrendsSkeleton", method: "GET" }; + pub const INIT_AGE_ASSURANCE: Endpoint = Endpoint { nsid: "app.bsky.unspecced.initAgeAssurance", method: "POST" }; + pub const SEARCH_ACTORS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.searchActorsSkeleton", method: "GET" }; + pub const SEARCH_POSTS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.searchPostsSkeleton", method: "GET" }; + pub const SEARCH_STARTER_PACKS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.searchStarterPacksSkeleton", method: "GET" }; +} + +pub mod app_bsky_video { + use super::Endpoint; + + pub const GET_JOB_STATUS: Endpoint = Endpoint { nsid: "app.bsky.video.getJobStatus", method: "GET" }; + pub const GET_UPLOAD_LIMITS: Endpoint = Endpoint { nsid: "app.bsky.video.getUploadLimits", method: "GET" }; + pub const UPLOAD_VIDEO: Endpoint = Endpoint { nsid: "app.bsky.video.uploadVideo", method: "POST" }; +} + +pub mod com_atproto_admin { + use super::Endpoint; + + pub const DELETE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.admin.deleteAccount", method: "POST" }; + pub const DISABLE_ACCOUNT_INVITES: Endpoint = Endpoint { nsid: "com.atproto.admin.disableAccountInvites", method: "POST" }; + pub const DISABLE_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.admin.disableInviteCodes", method: "POST" }; + pub const ENABLE_ACCOUNT_INVITES: Endpoint = Endpoint { nsid: "com.atproto.admin.enableAccountInvites", method: "POST" }; + pub const GET_ACCOUNT_INFO: Endpoint = Endpoint { nsid: "com.atproto.admin.getAccountInfo", method: "GET" }; + pub const GET_ACCOUNT_INFOS: Endpoint = Endpoint { nsid: "com.atproto.admin.getAccountInfos", method: "GET" }; + pub const GET_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.admin.getInviteCodes", method: "GET" }; + pub const GET_SUBJECT_STATUS: Endpoint = Endpoint { nsid: "com.atproto.admin.getSubjectStatus", method: "GET" }; + pub const SEARCH_ACCOUNTS: Endpoint = Endpoint { nsid: "com.atproto.admin.searchAccounts", method: "GET" }; + pub const SEND_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.admin.sendEmail", method: "POST" }; + pub const UPDATE_ACCOUNT_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountEmail", method: "POST" }; + pub const UPDATE_ACCOUNT_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountHandle", method: "POST" }; + pub const UPDATE_ACCOUNT_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountPassword", method: "POST" }; + pub const UPDATE_ACCOUNT_SIGNING_KEY: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountSigningKey", method: "POST" }; + pub const UPDATE_SUBJECT_STATUS: Endpoint = Endpoint { nsid: "com.atproto.admin.updateSubjectStatus", method: "POST" }; +} + +pub mod com_atproto_identity { + use super::Endpoint; + + pub const GET_RECOMMENDED_DID_CREDENTIALS: Endpoint = Endpoint { nsid: "com.atproto.identity.getRecommendedDidCredentials", method: "GET" }; + pub const REFRESH_IDENTITY: Endpoint = Endpoint { nsid: "com.atproto.identity.refreshIdentity", method: "POST" }; + pub const REQUEST_PLC_OPERATION_SIGNATURE: Endpoint = Endpoint { nsid: "com.atproto.identity.requestPlcOperationSignature", method: "POST" }; + pub const RESOLVE_DID: Endpoint = Endpoint { nsid: "com.atproto.identity.resolveDid", method: "GET" }; + pub const RESOLVE_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.identity.resolveHandle", method: "GET" }; + pub const RESOLVE_IDENTITY: Endpoint = Endpoint { nsid: "com.atproto.identity.resolveIdentity", method: "GET" }; + pub const SIGN_PLC_OPERATION: Endpoint = Endpoint { nsid: "com.atproto.identity.signPlcOperation", method: "POST" }; + pub const SUBMIT_PLC_OPERATION: Endpoint = Endpoint { nsid: "com.atproto.identity.submitPlcOperation", method: "POST" }; + pub const UPDATE_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.identity.updateHandle", method: "POST" }; +} + +pub mod com_atproto_label { + use super::Endpoint; + + pub const QUERY_LABELS: Endpoint = Endpoint { nsid: "com.atproto.label.queryLabels", method: "GET" }; +} + +pub mod com_atproto_lexicon { + use super::Endpoint; + + pub const RESOLVE_LEXICON: Endpoint = Endpoint { nsid: "com.atproto.lexicon.resolveLexicon", method: "GET" }; +} + +pub mod com_atproto_moderation { + use super::Endpoint; + + pub const CREATE_REPORT: Endpoint = Endpoint { nsid: "com.atproto.moderation.createReport", method: "POST" }; +} + +pub mod com_atproto_repo { + use super::Endpoint; + + pub const APPLY_WRITES: Endpoint = Endpoint { nsid: "com.atproto.repo.applyWrites", method: "POST" }; + pub const CREATE_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.createRecord", method: "POST" }; + pub const DELETE_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.deleteRecord", method: "POST" }; + pub const DESCRIBE_REPO: Endpoint = Endpoint { nsid: "com.atproto.repo.describeRepo", method: "GET" }; + pub const GET_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.getRecord", method: "GET" }; + pub const IMPORT_REPO: Endpoint = Endpoint { nsid: "com.atproto.repo.importRepo", method: "POST" }; + pub const LIST_MISSING_BLOBS: Endpoint = Endpoint { nsid: "com.atproto.repo.listMissingBlobs", method: "GET" }; + pub const LIST_RECORDS: Endpoint = Endpoint { nsid: "com.atproto.repo.listRecords", method: "GET" }; + pub const PUT_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.putRecord", method: "POST" }; + pub const UPLOAD_BLOB: Endpoint = Endpoint { nsid: "com.atproto.repo.uploadBlob", method: "POST" }; +} + +pub mod com_atproto_server { + use super::Endpoint; + + pub const ACTIVATE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.activateAccount", method: "POST" }; + pub const CHECK_ACCOUNT_STATUS: Endpoint = Endpoint { nsid: "com.atproto.server.checkAccountStatus", method: "GET" }; + pub const CONFIRM_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.server.confirmEmail", method: "POST" }; + pub const CREATE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.createAccount", method: "POST" }; + pub const CREATE_APP_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.server.createAppPassword", method: "POST" }; + pub const CREATE_INVITE_CODE: Endpoint = Endpoint { nsid: "com.atproto.server.createInviteCode", method: "POST" }; + pub const CREATE_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.server.createInviteCodes", method: "POST" }; + pub const CREATE_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.createSession", method: "POST" }; + pub const DEACTIVATE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.deactivateAccount", method: "POST" }; + pub const DELETE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.deleteAccount", method: "POST" }; + pub const DELETE_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.deleteSession", method: "POST" }; + pub const DESCRIBE_SERVER: Endpoint = Endpoint { nsid: "com.atproto.server.describeServer", method: "GET" }; + pub const GET_ACCOUNT_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.server.getAccountInviteCodes", method: "GET" }; + pub const GET_SERVICE_AUTH: Endpoint = Endpoint { nsid: "com.atproto.server.getServiceAuth", method: "GET" }; + pub const GET_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.getSession", method: "GET" }; + pub const LIST_APP_PASSWORDS: Endpoint = Endpoint { nsid: "com.atproto.server.listAppPasswords", method: "GET" }; + pub const REFRESH_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.refreshSession", method: "POST" }; + pub const REQUEST_ACCOUNT_DELETE: Endpoint = Endpoint { nsid: "com.atproto.server.requestAccountDelete", method: "POST" }; + pub const REQUEST_EMAIL_CONFIRMATION: Endpoint = Endpoint { nsid: "com.atproto.server.requestEmailConfirmation", method: "POST" }; + pub const REQUEST_EMAIL_UPDATE: Endpoint = Endpoint { nsid: "com.atproto.server.requestEmailUpdate", method: "POST" }; + pub const REQUEST_PASSWORD_RESET: Endpoint = Endpoint { nsid: "com.atproto.server.requestPasswordReset", method: "POST" }; + pub const RESERVE_SIGNING_KEY: Endpoint = Endpoint { nsid: "com.atproto.server.reserveSigningKey", method: "POST" }; + pub const RESET_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.server.resetPassword", method: "POST" }; + pub const REVOKE_APP_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.server.revokeAppPassword", method: "POST" }; + pub const UPDATE_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.server.updateEmail", method: "POST" }; +} + +pub mod com_atproto_sync { + use super::Endpoint; + + pub const GET_BLOB: Endpoint = Endpoint { nsid: "com.atproto.sync.getBlob", method: "GET" }; + pub const GET_BLOCKS: Endpoint = Endpoint { nsid: "com.atproto.sync.getBlocks", method: "GET" }; + pub const GET_CHECKOUT: Endpoint = Endpoint { nsid: "com.atproto.sync.getCheckout", method: "GET" }; + pub const GET_HEAD: Endpoint = Endpoint { nsid: "com.atproto.sync.getHead", method: "GET" }; + pub const GET_HOST_STATUS: Endpoint = Endpoint { nsid: "com.atproto.sync.getHostStatus", method: "GET" }; + pub const GET_LATEST_COMMIT: Endpoint = Endpoint { nsid: "com.atproto.sync.getLatestCommit", method: "GET" }; + pub const GET_RECORD: Endpoint = Endpoint { nsid: "com.atproto.sync.getRecord", method: "GET" }; + pub const GET_REPO: Endpoint = Endpoint { nsid: "com.atproto.sync.getRepo", method: "GET" }; + pub const GET_REPO_STATUS: Endpoint = Endpoint { nsid: "com.atproto.sync.getRepoStatus", method: "GET" }; + pub const LIST_BLOBS: Endpoint = Endpoint { nsid: "com.atproto.sync.listBlobs", method: "GET" }; + pub const LIST_HOSTS: Endpoint = Endpoint { nsid: "com.atproto.sync.listHosts", method: "GET" }; + pub const LIST_REPOS: Endpoint = Endpoint { nsid: "com.atproto.sync.listRepos", method: "GET" }; + pub const LIST_REPOS_BY_COLLECTION: Endpoint = Endpoint { nsid: "com.atproto.sync.listReposByCollection", method: "GET" }; + pub const NOTIFY_OF_UPDATE: Endpoint = Endpoint { nsid: "com.atproto.sync.notifyOfUpdate", method: "POST" }; + pub const REQUEST_CRAWL: Endpoint = Endpoint { nsid: "com.atproto.sync.requestCrawl", method: "POST" }; +} + +pub mod com_atproto_temp { + use super::Endpoint; + + pub const ADD_RESERVED_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.temp.addReservedHandle", method: "POST" }; + pub const CHECK_HANDLE_AVAILABILITY: Endpoint = Endpoint { nsid: "com.atproto.temp.checkHandleAvailability", method: "GET" }; + pub const CHECK_SIGNUP_QUEUE: Endpoint = Endpoint { nsid: "com.atproto.temp.checkSignupQueue", method: "GET" }; + pub const DEREFERENCE_SCOPE: Endpoint = Endpoint { nsid: "com.atproto.temp.dereferenceScope", method: "GET" }; + pub const FETCH_LABELS: Endpoint = Endpoint { nsid: "com.atproto.temp.fetchLabels", method: "GET" }; + pub const REQUEST_PHONE_VERIFICATION: Endpoint = Endpoint { nsid: "com.atproto.temp.requestPhoneVerification", method: "POST" }; + pub const REVOKE_ACCOUNT_CREDENTIALS: Endpoint = Endpoint { nsid: "com.atproto.temp.revokeAccountCredentials", method: "POST" }; +} + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2cff85a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,119 @@ +mod commands; +mod lexicons; + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "ailog")] +#[command(about = "ATProto blog CLI")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Login to ATProto PDS + #[command(alias = "l")] + Login { + /// Handle (e.g., user.bsky.social) + handle: String, + /// Password + #[arg(short, long)] + password: String, + /// PDS server + #[arg(short, long, default_value = "bsky.social")] + server: String, + }, + + /// Update lexicon schema + Lexicon { + /// Lexicon JSON file + file: String, + }, + + /// Post a record + #[command(alias = "p")] + Post { + /// Record JSON file + file: String, + /// Collection (e.g., ai.syui.log.post) + #[arg(short, long)] + collection: String, + /// Record key (auto-generated if not provided) + #[arg(short, long)] + rkey: Option, + }, + + /// Get records from collection + #[command(alias = "g")] + Get { + /// Collection (e.g., ai.syui.log.post) + #[arg(short, long)] + collection: String, + /// Limit + #[arg(short, long, default_value = "10")] + limit: u32, + }, + + /// Delete a record + #[command(alias = "d")] + Delete { + /// Collection (e.g., ai.syui.log.post) + #[arg(short, long)] + collection: String, + /// Record key + #[arg(short, long)] + rkey: String, + }, + + /// Sync PDS data to local content directory + #[command(alias = "s")] + Sync { + /// Output directory + #[arg(short, long, default_value = "content")] + output: String, + }, + + /// Generate lexicon Rust code from ATProto lexicon JSON files + Gen { + /// Input directory containing lexicon JSON files + #[arg(short, long, default_value = "./repos/atproto/lexicons")] + input: String, + /// Output directory for generated Rust code + #[arg(short, long, default_value = "./src/lexicons")] + output: String, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::Login { handle, password, server } => { + commands::auth::login(&handle, &password, &server).await?; + } + Commands::Lexicon { file } => { + commands::post::put_lexicon(&file).await?; + } + Commands::Post { file, collection, rkey } => { + commands::post::put_record(&file, &collection, rkey.as_deref()).await?; + } + Commands::Get { collection, limit } => { + commands::post::get_records(&collection, limit).await?; + } + Commands::Delete { collection, rkey } => { + commands::post::delete_record(&collection, &rkey).await?; + } + Commands::Sync { output } => { + commands::post::sync_to_local(&output).await?; + } + Commands::Gen { input, output } => { + commands::gen::generate(&input, &output)?; + } + } + + Ok(()) +}