This commit is contained in:
2026-01-18 12:15:43 +09:00
parent 28eb463b74
commit c27aebd25c
12 changed files with 934 additions and 50 deletions

11
.gitignore vendored
View File

@@ -1,8 +1,9 @@
dist /dist
repos /repos
/target
/CLAUDE.md
/.claude
node_modules node_modules
package-lock.json package-lock.json
CLAUDE.md Cargo.lock
.claude
.env .env
target

View File

@@ -2,4 +2,22 @@
name = "ailog" name = "ailog"
version = "0.2.0" version = "0.2.0"
edition = "2021" 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"

View File

@@ -1,18 +1,18 @@
{ {
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
"cid": "bafyreielgn743kg5xotfj5x53edl25vkbbd2d6v7s3tydyyjsvczcluyme", "cid": "bafyreielgn743kg5xotfj5x53edl25vkbbd2d6v7s3tydyyjsvczcluyme",
"value": {
"cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s", "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
"value": {
"$type": "ai.syui.log.post", "$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```", "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", "createdAt": "2026-01-15T13:59:52.367Z",
"title": "ailogを作り直した",
"translations": { "translations": {
"en": { "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"
} }
} }

View File

@@ -1,14 +1,14 @@
{ {
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self",
"cid": "bafyreihlch2vdee6wpydo2bwap7nyzszjz6focbtxikz7zljcejxz27npy", "cid": "bafyreihlch2vdee6wpydo2bwap7nyzszjz6focbtxikz7zljcejxz27npy",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self",
"value": { "value": {
"$type": "app.bsky.actor.profile", "$type": "app.bsky.actor.profile",
"avatar": { "avatar": {
"$type": "blob", "$type": "blob",
"mimeType": "image/jpeg",
"ref": { "ref": {
"$link": "bafkreigta4pf5h7uvx6jpfcm3d6aeq4g3qpsiqjdoeytnutwp6vwc2yo7u" "$link": "bafkreigta4pf5h7uvx6jpfcm3d6aeq4g3qpsiqjdoeytnutwp6vwc2yo7u"
}, },
"mimeType": "image/jpeg",
"size": 166370 "size": 166370
}, },
"createdAt": "2025-09-19T06:17:42Z", "createdAt": "2025-09-19T06:17:42Z",

View File

@@ -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": [ "collections": [
"ai.syui.log.post", "ai.syui.log.post",
"app.bsky.actor.profile", "app.bsky.actor.profile",
"app.bsky.feed.post", "app.bsky.feed.post",
"app.bsky.feed.repost", "app.bsky.feed.repost",
"app.bsky.graph.follow", "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"
} }

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ailog</title>
<link rel="stylesheet" href="/src/styles/main.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -152,13 +152,20 @@ curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collecti
### Local (Static File) ### 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 ```json
// content/did:plc:xxx/ai.syui.log.post/3xxx.json
{ {
"uri": "at://did:plc:xxx/ai.syui.log.post/3xxx", "uri": "at://did:plc:xxx/ai.syui.log.post/3xxx",
"cid": "local", "cid": "bafyrei...",
"value": { "value": {
"title": "Hello World", "title": "Hello World",
"content": "# Hello\n\nThis is my post.", "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 ### Resolution Strategy
``` ```
at-browser at-browser
├── admin (config.json user) ├── 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 │ └── 2. Fallback to remote: PDS API
└── user (/@handle) └── user (/@handle)

96
src/auth.rs Normal file
View File

@@ -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<Session> {
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)
}

272
src/lib/api.ts Normal file
View File

@@ -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<Config> {
if (configCache) return configCache
const res = await fetch('/config.json')
configCache = await res.json()
return configCache!
}
// Load networks.json
export async function getNetworks(): Promise<Networks> {
if (networksCache) return networksCache
const res = await fetch('/networks.json')
networksCache = await res.json()
return networksCache!
}
// Load links.json
export async function getLinks(): Promise<Links> {
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<string | null> {
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<string | null> {
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<DescribeRepo | null> {
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<Profile | null> {
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<string | null> {
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<Post[]> {
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<Post | null> {
// 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<string> {
const config = await getConfig()
const networks = await getNetworks()
const network = networks[config.network]
return `${network.web}/profile/${did}`
}

107
src/main.rs Normal file
View File

@@ -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<String>,
},
/// 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(())
}

340
src/post.rs Normal file
View File

@@ -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<Record>,
#[serde(default)]
cursor: Option<String>,
}
#[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<String>,
}
#[derive(Debug, Deserialize)]
struct DescribeRepoResponse {
did: String,
handle: String,
collections: Vec<String>,
}
/// 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(())
}

46
src/token.rs Normal file
View File

@@ -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<String>,
}
/// Get token file path: ~/Library/Application Support/ai.syui.log/token.json
pub fn token_path() -> Result<PathBuf> {
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<Session> {
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(())
}