add cmd lexicon
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",
|
||||
"value": {
|
||||
"cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e",
|
||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
|
||||
"value": {
|
||||
"$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,14 +1,14 @@
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -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>
|
||||
67
public/favicon.svg
Normal file
67
public/favicon.svg
Normal file
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
syui
|
||||
</metadata>
|
||||
|
||||
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92
|
||||
-98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22
|
||||
-33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5
|
||||
-13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247
|
||||
-1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31
|
||||
-14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83
|
||||
-143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37
|
||||
-28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121
|
||||
-17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51
|
||||
-112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4
|
||||
-9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82
|
||||
-123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34
|
||||
-18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95
|
||||
-62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17
|
||||
-4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3
|
||||
45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7
|
||||
-7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10
|
||||
23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15
|
||||
72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52
|
||||
32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12
|
||||
24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106
|
||||
27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534
|
||||
10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13
|
||||
200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60
|
||||
-40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25
|
||||
83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25
|
||||
18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49
|
||||
-3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0
|
||||
-53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7
|
||||
75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24
|
||||
-46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96
|
||||
-53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0
|
||||
-7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85
|
||||
-38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77
|
||||
-25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91
|
||||
-20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18
|
||||
15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92
|
||||
-113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115
|
||||
301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46
|
||||
89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23
|
||||
15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z
|
||||
m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7
|
||||
-187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0
|
||||
84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25
|
||||
-32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17
|
||||
-13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11
|
||||
-14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49
|
||||
-146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29
|
||||
-104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48
|
||||
22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0
|
||||
10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40
|
||||
16 57 18 38 52 41 99 11z" fill="#EF454A"/>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
99
readme.md
99
readme.md
@@ -152,13 +152,20 @@ curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collecti
|
||||
### Local (Static File)
|
||||
|
||||
```
|
||||
public/records/ai.syui.log.post/3xxx.json
|
||||
content/
|
||||
└── did:plc:xxx/
|
||||
├── describe.json # describeRepo (special)
|
||||
├── app.bsky.actor.profile/
|
||||
│ └── self.json # {collection}/{rkey}.json
|
||||
└── ai.syui.log.post/
|
||||
└── 3xxx.json # {collection}/{rkey}.json
|
||||
```
|
||||
|
||||
```json
|
||||
// content/did:plc:xxx/ai.syui.log.post/3xxx.json
|
||||
{
|
||||
"uri": "at://did:plc:xxx/ai.syui.log.post/3xxx",
|
||||
"cid": "local",
|
||||
"cid": "bafyrei...",
|
||||
"value": {
|
||||
"title": "Hello World",
|
||||
"content": "# Hello\n\nThis is my post.",
|
||||
@@ -167,13 +174,23 @@ public/records/ai.syui.log.post/3xxx.json
|
||||
}
|
||||
```
|
||||
|
||||
### ATProto API Reference
|
||||
|
||||
| API | Path | Description |
|
||||
|-----|------|-------------|
|
||||
| getRecord | `/xrpc/com.atproto.repo.getRecord` | Get single record |
|
||||
| listRecords | `/xrpc/com.atproto.repo.listRecords` | List records in collection |
|
||||
| describeRepo | `/xrpc/com.atproto.repo.describeRepo` | Get repo info + collections list |
|
||||
|
||||
See: [com.atproto.repo.describeRepo](https://docs.bsky.app/docs/api/com-atproto-repo-describe-repo)
|
||||
|
||||
### Resolution Strategy
|
||||
|
||||
```
|
||||
at-browser
|
||||
│
|
||||
├── admin (config.json user)
|
||||
│ ├── 1. Check local: /records/{collection}/{rkey}.json
|
||||
│ ├── 1. Check local: /content/{did}/{collection}/{rkey}.json
|
||||
│ └── 2. Fallback to remote: PDS API
|
||||
│
|
||||
└── user (/@handle)
|
||||
@@ -202,11 +219,87 @@ at-browser
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **CLI**: Rust (ailog)
|
||||
- **Frontend**: Vite + TypeScript
|
||||
- **ATProto**: @atproto/api
|
||||
- **OAuth**: @atproto/oauth-client-browser
|
||||
- **Markdown**: marked + highlight.js
|
||||
|
||||
## CLI (ailog)
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
cp target/release/ailog ~/.local/bin/
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Login to ATProto PDS
|
||||
ailog login <handle> -p <password> [-s <server>]
|
||||
|
||||
# Post a record
|
||||
ailog post <file.json> -c <collection> [-r <rkey>]
|
||||
|
||||
# Get records from collection
|
||||
ailog get -c <collection> [-l <limit>]
|
||||
|
||||
# Delete a record
|
||||
ailog delete -c <collection> -r <rkey>
|
||||
|
||||
# Sync PDS data to local content directory
|
||||
ailog sync [-o <output>]
|
||||
|
||||
# Generate lexicon Rust code from ATProto lexicons
|
||||
ailog gen [-i <input>] [-o <output>]
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
# Login
|
||||
ailog login syui.syui.ai -p "app-password" -s syu.is
|
||||
|
||||
# Post
|
||||
echo '{"title":"Hello","content":"World","createdAt":"2025-01-01T00:00:00Z"}' > post.json
|
||||
ailog post post.json -c ai.syui.log.post
|
||||
|
||||
# Sync to local
|
||||
ailog sync -o content
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs
|
||||
├── commands/
|
||||
│ ├── mod.rs
|
||||
│ ├── auth.rs # login, refresh session
|
||||
│ ├── token.rs # token management
|
||||
│ ├── post.rs # post, get, delete, sync
|
||||
│ └── gen.rs # lexicon code generation
|
||||
└── lexicons/
|
||||
└── mod.rs # auto-generated from ATProto lexicons
|
||||
```
|
||||
|
||||
### Lexicon Generation
|
||||
|
||||
Generate Rust endpoint definitions from ATProto lexicon JSON files:
|
||||
|
||||
```bash
|
||||
# Clone atproto repo (if not exists)
|
||||
git clone https://github.com/bluesky-social/atproto repos/atproto
|
||||
|
||||
# Generate lexicons
|
||||
ailog gen -i ./repos/atproto/lexicons -o ./src/lexicons
|
||||
|
||||
# Rebuild
|
||||
cargo build
|
||||
```
|
||||
|
||||
## Collection Schema
|
||||
|
||||
### ai.syui.log.post
|
||||
|
||||
97
src/commands/auth.rs
Normal file
97
src/commands/auth.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::token::{self, Session};
|
||||
use crate::lexicons::{self, com_atproto_server};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateSessionRequest {
|
||||
identifier: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CreateSessionResponse {
|
||||
did: String,
|
||||
handle: String,
|
||||
access_jwt: String,
|
||||
refresh_jwt: String,
|
||||
}
|
||||
|
||||
/// Login to ATProto PDS
|
||||
pub async fn login(handle: &str, password: &str, pds: &str) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = lexicons::url(pds, &com_atproto_server::CREATE_SESSION);
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
identifier: handle.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
|
||||
println!("Logging in to {} as {}...", pds, handle);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send login request")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Login failed: {} - {}", status, body);
|
||||
}
|
||||
|
||||
let session_res: CreateSessionResponse = res.json().await?;
|
||||
|
||||
let session = Session {
|
||||
did: session_res.did,
|
||||
handle: session_res.handle,
|
||||
access_jwt: session_res.access_jwt,
|
||||
refresh_jwt: session_res.refresh_jwt,
|
||||
pds: Some(pds.to_string()),
|
||||
};
|
||||
|
||||
token::save_session(&session)?;
|
||||
println!("Logged in as {} ({})", session.handle, session.did);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh access token
|
||||
pub async fn refresh_session() -> Result<Session> {
|
||||
let session = token::load_session()?;
|
||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = lexicons::url(pds, &com_atproto_server::REFRESH_SESSION);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", session.refresh_jwt))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to refresh session")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Refresh failed: {} - {}. Try logging in again.", status, body);
|
||||
}
|
||||
|
||||
let new_session: CreateSessionResponse = res.json().await?;
|
||||
|
||||
let session = Session {
|
||||
did: new_session.did,
|
||||
handle: new_session.handle,
|
||||
access_jwt: new_session.access_jwt,
|
||||
refresh_jwt: new_session.refresh_jwt,
|
||||
pds: Some(pds.to_string()),
|
||||
};
|
||||
|
||||
token::save_session(&session)?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
192
src/commands/gen.rs
Normal file
192
src/commands/gen.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Lexicon {
|
||||
id: String,
|
||||
defs: BTreeMap<String, LexiconDef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LexiconDef {
|
||||
#[serde(rename = "type")]
|
||||
def_type: Option<String>,
|
||||
}
|
||||
|
||||
struct EndpointInfo {
|
||||
nsid: String,
|
||||
method: String, // GET or POST
|
||||
}
|
||||
|
||||
/// Generate lexicon Rust code from ATProto lexicon JSON files
|
||||
pub fn generate(input: &str, output: &str) -> Result<()> {
|
||||
let input_path = Path::new(input);
|
||||
|
||||
if !input_path.exists() {
|
||||
anyhow::bail!("Input directory does not exist: {}", input);
|
||||
}
|
||||
|
||||
println!("Scanning lexicons from: {}", input);
|
||||
|
||||
// Collect all endpoints grouped by namespace
|
||||
let mut namespaces: BTreeMap<String, Vec<EndpointInfo>> = BTreeMap::new();
|
||||
|
||||
// Scan com/atproto directory
|
||||
let atproto_path = input_path.join("com/atproto");
|
||||
if atproto_path.exists() {
|
||||
scan_namespace(&atproto_path, "com.atproto", &mut namespaces)?;
|
||||
}
|
||||
|
||||
// Scan app/bsky directory
|
||||
let bsky_path = input_path.join("app/bsky");
|
||||
if bsky_path.exists() {
|
||||
scan_namespace(&bsky_path, "app.bsky", &mut namespaces)?;
|
||||
}
|
||||
|
||||
// Generate Rust code
|
||||
let code = generate_rust_code(&namespaces);
|
||||
|
||||
// Write output
|
||||
let output_path = Path::new(output).join("mod.rs");
|
||||
fs::create_dir_all(output)?;
|
||||
fs::write(&output_path, &code)?;
|
||||
|
||||
println!("Generated: {}", output_path.display());
|
||||
println!("Total namespaces: {}", namespaces.len());
|
||||
let total_endpoints: usize = namespaces.values().map(|v| v.len()).sum();
|
||||
println!("Total endpoints: {}", total_endpoints);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scan_namespace(
|
||||
base_path: &Path,
|
||||
prefix: &str,
|
||||
namespaces: &mut BTreeMap<String, Vec<EndpointInfo>>,
|
||||
) -> Result<()> {
|
||||
for entry in fs::read_dir(base_path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
let ns_name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.context("Invalid directory name")?;
|
||||
|
||||
let full_ns = format!("{}.{}", prefix, ns_name);
|
||||
let mut endpoints = Vec::new();
|
||||
|
||||
// Scan JSON files in this namespace
|
||||
for file_entry in fs::read_dir(&path)? {
|
||||
let file_entry = file_entry?;
|
||||
let file_path = file_entry.path();
|
||||
|
||||
if file_path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
if let Some(endpoint) = parse_lexicon_file(&file_path)? {
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !endpoints.is_empty() {
|
||||
endpoints.sort_by(|a, b| a.nsid.cmp(&b.nsid));
|
||||
namespaces.insert(full_ns, endpoints);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_lexicon_file(path: &Path) -> Result<Option<EndpointInfo>> {
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read: {}", path.display()))?;
|
||||
|
||||
let lexicon: Lexicon = serde_json::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse: {}", path.display()))?;
|
||||
|
||||
// Get the main definition type
|
||||
let main_def = match lexicon.defs.get("main") {
|
||||
Some(def) => def,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let method = match main_def.def_type.as_deref() {
|
||||
Some("query") => "GET",
|
||||
Some("procedure") => "POST",
|
||||
Some("subscription") => return Ok(None), // Skip websocket subscriptions
|
||||
_ => return Ok(None), // Skip records, tokens, etc.
|
||||
};
|
||||
|
||||
Ok(Some(EndpointInfo {
|
||||
nsid: lexicon.id,
|
||||
method: method.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn generate_rust_code(namespaces: &BTreeMap<String, Vec<EndpointInfo>>) -> String {
|
||||
let mut code = String::new();
|
||||
|
||||
// Header
|
||||
code.push_str("//! Auto-generated from ATProto lexicons\n");
|
||||
code.push_str("//! Run `ailog gen` to regenerate\n");
|
||||
code.push_str("//! Do not edit manually\n\n");
|
||||
|
||||
// Endpoint struct
|
||||
code.push_str("#[derive(Debug, Clone, Copy)]\n");
|
||||
code.push_str("pub struct Endpoint {\n");
|
||||
code.push_str(" pub nsid: &'static str,\n");
|
||||
code.push_str(" pub method: &'static str,\n");
|
||||
code.push_str("}\n\n");
|
||||
|
||||
// URL helper function
|
||||
code.push_str("/// Build XRPC URL for an endpoint\n");
|
||||
code.push_str("pub fn url(pds: &str, endpoint: &Endpoint) -> String {\n");
|
||||
code.push_str(" format!(\"https://{}/xrpc/{}\", pds, endpoint.nsid)\n");
|
||||
code.push_str("}\n\n");
|
||||
|
||||
// Generate modules for each namespace
|
||||
for (ns, endpoints) in namespaces {
|
||||
// Convert namespace to module name: com.atproto.repo -> com_atproto_repo
|
||||
let mod_name = ns.replace('.', "_");
|
||||
|
||||
code.push_str(&format!("pub mod {} {{\n", mod_name));
|
||||
code.push_str(" use super::Endpoint;\n\n");
|
||||
|
||||
for endpoint in endpoints {
|
||||
// Extract the method name from NSID: com.atproto.repo.listRecords -> LIST_RECORDS
|
||||
let method_name = endpoint.nsid
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or(&endpoint.nsid);
|
||||
|
||||
// Convert camelCase to SCREAMING_SNAKE_CASE
|
||||
let const_name = to_screaming_snake_case(method_name);
|
||||
|
||||
code.push_str(&format!(
|
||||
" pub const {}: Endpoint = Endpoint {{ nsid: \"{}\", method: \"{}\" }};\n",
|
||||
const_name, endpoint.nsid, endpoint.method
|
||||
));
|
||||
}
|
||||
|
||||
code.push_str("}\n\n");
|
||||
}
|
||||
|
||||
code
|
||||
}
|
||||
|
||||
fn to_screaming_snake_case(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
|
||||
for (i, c) in s.chars().enumerate() {
|
||||
if c.is_uppercase() && i > 0 {
|
||||
result.push('_');
|
||||
}
|
||||
result.push(c.to_ascii_uppercase());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
4
src/commands/mod.rs
Normal file
4
src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod token;
|
||||
pub mod post;
|
||||
pub mod gen;
|
||||
342
src/commands/post.rs
Normal file
342
src/commands/post.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
use anyhow::{Context, Result};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
use super::auth;
|
||||
use crate::lexicons::{self, com_atproto_repo, com_atproto_identity};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PutRecordRequest {
|
||||
repo: String,
|
||||
collection: String,
|
||||
rkey: String,
|
||||
record: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DeleteRecordRequest {
|
||||
repo: String,
|
||||
collection: String,
|
||||
rkey: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PutRecordResponse {
|
||||
uri: String,
|
||||
cid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListRecordsResponse {
|
||||
records: Vec<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<()> {
|
||||
let session = auth::refresh_session().await?;
|
||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||
|
||||
let content = fs::read_to_string(file)
|
||||
.with_context(|| format!("Failed to read file: {}", file))?;
|
||||
let record: Value = serde_json::from_str(&content)?;
|
||||
|
||||
let rkey = rkey.map(|s| s.to_string()).unwrap_or_else(generate_tid);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD);
|
||||
|
||||
let req = PutRecordRequest {
|
||||
repo: session.did.clone(),
|
||||
collection: collection.to_string(),
|
||||
rkey: rkey.clone(),
|
||||
record,
|
||||
};
|
||||
|
||||
println!("Posting to {} with rkey: {}", collection, rkey);
|
||||
println!("{}", serde_json::to_string_pretty(&req)?);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", session.access_jwt))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Put record failed: {} - {}", status, body);
|
||||
}
|
||||
|
||||
let result: PutRecordResponse = res.json().await?;
|
||||
println!("Success!");
|
||||
println!(" URI: {}", result.uri);
|
||||
println!(" CID: {}", result.cid);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put a lexicon schema
|
||||
pub async fn put_lexicon(file: &str) -> Result<()> {
|
||||
let session = auth::refresh_session().await?;
|
||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||
|
||||
let content = fs::read_to_string(file)
|
||||
.with_context(|| format!("Failed to read file: {}", file))?;
|
||||
let lexicon: Value = serde_json::from_str(&content)?;
|
||||
|
||||
let lexicon_id = lexicon["id"]
|
||||
.as_str()
|
||||
.context("Lexicon file must have 'id' field")?
|
||||
.to_string();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD);
|
||||
|
||||
let req = PutRecordRequest {
|
||||
repo: session.did.clone(),
|
||||
collection: "com.atproto.lexicon.schema".to_string(),
|
||||
rkey: lexicon_id.clone(),
|
||||
record: lexicon,
|
||||
};
|
||||
|
||||
println!("Putting lexicon: {}", lexicon_id);
|
||||
println!("{}", serde_json::to_string_pretty(&req)?);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", session.access_jwt))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Put lexicon failed: {} - {}", status, body);
|
||||
}
|
||||
|
||||
let result: PutRecordResponse = res.json().await?;
|
||||
println!("Success!");
|
||||
println!(" URI: {}", result.uri);
|
||||
println!(" CID: {}", result.cid);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get records from a collection
|
||||
pub async fn get_records(collection: &str, limit: u32) -> Result<()> {
|
||||
let session = auth::refresh_session().await?;
|
||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let base_url = lexicons::url(pds, &com_atproto_repo::LIST_RECORDS);
|
||||
let url = format!(
|
||||
"{}?repo={}&collection={}&limit={}",
|
||||
base_url, session.did, collection, limit
|
||||
);
|
||||
|
||||
let res = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", session.access_jwt))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Get records failed: {} - {}", status, body);
|
||||
}
|
||||
|
||||
let result: ListRecordsResponse = res.json().await?;
|
||||
|
||||
println!("Found {} records in {}", result.records.len(), collection);
|
||||
for record in &result.records {
|
||||
println!("---");
|
||||
println!("URI: {}", record.uri);
|
||||
println!("CID: {}", record.cid);
|
||||
println!("{}", serde_json::to_string_pretty(&record.value)?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a record
|
||||
pub async fn delete_record(collection: &str, rkey: &str) -> Result<()> {
|
||||
let session = auth::refresh_session().await?;
|
||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = lexicons::url(pds, &com_atproto_repo::DELETE_RECORD);
|
||||
|
||||
let req = DeleteRecordRequest {
|
||||
repo: session.did.clone(),
|
||||
collection: collection.to_string(),
|
||||
rkey: rkey.to_string(),
|
||||
};
|
||||
|
||||
println!("Deleting {} from {}", rkey, collection);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", session.access_jwt))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Delete failed: {} - {}", status, body);
|
||||
}
|
||||
|
||||
println!("Deleted successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Config {
|
||||
handle: String,
|
||||
#[serde(default)]
|
||||
collection: Option<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<()> {
|
||||
let config_content = fs::read_to_string("config.json")
|
||||
.context("config.json not found")?;
|
||||
let config: Config = serde_json::from_str(&config_content)?;
|
||||
|
||||
println!("Syncing data for {}", config.handle);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Resolve handle to DID
|
||||
let resolve_url = format!(
|
||||
"{}?handle={}",
|
||||
lexicons::url("public.api.bsky.app", &com_atproto_identity::RESOLVE_HANDLE),
|
||||
config.handle
|
||||
);
|
||||
let res = client.get(&resolve_url).send().await?;
|
||||
let resolve: serde_json::Value = res.json().await?;
|
||||
let did = resolve["did"].as_str().context("Could not resolve handle")?;
|
||||
|
||||
println!("DID: {}", did);
|
||||
|
||||
// Get PDS from DID document
|
||||
let plc_url = format!("https://plc.directory/{}", did);
|
||||
let res = client.get(&plc_url).send().await?;
|
||||
let did_doc: serde_json::Value = res.json().await?;
|
||||
let pds = did_doc["service"]
|
||||
.as_array()
|
||||
.and_then(|services| {
|
||||
services.iter().find(|s| s["type"] == "AtprotoPersonalDataServer")
|
||||
})
|
||||
.and_then(|s| s["serviceEndpoint"].as_str())
|
||||
.context("Could not find PDS")?;
|
||||
|
||||
println!("PDS: {}", pds);
|
||||
|
||||
// Remove https:// prefix for lexicons::url
|
||||
let pds_host = pds.trim_start_matches("https://");
|
||||
|
||||
// Create output directory
|
||||
let did_dir = format!("{}/{}", output, did);
|
||||
fs::create_dir_all(&did_dir)?;
|
||||
|
||||
// 1. Sync describeRepo
|
||||
let describe_url = format!(
|
||||
"{}?repo={}",
|
||||
lexicons::url(pds_host, &com_atproto_repo::DESCRIBE_REPO),
|
||||
did
|
||||
);
|
||||
let res = client.get(&describe_url).send().await?;
|
||||
let describe: DescribeRepoResponse = res.json().await?;
|
||||
|
||||
let describe_path = format!("{}/describe.json", did_dir);
|
||||
let describe_json = serde_json::to_string_pretty(&serde_json::json!({
|
||||
"did": describe.did,
|
||||
"handle": describe.handle,
|
||||
"collections": describe.collections,
|
||||
}))?;
|
||||
fs::write(&describe_path, &describe_json)?;
|
||||
println!("Saved: {}", describe_path);
|
||||
|
||||
// 2. Sync profile
|
||||
let profile_url = format!(
|
||||
"{}?repo={}&collection=app.bsky.actor.profile&rkey=self",
|
||||
lexicons::url(pds_host, &com_atproto_repo::GET_RECORD),
|
||||
did
|
||||
);
|
||||
let res = client.get(&profile_url).send().await?;
|
||||
if res.status().is_success() {
|
||||
let profile: serde_json::Value = res.json().await?;
|
||||
let profile_dir = format!("{}/app.bsky.actor.profile", did_dir);
|
||||
fs::create_dir_all(&profile_dir)?;
|
||||
let profile_path = format!("{}/self.json", profile_dir);
|
||||
fs::write(&profile_path, serde_json::to_string_pretty(&profile)?)?;
|
||||
println!("Saved: {}", profile_path);
|
||||
}
|
||||
|
||||
// 3. Sync collection records
|
||||
let collection = config.collection.as_deref().unwrap_or("ai.syui.log.post");
|
||||
let records_url = format!(
|
||||
"{}?repo={}&collection={}&limit=100",
|
||||
lexicons::url(pds_host, &com_atproto_repo::LIST_RECORDS),
|
||||
did, collection
|
||||
);
|
||||
let res = client.get(&records_url).send().await?;
|
||||
if res.status().is_success() {
|
||||
let list: ListRecordsResponse = res.json().await?;
|
||||
let collection_dir = format!("{}/{}", did_dir, collection);
|
||||
fs::create_dir_all(&collection_dir)?;
|
||||
|
||||
for record in &list.records {
|
||||
let rkey = record.uri.split('/').last().unwrap_or("unknown");
|
||||
let record_path = format!("{}/{}.json", collection_dir, rkey);
|
||||
let record_json = serde_json::json!({
|
||||
"uri": record.uri,
|
||||
"cid": record.cid,
|
||||
"value": record.value,
|
||||
});
|
||||
fs::write(&record_path, serde_json::to_string_pretty(&record_json)?)?;
|
||||
println!("Saved: {}", record_path);
|
||||
}
|
||||
println!("Synced {} records from {}", list.records.len(), collection);
|
||||
}
|
||||
|
||||
println!("Sync complete!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
46
src/commands/token.rs
Normal file
46
src/commands/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(())
|
||||
}
|
||||
301
src/lexicons/mod.rs
Normal file
301
src/lexicons/mod.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
//! Auto-generated from ATProto lexicons
|
||||
//! Run `ailog gen` to regenerate
|
||||
//! Do not edit manually
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Endpoint {
|
||||
pub nsid: &'static str,
|
||||
pub method: &'static str,
|
||||
}
|
||||
|
||||
/// Build XRPC URL for an endpoint
|
||||
pub fn url(pds: &str, endpoint: &Endpoint) -> String {
|
||||
format!("https://{}/xrpc/{}", pds, endpoint.nsid)
|
||||
}
|
||||
|
||||
pub mod app_bsky_actor {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const GET_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.actor.getPreferences", method: "GET" };
|
||||
pub const GET_PROFILE: Endpoint = Endpoint { nsid: "app.bsky.actor.getProfile", method: "GET" };
|
||||
pub const GET_PROFILES: Endpoint = Endpoint { nsid: "app.bsky.actor.getProfiles", method: "GET" };
|
||||
pub const GET_SUGGESTIONS: Endpoint = Endpoint { nsid: "app.bsky.actor.getSuggestions", method: "GET" };
|
||||
pub const PUT_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.actor.putPreferences", method: "POST" };
|
||||
pub const SEARCH_ACTORS: Endpoint = Endpoint { nsid: "app.bsky.actor.searchActors", method: "GET" };
|
||||
pub const SEARCH_ACTORS_TYPEAHEAD: Endpoint = Endpoint { nsid: "app.bsky.actor.searchActorsTypeahead", method: "GET" };
|
||||
}
|
||||
|
||||
pub mod app_bsky_ageassurance {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const BEGIN: Endpoint = Endpoint { nsid: "app.bsky.ageassurance.begin", method: "POST" };
|
||||
pub const GET_CONFIG: Endpoint = Endpoint { nsid: "app.bsky.ageassurance.getConfig", method: "GET" };
|
||||
pub const GET_STATE: Endpoint = Endpoint { nsid: "app.bsky.ageassurance.getState", method: "GET" };
|
||||
}
|
||||
|
||||
pub mod app_bsky_bookmark {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const CREATE_BOOKMARK: Endpoint = Endpoint { nsid: "app.bsky.bookmark.createBookmark", method: "POST" };
|
||||
pub const DELETE_BOOKMARK: Endpoint = Endpoint { nsid: "app.bsky.bookmark.deleteBookmark", method: "POST" };
|
||||
pub const GET_BOOKMARKS: Endpoint = Endpoint { nsid: "app.bsky.bookmark.getBookmarks", method: "GET" };
|
||||
}
|
||||
|
||||
pub mod app_bsky_contact {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const DISMISS_MATCH: Endpoint = Endpoint { nsid: "app.bsky.contact.dismissMatch", method: "POST" };
|
||||
pub const GET_MATCHES: Endpoint = Endpoint { nsid: "app.bsky.contact.getMatches", method: "GET" };
|
||||
pub const GET_SYNC_STATUS: Endpoint = Endpoint { nsid: "app.bsky.contact.getSyncStatus", method: "GET" };
|
||||
pub const IMPORT_CONTACTS: Endpoint = Endpoint { nsid: "app.bsky.contact.importContacts", method: "POST" };
|
||||
pub const REMOVE_DATA: Endpoint = Endpoint { nsid: "app.bsky.contact.removeData", method: "POST" };
|
||||
pub const SEND_NOTIFICATION: Endpoint = Endpoint { nsid: "app.bsky.contact.sendNotification", method: "POST" };
|
||||
pub const START_PHONE_VERIFICATION: Endpoint = Endpoint { nsid: "app.bsky.contact.startPhoneVerification", method: "POST" };
|
||||
pub const VERIFY_PHONE: Endpoint = Endpoint { nsid: "app.bsky.contact.verifyPhone", method: "POST" };
|
||||
}
|
||||
|
||||
pub mod app_bsky_draft {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const CREATE_DRAFT: Endpoint = Endpoint { nsid: "app.bsky.draft.createDraft", method: "POST" };
|
||||
pub const DELETE_DRAFT: Endpoint = Endpoint { nsid: "app.bsky.draft.deleteDraft", method: "POST" };
|
||||
pub const GET_DRAFTS: Endpoint = Endpoint { nsid: "app.bsky.draft.getDrafts", method: "GET" };
|
||||
pub const UPDATE_DRAFT: Endpoint = Endpoint { nsid: "app.bsky.draft.updateDraft", method: "POST" };
|
||||
}
|
||||
|
||||
pub mod app_bsky_feed {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const DESCRIBE_FEED_GENERATOR: Endpoint = Endpoint { nsid: "app.bsky.feed.describeFeedGenerator", method: "GET" };
|
||||
pub const GET_ACTOR_FEEDS: Endpoint = Endpoint { nsid: "app.bsky.feed.getActorFeeds", method: "GET" };
|
||||
pub const GET_ACTOR_LIKES: Endpoint = Endpoint { nsid: "app.bsky.feed.getActorLikes", method: "GET" };
|
||||
pub const GET_AUTHOR_FEED: Endpoint = Endpoint { nsid: "app.bsky.feed.getAuthorFeed", method: "GET" };
|
||||
pub const GET_FEED: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeed", method: "GET" };
|
||||
pub const GET_FEED_GENERATOR: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeedGenerator", method: "GET" };
|
||||
pub const GET_FEED_GENERATORS: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeedGenerators", method: "GET" };
|
||||
pub const GET_FEED_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeedSkeleton", method: "GET" };
|
||||
pub const GET_LIKES: Endpoint = Endpoint { nsid: "app.bsky.feed.getLikes", method: "GET" };
|
||||
pub const GET_LIST_FEED: Endpoint = Endpoint { nsid: "app.bsky.feed.getListFeed", method: "GET" };
|
||||
pub const GET_POST_THREAD: Endpoint = Endpoint { nsid: "app.bsky.feed.getPostThread", method: "GET" };
|
||||
pub const GET_POSTS: Endpoint = Endpoint { nsid: "app.bsky.feed.getPosts", method: "GET" };
|
||||
pub const GET_QUOTES: Endpoint = Endpoint { nsid: "app.bsky.feed.getQuotes", method: "GET" };
|
||||
pub const GET_REPOSTED_BY: Endpoint = Endpoint { nsid: "app.bsky.feed.getRepostedBy", method: "GET" };
|
||||
pub const GET_SUGGESTED_FEEDS: Endpoint = Endpoint { nsid: "app.bsky.feed.getSuggestedFeeds", method: "GET" };
|
||||
pub const GET_TIMELINE: Endpoint = Endpoint { nsid: "app.bsky.feed.getTimeline", method: "GET" };
|
||||
pub const SEARCH_POSTS: Endpoint = Endpoint { nsid: "app.bsky.feed.searchPosts", method: "GET" };
|
||||
pub const SEND_INTERACTIONS: Endpoint = Endpoint { nsid: "app.bsky.feed.sendInteractions", method: "POST" };
|
||||
}
|
||||
|
||||
pub mod app_bsky_graph {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const GET_ACTOR_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getActorStarterPacks", method: "GET" };
|
||||
pub const GET_BLOCKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getBlocks", method: "GET" };
|
||||
pub const GET_FOLLOWERS: Endpoint = Endpoint { nsid: "app.bsky.graph.getFollowers", method: "GET" };
|
||||
pub const GET_FOLLOWS: Endpoint = Endpoint { nsid: "app.bsky.graph.getFollows", method: "GET" };
|
||||
pub const GET_KNOWN_FOLLOWERS: Endpoint = Endpoint { nsid: "app.bsky.graph.getKnownFollowers", method: "GET" };
|
||||
pub const GET_LIST: Endpoint = Endpoint { nsid: "app.bsky.graph.getList", method: "GET" };
|
||||
pub const GET_LIST_BLOCKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getListBlocks", method: "GET" };
|
||||
pub const GET_LIST_MUTES: Endpoint = Endpoint { nsid: "app.bsky.graph.getListMutes", method: "GET" };
|
||||
pub const GET_LISTS: Endpoint = Endpoint { nsid: "app.bsky.graph.getLists", method: "GET" };
|
||||
pub const GET_LISTS_WITH_MEMBERSHIP: Endpoint = Endpoint { nsid: "app.bsky.graph.getListsWithMembership", method: "GET" };
|
||||
pub const GET_MUTES: Endpoint = Endpoint { nsid: "app.bsky.graph.getMutes", method: "GET" };
|
||||
pub const GET_RELATIONSHIPS: Endpoint = Endpoint { nsid: "app.bsky.graph.getRelationships", method: "GET" };
|
||||
pub const GET_STARTER_PACK: Endpoint = Endpoint { nsid: "app.bsky.graph.getStarterPack", method: "GET" };
|
||||
pub const GET_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getStarterPacks", method: "GET" };
|
||||
pub const GET_STARTER_PACKS_WITH_MEMBERSHIP: Endpoint = Endpoint { nsid: "app.bsky.graph.getStarterPacksWithMembership", method: "GET" };
|
||||
pub const GET_SUGGESTED_FOLLOWS_BY_ACTOR: Endpoint = Endpoint { nsid: "app.bsky.graph.getSuggestedFollowsByActor", method: "GET" };
|
||||
pub const MUTE_ACTOR: Endpoint = Endpoint { nsid: "app.bsky.graph.muteActor", method: "POST" };
|
||||
pub const MUTE_ACTOR_LIST: Endpoint = Endpoint { nsid: "app.bsky.graph.muteActorList", method: "POST" };
|
||||
pub const MUTE_THREAD: Endpoint = Endpoint { nsid: "app.bsky.graph.muteThread", method: "POST" };
|
||||
pub const SEARCH_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.graph.searchStarterPacks", method: "GET" };
|
||||
pub const UNMUTE_ACTOR: Endpoint = Endpoint { nsid: "app.bsky.graph.unmuteActor", method: "POST" };
|
||||
pub const UNMUTE_ACTOR_LIST: Endpoint = Endpoint { nsid: "app.bsky.graph.unmuteActorList", method: "POST" };
|
||||
pub const UNMUTE_THREAD: Endpoint = Endpoint { nsid: "app.bsky.graph.unmuteThread", method: "POST" };
|
||||
}
|
||||
|
||||
pub mod app_bsky_labeler {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const GET_SERVICES: Endpoint = Endpoint { nsid: "app.bsky.labeler.getServices", method: "GET" };
|
||||
}
|
||||
|
||||
pub mod app_bsky_notification {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const GET_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.notification.getPreferences", method: "GET" };
|
||||
pub const GET_UNREAD_COUNT: Endpoint = Endpoint { nsid: "app.bsky.notification.getUnreadCount", method: "GET" };
|
||||
pub const LIST_ACTIVITY_SUBSCRIPTIONS: Endpoint = Endpoint { nsid: "app.bsky.notification.listActivitySubscriptions", method: "GET" };
|
||||
pub const LIST_NOTIFICATIONS: Endpoint = Endpoint { nsid: "app.bsky.notification.listNotifications", method: "GET" };
|
||||
pub const PUT_ACTIVITY_SUBSCRIPTION: Endpoint = Endpoint { nsid: "app.bsky.notification.putActivitySubscription", method: "POST" };
|
||||
pub const PUT_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.notification.putPreferences", method: "POST" };
|
||||
pub const PUT_PREFERENCES_V2: Endpoint = Endpoint { nsid: "app.bsky.notification.putPreferencesV2", method: "POST" };
|
||||
pub const REGISTER_PUSH: Endpoint = Endpoint { nsid: "app.bsky.notification.registerPush", method: "POST" };
|
||||
pub const UNREGISTER_PUSH: Endpoint = Endpoint { nsid: "app.bsky.notification.unregisterPush", method: "POST" };
|
||||
pub const UPDATE_SEEN: Endpoint = Endpoint { nsid: "app.bsky.notification.updateSeen", method: "POST" };
|
||||
}
|
||||
|
||||
pub mod app_bsky_unspecced {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const GET_AGE_ASSURANCE_STATE: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getAgeAssuranceState", method: "GET" };
|
||||
pub const GET_CONFIG: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getConfig", method: "GET" };
|
||||
pub const GET_ONBOARDING_SUGGESTED_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getOnboardingSuggestedStarterPacks", method: "GET" };
|
||||
pub const GET_ONBOARDING_SUGGESTED_STARTER_PACKS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton", method: "GET" };
|
||||
pub const GET_POPULAR_FEED_GENERATORS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getPopularFeedGenerators", method: "GET" };
|
||||
pub const GET_POST_THREAD_OTHER_V2: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getPostThreadOtherV2", method: "GET" };
|
||||
pub const GET_POST_THREAD_V2: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getPostThreadV2", method: "GET" };
|
||||
pub const GET_SUGGESTED_FEEDS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedFeeds", method: "GET" };
|
||||
pub const GET_SUGGESTED_FEEDS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedFeedsSkeleton", method: "GET" };
|
||||
pub const GET_SUGGESTED_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedStarterPacks", method: "GET" };
|
||||
pub const GET_SUGGESTED_STARTER_PACKS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedStarterPacksSkeleton", method: "GET" };
|
||||
pub const GET_SUGGESTED_USERS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedUsers", method: "GET" };
|
||||
pub const GET_SUGGESTED_USERS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedUsersSkeleton", method: "GET" };
|
||||
pub const GET_SUGGESTIONS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestionsSkeleton", method: "GET" };
|
||||
pub const GET_TAGGED_SUGGESTIONS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTaggedSuggestions", method: "GET" };
|
||||
pub const GET_TRENDING_TOPICS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTrendingTopics", method: "GET" };
|
||||
pub const GET_TRENDS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTrends", method: "GET" };
|
||||
pub const GET_TRENDS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTrendsSkeleton", method: "GET" };
|
||||
pub const INIT_AGE_ASSURANCE: Endpoint = Endpoint { nsid: "app.bsky.unspecced.initAgeAssurance", method: "POST" };
|
||||
pub const SEARCH_ACTORS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.searchActorsSkeleton", method: "GET" };
|
||||
pub const SEARCH_POSTS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.searchPostsSkeleton", method: "GET" };
|
||||
pub const SEARCH_STARTER_PACKS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.searchStarterPacksSkeleton", method: "GET" };
|
||||
}
|
||||
|
||||
pub mod app_bsky_video {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const GET_JOB_STATUS: Endpoint = Endpoint { nsid: "app.bsky.video.getJobStatus", method: "GET" };
|
||||
pub const GET_UPLOAD_LIMITS: Endpoint = Endpoint { nsid: "app.bsky.video.getUploadLimits", method: "GET" };
|
||||
pub const UPLOAD_VIDEO: Endpoint = Endpoint { nsid: "app.bsky.video.uploadVideo", method: "POST" };
|
||||
}
|
||||
|
||||
pub mod com_atproto_admin {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const DELETE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.admin.deleteAccount", method: "POST" };
|
||||
pub const DISABLE_ACCOUNT_INVITES: Endpoint = Endpoint { nsid: "com.atproto.admin.disableAccountInvites", method: "POST" };
|
||||
pub const DISABLE_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.admin.disableInviteCodes", method: "POST" };
|
||||
pub const ENABLE_ACCOUNT_INVITES: Endpoint = Endpoint { nsid: "com.atproto.admin.enableAccountInvites", method: "POST" };
|
||||
pub const GET_ACCOUNT_INFO: Endpoint = Endpoint { nsid: "com.atproto.admin.getAccountInfo", method: "GET" };
|
||||
pub const GET_ACCOUNT_INFOS: Endpoint = Endpoint { nsid: "com.atproto.admin.getAccountInfos", method: "GET" };
|
||||
pub const GET_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.admin.getInviteCodes", method: "GET" };
|
||||
pub const GET_SUBJECT_STATUS: Endpoint = Endpoint { nsid: "com.atproto.admin.getSubjectStatus", method: "GET" };
|
||||
pub const SEARCH_ACCOUNTS: Endpoint = Endpoint { nsid: "com.atproto.admin.searchAccounts", method: "GET" };
|
||||
pub const SEND_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.admin.sendEmail", method: "POST" };
|
||||
pub const UPDATE_ACCOUNT_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountEmail", method: "POST" };
|
||||
pub const UPDATE_ACCOUNT_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountHandle", method: "POST" };
|
||||
pub const UPDATE_ACCOUNT_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountPassword", method: "POST" };
|
||||
pub const UPDATE_ACCOUNT_SIGNING_KEY: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountSigningKey", method: "POST" };
|
||||
pub const UPDATE_SUBJECT_STATUS: Endpoint = Endpoint { nsid: "com.atproto.admin.updateSubjectStatus", method: "POST" };
|
||||
}
|
||||
|
||||
pub mod com_atproto_identity {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const GET_RECOMMENDED_DID_CREDENTIALS: Endpoint = Endpoint { nsid: "com.atproto.identity.getRecommendedDidCredentials", method: "GET" };
|
||||
pub const REFRESH_IDENTITY: Endpoint = Endpoint { nsid: "com.atproto.identity.refreshIdentity", method: "POST" };
|
||||
pub const REQUEST_PLC_OPERATION_SIGNATURE: Endpoint = Endpoint { nsid: "com.atproto.identity.requestPlcOperationSignature", method: "POST" };
|
||||
pub const RESOLVE_DID: Endpoint = Endpoint { nsid: "com.atproto.identity.resolveDid", method: "GET" };
|
||||
pub const RESOLVE_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.identity.resolveHandle", method: "GET" };
|
||||
pub const RESOLVE_IDENTITY: Endpoint = Endpoint { nsid: "com.atproto.identity.resolveIdentity", method: "GET" };
|
||||
pub const SIGN_PLC_OPERATION: Endpoint = Endpoint { nsid: "com.atproto.identity.signPlcOperation", method: "POST" };
|
||||
pub const SUBMIT_PLC_OPERATION: Endpoint = Endpoint { nsid: "com.atproto.identity.submitPlcOperation", method: "POST" };
|
||||
pub const UPDATE_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.identity.updateHandle", method: "POST" };
|
||||
}
|
||||
|
||||
pub mod com_atproto_label {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const QUERY_LABELS: Endpoint = Endpoint { nsid: "com.atproto.label.queryLabels", method: "GET" };
|
||||
}
|
||||
|
||||
pub mod com_atproto_lexicon {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const RESOLVE_LEXICON: Endpoint = Endpoint { nsid: "com.atproto.lexicon.resolveLexicon", method: "GET" };
|
||||
}
|
||||
|
||||
pub mod com_atproto_moderation {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const CREATE_REPORT: Endpoint = Endpoint { nsid: "com.atproto.moderation.createReport", method: "POST" };
|
||||
}
|
||||
|
||||
pub mod com_atproto_repo {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const APPLY_WRITES: Endpoint = Endpoint { nsid: "com.atproto.repo.applyWrites", method: "POST" };
|
||||
pub const CREATE_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.createRecord", method: "POST" };
|
||||
pub const DELETE_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.deleteRecord", method: "POST" };
|
||||
pub const DESCRIBE_REPO: Endpoint = Endpoint { nsid: "com.atproto.repo.describeRepo", method: "GET" };
|
||||
pub const GET_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.getRecord", method: "GET" };
|
||||
pub const IMPORT_REPO: Endpoint = Endpoint { nsid: "com.atproto.repo.importRepo", method: "POST" };
|
||||
pub const LIST_MISSING_BLOBS: Endpoint = Endpoint { nsid: "com.atproto.repo.listMissingBlobs", method: "GET" };
|
||||
pub const LIST_RECORDS: Endpoint = Endpoint { nsid: "com.atproto.repo.listRecords", method: "GET" };
|
||||
pub const PUT_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.putRecord", method: "POST" };
|
||||
pub const UPLOAD_BLOB: Endpoint = Endpoint { nsid: "com.atproto.repo.uploadBlob", method: "POST" };
|
||||
}
|
||||
|
||||
pub mod com_atproto_server {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const ACTIVATE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.activateAccount", method: "POST" };
|
||||
pub const CHECK_ACCOUNT_STATUS: Endpoint = Endpoint { nsid: "com.atproto.server.checkAccountStatus", method: "GET" };
|
||||
pub const CONFIRM_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.server.confirmEmail", method: "POST" };
|
||||
pub const CREATE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.createAccount", method: "POST" };
|
||||
pub const CREATE_APP_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.server.createAppPassword", method: "POST" };
|
||||
pub const CREATE_INVITE_CODE: Endpoint = Endpoint { nsid: "com.atproto.server.createInviteCode", method: "POST" };
|
||||
pub const CREATE_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.server.createInviteCodes", method: "POST" };
|
||||
pub const CREATE_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.createSession", method: "POST" };
|
||||
pub const DEACTIVATE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.deactivateAccount", method: "POST" };
|
||||
pub const DELETE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.deleteAccount", method: "POST" };
|
||||
pub const DELETE_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.deleteSession", method: "POST" };
|
||||
pub const DESCRIBE_SERVER: Endpoint = Endpoint { nsid: "com.atproto.server.describeServer", method: "GET" };
|
||||
pub const GET_ACCOUNT_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.server.getAccountInviteCodes", method: "GET" };
|
||||
pub const GET_SERVICE_AUTH: Endpoint = Endpoint { nsid: "com.atproto.server.getServiceAuth", method: "GET" };
|
||||
pub const GET_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.getSession", method: "GET" };
|
||||
pub const LIST_APP_PASSWORDS: Endpoint = Endpoint { nsid: "com.atproto.server.listAppPasswords", method: "GET" };
|
||||
pub const REFRESH_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.refreshSession", method: "POST" };
|
||||
pub const REQUEST_ACCOUNT_DELETE: Endpoint = Endpoint { nsid: "com.atproto.server.requestAccountDelete", method: "POST" };
|
||||
pub const REQUEST_EMAIL_CONFIRMATION: Endpoint = Endpoint { nsid: "com.atproto.server.requestEmailConfirmation", method: "POST" };
|
||||
pub const REQUEST_EMAIL_UPDATE: Endpoint = Endpoint { nsid: "com.atproto.server.requestEmailUpdate", method: "POST" };
|
||||
pub const REQUEST_PASSWORD_RESET: Endpoint = Endpoint { nsid: "com.atproto.server.requestPasswordReset", method: "POST" };
|
||||
pub const RESERVE_SIGNING_KEY: Endpoint = Endpoint { nsid: "com.atproto.server.reserveSigningKey", method: "POST" };
|
||||
pub const RESET_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.server.resetPassword", method: "POST" };
|
||||
pub const REVOKE_APP_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.server.revokeAppPassword", method: "POST" };
|
||||
pub const UPDATE_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.server.updateEmail", method: "POST" };
|
||||
}
|
||||
|
||||
pub mod com_atproto_sync {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const GET_BLOB: Endpoint = Endpoint { nsid: "com.atproto.sync.getBlob", method: "GET" };
|
||||
pub const GET_BLOCKS: Endpoint = Endpoint { nsid: "com.atproto.sync.getBlocks", method: "GET" };
|
||||
pub const GET_CHECKOUT: Endpoint = Endpoint { nsid: "com.atproto.sync.getCheckout", method: "GET" };
|
||||
pub const GET_HEAD: Endpoint = Endpoint { nsid: "com.atproto.sync.getHead", method: "GET" };
|
||||
pub const GET_HOST_STATUS: Endpoint = Endpoint { nsid: "com.atproto.sync.getHostStatus", method: "GET" };
|
||||
pub const GET_LATEST_COMMIT: Endpoint = Endpoint { nsid: "com.atproto.sync.getLatestCommit", method: "GET" };
|
||||
pub const GET_RECORD: Endpoint = Endpoint { nsid: "com.atproto.sync.getRecord", method: "GET" };
|
||||
pub const GET_REPO: Endpoint = Endpoint { nsid: "com.atproto.sync.getRepo", method: "GET" };
|
||||
pub const GET_REPO_STATUS: Endpoint = Endpoint { nsid: "com.atproto.sync.getRepoStatus", method: "GET" };
|
||||
pub const LIST_BLOBS: Endpoint = Endpoint { nsid: "com.atproto.sync.listBlobs", method: "GET" };
|
||||
pub const LIST_HOSTS: Endpoint = Endpoint { nsid: "com.atproto.sync.listHosts", method: "GET" };
|
||||
pub const LIST_REPOS: Endpoint = Endpoint { nsid: "com.atproto.sync.listRepos", method: "GET" };
|
||||
pub const LIST_REPOS_BY_COLLECTION: Endpoint = Endpoint { nsid: "com.atproto.sync.listReposByCollection", method: "GET" };
|
||||
pub const NOTIFY_OF_UPDATE: Endpoint = Endpoint { nsid: "com.atproto.sync.notifyOfUpdate", method: "POST" };
|
||||
pub const REQUEST_CRAWL: Endpoint = Endpoint { nsid: "com.atproto.sync.requestCrawl", method: "POST" };
|
||||
}
|
||||
|
||||
pub mod com_atproto_temp {
|
||||
use super::Endpoint;
|
||||
|
||||
pub const ADD_RESERVED_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.temp.addReservedHandle", method: "POST" };
|
||||
pub const CHECK_HANDLE_AVAILABILITY: Endpoint = Endpoint { nsid: "com.atproto.temp.checkHandleAvailability", method: "GET" };
|
||||
pub const CHECK_SIGNUP_QUEUE: Endpoint = Endpoint { nsid: "com.atproto.temp.checkSignupQueue", method: "GET" };
|
||||
pub const DEREFERENCE_SCOPE: Endpoint = Endpoint { nsid: "com.atproto.temp.dereferenceScope", method: "GET" };
|
||||
pub const FETCH_LABELS: Endpoint = Endpoint { nsid: "com.atproto.temp.fetchLabels", method: "GET" };
|
||||
pub const REQUEST_PHONE_VERIFICATION: Endpoint = Endpoint { nsid: "com.atproto.temp.requestPhoneVerification", method: "POST" };
|
||||
pub const REVOKE_ACCOUNT_CREDENTIALS: Endpoint = Endpoint { nsid: "com.atproto.temp.revokeAccountCredentials", method: "POST" };
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
119
src/main.rs
Normal file
119
src/main.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
mod commands;
|
||||
mod lexicons;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ailog")]
|
||||
#[command(about = "ATProto blog CLI")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Login to ATProto PDS
|
||||
#[command(alias = "l")]
|
||||
Login {
|
||||
/// Handle (e.g., user.bsky.social)
|
||||
handle: String,
|
||||
/// Password
|
||||
#[arg(short, long)]
|
||||
password: String,
|
||||
/// PDS server
|
||||
#[arg(short, long, default_value = "bsky.social")]
|
||||
server: String,
|
||||
},
|
||||
|
||||
/// Update lexicon schema
|
||||
Lexicon {
|
||||
/// Lexicon JSON file
|
||||
file: String,
|
||||
},
|
||||
|
||||
/// Post a record
|
||||
#[command(alias = "p")]
|
||||
Post {
|
||||
/// Record JSON file
|
||||
file: String,
|
||||
/// Collection (e.g., ai.syui.log.post)
|
||||
#[arg(short, long)]
|
||||
collection: String,
|
||||
/// Record key (auto-generated if not provided)
|
||||
#[arg(short, long)]
|
||||
rkey: Option<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,
|
||||
},
|
||||
|
||||
/// Generate lexicon Rust code from ATProto lexicon JSON files
|
||||
Gen {
|
||||
/// Input directory containing lexicon JSON files
|
||||
#[arg(short, long, default_value = "./repos/atproto/lexicons")]
|
||||
input: String,
|
||||
/// Output directory for generated Rust code
|
||||
#[arg(short, long, default_value = "./src/lexicons")]
|
||||
output: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Login { handle, password, server } => {
|
||||
commands::auth::login(&handle, &password, &server).await?;
|
||||
}
|
||||
Commands::Lexicon { file } => {
|
||||
commands::post::put_lexicon(&file).await?;
|
||||
}
|
||||
Commands::Post { file, collection, rkey } => {
|
||||
commands::post::put_record(&file, &collection, rkey.as_deref()).await?;
|
||||
}
|
||||
Commands::Get { collection, limit } => {
|
||||
commands::post::get_records(&collection, limit).await?;
|
||||
}
|
||||
Commands::Delete { collection, rkey } => {
|
||||
commands::post::delete_record(&collection, &rkey).await?;
|
||||
}
|
||||
Commands::Sync { output } => {
|
||||
commands::post::sync_to_local(&output).await?;
|
||||
}
|
||||
Commands::Gen { input, output } => {
|
||||
commands::gen::generate(&input, &output)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user