fix
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,8 +1,9 @@
|
||||
dist
|
||||
repos
|
||||
/dist
|
||||
/repos
|
||||
/target
|
||||
/CLAUDE.md
|
||||
/.claude
|
||||
node_modules
|
||||
package-lock.json
|
||||
CLAUDE.md
|
||||
.claude
|
||||
Cargo.lock
|
||||
.env
|
||||
target
|
||||
|
||||
18
Cargo.toml
18
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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
13
index.html
Normal file
13
index.html
Normal 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>
|
||||
23
readme.md
23
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)
|
||||
|
||||
96
src/auth.rs
Normal file
96
src/auth.rs
Normal 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
272
src/lib/api.ts
Normal 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
107
src/main.rs
Normal 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
340
src/post.rs
Normal 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
46
src/token.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user