From c27aebd25cdd4b60d6a503bc6e2836b8cc59c6ef Mon Sep 17 00:00:00 2001 From: syui Date: Sun, 18 Jan 2026 12:15:43 +0900 Subject: [PATCH] fix --- .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 + readme.md | 23 +- src/auth.rs | 96 +++++ src/lib/api.ts | 272 ++++++++++++++ src/main.rs | 107 ++++++ src/post.rs | 340 ++++++++++++++++++ src/token.rs | 46 +++ 12 files changed, 934 insertions(+), 50 deletions(-) create mode 100644 index.html create mode 100644 src/auth.rs create mode 100644 src/lib/api.ts create mode 100644 src/main.rs create mode 100644 src/post.rs create mode 100644 src/token.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/readme.md b/readme.md index bb09443..59b155e 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) diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..45304d7 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,96 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::token::{self, Session}; + +#[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 = format!("https://{}/xrpc/com.atproto.server.createSession", pds); + + 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 = format!("https://{}/xrpc/com.atproto.server.refreshSession", pds); + + 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/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..a530e15 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,272 @@ +// Types matching ATProto record format +export interface Config { + title: string + handle: string + collection: string + network: string + color: string + siteUrl: string +} + +export interface Networks { + [domain: string]: { + plc: string + bsky: string + web: string + } +} + +export interface Links { + footer: Array<{ title: string; url: string }> +} + +export interface DescribeRepo { + did: string + handle: string + collections: string[] +} + +export interface Profile { + cid: string + uri: string + value: { + $type: string + avatar?: { + $type: string + mimeType: string + ref: { $link: string } + size: number + } + displayName?: string + description?: string + createdAt?: string + } +} + +export interface Post { + cid: string + uri: string + value: { + $type: string + title: string + content: string + createdAt: string + lang?: string + translations?: { + [lang: string]: { + title: string + content: string + } + } + } +} + +// Cache for loaded data +let configCache: Config | null = null +let networksCache: Networks | null = null +let linksCache: Links | null = null + +// Load config.json +export async function getConfig(): Promise { + if (configCache) return configCache + const res = await fetch('/config.json') + configCache = await res.json() + return configCache! +} + +// Load networks.json +export async function getNetworks(): Promise { + if (networksCache) return networksCache + const res = await fetch('/networks.json') + networksCache = await res.json() + return networksCache! +} + +// Load links.json +export async function getLinks(): Promise { + if (linksCache) return linksCache + const res = await fetch('/links.json') + linksCache = await res.json() + return linksCache! +} + +// Resolve handle to DID using local describe.json or remote API +export async function resolveHandle(handle: string): Promise { + const config = await getConfig() + const networks = await getNetworks() + const network = networks[config.network] + + // Try remote resolution + try { + const res = await fetch(`${network.bsky}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`) + if (res.ok) { + const data = await res.json() + return data.did + } + } catch { + // Fall back to searching local content + } + + return null +} + +// Get DID from local content directory +export async function getLocalDid(): Promise { + const config = await getConfig() + + // Try to resolve via API first + const did = await resolveHandle(config.handle) + if (did) return did + + return null +} + +// Load describe.json for a DID +export async function getDescribe(did: string): Promise { + try { + const res = await fetch(`/content/${did}/describe.json`) + if (res.ok) { + return await res.json() + } + } catch { + // File not found + } + return null +} + +// Load profile for a DID +export async function getProfile(did: string): Promise { + try { + const res = await fetch(`/content/${did}/app.bsky.actor.profile/self.json`) + if (res.ok) { + return await res.json() + } + } catch { + // File not found + } + return null +} + +// Get avatar URL from profile +export async function getAvatarUrl(did: string, profile: Profile): Promise { + if (!profile.value.avatar) return null + + const config = await getConfig() + const networks = await getNetworks() + const network = networks[config.network] + + // Get PDS endpoint for this DID + try { + const plcRes = await fetch(`${network.plc}/${did}`) + if (plcRes.ok) { + const didDoc = await plcRes.json() + const pds = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint + if (pds) { + return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${profile.value.avatar.ref.$link}` + } + } + } catch { + // Fall back to bsky.social + } + + return `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${profile.value.avatar.ref.$link}` +} + +// List all posts from a collection +export async function listPosts(did: string, collection: string): Promise { + const posts: Post[] = [] + + // Try to load index.json which lists all rkeys + try { + const indexRes = await fetch(`/content/${did}/${collection}/index.json`) + if (indexRes.ok) { + const index: string[] = await indexRes.json() + for (const rkey of index) { + const postRes = await fetch(`/content/${did}/${collection}/${rkey}.json`) + if (postRes.ok) { + posts.push(await postRes.json()) + } + } + return posts.sort((a, b) => + new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime() + ) + } + } catch { + // No index file + } + + // Fallback: load from remote API + const config = await getConfig() + const networks = await getNetworks() + const network = networks[config.network] + + try { + // Get PDS endpoint + const plcRes = await fetch(`${network.plc}/${did}`) + if (plcRes.ok) { + const didDoc = await plcRes.json() + const pds = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint + if (pds) { + const recordsRes = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=100`) + if (recordsRes.ok) { + const data = await recordsRes.json() + return data.records.map((r: { uri: string; cid: string; value: Post['value'] }) => ({ + uri: r.uri, + cid: r.cid, + value: r.value + })).sort((a: Post, b: Post) => + new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime() + ) + } + } + } + } catch { + // Remote API failed + } + + return posts +} + +// Get a single post by rkey +export async function getPost(did: string, collection: string, rkey: string): Promise { + // Try local first + try { + const res = await fetch(`/content/${did}/${collection}/${rkey}.json`) + if (res.ok) { + return await res.json() + } + } catch { + // File not found + } + + // Fallback: load from remote API + const config = await getConfig() + const networks = await getNetworks() + const network = networks[config.network] + + try { + const plcRes = await fetch(`${network.plc}/${did}`) + if (plcRes.ok) { + const didDoc = await plcRes.json() + const pds = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint + if (pds) { + const recordRes = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${collection}&rkey=${rkey}`) + if (recordRes.ok) { + return await recordRes.json() + } + } + } + } catch { + // Remote API failed + } + + return null +} + +// Get profile link URL +export async function getProfileUrl(did: string): Promise { + const config = await getConfig() + const networks = await getNetworks() + const network = networks[config.network] + return `${network.web}/profile/${did}` +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a7e965f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,107 @@ +mod auth; +mod token; +mod post; + +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, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::Login { handle, password, server } => { + auth::login(&handle, &password, &server).await?; + } + Commands::Lexicon { file } => { + post::put_lexicon(&file).await?; + } + Commands::Post { file, collection, rkey } => { + post::put_record(&file, &collection, rkey.as_deref()).await?; + } + Commands::Get { collection, limit } => { + post::get_records(&collection, limit).await?; + } + Commands::Delete { collection, rkey } => { + post::delete_record(&collection, &rkey).await?; + } + Commands::Sync { output } => { + post::sync_to_local(&output).await?; + } + } + + Ok(()) +} diff --git a/src/post.rs b/src/post.rs new file mode 100644 index 0000000..f560939 --- /dev/null +++ b/src/post.rs @@ -0,0 +1,340 @@ +use anyhow::{Context, Result}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fs; + +use crate::auth; + +#[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<()> { + // Refresh token first + let session = auth::refresh_session().await?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + + // Load record from file + let content = fs::read_to_string(file) + .with_context(|| format!("Failed to read file: {}", file))?; + let record: Value = serde_json::from_str(&content)?; + + // Generate rkey if not provided + let rkey = rkey.map(|s| s.to_string()).unwrap_or_else(generate_tid); + + let client = reqwest::Client::new(); + let url = format!("https://{}/xrpc/com.atproto.repo.putRecord", pds); + + 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<()> { + // Refresh token first + let session = auth::refresh_session().await?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + + // Load lexicon from file + let content = fs::read_to_string(file) + .with_context(|| format!("Failed to read file: {}", file))?; + let lexicon: Value = serde_json::from_str(&content)?; + + // Get lexicon id for rkey + let lexicon_id = lexicon["id"] + .as_str() + .context("Lexicon file must have 'id' field")? + .to_string(); + + let client = reqwest::Client::new(); + let url = format!("https://{}/xrpc/com.atproto.repo.putRecord", pds); + + 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 url = format!( + "https://{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={}", + pds, 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 = format!("https://{}/xrpc/com.atproto.repo.deleteRecord", pds); + + 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<()> { + // Load config.json + 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 and PDS + let resolve_url = format!( + "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?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); + + // Create output directory + let did_dir = format!("{}/{}", output, did); + fs::create_dir_all(&did_dir)?; + + // 1. Sync describeRepo + let describe_url = format!( + "{}/xrpc/com.atproto.repo.describeRepo?repo={}", + pds, 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!( + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=app.bsky.actor.profile&rkey=self", + pds, 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 (from config or default) + let collection = config.collection.as_deref().unwrap_or("ai.syui.log.post"); + let records_url = format!( + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=100", + pds, 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/token.rs b/src/token.rs new file mode 100644 index 0000000..f37b5a6 --- /dev/null +++ b/src/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(()) +}