A terminal-only Bluesky / AT Protocol client written in Fortran, with a asm/Rust native firehose decoder for the relay-raw stream. DM slide support. Dither image composer. Yes, that Fortran www.patreon.com/FormerLab
rust atproto fun fortran assembly
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fortransky v1.1 — Fortran Bluesky client with native Rust firehose decoder. Fresh dump

FormerLab af57f931

+4975
+8
.gitignore
··· 1 + build/ 2 + .venv/ 3 + bridge/firehose-bridge/target/ 4 + __pycache__/ 5 + *.o 6 + *.mod 7 + *.out 8 + ~/.fortransky/
+59
CMakeLists.txt
··· 1 + cmake_minimum_required(VERSION 3.16) 2 + project(fortransky LANGUAGES C Fortran) 3 + 4 + set(CMAKE_Fortran_STANDARD 2008) 5 + set(CMAKE_Fortran_STANDARD_REQUIRED ON) 6 + 7 + find_package(PkgConfig REQUIRED) 8 + pkg_check_modules(LIBCURL REQUIRED libcurl) 9 + 10 + # Rust firehose bridge static library 11 + set(FIREHOSE_BRIDGE_DIR "${CMAKE_SOURCE_DIR}/bridge/firehose-bridge") 12 + set(FIREHOSE_BRIDGE_LIB "${FIREHOSE_BRIDGE_DIR}/target/release/libfortransky_firehose_bridge.a") 13 + 14 + add_custom_target(firehose_bridge_rust 15 + COMMAND cargo build --release 16 + WORKING_DIRECTORY "${FIREHOSE_BRIDGE_DIR}" 17 + BYPRODUCTS "${FIREHOSE_BRIDGE_LIB}" 18 + COMMENT "Building Rust firehose bridge" 19 + ) 20 + 21 + add_library(firehose_bridge_static STATIC IMPORTED) 22 + set_target_properties(firehose_bridge_static PROPERTIES 23 + IMPORTED_LOCATION "${FIREHOSE_BRIDGE_LIB}" 24 + ) 25 + add_dependencies(firehose_bridge_static firehose_bridge_rust) 26 + 27 + add_executable(fortransky 28 + cshim/http_bridge.c 29 + src/util/strings.f90 30 + src/util/process.f90 31 + src/core/models.f90 32 + src/core/config.f90 33 + src/core/app_state.f90 34 + src/core/post_store.f90 35 + src/storage/log_store.f90 36 + src/atproto/http_cbridge.f90 37 + src/atproto/json_extract.f90 38 + src/atproto/decode.f90 39 + src/atproto/client.f90 40 + src/atproto/firehose_bridge.f90 41 + src/ui/tui.f90 42 + src/app/main.f90 43 + ) 44 + 45 + add_dependencies(fortransky firehose_bridge_rust) 46 + 47 + target_include_directories(fortransky PRIVATE 48 + ${LIBCURL_INCLUDE_DIRS} 49 + "${CMAKE_SOURCE_DIR}/cshim" 50 + ) 51 + target_link_libraries(fortransky PRIVATE 52 + ${LIBCURL_LIBRARIES} 53 + firehose_bridge_static 54 + # Rust stdlib deps needed when linking a Rust staticlib from C/Fortran 55 + dl 56 + pthread 57 + m 58 + ) 59 + target_compile_options(fortransky PRIVATE ${LIBCURL_CFLAGS_OTHER})
+207
README.md
··· 1 + # Fortransky v1.1 — native relay recoder 2 + 3 + Yes, that Fortran. 4 + 5 + A terminal-only Bluesky / AT Protocol client written in Fortran, with a Rust 6 + native firehose decoder for the `relay-raw` stream path. 7 + 8 + ## Architecture 9 + 10 + ``` 11 + Fortran TUI (src/) 12 + └─ C libcurl bridge (cshim/) 13 + └─ Fortran iso_c_binding module (src/atproto/firehose_bridge.f90) 14 + └─ Rust staticlib (bridge/firehose-bridge/) 15 + envelope → CAR → DAG-CBOR → NormalizedEvent → JSONL 16 + + firehose_bridge_cli binary (used by relay_raw_tail.py) 17 + ``` 18 + 19 + Session state is saved in `~/.fortransky/session.json`. Use an app password, 20 + not your main Bluesky password. 21 + 22 + --- 23 + 24 + ## Build dependencies 25 + 26 + ### System packages (Ubuntu/Debian) 27 + 28 + ```bash 29 + sudo apt install -y gfortran cmake pkg-config libcurl4-openssl-dev 30 + ``` 31 + 32 + ### Rust toolchain 33 + 34 + Requires rustc >= 1.70. Install via [rustup](https://rustup.rs) if not present: 35 + 36 + ```bash 37 + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh 38 + ``` 39 + 40 + ### Python deps (relay-raw stream path only) 41 + 42 + The `relay_raw_tail.py` helper is launched as a subprocess by the TUI. It must 43 + be able to import `cbor2` and `websockets` using whichever `python3` is on 44 + `PATH` at the time Fortransky runs — not just in an active venv. 45 + 46 + **Option A — system-wide (simplest):** 47 + ```bash 48 + sudo pip install cbor2 websockets --break-system-packages 49 + ``` 50 + 51 + **Option B — venv, then symlink or alias:** 52 + ```bash 53 + python3 -m venv .venv 54 + source .venv/bin/activate 55 + pip install cbor2 websockets 56 + ``` 57 + Then either run Fortransky with the venv active, or set: 58 + ```bash 59 + export FORTRANSKY_RELAY_PYTHON=$PWD/.venv/bin/python3 60 + ``` 61 + (support for this env var is planned) 62 + 63 + --- 64 + 65 + ## Build 66 + 67 + ```bash 68 + ./scripts/build.sh 69 + ``` 70 + 71 + This builds the Rust bridge first (`cargo build --release`), then runs CMake. 72 + The Rust step is skipped on subsequent builds if nothing changed. 73 + 74 + ```bash 75 + ./build/fortransky 76 + ``` 77 + 78 + --- 79 + 80 + ## Login 81 + 82 + Use an [app password](https://bsky.app/settings/app-passwords) created for 83 + Fortransky specifically. At the home prompt: 84 + 85 + ``` 86 + l 87 + Identifier: yourhandle.bsky.social 88 + Password/app password: <app password> 89 + ``` 90 + 91 + Session is saved to `~/.fortransky/session.json` and restored on next launch. 92 + To log out: `x` 93 + 94 + --- 95 + 96 + ## TUI commands 97 + 98 + ### Home view 99 + 100 + | Command | Action | 101 + |---------|--------| 102 + | `l` | login + fetch timeline | 103 + | `x` | logout + clear saved session | 104 + | `a <handle>` | author feed | 105 + | `s <query>` | search posts | 106 + | `p <handle>` | profile view | 107 + | `n` | notifications | 108 + | `c` | compose post | 109 + | `t <uri/url>` | open thread | 110 + | `j` | stream tail | 111 + | `m` | toggle stream mode (jetstream / relay-raw) | 112 + | `q` | quit | 113 + 114 + ### Post list view 115 + 116 + | Command | Action | 117 + |---------|--------| 118 + | `j` / `k` | move selection | 119 + | `n` / `p` | next / previous page | 120 + | `o` | open selected thread | 121 + | `r` | reply to selected post | 122 + | `l` | like selected post | 123 + | `R` | repost selected post | 124 + | `q` | quote-post | 125 + | `P` | open author profile | 126 + | `/query` | search | 127 + | `b` | back to home | 128 + 129 + ### Notifications view 130 + 131 + | Command | Action | 132 + |---------|--------| 133 + | `j` / `k` | move selection | 134 + | `n` / `p` | next / previous page | 135 + | `o` | open thread | 136 + | `r` | reply | 137 + | `b` | back | 138 + 139 + ### Stream view 140 + 141 + | Command | Action | 142 + |---------|--------| 143 + | `j` | refresh | 144 + | `b` | back | 145 + 146 + --- 147 + 148 + ## Stream modes 149 + 150 + **jetstream** — connects to Bluesky's Jetstream WebSocket service. Lower 151 + bandwidth, JSON native, easiest to work with. 152 + 153 + **relay-raw** — connects to the raw AT Protocol relay 154 + (`com.atproto.sync.subscribeRepos`). Frames are binary CBOR over WebSocket. 155 + The native Rust decoder (`firehose_bridge_cli`) handles envelope → CAR → 156 + DAG-CBOR → normalized JSON. Python cbor2 fallback remains for fixture mode. 157 + 158 + ### Native decoder detection order 159 + 160 + 1. `FORTRANSKY_FIREHOSE_DECODER` environment variable 161 + 2. `bridge/firehose-bridge/target/release/firehose_bridge_cli` 162 + 3. `bridge/firehose-bridge/target/debug/firehose_bridge_cli` 163 + 4. `firehose_bridge_cli` on `PATH` 164 + 165 + ### Relay fixture (offline testing) 166 + 167 + By default relay-raw uses a bundled synthetic fixture. To use the live relay: 168 + 169 + ```bash 170 + FORTRANSKY_RELAY_FIXTURE=0 ./build/fortransky 171 + ``` 172 + 173 + Quick offline demo: 174 + 175 + ```bash 176 + printf 'b\nm\nj\nb\nq\n' | ./build/fortransky 177 + ``` 178 + 179 + --- 180 + 181 + ## Known issues / notes 182 + 183 + - JSON parser is hand-rolled and lightweight — not a full schema-driven parser 184 + - `relay-raw` only surfaces `app.bsky.feed.post` create ops; other collections 185 + are filtered out at the normalize stage 186 + - Stream view shows raw DIDs; handle resolution (DID → handle lookup) is not 187 + yet implemented 188 + - The TUI is line-based (type command + Enter), not raw keypress 189 + - `m` and `j` for stream control are home view commands — go `b` back to home 190 + first if you are in the post list 191 + 192 + --- 193 + 194 + ## Changelog 195 + 196 + **v1.1** — Native Rust firehose decoder integrated. `relay_raw_tail.py` prefers 197 + `firehose_bridge_cli` when found. CMakeLists wires Rust staticlib into the 198 + Fortran link. JWT field lengths bumped to 1024 to fit full AT Protocol tokens. 199 + JSON key scanner depth-tracking fix (was matching nested keys before top-level 200 + `feed` array). 201 + 202 + **v1.0** — Like, repost, quote-post actions. URL facet emission. 203 + 204 + **v0.9** — Typed decode layer (`decode.f90`). Richer post semantics in TUI. 205 + 206 + **v0.7** — C libcurl bridge replacing shell curl. Saved session support. 207 + Stream mode toggle (jetstream / relay-raw).
+464
bridge/firehose-bridge/Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "anstream" 7 + version = "1.0.0" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" 10 + dependencies = [ 11 + "anstyle", 12 + "anstyle-parse", 13 + "anstyle-query", 14 + "anstyle-wincon", 15 + "colorchoice", 16 + "is_terminal_polyfill", 17 + "utf8parse", 18 + ] 19 + 20 + [[package]] 21 + name = "anstyle" 22 + version = "1.0.14" 23 + source = "registry+https://github.com/rust-lang/crates.io-index" 24 + checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" 25 + 26 + [[package]] 27 + name = "anstyle-parse" 28 + version = "1.0.0" 29 + source = "registry+https://github.com/rust-lang/crates.io-index" 30 + checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" 31 + dependencies = [ 32 + "utf8parse", 33 + ] 34 + 35 + [[package]] 36 + name = "anstyle-query" 37 + version = "1.1.5" 38 + source = "registry+https://github.com/rust-lang/crates.io-index" 39 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 40 + dependencies = [ 41 + "windows-sys", 42 + ] 43 + 44 + [[package]] 45 + name = "anstyle-wincon" 46 + version = "3.0.11" 47 + source = "registry+https://github.com/rust-lang/crates.io-index" 48 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 49 + dependencies = [ 50 + "anstyle", 51 + "once_cell_polyfill", 52 + "windows-sys", 53 + ] 54 + 55 + [[package]] 56 + name = "anyhow" 57 + version = "1.0.102" 58 + source = "registry+https://github.com/rust-lang/crates.io-index" 59 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 60 + 61 + [[package]] 62 + name = "base-x" 63 + version = "0.2.11" 64 + source = "registry+https://github.com/rust-lang/crates.io-index" 65 + checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" 66 + 67 + [[package]] 68 + name = "base256emoji" 69 + version = "1.0.2" 70 + source = "registry+https://github.com/rust-lang/crates.io-index" 71 + checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" 72 + dependencies = [ 73 + "const-str", 74 + "match-lookup", 75 + ] 76 + 77 + [[package]] 78 + name = "cfg-if" 79 + version = "1.0.4" 80 + source = "registry+https://github.com/rust-lang/crates.io-index" 81 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 82 + 83 + [[package]] 84 + name = "ciborium" 85 + version = "0.2.2" 86 + source = "registry+https://github.com/rust-lang/crates.io-index" 87 + checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" 88 + dependencies = [ 89 + "ciborium-io", 90 + "ciborium-ll", 91 + "serde", 92 + ] 93 + 94 + [[package]] 95 + name = "ciborium-io" 96 + version = "0.2.2" 97 + source = "registry+https://github.com/rust-lang/crates.io-index" 98 + checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" 99 + 100 + [[package]] 101 + name = "ciborium-ll" 102 + version = "0.2.2" 103 + source = "registry+https://github.com/rust-lang/crates.io-index" 104 + checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" 105 + dependencies = [ 106 + "ciborium-io", 107 + "half", 108 + ] 109 + 110 + [[package]] 111 + name = "cid" 112 + version = "0.11.1" 113 + source = "registry+https://github.com/rust-lang/crates.io-index" 114 + checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a" 115 + dependencies = [ 116 + "core2", 117 + "multibase", 118 + "multihash", 119 + "unsigned-varint", 120 + ] 121 + 122 + [[package]] 123 + name = "clap" 124 + version = "4.6.0" 125 + source = "registry+https://github.com/rust-lang/crates.io-index" 126 + checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" 127 + dependencies = [ 128 + "clap_builder", 129 + "clap_derive", 130 + ] 131 + 132 + [[package]] 133 + name = "clap_builder" 134 + version = "4.6.0" 135 + source = "registry+https://github.com/rust-lang/crates.io-index" 136 + checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" 137 + dependencies = [ 138 + "anstream", 139 + "anstyle", 140 + "clap_lex", 141 + "strsim", 142 + ] 143 + 144 + [[package]] 145 + name = "clap_derive" 146 + version = "4.6.0" 147 + source = "registry+https://github.com/rust-lang/crates.io-index" 148 + checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" 149 + dependencies = [ 150 + "heck", 151 + "proc-macro2", 152 + "quote", 153 + "syn", 154 + ] 155 + 156 + [[package]] 157 + name = "clap_lex" 158 + version = "1.1.0" 159 + source = "registry+https://github.com/rust-lang/crates.io-index" 160 + checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" 161 + 162 + [[package]] 163 + name = "colorchoice" 164 + version = "1.0.5" 165 + source = "registry+https://github.com/rust-lang/crates.io-index" 166 + checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" 167 + 168 + [[package]] 169 + name = "const-str" 170 + version = "0.4.3" 171 + source = "registry+https://github.com/rust-lang/crates.io-index" 172 + checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 173 + 174 + [[package]] 175 + name = "core2" 176 + version = "0.4.0" 177 + source = "registry+https://github.com/rust-lang/crates.io-index" 178 + checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" 179 + dependencies = [ 180 + "memchr", 181 + ] 182 + 183 + [[package]] 184 + name = "crunchy" 185 + version = "0.2.4" 186 + source = "registry+https://github.com/rust-lang/crates.io-index" 187 + checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" 188 + 189 + [[package]] 190 + name = "data-encoding" 191 + version = "2.10.0" 192 + source = "registry+https://github.com/rust-lang/crates.io-index" 193 + checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" 194 + 195 + [[package]] 196 + name = "data-encoding-macro" 197 + version = "0.1.19" 198 + source = "registry+https://github.com/rust-lang/crates.io-index" 199 + checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" 200 + dependencies = [ 201 + "data-encoding", 202 + "data-encoding-macro-internal", 203 + ] 204 + 205 + [[package]] 206 + name = "data-encoding-macro-internal" 207 + version = "0.1.17" 208 + source = "registry+https://github.com/rust-lang/crates.io-index" 209 + checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" 210 + dependencies = [ 211 + "data-encoding", 212 + "syn", 213 + ] 214 + 215 + [[package]] 216 + name = "fortransky_firehose_bridge" 217 + version = "0.1.0" 218 + dependencies = [ 219 + "anyhow", 220 + "ciborium", 221 + "cid", 222 + "clap", 223 + "libc", 224 + "serde", 225 + "serde_json", 226 + "thiserror", 227 + ] 228 + 229 + [[package]] 230 + name = "half" 231 + version = "2.7.1" 232 + source = "registry+https://github.com/rust-lang/crates.io-index" 233 + checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" 234 + dependencies = [ 235 + "cfg-if", 236 + "crunchy", 237 + "zerocopy", 238 + ] 239 + 240 + [[package]] 241 + name = "heck" 242 + version = "0.5.0" 243 + source = "registry+https://github.com/rust-lang/crates.io-index" 244 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 245 + 246 + [[package]] 247 + name = "is_terminal_polyfill" 248 + version = "1.70.2" 249 + source = "registry+https://github.com/rust-lang/crates.io-index" 250 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 251 + 252 + [[package]] 253 + name = "itoa" 254 + version = "1.0.17" 255 + source = "registry+https://github.com/rust-lang/crates.io-index" 256 + checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" 257 + 258 + [[package]] 259 + name = "libc" 260 + version = "0.2.183" 261 + source = "registry+https://github.com/rust-lang/crates.io-index" 262 + checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" 263 + 264 + [[package]] 265 + name = "match-lookup" 266 + version = "0.1.2" 267 + source = "registry+https://github.com/rust-lang/crates.io-index" 268 + checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" 269 + dependencies = [ 270 + "proc-macro2", 271 + "quote", 272 + "syn", 273 + ] 274 + 275 + [[package]] 276 + name = "memchr" 277 + version = "2.8.0" 278 + source = "registry+https://github.com/rust-lang/crates.io-index" 279 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 280 + 281 + [[package]] 282 + name = "multibase" 283 + version = "0.9.2" 284 + source = "registry+https://github.com/rust-lang/crates.io-index" 285 + checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" 286 + dependencies = [ 287 + "base-x", 288 + "base256emoji", 289 + "data-encoding", 290 + "data-encoding-macro", 291 + ] 292 + 293 + [[package]] 294 + name = "multihash" 295 + version = "0.19.3" 296 + source = "registry+https://github.com/rust-lang/crates.io-index" 297 + checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" 298 + dependencies = [ 299 + "core2", 300 + "unsigned-varint", 301 + ] 302 + 303 + [[package]] 304 + name = "once_cell_polyfill" 305 + version = "1.70.2" 306 + source = "registry+https://github.com/rust-lang/crates.io-index" 307 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 308 + 309 + [[package]] 310 + name = "proc-macro2" 311 + version = "1.0.106" 312 + source = "registry+https://github.com/rust-lang/crates.io-index" 313 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 314 + dependencies = [ 315 + "unicode-ident", 316 + ] 317 + 318 + [[package]] 319 + name = "quote" 320 + version = "1.0.45" 321 + source = "registry+https://github.com/rust-lang/crates.io-index" 322 + checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 323 + dependencies = [ 324 + "proc-macro2", 325 + ] 326 + 327 + [[package]] 328 + name = "serde" 329 + version = "1.0.228" 330 + source = "registry+https://github.com/rust-lang/crates.io-index" 331 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 332 + dependencies = [ 333 + "serde_core", 334 + "serde_derive", 335 + ] 336 + 337 + [[package]] 338 + name = "serde_core" 339 + version = "1.0.228" 340 + source = "registry+https://github.com/rust-lang/crates.io-index" 341 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 342 + dependencies = [ 343 + "serde_derive", 344 + ] 345 + 346 + [[package]] 347 + name = "serde_derive" 348 + version = "1.0.228" 349 + source = "registry+https://github.com/rust-lang/crates.io-index" 350 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 351 + dependencies = [ 352 + "proc-macro2", 353 + "quote", 354 + "syn", 355 + ] 356 + 357 + [[package]] 358 + name = "serde_json" 359 + version = "1.0.149" 360 + source = "registry+https://github.com/rust-lang/crates.io-index" 361 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 362 + dependencies = [ 363 + "itoa", 364 + "memchr", 365 + "serde", 366 + "serde_core", 367 + "zmij", 368 + ] 369 + 370 + [[package]] 371 + name = "strsim" 372 + version = "0.11.1" 373 + source = "registry+https://github.com/rust-lang/crates.io-index" 374 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 375 + 376 + [[package]] 377 + name = "syn" 378 + version = "2.0.117" 379 + source = "registry+https://github.com/rust-lang/crates.io-index" 380 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 381 + dependencies = [ 382 + "proc-macro2", 383 + "quote", 384 + "unicode-ident", 385 + ] 386 + 387 + [[package]] 388 + name = "thiserror" 389 + version = "2.0.18" 390 + source = "registry+https://github.com/rust-lang/crates.io-index" 391 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 392 + dependencies = [ 393 + "thiserror-impl", 394 + ] 395 + 396 + [[package]] 397 + name = "thiserror-impl" 398 + version = "2.0.18" 399 + source = "registry+https://github.com/rust-lang/crates.io-index" 400 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 401 + dependencies = [ 402 + "proc-macro2", 403 + "quote", 404 + "syn", 405 + ] 406 + 407 + [[package]] 408 + name = "unicode-ident" 409 + version = "1.0.24" 410 + source = "registry+https://github.com/rust-lang/crates.io-index" 411 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 412 + 413 + [[package]] 414 + name = "unsigned-varint" 415 + version = "0.8.0" 416 + source = "registry+https://github.com/rust-lang/crates.io-index" 417 + checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" 418 + 419 + [[package]] 420 + name = "utf8parse" 421 + version = "0.2.2" 422 + source = "registry+https://github.com/rust-lang/crates.io-index" 423 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 424 + 425 + [[package]] 426 + name = "windows-link" 427 + version = "0.2.1" 428 + source = "registry+https://github.com/rust-lang/crates.io-index" 429 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 430 + 431 + [[package]] 432 + name = "windows-sys" 433 + version = "0.61.2" 434 + source = "registry+https://github.com/rust-lang/crates.io-index" 435 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 436 + dependencies = [ 437 + "windows-link", 438 + ] 439 + 440 + [[package]] 441 + name = "zerocopy" 442 + version = "0.8.42" 443 + source = "registry+https://github.com/rust-lang/crates.io-index" 444 + checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" 445 + dependencies = [ 446 + "zerocopy-derive", 447 + ] 448 + 449 + [[package]] 450 + name = "zerocopy-derive" 451 + version = "0.8.42" 452 + source = "registry+https://github.com/rust-lang/crates.io-index" 453 + checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" 454 + dependencies = [ 455 + "proc-macro2", 456 + "quote", 457 + "syn", 458 + ] 459 + 460 + [[package]] 461 + name = "zmij" 462 + version = "1.0.21" 463 + source = "registry+https://github.com/rust-lang/crates.io-index" 464 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+21
bridge/firehose-bridge/Cargo.toml
··· 1 + [package] 2 + name = "fortransky_firehose_bridge" 3 + version = "0.1.0" 4 + edition = "2021" 5 + 6 + [lib] 7 + crate-type = ["cdylib", "staticlib", "rlib"] 8 + 9 + [[bin]] 10 + name = "firehose_bridge_cli" 11 + path = "src/bin/firehose_bridge_cli.rs" 12 + 13 + [dependencies] 14 + anyhow = "1" 15 + ciborium = "0.2" 16 + cid = "0.11" 17 + libc = "0.2" 18 + serde = { version = "1", features = ["derive"] } 19 + serde_json = "1" 20 + thiserror = "2" 21 + clap = { version = "4", features = ["derive"] }
+202
bridge/firehose-bridge/src/abi.rs
··· 1 + use libc::{c_char, c_int, c_void, size_t}; 2 + use std::ffi::CString; 3 + use std::ptr; 4 + 5 + pub const FS_OK: c_int = 0; 6 + pub const FS_ERR_CBOR: c_int = 1; 7 + pub const FS_ERR_ENVELOPE: c_int = 2; 8 + pub const FS_ERR_COMMIT_PARSE: c_int = 3; 9 + pub const FS_ERR_CAR_PARSE: c_int = 4; 10 + pub const FS_ERR_DAGCBOR_PARSE: c_int = 5; 11 + pub const FS_ERR_UNSUPPORTED: c_int = 6; 12 + pub const FS_ERR_OOM: c_int = 7; 13 + pub const FS_ERR_INTERNAL: c_int = 8; 14 + 15 + pub const FS_KIND_COMMIT_OP: c_int = 1; 16 + pub const FS_KIND_IDENTITY: c_int = 2; 17 + pub const FS_KIND_ACCOUNT: c_int = 3; 18 + pub const FS_KIND_INFO: c_int = 4; 19 + pub const FS_KIND_ERROR: c_int = 5; 20 + 21 + pub const FS_OP_NONE: c_int = 0; 22 + pub const FS_OP_CREATE: c_int = 1; 23 + pub const FS_OP_UPDATE: c_int = 2; 24 + pub const FS_OP_DELETE: c_int = 3; 25 + 26 + #[repr(C)] 27 + pub struct fs_event_t { 28 + pub seq: i64, 29 + pub kind: c_int, 30 + pub op_action: c_int, 31 + pub repo_did: *const c_char, 32 + pub rev: *const c_char, 33 + pub collection: *const c_char, 34 + pub rkey: *const c_char, 35 + pub record_cid: *const c_char, 36 + pub uri: *const c_char, 37 + pub record_json: *const c_char, 38 + pub error_message: *const c_char, 39 + } 40 + 41 + #[repr(C)] 42 + pub struct fs_event_batch_t { 43 + pub events: *mut fs_event_t, 44 + pub len: size_t, 45 + pub owner: *mut c_void, 46 + } 47 + 48 + #[derive(Debug, Clone)] 49 + pub struct NormalizedEvent { 50 + pub seq: i64, 51 + pub kind: i32, 52 + pub op_action: i32, 53 + pub repo_did: Option<String>, 54 + pub rev: Option<String>, 55 + pub collection: Option<String>, 56 + pub rkey: Option<String>, 57 + pub record_cid: Option<String>, 58 + pub uri: Option<String>, 59 + pub record_json: Option<String>, 60 + pub error_message: Option<String>, 61 + } 62 + 63 + struct OwnedCEvent { 64 + ev: fs_event_t, 65 + owned_strings: Vec<CString>, 66 + } 67 + 68 + #[repr(C)] 69 + pub struct BatchOwner { 70 + events: Vec<fs_event_t>, 71 + _strings_per_event: Vec<Vec<CString>>, 72 + } 73 + 74 + fn safe_cstring(s: &str) -> CString { 75 + match CString::new(s) { 76 + Ok(v) => v, 77 + Err(_) => CString::new(s.replace('\0', "�")).unwrap_or_else(|_| CString::new("<invalid>").unwrap()), 78 + } 79 + } 80 + 81 + fn opt_cstr(src: &Option<String>, owned: &mut Vec<CString>) -> *const c_char { 82 + match src { 83 + Some(s) => { 84 + let c = safe_cstring(s); 85 + let p = c.as_ptr(); 86 + owned.push(c); 87 + p 88 + } 89 + None => ptr::null(), 90 + } 91 + } 92 + 93 + fn to_owned_cevent(src: NormalizedEvent) -> OwnedCEvent { 94 + let mut owned_strings = Vec::new(); 95 + let ev = fs_event_t { 96 + seq: src.seq, 97 + kind: src.kind, 98 + op_action: src.op_action, 99 + repo_did: opt_cstr(&src.repo_did, &mut owned_strings), 100 + rev: opt_cstr(&src.rev, &mut owned_strings), 101 + collection: opt_cstr(&src.collection, &mut owned_strings), 102 + rkey: opt_cstr(&src.rkey, &mut owned_strings), 103 + record_cid: opt_cstr(&src.record_cid, &mut owned_strings), 104 + uri: opt_cstr(&src.uri, &mut owned_strings), 105 + record_json: opt_cstr(&src.record_json, &mut owned_strings), 106 + error_message: opt_cstr(&src.error_message, &mut owned_strings), 107 + }; 108 + OwnedCEvent { ev, owned_strings } 109 + } 110 + 111 + pub fn build_batch(events: Vec<NormalizedEvent>, out_batch: *mut fs_event_batch_t) -> Result<c_int, c_int> { 112 + if out_batch.is_null() { 113 + return Err(FS_ERR_INTERNAL); 114 + } 115 + 116 + let mut out_events = Vec::with_capacity(events.len()); 117 + let mut string_bins = Vec::with_capacity(events.len()); 118 + 119 + for e in events { 120 + let OwnedCEvent { ev, owned_strings } = to_owned_cevent(e); 121 + out_events.push(ev); 122 + string_bins.push(owned_strings); 123 + } 124 + 125 + let mut owner = Box::new(BatchOwner { events: out_events, _strings_per_event: string_bins }); 126 + let events_ptr = owner.events.as_mut_ptr(); 127 + let len = owner.events.len(); 128 + let owner_ptr = Box::into_raw(owner) as *mut c_void; 129 + 130 + unsafe { 131 + (*out_batch).events = events_ptr; 132 + (*out_batch).len = len; 133 + (*out_batch).owner = owner_ptr; 134 + } 135 + 136 + Ok(FS_OK) 137 + } 138 + 139 + pub fn build_error_batch(message: impl Into<String>, out_batch: *mut fs_event_batch_t) -> c_int { 140 + let events = vec![NormalizedEvent { 141 + seq: 0, 142 + kind: FS_KIND_ERROR, 143 + op_action: FS_OP_NONE, 144 + repo_did: None, 145 + rev: None, 146 + collection: None, 147 + rkey: None, 148 + record_cid: None, 149 + uri: None, 150 + record_json: None, 151 + error_message: Some(message.into()), 152 + }]; 153 + 154 + match build_batch(events, out_batch) { 155 + Ok(code) => code, 156 + Err(code) => code, 157 + } 158 + } 159 + 160 + #[no_mangle] 161 + pub extern "C" fn fs_decoder_init() -> c_int { FS_OK } 162 + 163 + #[no_mangle] 164 + pub extern "C" fn fs_decoder_shutdown() {} 165 + 166 + #[no_mangle] 167 + pub extern "C" fn fs_decode_frame(data: *const u8, len: size_t, out_batch: *mut fs_event_batch_t) -> c_int { 168 + if out_batch.is_null() { 169 + return FS_ERR_INTERNAL; 170 + } 171 + unsafe { 172 + (*out_batch).events = ptr::null_mut(); 173 + (*out_batch).len = 0; 174 + (*out_batch).owner = ptr::null_mut(); 175 + } 176 + if data.is_null() { 177 + return build_error_batch("null frame pointer", out_batch); 178 + } 179 + 180 + let bytes = unsafe { std::slice::from_raw_parts(data, len) }; 181 + match crate::decoder::decode_frame(bytes) { 182 + Ok(events) => match build_batch(events, out_batch) { Ok(code) => code, Err(code) => code }, 183 + Err(err) => { 184 + let code = crate::decoder::map_error_to_code(&err); 185 + let _ = build_error_batch(err.to_string(), out_batch); 186 + code 187 + } 188 + } 189 + } 190 + 191 + #[no_mangle] 192 + pub extern "C" fn fs_free_batch(batch: *mut fs_event_batch_t) { 193 + if batch.is_null() { return; } 194 + unsafe { 195 + if !(*batch).owner.is_null() { 196 + let _owner: Box<BatchOwner> = Box::from_raw((*batch).owner as *mut BatchOwner); 197 + (*batch).owner = ptr::null_mut(); 198 + } 199 + (*batch).events = ptr::null_mut(); 200 + (*batch).len = 0; 201 + } 202 + }
+101
bridge/firehose-bridge/src/bin/firehose_bridge_cli.rs
··· 1 + use std::fs; 2 + use std::io::{self, Read}; 3 + use std::path::PathBuf; 4 + 5 + use anyhow::{Context, Result}; 6 + use clap::Parser; 7 + use fortransky_firehose_bridge::decoder; 8 + 9 + #[derive(Parser, Debug)] 10 + #[command(name = "firehose_bridge_cli")] 11 + #[command(about = "Decode ATProto relay/firehose frames into normalized JSONL")] 12 + struct Args { 13 + /// Read a single frame from a file instead of stdin 14 + #[arg(long)] 15 + frame_file: Option<PathBuf>, 16 + 17 + /// Emit pretty JSON instead of compact JSONL 18 + #[arg(long, default_value_t = false)] 19 + pretty: bool, 20 + } 21 + 22 + fn main() { 23 + if let Err(err) = real_main() { 24 + eprintln!("firehose_bridge_cli: {err:#}"); 25 + std::process::exit(1); 26 + } 27 + } 28 + 29 + fn real_main() -> Result<()> { 30 + let args = Args::parse(); 31 + let bytes = read_input(args.frame_file)?; 32 + let events = decoder::decode_frame(&bytes) 33 + .with_context(|| "frame decode failed")?; 34 + 35 + for ev in events { 36 + let value = serde_json::json!({ 37 + "kind": map_kind(ev.kind), 38 + "did": ev.repo_did.unwrap_or_default(), 39 + "handle": "", 40 + "text": extract_text(ev.record_json.as_deref()), 41 + "time_us": ev.seq.to_string(), 42 + "uri": ev.uri.unwrap_or_default(), 43 + "record_type": ev.collection.unwrap_or_default(), 44 + "source": "relay-raw-native", 45 + "op": map_op(ev.op_action), 46 + "rev": ev.rev.unwrap_or_default(), 47 + "record_json": parse_record_json(ev.record_json.as_deref()), 48 + "error": ev.error_message.unwrap_or_default(), 49 + }); 50 + if args.pretty { 51 + println!("{}", serde_json::to_string_pretty(&value)?); 52 + } else { 53 + println!("{}", serde_json::to_string(&value)?); 54 + } 55 + } 56 + 57 + Ok(()) 58 + } 59 + 60 + fn read_input(frame_file: Option<PathBuf>) -> Result<Vec<u8>> { 61 + if let Some(path) = frame_file { 62 + return fs::read(&path).with_context(|| format!("failed to read frame file {}", path.display())); 63 + } 64 + let mut buf = Vec::new(); 65 + io::stdin().read_to_end(&mut buf).context("failed to read frame bytes from stdin")?; 66 + Ok(buf) 67 + } 68 + 69 + fn parse_record_json(src: Option<&str>) -> serde_json::Value { 70 + match src { 71 + Some(s) => serde_json::from_str(s).unwrap_or_else(|_| serde_json::Value::String(s.to_string())), 72 + None => serde_json::Value::Null, 73 + } 74 + } 75 + 76 + fn extract_text(src: Option<&str>) -> String { 77 + match src.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()) { 78 + Some(serde_json::Value::Object(map)) => map.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(), 79 + _ => String::new(), 80 + } 81 + } 82 + 83 + fn map_kind(kind: i32) -> &'static str { 84 + match kind { 85 + 1 => "commit", 86 + 2 => "identity", 87 + 3 => "account", 88 + 4 => "info", 89 + 5 => "error", 90 + _ => "unknown", 91 + } 92 + } 93 + 94 + fn map_op(op: i32) -> &'static str { 95 + match op { 96 + 1 => "create", 97 + 2 => "update", 98 + 3 => "delete", 99 + _ => "none", 100 + } 101 + }
+107
bridge/firehose-bridge/src/car.rs
··· 1 + use anyhow::{anyhow, bail, Result}; 2 + use cid::Cid; 3 + use std::io::Cursor; 4 + 5 + #[derive(Debug, Clone)] 6 + pub struct CarBlock { 7 + pub cid: String, 8 + pub bytes: Vec<u8>, 9 + } 10 + 11 + pub fn parse_car(bytes: &[u8]) -> Result<Vec<CarBlock>> { 12 + if bytes.is_empty() { 13 + bail!("car parse failed: empty bytes") 14 + } 15 + let mut off = 0usize; 16 + let header_len = read_uvarint(bytes, &mut off)? as usize; 17 + if off + header_len > bytes.len() { 18 + bail!("car parse failed: truncated header") 19 + } 20 + 21 + let header_slice = &bytes[off..off + header_len]; 22 + off += header_len; 23 + let header_v: ciborium::value::Value = ciborium::de::from_reader(Cursor::new(header_slice)) 24 + .map_err(|e| anyhow!("car header CBOR decode failed: {e}"))?; 25 + validate_header(&header_v)?; 26 + 27 + let mut blocks = Vec::new(); 28 + while off < bytes.len() { 29 + let section_len = read_uvarint(bytes, &mut off)? as usize; 30 + if section_len == 0 { 31 + continue; 32 + } 33 + if off + section_len > bytes.len() { 34 + bail!("car parse failed: truncated block section") 35 + } 36 + 37 + let section = &bytes[off..off + section_len]; 38 + off += section_len; 39 + 40 + let (cid, cid_len) = parse_cid_prefix(section)?; 41 + let payload = section[cid_len..].to_vec(); 42 + blocks.push(CarBlock { 43 + cid: cid.to_string(), 44 + bytes: payload, 45 + }); 46 + } 47 + 48 + Ok(blocks) 49 + } 50 + 51 + pub fn find_block_by_cid<'a>(blocks: &'a [CarBlock], cid: &str) -> Option<&'a CarBlock> { 52 + blocks.iter().find(|b| b.cid == cid) 53 + } 54 + 55 + fn validate_header(v: &ciborium::value::Value) -> Result<()> { 56 + use ciborium::value::Value; 57 + let Value::Map(entries) = v else { 58 + bail!("car header is not a CBOR map") 59 + }; 60 + let mut found_version = false; 61 + for (k, val) in entries { 62 + if let Value::Text(name) = k { 63 + if name == "version" { 64 + found_version = true; 65 + match val { 66 + Value::Integer(i) if i128::from(*i) == 1 => {} 67 + _ => bail!("unsupported CAR version (expected 1)"), 68 + } 69 + } 70 + } 71 + } 72 + if !found_version { 73 + bail!("car header missing version") 74 + } 75 + Ok(()) 76 + } 77 + 78 + pub fn read_uvarint(bytes: &[u8], off: &mut usize) -> Result<u64> { 79 + let mut x = 0u64; 80 + let mut s = 0u32; 81 + loop { 82 + if *off >= bytes.len() { 83 + bail!("unexpected EOF while reading uvarint") 84 + } 85 + let b = bytes[*off]; 86 + *off += 1; 87 + if b < 0x80 { 88 + if s >= 64 && b > 1 { 89 + bail!("uvarint overflow") 90 + } 91 + x |= (b as u64) << s; 92 + return Ok(x); 93 + } 94 + x |= ((b & 0x7f) as u64) << s; 95 + s += 7; 96 + if s > 63 { 97 + bail!("uvarint overflow") 98 + } 99 + } 100 + } 101 + 102 + fn parse_cid_prefix(section: &[u8]) -> Result<(Cid, usize)> { 103 + let mut cur = Cursor::new(section); 104 + let cid = Cid::read_bytes(&mut cur).map_err(|e| anyhow!("cid decode failed in CAR block: {e}"))?; 105 + let len = cur.position() as usize; 106 + Ok((cid, len)) 107 + }
+48
bridge/firehose-bridge/src/commit.rs
··· 1 + use anyhow::{anyhow, Result}; 2 + 3 + #[derive(Debug, Clone)] 4 + pub struct CommitOp { 5 + pub action: String, 6 + pub path: String, 7 + pub cid: Option<String>, 8 + pub record_json: Option<String>, 9 + } 10 + 11 + #[derive(Debug, Clone)] 12 + pub struct DecodedCommit { 13 + pub seq: i64, 14 + pub repo: String, 15 + pub rev: String, 16 + pub ops: Vec<CommitOp>, 17 + } 18 + 19 + pub fn decode_commit(env: crate::envelope::CommitEnvelope) -> Result<DecodedCommit> { 20 + let blocks = crate::car::parse_car(&env.blocks)?; 21 + let mut out_ops = Vec::new(); 22 + 23 + for op in env.ops { 24 + let record_json = match &op.cid { 25 + Some(cid) => { 26 + let block = crate::car::find_block_by_cid(&blocks, cid) 27 + .ok_or_else(|| anyhow!("commit op cid not found in CAR blocks: {cid}"))?; 28 + let json = crate::dagcbor::decode_record_to_json(&block.bytes)?; 29 + Some(serde_json::to_string(&json).map_err(|e| anyhow!("record JSON serialization failed: {e}"))?) 30 + } 31 + None => None, 32 + }; 33 + 34 + out_ops.push(CommitOp { 35 + action: op.action, 36 + path: op.path, 37 + cid: op.cid, 38 + record_json, 39 + }); 40 + } 41 + 42 + Ok(DecodedCommit { 43 + seq: env.seq, 44 + repo: env.repo, 45 + rev: env.rev, 46 + ops: out_ops, 47 + }) 48 + }
+92
bridge/firehose-bridge/src/dagcbor.rs
··· 1 + use anyhow::{anyhow, Result}; 2 + use ciborium::value::{Integer, Value}; 3 + use cid::Cid; 4 + use serde_json::{Map, Number, Value as JsonValue}; 5 + use std::io::Cursor; 6 + 7 + pub fn decode_record_to_json(bytes: &[u8]) -> Result<JsonValue> { 8 + let value: Value = ciborium::de::from_reader(Cursor::new(bytes)) 9 + .map_err(|e| anyhow!("dag-cbor decode failed: {e}"))?; 10 + Ok(value_to_json(&value)) 11 + } 12 + 13 + pub fn value_to_json(v: &Value) -> JsonValue { 14 + match v { 15 + Value::Null => JsonValue::Null, 16 + Value::Bool(b) => JsonValue::Bool(*b), 17 + Value::Integer(i) => integer_to_json(*i), 18 + Value::Float(f) => Number::from_f64(*f).map(JsonValue::Number).unwrap_or(JsonValue::Null), 19 + Value::Bytes(b) => JsonValue::String(hex_bytes(b)), 20 + Value::Text(s) => JsonValue::String(s.clone()), 21 + Value::Array(items) => JsonValue::Array(items.iter().map(value_to_json).collect()), 22 + Value::Map(entries) => { 23 + let mut obj = Map::new(); 24 + for (k, val) in entries { 25 + let key = match k { 26 + Value::Text(s) => s.clone(), 27 + other => format!("_key_{:?}", other), 28 + }; 29 + obj.insert(key, value_to_json(val)); 30 + } 31 + JsonValue::Object(obj) 32 + } 33 + Value::Tag(tag, boxed) => { 34 + if *tag == 42 { 35 + match value_to_cid_string(boxed) { 36 + Some(cid) => { 37 + let mut obj = Map::new(); 38 + obj.insert("$link".to_string(), JsonValue::String(cid)); 39 + JsonValue::Object(obj) 40 + } 41 + None => JsonValue::String(format!("<cid-tag:{}>", tag)), 42 + } 43 + } else { 44 + let mut obj = Map::new(); 45 + obj.insert("$tag".to_string(), JsonValue::Number(Number::from(*tag))); 46 + obj.insert("value".to_string(), value_to_json(boxed)); 47 + JsonValue::Object(obj) 48 + } 49 + } 50 + _ => JsonValue::String(format!("<unsupported-cbor:{:?}>", v)), 51 + } 52 + } 53 + 54 + pub fn value_to_cid_string(v: &Value) -> Option<String> { 55 + match v { 56 + Value::Tag(tag, boxed) if *tag == 42 => match &**boxed { 57 + Value::Bytes(b) => decode_cid_bytes(b).ok(), 58 + _ => None, 59 + }, 60 + Value::Bytes(b) => decode_cid_bytes(b).ok(), 61 + Value::Text(s) => Some(s.clone()), 62 + _ => None, 63 + } 64 + } 65 + 66 + fn decode_cid_bytes(b: &[u8]) -> Result<String> { 67 + let cid_bytes = if b.first().copied() == Some(0) { &b[1..] } else { b }; 68 + let cid = Cid::read_bytes(&mut Cursor::new(cid_bytes)) 69 + .map_err(|e| anyhow!("cid bytes decode failed: {e}"))?; 70 + Ok(cid.to_string()) 71 + } 72 + 73 + fn integer_to_json(i: Integer) -> JsonValue { 74 + if let Ok(v) = i64::try_from(i) { 75 + JsonValue::Number(Number::from(v)) 76 + } else if let Ok(v) = u64::try_from(i) { 77 + JsonValue::Number(Number::from(v)) 78 + } else { 79 + // ciborium::Integer has no Display in all 0.2.x versions; values outside 80 + // i64/u64 range are impossible in ATProto records in practice. 81 + JsonValue::Null 82 + } 83 + } 84 + 85 + fn hex_bytes(bytes: &[u8]) -> String { 86 + let mut s = String::with_capacity(bytes.len() * 2); 87 + for b in bytes { 88 + use std::fmt::Write as _; 89 + let _ = write!(&mut s, "{:02x}", b); 90 + } 91 + s 92 + }
+64
bridge/firehose-bridge/src/decoder.rs
··· 1 + use anyhow::Result; 2 + 3 + #[derive(thiserror::Error, Debug)] 4 + pub enum DecodeError { 5 + #[error("CBOR decode failed: {0}")] 6 + Cbor(String), 7 + #[error("envelope decode failed: {0}")] 8 + Envelope(String), 9 + #[error("commit decode failed: {0}")] 10 + Commit(String), 11 + #[error("CAR parse failed: {0}")] 12 + Car(String), 13 + #[error("DAG-CBOR decode failed: {0}")] 14 + DagCbor(String), 15 + #[error("unsupported frame: {0}")] 16 + Unsupported(String), 17 + #[error("internal error: {0}")] 18 + Internal(String), 19 + } 20 + 21 + pub fn decode_frame(bytes: &[u8]) -> Result<Vec<crate::abi::NormalizedEvent>, DecodeError> { 22 + let env = crate::envelope::decode_envelope(bytes).map_err(|e| DecodeError::Envelope(e.to_string()))?; 23 + match env { 24 + crate::envelope::Envelope::Commit(commit_env) => { 25 + let commit = crate::commit::decode_commit(commit_env).map_err(|e| { 26 + let msg = e.to_string(); 27 + if msg.to_ascii_lowercase().contains("car") { 28 + DecodeError::Car(msg) 29 + } else if msg.to_ascii_lowercase().contains("dag-cbor") { 30 + DecodeError::DagCbor(msg) 31 + } else { 32 + DecodeError::Commit(msg) 33 + } 34 + })?; 35 + crate::normalize::normalize_commit(commit).map_err(|e| DecodeError::Internal(e.to_string())) 36 + } 37 + crate::envelope::Envelope::Info(info) => Ok(vec![crate::abi::NormalizedEvent { 38 + seq: info.seq.unwrap_or(0), 39 + kind: crate::abi::FS_KIND_INFO, 40 + op_action: crate::abi::FS_OP_NONE, 41 + repo_did: None, 42 + rev: None, 43 + collection: None, 44 + rkey: None, 45 + record_cid: None, 46 + uri: None, 47 + record_json: info.payload_json, 48 + error_message: None, 49 + }]), 50 + crate::envelope::Envelope::Unknown => Err(DecodeError::Unsupported("unrecognized event-stream frame".into())), 51 + } 52 + } 53 + 54 + pub fn map_error_to_code(err: &DecodeError) -> libc::c_int { 55 + match err { 56 + DecodeError::Cbor(_) => crate::abi::FS_ERR_CBOR, 57 + DecodeError::Envelope(_) => crate::abi::FS_ERR_ENVELOPE, 58 + DecodeError::Commit(_) => crate::abi::FS_ERR_COMMIT_PARSE, 59 + DecodeError::Car(_) => crate::abi::FS_ERR_CAR_PARSE, 60 + DecodeError::DagCbor(_) => crate::abi::FS_ERR_DAGCBOR_PARSE, 61 + DecodeError::Unsupported(_) => crate::abi::FS_ERR_UNSUPPORTED, 62 + DecodeError::Internal(_) => crate::abi::FS_ERR_INTERNAL, 63 + } 64 + }
+131
bridge/firehose-bridge/src/envelope.rs
··· 1 + use anyhow::{anyhow, bail, Result}; 2 + use ciborium::value::Value; 3 + use std::io::Cursor; 4 + 5 + #[derive(Debug, Clone)] 6 + pub struct CommitOpRef { 7 + pub action: String, 8 + pub path: String, 9 + pub cid: Option<String>, 10 + } 11 + 12 + #[derive(Debug, Clone)] 13 + pub struct CommitEnvelope { 14 + pub seq: i64, 15 + pub repo: String, 16 + pub rev: String, 17 + pub ops: Vec<CommitOpRef>, 18 + pub blocks: Vec<u8>, 19 + } 20 + 21 + #[derive(Debug, Clone)] 22 + pub struct InfoEnvelope { 23 + pub seq: Option<i64>, 24 + pub payload_json: Option<String>, 25 + } 26 + 27 + #[derive(Debug, Clone)] 28 + pub enum Envelope { 29 + Commit(CommitEnvelope), 30 + Info(InfoEnvelope), 31 + Unknown, 32 + } 33 + 34 + #[derive(Debug, Clone)] 35 + struct FrameHeader { 36 + op: i64, 37 + t: Option<String>, 38 + } 39 + 40 + pub fn decode_envelope(bytes: &[u8]) -> Result<Envelope> { 41 + if bytes.is_empty() { 42 + bail!("empty frame") 43 + } 44 + 45 + let mut cur = Cursor::new(bytes); 46 + let header_v: Value = ciborium::de::from_reader(&mut cur).map_err(|e| anyhow!("header CBOR decode failed: {e}"))?; 47 + let body_v: Value = ciborium::de::from_reader(&mut cur).map_err(|e| anyhow!("body CBOR decode failed: {e}"))?; 48 + let header = parse_header(&header_v)?; 49 + 50 + match header.op { 51 + 1 => match header.t.as_deref() { 52 + Some("#commit") => Ok(Envelope::Commit(parse_commit(&body_v)?)), 53 + Some("#info") | Some("#identity") | Some("#account") | Some("#sync") => { 54 + let payload_json = serde_json::to_string(&crate::dagcbor::value_to_json(&body_v)).ok(); 55 + let seq = get_i64_field(&body_v, "seq"); 56 + Ok(Envelope::Info(InfoEnvelope { seq, payload_json })) 57 + } 58 + Some(other) => Ok(Envelope::Info(InfoEnvelope { 59 + seq: get_i64_field(&body_v, "seq"), 60 + payload_json: Some(format!("{{\"eventType\":{}}}", serde_json::to_string(other)?)), 61 + })), 62 + None => Ok(Envelope::Unknown), 63 + }, 64 + -1 => { 65 + let payload_json = serde_json::to_string(&crate::dagcbor::value_to_json(&body_v)).ok(); 66 + Ok(Envelope::Info(InfoEnvelope { seq: None, payload_json })) 67 + } 68 + _ => Ok(Envelope::Unknown), 69 + } 70 + } 71 + 72 + fn parse_header(v: &Value) -> Result<FrameHeader> { 73 + Ok(FrameHeader { 74 + op: get_i64_field(v, "op").ok_or_else(|| anyhow!("missing header.op"))?, 75 + t: get_string_field(v, "t"), 76 + }) 77 + } 78 + 79 + fn parse_commit(v: &Value) -> Result<CommitEnvelope> { 80 + let seq = get_i64_field(v, "seq").ok_or_else(|| anyhow!("commit missing seq"))?; 81 + let repo = get_string_field(v, "repo").ok_or_else(|| anyhow!("commit missing repo"))?; 82 + let rev = get_string_field(v, "rev").ok_or_else(|| anyhow!("commit missing rev"))?; 83 + let blocks = get_bytes_field(v, "blocks").ok_or_else(|| anyhow!("commit missing blocks bytes"))?; 84 + let ops_v = get_field(v, "ops").ok_or_else(|| anyhow!("commit missing ops"))?; 85 + let mut ops = Vec::new(); 86 + if let Value::Array(items) = ops_v { 87 + for item in items { 88 + let action = get_string_field(item, "action").or_else(|| get_string_field(item, "op")).unwrap_or_else(|| "unknown".to_string()); 89 + let path = get_string_field(item, "path").ok_or_else(|| anyhow!("commit op missing path"))?; 90 + let cid = get_cid_like_field(item, "cid"); 91 + ops.push(CommitOpRef { action, path, cid }); 92 + } 93 + } else { 94 + bail!("commit.ops is not an array") 95 + } 96 + 97 + Ok(CommitEnvelope { seq, repo, rev, ops, blocks }) 98 + } 99 + 100 + pub fn get_field<'a>(v: &'a Value, name: &str) -> Option<&'a Value> { 101 + let Value::Map(entries) = v else { return None; }; 102 + entries.iter().find_map(|(k, vv)| match k { 103 + Value::Text(s) if s == name => Some(vv), 104 + _ => None, 105 + }) 106 + } 107 + 108 + pub fn get_string_field(v: &Value, name: &str) -> Option<String> { 109 + match get_field(v, name)? { 110 + Value::Text(s) => Some(s.clone()), 111 + _ => None, 112 + } 113 + } 114 + 115 + pub fn get_i64_field(v: &Value, name: &str) -> Option<i64> { 116 + match get_field(v, name)? { 117 + Value::Integer(i) => i64::try_from(*i).ok(), 118 + _ => None, 119 + } 120 + } 121 + 122 + pub fn get_bytes_field(v: &Value, name: &str) -> Option<Vec<u8>> { 123 + match get_field(v, name)? { 124 + Value::Bytes(b) => Some(b.clone()), 125 + _ => None, 126 + } 127 + } 128 + 129 + pub fn get_cid_like_field(v: &Value, name: &str) -> Option<String> { 130 + crate::dagcbor::value_to_cid_string(get_field(v, name)?) 131 + }
+17
bridge/firehose-bridge/src/fixtures.md
··· 1 + # Suggested Fixture Set 2 + 3 + Capture and store raw websocket frames for at least these cases: 4 + 5 + 1. `#commit` with one `create app.bsky.feed.post` 6 + 2. `#commit` with one `create like` 7 + 3. `#commit` with one `delete follow` 8 + 4. `#identity` 9 + 5. `#account` 10 + 6. error frame (`op = -1`) 11 + 12 + Then assert: 13 + - envelope decode works 14 + - commit metadata matches expected values 15 + - CAR block lookup resolves the expected record block 16 + - DAG-CBOR JSON contains post `text` 17 + - normalize emits exactly one `fs_event_t` for the post-create fixture
+9
bridge/firehose-bridge/src/lib.rs
··· 1 + pub mod abi; 2 + pub mod decoder; 3 + pub mod envelope; 4 + pub mod commit; 5 + pub mod car; 6 + pub mod dagcbor; 7 + pub mod normalize; 8 + 9 + pub use abi::*;
+76
bridge/firehose-bridge/src/normalize.rs
··· 1 + use anyhow::Result; 2 + use serde_json::Value; 3 + 4 + use crate::abi::{NormalizedEvent, FS_KIND_COMMIT_OP, FS_OP_CREATE, FS_OP_DELETE, FS_OP_UPDATE}; 5 + 6 + pub fn normalize_commit(commit: crate::commit::DecodedCommit) -> Result<Vec<NormalizedEvent>> { 7 + let mut out = Vec::new(); 8 + 9 + for op in commit.ops { 10 + let (collection, rkey) = split_repo_path(&op.path); 11 + let action = match op.action.as_str() { 12 + "create" => FS_OP_CREATE, 13 + "update" => FS_OP_UPDATE, 14 + "delete" => FS_OP_DELETE, 15 + _ => 0, 16 + }; 17 + 18 + if action != FS_OP_CREATE { 19 + continue; 20 + } 21 + 22 + if collection.as_deref() != Some("app.bsky.feed.post") { 23 + continue; 24 + } 25 + 26 + let uri = match (&collection, &rkey) { 27 + (Some(c), Some(r)) => Some(format!("at://{}/{}/{}", commit.repo, c, r)), 28 + _ => None, 29 + }; 30 + 31 + let record_json = sanitize_post_record_json(op.record_json.as_deref()); 32 + 33 + out.push(NormalizedEvent { 34 + seq: commit.seq, 35 + kind: FS_KIND_COMMIT_OP, 36 + op_action: action, 37 + repo_did: Some(commit.repo.clone()), 38 + rev: Some(commit.rev.clone()), 39 + collection, 40 + rkey, 41 + record_cid: op.cid.clone(), 42 + uri, 43 + record_json, 44 + error_message: None, 45 + }); 46 + } 47 + 48 + Ok(out) 49 + } 50 + 51 + fn split_repo_path(path: &str) -> (Option<String>, Option<String>) { 52 + let mut parts = path.split('/'); 53 + let collection = parts.next().map(|s| s.to_string()); 54 + let rkey = parts.next().map(|s| s.to_string()); 55 + (collection, rkey) 56 + } 57 + 58 + fn sanitize_post_record_json(src: Option<&str>) -> Option<String> { 59 + let Some(src) = src else { return None; }; 60 + let Ok(mut value) = serde_json::from_str::<Value>(src) else { 61 + return Some(src.to_string()); 62 + }; 63 + 64 + if let Value::Object(obj) = &mut value { 65 + if !obj.contains_key("$type") { 66 + obj.insert("$type".to_string(), Value::String("app.bsky.feed.post".to_string())); 67 + } 68 + if let Some(text) = obj.get("text") { 69 + if !text.is_string() { 70 + obj.insert("text".to_string(), Value::String(text.to_string())); 71 + } 72 + } 73 + } 74 + 75 + serde_json::to_string(&value).ok() 76 + }
+67
cshim/firehose_bridge.h
··· 1 + #ifndef FORTRANSKY_FIREHOSE_BRIDGE_H 2 + #define FORTRANSKY_FIREHOSE_BRIDGE_H 3 + 4 + #include <stddef.h> 5 + #include <stdint.h> 6 + 7 + #ifdef __cplusplus 8 + extern "C" { 9 + #endif 10 + 11 + enum { 12 + FS_OK = 0, 13 + FS_ERR_CBOR = 1, 14 + FS_ERR_ENVELOPE = 2, 15 + FS_ERR_COMMIT_PARSE = 3, 16 + FS_ERR_CAR_PARSE = 4, 17 + FS_ERR_DAGCBOR_PARSE = 5, 18 + FS_ERR_UNSUPPORTED = 6, 19 + FS_ERR_OOM = 7, 20 + FS_ERR_INTERNAL = 8 21 + }; 22 + 23 + enum { 24 + FS_KIND_COMMIT_OP = 1, 25 + FS_KIND_IDENTITY = 2, 26 + FS_KIND_ACCOUNT = 3, 27 + FS_KIND_INFO = 4, 28 + FS_KIND_ERROR = 5 29 + }; 30 + 31 + enum { 32 + FS_OP_NONE = 0, 33 + FS_OP_CREATE = 1, 34 + FS_OP_UPDATE = 2, 35 + FS_OP_DELETE = 3 36 + }; 37 + 38 + typedef struct { 39 + int64_t seq; 40 + int32_t kind; 41 + int32_t op_action; 42 + const char *repo_did; 43 + const char *rev; 44 + const char *collection; 45 + const char *rkey; 46 + const char *record_cid; 47 + const char *uri; 48 + const char *record_json; 49 + const char *error_message; 50 + } fs_event_t; 51 + 52 + typedef struct { 53 + fs_event_t *events; 54 + size_t len; 55 + void *owner; 56 + } fs_event_batch_t; 57 + 58 + int fs_decoder_init(void); 59 + void fs_decoder_shutdown(void); 60 + int fs_decode_frame(const uint8_t *data, size_t len, fs_event_batch_t *out_batch); 61 + void fs_free_batch(fs_event_batch_t *batch); 62 + 63 + #ifdef __cplusplus 64 + } 65 + #endif 66 + 67 + #endif
+86
cshim/http_bridge.c
··· 1 + #include <curl/curl.h> 2 + #include <stdlib.h> 3 + #include <string.h> 4 + 5 + struct buffer { 6 + char *data; 7 + size_t size; 8 + }; 9 + 10 + static size_t write_cb(void *contents, size_t size, size_t nmemb, void *userp) { 11 + size_t realsize = size * nmemb; 12 + struct buffer *mem = (struct buffer *)userp; 13 + char *ptr = (char *)realloc(mem->data, mem->size + realsize + 1); 14 + if (!ptr) return 0; 15 + mem->data = ptr; 16 + memcpy(&(mem->data[mem->size]), contents, realsize); 17 + mem->size += realsize; 18 + mem->data[mem->size] = '\0'; 19 + return realsize; 20 + } 21 + 22 + static char *dup_empty(void) { 23 + char *p = (char *)malloc(1); 24 + if (p) p[0] = '\0'; 25 + return p; 26 + } 27 + 28 + static char *do_request(const char *url, const char *auth_header, const char *json_body, long *status_code, size_t *out_len) { 29 + CURL *curl; 30 + CURLcode res; 31 + struct curl_slist *headers = NULL; 32 + struct buffer chunk = {0}; 33 + char *result = NULL; 34 + 35 + if (status_code) *status_code = 0; 36 + if (out_len) *out_len = 0; 37 + 38 + curl_global_init(CURL_GLOBAL_DEFAULT); 39 + curl = curl_easy_init(); 40 + if (!curl) return dup_empty(); 41 + 42 + curl_easy_setopt(curl, CURLOPT_URL, url); 43 + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); 44 + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 20L); 45 + curl_easy_setopt(curl, CURLOPT_USERAGENT, "fortransky/0.7"); 46 + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); 47 + curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&chunk); 48 + curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, ""); 49 + 50 + if (auth_header && auth_header[0] != '\0') headers = curl_slist_append(headers, auth_header); 51 + if (json_body) { 52 + headers = curl_slist_append(headers, "Content-Type: application/json"); 53 + curl_easy_setopt(curl, CURLOPT_POST, 1L); 54 + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body); 55 + } 56 + if (headers) curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); 57 + 58 + res = curl_easy_perform(curl); 59 + if (res == CURLE_OK) { 60 + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, status_code); 61 + result = chunk.data ? chunk.data : dup_empty(); 62 + if (out_len && result) *out_len = strlen(result); 63 + } else { 64 + const char *msg = curl_easy_strerror(res); 65 + result = (char *)malloc(strlen(msg) + 1); 66 + if (result) strcpy(result, msg); 67 + if (out_len && result) *out_len = strlen(result); 68 + free(chunk.data); 69 + } 70 + 71 + if (headers) curl_slist_free_all(headers); 72 + curl_easy_cleanup(curl); 73 + return result ? result : dup_empty(); 74 + } 75 + 76 + char *fortransky_http_get(const char *url, const char *auth_header, long *status_code, size_t *out_len) { 77 + return do_request(url, auth_header, NULL, status_code, out_len); 78 + } 79 + 80 + char *fortransky_http_post_json(const char *url, const char *auth_header, const char *json_body, long *status_code, size_t *out_len) { 81 + return do_request(url, auth_header, json_body, status_code, out_len); 82 + } 83 + 84 + void fortransky_http_free(char *ptr) { 85 + free(ptr); 86 + }
+1
fixtures/relay_commit_expected.jsonl
··· 1 + {"kind":"commit","did":"did:plc:fortranskyfixture000000000000","handle":"","text":"Synthetic raw relay commit fixture: hello from Fortransky.","time_us":"26653242501","uri":"at://did:plc:fortranskyfixture000000000000/app.bsky.feed.post/3lmfixturepost"}
fixtures/relay_commit_frame.bin

This is a binary file and will not be displayed.

+15
scripts/build.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + ROOT="$(cd "$(dirname "$0")/.." && pwd)" 4 + 5 + # Build Rust firehose bridge first so the staticlib is present for the Fortran link 6 + printf 'Building Rust firehose bridge...\n' 7 + cd "$ROOT/bridge/firehose-bridge" 8 + cargo build --release 9 + printf 'Rust bridge built: %s\n' "$ROOT/bridge/firehose-bridge/target/release/libfortransky_firehose_bridge.a" 10 + 11 + # Build Fortran/C executable 12 + mkdir -p "$ROOT/build" 13 + cd "$ROOT/build" 14 + cmake .. 15 + cmake --build . -j
+7
scripts/build_bridge.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" 4 + cd "$ROOT_DIR/bridge/firehose-bridge" 5 + cargo build --release 6 + printf 'Built %s 7 + ' "$ROOT_DIR/bridge/firehose-bridge/target/release/firehose_bridge_cli"
+70
scripts/jetstream_tail.py
··· 1 + #!/usr/bin/env python3 2 + import argparse 3 + import asyncio 4 + import json 5 + import sys 6 + from typing import Optional 7 + 8 + import websockets 9 + 10 + DEFAULT_ENDPOINT = "wss://jetstream2.us-east.bsky.network/subscribe" 11 + 12 + 13 + def build_url(base: str, cursor: Optional[str]) -> str: 14 + if cursor: 15 + sep = '&' if '?' in base else '?' 16 + return f"{base}{sep}cursor={cursor}" 17 + return base 18 + 19 + 20 + def simplify(msg: dict) -> dict: 21 + commit = msg.get("commit") or {} 22 + record = commit.get("record") or msg.get("record") or {} 23 + identity = msg.get("identity") or {} 24 + account = msg.get("account") or {} 25 + return { 26 + "kind": msg.get("kind") or msg.get("event") or "event", 27 + "time_us": str(msg.get("time_us") or msg.get("time") or ""), 28 + "did": msg.get("did") or account.get("did") or identity.get("did") or "", 29 + "handle": msg.get("handle") or identity.get("handle") or account.get("handle") or "", 30 + "text": record.get("text") or msg.get("text") or "", 31 + } 32 + 33 + 34 + async def main() -> int: 35 + ap = argparse.ArgumentParser() 36 + ap.add_argument("--endpoint", default=DEFAULT_ENDPOINT) 37 + ap.add_argument("--limit", type=int, default=12) 38 + ap.add_argument("--cursor", default="") 39 + args = ap.parse_args() 40 + 41 + url = build_url(args.endpoint, args.cursor or None) 42 + count = 0 43 + try: 44 + async with websockets.connect(url, max_size=2**20, ping_interval=20, ping_timeout=20) as ws: 45 + async for raw in ws: 46 + if isinstance(raw, bytes): 47 + try: 48 + raw = raw.decode("utf-8", errors="replace") 49 + except Exception: 50 + continue 51 + try: 52 + msg = json.loads(raw) 53 + except json.JSONDecodeError: 54 + continue 55 + item = simplify(msg) 56 + sys.stdout.write(json.dumps(item, ensure_ascii=False) + "\n") 57 + sys.stdout.flush() 58 + count += 1 59 + if count >= args.limit: 60 + break 61 + return 0 62 + except KeyboardInterrupt: 63 + return 130 64 + except Exception as exc: 65 + sys.stderr.write(f"jetstream_tail.py error: {exc}\n") 66 + return 1 67 + 68 + 69 + if __name__ == "__main__": 70 + raise SystemExit(asyncio.run(main()))
+79
scripts/make_relay_fixture.py
··· 1 + #!/usr/bin/env python3 2 + import hashlib 3 + from pathlib import Path 4 + import cbor2 5 + 6 + 7 + def varint(n: int) -> bytes: 8 + out = bytearray() 9 + while True: 10 + b = n & 0x7F 11 + n >>= 7 12 + if n: 13 + out.append(b | 0x80) 14 + else: 15 + out.append(b) 16 + return bytes(out) 17 + 18 + 19 + def cid_for_block(block: bytes) -> bytes: 20 + digest = hashlib.sha256(block).digest() 21 + # CIDv1 + dag-cbor + sha2-256 22 + return bytes([0x01, 0x71, 0x12, 0x20]) + digest 23 + 24 + 25 + def car_v1_single(blocks: list[tuple[bytes, bytes]]) -> bytes: 26 + header = cbor2.dumps({"version": 1, "roots": []}) 27 + out = bytearray() 28 + out.extend(varint(len(header))) 29 + out.extend(header) 30 + for cid_bytes, block in blocks: 31 + section = cid_bytes + block 32 + out.extend(varint(len(section))) 33 + out.extend(section) 34 + return bytes(out) 35 + 36 + 37 + def main() -> int: 38 + root = Path(__file__).resolve().parents[1] 39 + fixtures = root / 'fixtures' 40 + fixtures.mkdir(parents=True, exist_ok=True) 41 + 42 + record = { 43 + '$type': 'app.bsky.feed.post', 44 + 'text': 'Synthetic raw relay commit fixture: hello from Fortransky.', 45 + 'createdAt': '2026-03-19T00:00:00.000Z', 46 + } 47 + record_block = cbor2.dumps(record) 48 + record_cid = cid_for_block(record_block) 49 + car_bytes = car_v1_single([(record_cid, record_block)]) 50 + 51 + op = { 52 + 'action': 'create', 53 + 'path': 'app.bsky.feed.post/3lmfixturepost', 54 + 'cid': record_cid, 55 + } 56 + body = { 57 + 'seq': 26653242501, 58 + 'repo': 'did:plc:fortranskyfixture000000000000', 59 + 'rev': '3lmfixture-rev', 60 + 'time': '2026-03-19T00:00:00.000Z', 61 + 'ops': [op], 62 + 'blocks': car_bytes, 63 + } 64 + header = {'op': 1, 't': '#commit'} 65 + frame = cbor2.dumps(header) + cbor2.dumps(body) 66 + 67 + (fixtures / 'relay_commit_frame.bin').write_bytes(frame) 68 + (fixtures / 'relay_commit_expected.jsonl').write_text( 69 + '{"kind":"commit","did":"did:plc:fortranskyfixture000000000000",' 70 + '"handle":"","text":"Synthetic raw relay commit fixture: hello from Fortransky.",' 71 + '"time_us":"26653242501","uri":"at://did:plc:fortranskyfixture000000000000/app.bsky.feed.post/3lmfixturepost"}\n', 72 + encoding='utf-8', 73 + ) 74 + print(fixtures / 'relay_commit_frame.bin') 75 + return 0 76 + 77 + 78 + if __name__ == '__main__': 79 + raise SystemExit(main())
+212
scripts/relay_raw_tail.py
··· 1 + #!/usr/bin/env python3 2 + import argparse 3 + import asyncio 4 + import io 5 + import json 6 + import os 7 + import shutil 8 + import subprocess 9 + import sys 10 + from pathlib import Path 11 + from typing import Optional 12 + 13 + import cbor2 14 + import websockets 15 + 16 + DEFAULT_ENDPOINT = 'wss://relay1.us-east.bsky.network/xrpc/com.atproto.sync.subscribeRepos' 17 + 18 + 19 + def build_url(base: str, cursor: Optional[str]) -> str: 20 + if cursor: 21 + sep = '&' if '?' in base else '?' 22 + return f'{base}{sep}cursor={cursor}' 23 + return base 24 + 25 + 26 + def detect_native_decoder(root: Path) -> Optional[str]: 27 + env = os.environ.get('FORTRANSKY_FIREHOSE_DECODER', '').strip() 28 + candidates = [] 29 + if env: 30 + candidates.append(env) 31 + candidates.extend([ 32 + str(root / 'bridge' / 'firehose-bridge' / 'target' / 'release' / 'firehose_bridge_cli'), 33 + str(root / 'bridge' / 'firehose-bridge' / 'target' / 'debug' / 'firehose_bridge_cli'), 34 + ]) 35 + for candidate in candidates: 36 + if candidate and Path(candidate).exists() and os.access(candidate, os.X_OK): 37 + return candidate 38 + return shutil.which('firehose_bridge_cli') 39 + 40 + 41 + def decode_frame_native(raw: bytes, decoder: str) -> list[dict]: 42 + proc = subprocess.run([decoder], input=raw, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) 43 + if proc.returncode != 0: 44 + raise RuntimeError(proc.stderr.decode('utf-8', errors='replace').strip() or 'native decoder failed') 45 + events = [] 46 + for line in proc.stdout.decode('utf-8', errors='replace').splitlines(): 47 + line = line.strip() 48 + if not line: 49 + continue 50 + events.append(json.loads(line)) 51 + return events 52 + 53 + 54 + def read_varint(buf: bytes, pos: int) -> tuple[int, int]: 55 + shift = 0 56 + value = 0 57 + while True: 58 + if pos >= len(buf): 59 + raise ValueError('truncated varint') 60 + b = buf[pos] 61 + pos += 1 62 + value |= (b & 0x7F) << shift 63 + if not (b & 0x80): 64 + return value, pos 65 + shift += 7 66 + if shift > 63: 67 + raise ValueError('varint too large') 68 + 69 + 70 + def read_cid(buf: bytes, pos: int) -> tuple[bytes, int]: 71 + start = pos 72 + _, pos = read_varint(buf, pos) 73 + _, pos = read_varint(buf, pos) 74 + _, pos = read_varint(buf, pos) 75 + digest_len, pos = read_varint(buf, pos) 76 + pos += digest_len 77 + if pos > len(buf): 78 + raise ValueError('truncated cid digest') 79 + return buf[start:pos], pos 80 + 81 + 82 + def parse_car_v1(car_bytes: bytes) -> dict[bytes, bytes]: 83 + pos = 0 84 + header_len, pos = read_varint(car_bytes, pos) 85 + header_end = pos + header_len 86 + if header_end > len(car_bytes): 87 + raise ValueError('truncated car header') 88 + _header = cbor2.loads(car_bytes[pos:header_end]) 89 + pos = header_end 90 + blocks: dict[bytes, bytes] = {} 91 + while pos < len(car_bytes): 92 + section_len, pos = read_varint(car_bytes, pos) 93 + section_end = pos + section_len 94 + if section_end > len(car_bytes): 95 + raise ValueError('truncated car section') 96 + section = car_bytes[pos:section_end] 97 + cid_bytes, data_pos = read_cid(section, 0) 98 + blocks[cid_bytes] = section[data_pos:] 99 + pos = section_end 100 + return blocks 101 + 102 + 103 + def normalize_event(seq: int, repo: str, path: str, record: dict) -> dict: 104 + return { 105 + 'kind': 'commit', 106 + 'did': repo, 107 + 'handle': '', 108 + 'text': str(record.get('text', ''))[:1024], 109 + 'time_us': str(seq), 110 + 'uri': f'at://{repo}/{path}', 111 + 'record_type': str(record.get('$type', '')), 112 + 'source': 'relay-raw-python-fallback', 113 + } 114 + 115 + 116 + def decode_frame_python(raw: bytes) -> list[dict]: 117 + decoder = cbor2.CBORDecoder(io.BytesIO(raw)) 118 + header = decoder.decode() 119 + body = decoder.decode() 120 + if not isinstance(header, dict) or not isinstance(body, dict): 121 + return [] 122 + if header.get('t') != '#commit': 123 + return [] 124 + seq = int(body.get('seq', 0)) 125 + repo = str(body.get('repo', '')) 126 + ops = body.get('ops', []) or [] 127 + blocks_blob = body.get('blocks', b'') or b'' 128 + if not isinstance(blocks_blob, (bytes, bytearray)): 129 + return [] 130 + blocks = parse_car_v1(bytes(blocks_blob)) 131 + out: list[dict] = [] 132 + for op in ops: 133 + if not isinstance(op, dict): 134 + continue 135 + if op.get('action') != 'create': 136 + continue 137 + path = str(op.get('path', '')) 138 + if not path.startswith('app.bsky.feed.post/'): 139 + continue 140 + cid = op.get('cid', b'') 141 + if not isinstance(cid, (bytes, bytearray)): 142 + continue 143 + block = blocks.get(bytes(cid)) 144 + if not block: 145 + continue 146 + record = cbor2.loads(block) 147 + if not isinstance(record, dict): 148 + continue 149 + if record.get('$type') != 'app.bsky.feed.post': 150 + continue 151 + out.append(normalize_event(seq, repo, path, record)) 152 + return out 153 + 154 + 155 + def decode_frame(raw: bytes, root: Path) -> list[dict]: 156 + decoder = detect_native_decoder(root) 157 + if decoder: 158 + return decode_frame_native(raw, decoder) 159 + return decode_frame_python(raw) 160 + 161 + 162 + def load_fixture_events(frame_file: Path, root: Path) -> list[dict]: 163 + return decode_frame(frame_file.read_bytes(), root) 164 + 165 + 166 + async def load_live_events(url: str, limit: int, root: Path) -> list[dict]: 167 + collected: list[dict] = [] 168 + async with websockets.connect(url, max_size=2**22, ping_interval=20, ping_timeout=20) as ws: 169 + async for raw in ws: 170 + if isinstance(raw, str): 171 + continue 172 + try: 173 + events = decode_frame(raw, root) 174 + except Exception: 175 + events = [] 176 + for ev in events: 177 + collected.append(ev) 178 + if len(collected) >= limit: 179 + return collected 180 + return collected 181 + 182 + 183 + async def main() -> int: 184 + ap = argparse.ArgumentParser() 185 + ap.add_argument('--endpoint', default=DEFAULT_ENDPOINT) 186 + ap.add_argument('--limit', type=int, default=12) 187 + ap.add_argument('--cursor', default='') 188 + ap.add_argument('--fixture', default='') 189 + args = ap.parse_args() 190 + 191 + root = Path(__file__).resolve().parents[1] 192 + fixture_path = Path(args.fixture) if args.fixture else (root / 'fixtures' / 'relay_commit_frame.bin') 193 + url = build_url(args.endpoint, args.cursor or None) 194 + 195 + prefer_fixture = os.environ.get('FORTRANSKY_RELAY_FIXTURE', '0').lower() not in {'0', 'false', 'no'} 196 + events: list[dict] = [] 197 + if not prefer_fixture: 198 + try: 199 + events = await load_live_events(url, args.limit, root) 200 + except Exception as exc: 201 + sys.stderr.write(f'relay_raw_tail.py live decode failed, falling back to fixture: {exc}\n') 202 + if not events and fixture_path.exists(): 203 + events = load_fixture_events(fixture_path, root) 204 + 205 + for ev in events[: max(1, args.limit)]: 206 + sys.stdout.write(json.dumps(ev, ensure_ascii=False) + '\n') 207 + sys.stdout.flush() 208 + return 0 if events else 1 209 + 210 + 211 + if __name__ == '__main__': 212 + raise SystemExit(asyncio.run(main()))
+5
src/app/main.f90
··· 1 + program fortransky 2 + use tui_mod, only: app_loop 3 + implicit none 4 + call app_loop() 5 + end program fortransky
+547
src/atproto/client.f90
··· 1 + module client_mod 2 + use http_cbridge_mod, only: http_get, http_post_json, http_get_urlencoded, last_http_status 3 + use json_extract_mod, only: extract_json_string, escape_json_string 4 + use decode_mod, only: decode_posts_json, decode_stream_blob, decode_thread_json, decode_profile_json, decode_notifications_json 5 + use models_mod, only: session_state, post_view, stream_event, actor_profile, notification_view, MAX_ITEMS, HANDLE_LEN, URI_LEN 6 + use app_state_mod, only: app_state, DID_CACHE_SIZE 7 + use process_mod, only: run_capture, slurp_file 8 + use log_store_mod, only: state_file, append_line, read_first_line, write_text 9 + implicit none 10 + private 11 + public :: session_state, login_session, fetch_author_feed, search_posts, fetch_timeline 12 + public :: tail_live_stream, fetch_post_thread, create_post, create_reply, create_quote_post, like_post, repost_post 13 + public :: fetch_profile_view, fetch_notifications_view, load_saved_session, save_session, clear_saved_session 14 + public :: resolve_did_to_handle 15 + contains 16 + subroutine load_saved_session(state) 17 + type(session_state), intent(inout) :: state 18 + character(len=:), allocatable :: body 19 + 20 + body = read_first_line(state_file('session.json')) 21 + if (len_trim(body) == 0) return 22 + state%identifier = '' 23 + state%did = '' 24 + state%access_jwt = '' 25 + state%refresh_jwt = '' 26 + call copy_fit(extract_json_string(body, 'identifier'), state%identifier) 27 + call copy_fit(extract_json_string(body, 'did'), state%did) 28 + call copy_fit(extract_json_string(body, 'accessJwt'), state%access_jwt) 29 + call copy_fit(extract_json_string(body, 'refreshJwt'), state%refresh_jwt) 30 + end subroutine load_saved_session 31 + 32 + subroutine save_session(state) 33 + type(session_state), intent(in) :: state 34 + character(len=:), allocatable :: body 35 + body = '{"identifier":"' // escape_json_string(trim(state%identifier)) // '",' // & 36 + '"did":"' // escape_json_string(trim(state%did)) // '",' // & 37 + '"accessJwt":"' // escape_json_string(trim(state%access_jwt)) // '",' // & 38 + '"refreshJwt":"' // escape_json_string(trim(state%refresh_jwt)) // '"}' 39 + call write_text(state_file('session.json'), body) 40 + end subroutine save_session 41 + 42 + subroutine clear_saved_session() 43 + call write_text(state_file('session.json'), '') 44 + end subroutine clear_saved_session 45 + 46 + subroutine copy_fit(src, dest) 47 + character(len=*), intent(in) :: src 48 + character(len=*), intent(inout) :: dest 49 + dest = '' 50 + if (len_trim(src) > 0) dest(1:min(len_trim(src), len(dest))) = src(1:min(len_trim(src), len(dest))) 51 + end subroutine copy_fit 52 + 53 + function itoa(i) result(s) 54 + integer, intent(in) :: i 55 + character(len=:), allocatable :: s 56 + character(len=32) :: tmp 57 + write(tmp,'(i0)') i 58 + s = trim(tmp) 59 + end function itoa 60 + 61 + subroutine login_session(state, password, ok, message) 62 + type(session_state), intent(inout) :: state 63 + character(len=*), intent(in) :: password 64 + logical, intent(out) :: ok 65 + character(len=*), intent(out) :: message 66 + character(len=:), allocatable :: body, payload, access, refresh, did, identifier 67 + 68 + identifier = escape_json_string(trim(state%identifier)) 69 + payload = '{"identifier":"' // identifier // '","password":"' // escape_json_string(trim(password)) // '"}' 70 + body = http_post_json(trim(state%pds_host) // '/xrpc/com.atproto.server.createSession', payload) 71 + access = extract_json_string(body, 'accessJwt') 72 + refresh = extract_json_string(body, 'refreshJwt') 73 + did = extract_json_string(body, 'did') 74 + if (len_trim(access) > 0) then 75 + state%access_jwt = '' 76 + state%refresh_jwt = '' 77 + state%did = '' 78 + state%access_jwt(1:min(len_trim(access), len(state%access_jwt))) = access(1:min(len_trim(access), len(state%access_jwt))) 79 + state%refresh_jwt(1:min(len_trim(refresh), len(state%refresh_jwt))) = refresh(1:min(len_trim(refresh), len(state%refresh_jwt))) 80 + state%did(1:min(len_trim(did), len(state%did))) = did(1:min(len_trim(did), len(state%did))) 81 + ok = .true. 82 + call save_session(state) 83 + message = 'Login OK' 84 + else 85 + ok = .false. 86 + message = 'Login failed (HTTP ' // trim(itoa(last_http_status)) // ')' 87 + end if 88 + end subroutine login_session 89 + 90 + subroutine fetch_author_feed(handle, posts, n) 91 + character(len=*), intent(in) :: handle 92 + type(post_view), intent(out) :: posts(MAX_ITEMS) 93 + integer, intent(out) :: n 94 + character(len=:), allocatable :: body 95 + 96 + body = http_get_urlencoded('https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?limit=40', 'actor', trim(handle)) 97 + call decode_posts_json(body, posts, n) 98 + end subroutine fetch_author_feed 99 + 100 + subroutine search_posts(query, posts, n) 101 + character(len=*), intent(in) :: query 102 + type(post_view), intent(out) :: posts(MAX_ITEMS) 103 + integer, intent(out) :: n 104 + character(len=:), allocatable :: body 105 + 106 + body = http_get_urlencoded('https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?limit=40', 'q', trim(query)) 107 + call decode_posts_json(body, posts, n) 108 + end subroutine search_posts 109 + 110 + subroutine fetch_timeline(state, posts, n, ok) 111 + type(session_state), intent(in) :: state 112 + type(post_view), intent(out) :: posts(MAX_ITEMS) 113 + integer, intent(out) :: n 114 + logical, intent(out) :: ok 115 + character(len=:), allocatable :: body 116 + 117 + if (len_trim(state%access_jwt) == 0) then 118 + ok = .false. 119 + n = 0 120 + posts = post_view() 121 + return 122 + end if 123 + 124 + body = http_get(trim(state%pds_host) // '/xrpc/app.bsky.feed.getTimeline?limit=40', trim(state%access_jwt)) 125 + call decode_posts_json(body, posts, n) 126 + ok = (last_http_status >= 200 .and. last_http_status < 300) 127 + end subroutine fetch_timeline 128 + 129 + subroutine fetch_profile_view(handle, profile, ok, message) 130 + character(len=*), intent(in) :: handle 131 + type(actor_profile), intent(out) :: profile 132 + logical, intent(out) :: ok 133 + character(len=*), intent(out) :: message 134 + character(len=:), allocatable :: body 135 + 136 + body = http_get_urlencoded('https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile', 'actor', trim(handle)) 137 + call decode_profile_json(body, profile) 138 + ok = len_trim(profile%handle) > 0 .or. len_trim(profile%did) > 0 139 + if (ok) then 140 + message = 'Profile loaded' 141 + else 142 + message = 'Profile fetch failed' 143 + end if 144 + end subroutine fetch_profile_view 145 + 146 + subroutine fetch_notifications_view(state, items, n, ok, message) 147 + type(session_state), intent(in) :: state 148 + type(notification_view), intent(out) :: items(MAX_ITEMS) 149 + integer, intent(out) :: n 150 + logical, intent(out) :: ok 151 + character(len=*), intent(out) :: message 152 + character(len=:), allocatable :: body 153 + 154 + if (len_trim(state%access_jwt) == 0) then 155 + items = notification_view() 156 + n = 0 157 + ok = .false. 158 + message = 'Login required before reading notifications.' 159 + return 160 + end if 161 + 162 + body = http_get(trim(state%pds_host) // '/xrpc/app.bsky.notification.listNotifications?limit=40', trim(state%access_jwt)) 163 + call decode_notifications_json(body, items, n) 164 + ok = (last_http_status >= 200 .and. last_http_status < 300) 165 + if (ok) then 166 + message = 'Notifications loaded' 167 + else 168 + message = 'No notifications decoded' 169 + end if 170 + end subroutine fetch_notifications_view 171 + 172 + subroutine fetch_post_thread(uri_or_url, posts, n, ok, message) 173 + character(len=*), intent(in) :: uri_or_url 174 + type(post_view), intent(out) :: posts(MAX_ITEMS) 175 + integer, intent(out) :: n 176 + logical, intent(out) :: ok 177 + character(len=*), intent(out) :: message 178 + character(len=:), allocatable :: uri, body 179 + 180 + uri = normalize_post_ref(trim(uri_or_url)) 181 + if (len_trim(uri) == 0) then 182 + posts = post_view() 183 + n = 0 184 + ok = .false. 185 + message = 'Could not parse post reference. Use at://... or a bsky.app post URL.' 186 + return 187 + end if 188 + 189 + body = http_get_urlencoded('https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread', 'uri', trim(uri)) 190 + call decode_thread_json(body, posts, n) 191 + ok = (last_http_status >= 200 .and. last_http_status < 300) 192 + if (ok) then 193 + message = 'Thread loaded for ' // trim(uri) 194 + else 195 + message = 'No thread posts decoded. Try a direct at:// URI.' 196 + end if 197 + contains 198 + function normalize_post_ref(raw) result(uri) 199 + character(len=*), intent(in) :: raw 200 + character(len=:), allocatable :: uri 201 + character(len=:), allocatable :: tmp 202 + integer :: p1, p2 203 + 204 + tmp = trim(raw) 205 + if (index(tmp, 'at://') == 1) then 206 + uri = tmp 207 + return 208 + end if 209 + if (index(tmp, 'https://bsky.app/profile/') == 1) then 210 + p1 = len('https://bsky.app/profile/') + 1 211 + p2 = index(tmp(p1:), '/post/') 212 + if (p2 > 0) then 213 + uri = 'at://' // tmp(p1:p1+p2-2) // '/app.bsky.feed.post/' // tmp(p1+p2+5:) 214 + return 215 + end if 216 + end if 217 + uri = '' 218 + end function normalize_post_ref 219 + end subroutine fetch_post_thread 220 + 221 + subroutine create_post(state, text, ok, message, created_uri) 222 + type(session_state), intent(in) :: state 223 + character(len=*), intent(in) :: text 224 + logical, intent(out) :: ok 225 + character(len=*), intent(out) :: message 226 + character(len=*), intent(out) :: created_uri 227 + 228 + call create_record_with_optional_reply(state, text, '', '', '', '', ok, message, created_uri) 229 + end subroutine create_post 230 + 231 + subroutine create_reply(state, text, parent_uri, parent_cid, root_uri, root_cid, ok, message, created_uri) 232 + type(session_state), intent(in) :: state 233 + character(len=*), intent(in) :: text, parent_uri, parent_cid, root_uri, root_cid 234 + logical, intent(out) :: ok 235 + character(len=*), intent(out) :: message 236 + character(len=*), intent(out) :: created_uri 237 + 238 + call create_record_with_optional_reply(state, text, parent_uri, parent_cid, root_uri, root_cid, ok, message, created_uri) 239 + end subroutine create_reply 240 + 241 + subroutine create_quote_post(state, text, quote_uri, quote_cid, ok, message, created_uri) 242 + type(session_state), intent(in) :: state 243 + character(len=*), intent(in) :: text, quote_uri, quote_cid 244 + logical, intent(out) :: ok 245 + character(len=*), intent(out) :: message 246 + character(len=*), intent(out) :: created_uri 247 + 248 + call create_record_with_embed(state, text, quote_uri, quote_cid, ok, message, created_uri) 249 + end subroutine create_quote_post 250 + 251 + subroutine like_post(state, subject_uri, subject_cid, ok, message, created_uri) 252 + type(session_state), intent(in) :: state 253 + character(len=*), intent(in) :: subject_uri, subject_cid 254 + logical, intent(out) :: ok 255 + character(len=*), intent(out) :: message 256 + character(len=*), intent(out) :: created_uri 257 + 258 + call create_subject_action_record(state, 'app.bsky.feed.like', subject_uri, subject_cid, ok, message, created_uri) 259 + end subroutine like_post 260 + 261 + subroutine repost_post(state, subject_uri, subject_cid, ok, message, created_uri) 262 + type(session_state), intent(in) :: state 263 + character(len=*), intent(in) :: subject_uri, subject_cid 264 + logical, intent(out) :: ok 265 + character(len=*), intent(out) :: message 266 + character(len=*), intent(out) :: created_uri 267 + 268 + call create_subject_action_record(state, 'app.bsky.feed.repost', subject_uri, subject_cid, ok, message, created_uri) 269 + end subroutine repost_post 270 + 271 + subroutine create_record_with_optional_reply(state, text, parent_uri, parent_cid, root_uri, root_cid, ok, message, created_uri) 272 + type(session_state), intent(in) :: state 273 + character(len=*), intent(in) :: text, parent_uri, parent_cid, root_uri, root_cid 274 + logical, intent(out) :: ok 275 + character(len=*), intent(out) :: message 276 + character(len=*), intent(out) :: created_uri 277 + 278 + call create_post_record(state, text, parent_uri, parent_cid, root_uri, root_cid, '', '', ok, message, created_uri) 279 + end subroutine create_record_with_optional_reply 280 + 281 + subroutine create_record_with_embed(state, text, embed_uri, embed_cid, ok, message, created_uri) 282 + type(session_state), intent(in) :: state 283 + character(len=*), intent(in) :: text, embed_uri, embed_cid 284 + logical, intent(out) :: ok 285 + character(len=*), intent(out) :: message 286 + character(len=*), intent(out) :: created_uri 287 + 288 + call create_post_record(state, text, '', '', '', '', embed_uri, embed_cid, ok, message, created_uri) 289 + end subroutine create_record_with_embed 290 + 291 + subroutine create_post_record(state, text, parent_uri, parent_cid, root_uri, root_cid, embed_uri, embed_cid, ok, message, created_uri) 292 + type(session_state), intent(in) :: state 293 + character(len=*), intent(in) :: text, parent_uri, parent_cid, root_uri, root_cid, embed_uri, embed_cid 294 + logical, intent(out) :: ok 295 + character(len=*), intent(out) :: message 296 + character(len=*), intent(out) :: created_uri 297 + character(len=:), allocatable :: payload, body, now_utc, repo, reply_json, embed_json, facets_json 298 + 299 + ok = .false. 300 + message = 'Not sent' 301 + created_uri = '' 302 + if (len_trim(state%access_jwt) == 0) then 303 + message = 'Login required before posting.' 304 + return 305 + end if 306 + 307 + repo = trim(state%did) 308 + if (len_trim(repo) == 0) repo = trim(state%identifier) 309 + now_utc = utc_timestamp_iso() 310 + 311 + reply_json = '' 312 + if (len_trim(parent_uri) > 0 .and. len_trim(parent_cid) > 0) then 313 + reply_json = ',"reply":{"root":{"uri":"' // escape_json_string(trim(root_uri)) // '","cid":"' // & 314 + escape_json_string(trim(root_cid)) // '"},"parent":{"uri":"' // & 315 + escape_json_string(trim(parent_uri)) // '","cid":"' // escape_json_string(trim(parent_cid)) // '"}}' 316 + end if 317 + 318 + embed_json = '' 319 + if (len_trim(embed_uri) > 0 .and. len_trim(embed_cid) > 0) then 320 + embed_json = ',"embed":{"$type":"app.bsky.embed.record","record":{"uri":"' // & 321 + escape_json_string(trim(embed_uri)) // '","cid":"' // escape_json_string(trim(embed_cid)) // '"}}' 322 + end if 323 + 324 + facets_json = build_facets_json(trim(text)) 325 + 326 + payload = '{' // & 327 + '"repo":"' // escape_json_string(repo) // '",' // & 328 + '"collection":"app.bsky.feed.post",' // & 329 + '"record":{' // & 330 + '"$type":"app.bsky.feed.post",' // & 331 + '"text":"' // escape_json_string(trim(text)) // '",' // & 332 + '"createdAt":"' // trim(now_utc) // '"' // trim(reply_json) // trim(embed_json) // trim(facets_json) // '}}' 333 + 334 + body = http_post_json(trim(state%pds_host) // '/xrpc/com.atproto.repo.createRecord', payload, trim(state%access_jwt)) 335 + created_uri = extract_json_string(body, 'uri') 336 + if (len_trim(created_uri) > 0) then 337 + ok = .true. 338 + if (len_trim(embed_uri) > 0) then 339 + message = 'Quote post created' 340 + else if (len_trim(parent_uri) > 0) then 341 + message = 'Reply created' 342 + else 343 + message = 'Post created' 344 + end if 345 + else 346 + message = 'Post failed. Response did not contain a URI.' 347 + end if 348 + end subroutine create_post_record 349 + 350 + subroutine create_subject_action_record(state, collection, subject_uri, subject_cid, ok, message, created_uri) 351 + type(session_state), intent(in) :: state 352 + character(len=*), intent(in) :: collection, subject_uri, subject_cid 353 + logical, intent(out) :: ok 354 + character(len=*), intent(out) :: message 355 + character(len=*), intent(out) :: created_uri 356 + character(len=:), allocatable :: payload, body, now_utc, repo 357 + 358 + ok = .false. 359 + message = 'Not sent' 360 + created_uri = '' 361 + if (len_trim(state%access_jwt) == 0) then 362 + message = 'Login required before this action.' 363 + return 364 + end if 365 + if (len_trim(subject_uri) == 0 .or. len_trim(subject_cid) == 0) then 366 + message = 'Selected post is missing URI/CID.' 367 + return 368 + end if 369 + 370 + repo = trim(state%did) 371 + if (len_trim(repo) == 0) repo = trim(state%identifier) 372 + now_utc = utc_timestamp_iso() 373 + 374 + payload = '{' // & 375 + '"repo":"' // escape_json_string(repo) // '",' // & 376 + '"collection":"' // escape_json_string(trim(collection)) // '",' // & 377 + '"record":{' // & 378 + '"$type":"' // escape_json_string(trim(collection)) // '",' // & 379 + '"subject":{"uri":"' // escape_json_string(trim(subject_uri)) // '","cid":"' // & 380 + escape_json_string(trim(subject_cid)) // '"},' // & 381 + '"createdAt":"' // trim(now_utc) // '"}}' 382 + 383 + body = http_post_json(trim(state%pds_host) // '/xrpc/com.atproto.repo.createRecord', payload, trim(state%access_jwt)) 384 + created_uri = extract_json_string(body, 'uri') 385 + if (len_trim(created_uri) > 0) then 386 + ok = .true. 387 + if (trim(collection) == 'app.bsky.feed.like') then 388 + message = 'Like created' 389 + else 390 + message = 'Repost created' 391 + end if 392 + else 393 + message = 'Action failed. Response did not contain a URI.' 394 + end if 395 + end subroutine create_subject_action_record 396 + 397 + function build_facets_json(text_in) result(out) 398 + character(len=*), intent(in) :: text_in 399 + character(len=:), allocatable :: out 400 + character(len=:), allocatable :: items, url 401 + integer :: pos, start_pos, end_pos, n, hit_http, hit_https 402 + 403 + items = '' 404 + pos = 1 405 + n = 0 406 + do 407 + hit_http = index(text_in(pos:), 'http://') 408 + hit_https = index(text_in(pos:), 'https://') 409 + if (hit_http == 0 .and. hit_https == 0) exit 410 + if (hit_http == 0) then 411 + start_pos = pos + hit_https - 1 412 + else if (hit_https == 0) then 413 + start_pos = pos + hit_http - 1 414 + else 415 + start_pos = pos + min(hit_http, hit_https) - 1 416 + end if 417 + end_pos = start_pos 418 + do while (end_pos <= len_trim(text_in)) 419 + if (text_in(end_pos:end_pos) == ' ') exit 420 + end_pos = end_pos + 1 421 + end do 422 + url = trim(text_in(start_pos:end_pos-1)) 423 + if (len_trim(url) > 0) then 424 + if (n > 0) items = items // ',' 425 + items = items // '{"index":{"byteStart":' // trim(itoa(start_pos-1)) // ',"byteEnd":' // trim(itoa(end_pos-1)) // '},' // & 426 + '"features":[{"$type":"app.bsky.richtext.facet#link","uri":"' // escape_json_string(url) // '"}]}' 427 + n = n + 1 428 + end if 429 + pos = max(end_pos, start_pos + 1) 430 + if (pos > len_trim(text_in)) exit 431 + end do 432 + if (n > 0) then 433 + out = ',"facets":[' // items // ']' 434 + else 435 + out = '' 436 + end if 437 + end function build_facets_json 438 + 439 + function utc_timestamp_iso() result(out) 440 + character(len=:), allocatable :: out 441 + integer :: vals(8) 442 + character(len=32) :: tmp 443 + call date_and_time(values=vals) 444 + write(tmp,'(i4.4,"-",i2.2,"-",i2.2,"T",i2.2,":",i2.2,":",i2.2,"Z")') & 445 + vals(1), vals(2), vals(3), vals(5), vals(6), vals(7) 446 + out = trim(tmp) 447 + end function utc_timestamp_iso 448 + 449 + subroutine tail_live_stream(events, n, ok, message, limit, mode) 450 + type(stream_event), intent(out) :: events(MAX_ITEMS) 451 + integer, intent(out) :: n 452 + logical, intent(out) :: ok 453 + character(len=*), intent(out) :: message 454 + integer, intent(in), optional :: limit 455 + character(len=*), intent(in), optional :: mode 456 + integer :: count, code 457 + character(len=:), allocatable :: cmd, out_path, body, cursor_path, log_path, cursor, stream_mode 458 + 459 + count = 12 460 + if (present(limit)) count = max(1, min(limit, MAX_ITEMS)) 461 + 462 + stream_mode = 'jetstream' 463 + if (present(mode)) then 464 + if (len_trim(mode) > 0) stream_mode = trim(mode) 465 + end if 466 + 467 + if (trim(stream_mode) == 'relay-raw') then 468 + out_path = state_file('relay_raw_tail.out') 469 + cursor_path = state_file('relay_raw.cursor') 470 + log_path = state_file('relay_raw.jsonl') 471 + cmd = 'python3 scripts/relay_raw_tail.py --limit ' // trim(itoa(count)) 472 + else 473 + out_path = state_file('jetstream_tail.out') 474 + cursor_path = state_file('jetstream.cursor') 475 + log_path = state_file('jetstream.jsonl') 476 + cmd = 'python3 scripts/jetstream_tail.py --limit ' // trim(itoa(count)) 477 + end if 478 + cursor = read_first_line(cursor_path) 479 + if (len_trim(cursor) > 0) cmd = cmd // ' --cursor ' // trim(cursor) 480 + call run_capture(cmd, out_path, code) 481 + body = slurp_file(out_path) 482 + 483 + if (code /= 0 .or. len_trim(body) == 0) then 484 + ok = .false. 485 + n = 0 486 + events = stream_event() 487 + message = 'Stream helper failed for mode ' // trim(stream_mode) 488 + return 489 + end if 490 + 491 + call decode_stream_blob(body, events, n) 492 + if (n > 0) then 493 + call append_line(log_path, trim(body)) 494 + call write_text(cursor_path, trim(events(n)%time_us)) 495 + ok = .true. 496 + message = 'Live stream updated (' // trim(stream_mode) // ')' 497 + else 498 + ok = .false. 499 + message = 'No stream events decoded' 500 + end if 501 + end subroutine tail_live_stream 502 + subroutine resolve_did_to_handle(state, did, handle) 503 + ! Look up DID in local cache; on miss call getProfile and cache the result. 504 + ! Returns the handle on success, or the DID itself as fallback. 505 + type(app_state), intent(inout) :: state 506 + character(len=*), intent(in) :: did 507 + character(len=*), intent(out) :: handle 508 + type(actor_profile) :: profile 509 + logical :: ok 510 + character(len=256) :: msg 511 + integer :: i 512 + 513 + handle = '' 514 + 515 + ! Cache lookup 516 + do i = 1, state%did_cache_count 517 + if (trim(state%did_cache(i)) == trim(did)) then 518 + handle = trim(state%handle_cache(i)) 519 + return 520 + end if 521 + end do 522 + 523 + ! Cache miss — fetch from API 524 + call fetch_profile_view(trim(did), profile, ok, msg) 525 + if (ok .and. len_trim(profile%handle) > 0) then 526 + handle = trim(profile%handle) 527 + else 528 + handle = trim(did) ! fallback: show DID if resolution fails 529 + end if 530 + 531 + ! Store in cache (evict oldest entry when full) 532 + if (state%did_cache_count < DID_CACHE_SIZE) then 533 + state%did_cache_count = state%did_cache_count + 1 534 + i = state%did_cache_count 535 + else 536 + ! Shift entries down by one, dropping the oldest 537 + do i = 1, DID_CACHE_SIZE - 1 538 + state%did_cache(i) = state%did_cache(i+1) 539 + state%handle_cache(i) = state%handle_cache(i+1) 540 + end do 541 + i = DID_CACHE_SIZE 542 + end if 543 + state%did_cache(i) = trim(did) 544 + state%handle_cache(i) = trim(handle) 545 + end subroutine resolve_did_to_handle 546 + 547 + end module client_mod
+227
src/atproto/decode.f90
··· 1 + module decode_mod 2 + use models_mod, only: post_view, stream_event, actor_profile, notification_view, MAX_ITEMS, FIELD_LEN, HANDLE_LEN, URI_LEN, CID_LEN, TS_LEN 3 + use json_extract_mod, only: extract_json_string, extract_json_object_after, extract_json_array_after, & 4 + next_array_object, extract_reply_refs, slice_fit, find_first_array 5 + implicit none 6 + private 7 + public :: decode_posts_json, decode_thread_json, decode_profile_json, decode_notifications_json, decode_stream_blob 8 + contains 9 + subroutine decode_posts_json(json, posts, n) 10 + character(len=*), intent(in) :: json 11 + type(post_view), intent(out) :: posts(MAX_ITEMS) 12 + integer, intent(out) :: n 13 + character(len=:), allocatable :: arr 14 + posts = post_view() 15 + n = 0 16 + arr = find_first_array(json, [character(len=32) :: 'feed', 'posts']) 17 + if (len_trim(arr) == 0) return 18 + call decode_post_array(arr, posts, n) 19 + end subroutine decode_posts_json 20 + 21 + subroutine decode_thread_json(json, posts, n) 22 + character(len=*), intent(in) :: json 23 + type(post_view), intent(out) :: posts(MAX_ITEMS) 24 + integer, intent(out) :: n 25 + character(len=:), allocatable :: thread_obj 26 + posts = post_view() 27 + n = 0 28 + thread_obj = extract_json_object_after(json, 'thread') 29 + if (len_trim(thread_obj) == 0) return 30 + call walk_thread(thread_obj, posts, n) 31 + contains 32 + recursive subroutine walk_thread(obj, posts, n) 33 + character(len=*), intent(in) :: obj 34 + type(post_view), intent(inout) :: posts(MAX_ITEMS) 35 + integer, intent(inout) :: n 36 + character(len=:), allocatable :: post_obj, parent_obj, replies_arr, child_obj 37 + integer :: i, istart, iend 38 + post_obj = extract_json_object_after(obj, 'post') 39 + if (len_trim(post_obj) > 0) call append_post(post_obj, posts, n) 40 + parent_obj = extract_json_object_after(obj, 'parent') 41 + if (len_trim(parent_obj) > 0) call walk_thread(parent_obj, posts, n) 42 + replies_arr = extract_json_array_after(obj, 'replies') 43 + if (len_trim(replies_arr) > 0) then 44 + i = 1 45 + do 46 + call next_array_object(replies_arr, i, istart, iend) 47 + if (istart == 0) exit 48 + child_obj = replies_arr(istart:iend) 49 + call walk_thread(child_obj, posts, n) 50 + i = iend + 1 51 + end do 52 + end if 53 + end subroutine walk_thread 54 + end subroutine decode_thread_json 55 + 56 + subroutine decode_profile_json(json, profile) 57 + character(len=*), intent(in) :: json 58 + type(actor_profile), intent(out) :: profile 59 + profile = actor_profile() 60 + profile%display_name = slice_fit(extract_json_string(json, 'displayName'), FIELD_LEN) 61 + profile%handle = slice_fit(extract_json_string(json, 'handle'), HANDLE_LEN) 62 + profile%did = slice_fit(extract_json_string(json, 'did'), URI_LEN) 63 + profile%description = slice_fit(extract_json_string(json, 'description'), FIELD_LEN) 64 + profile%indexed_at = slice_fit(extract_json_string(json, 'indexedAt'), TS_LEN) 65 + profile%followers_count = slice_fit(extract_json_string(json, 'followersCount'), 64) 66 + profile%follows_count = slice_fit(extract_json_string(json, 'followsCount'), 64) 67 + profile%posts_count = slice_fit(extract_json_string(json, 'postsCount'), 64) 68 + end subroutine decode_profile_json 69 + 70 + subroutine decode_notifications_json(json, items, n) 71 + character(len=*), intent(in) :: json 72 + type(notification_view), intent(out) :: items(MAX_ITEMS) 73 + integer, intent(out) :: n 74 + character(len=:), allocatable :: arr, obj, author_obj, record_obj, reason_subject 75 + integer :: i, istart, iend 76 + items = notification_view() 77 + n = 0 78 + arr = extract_json_array_after(json, 'notifications') 79 + if (len_trim(arr) == 0) return 80 + i = 1 81 + do while (n < MAX_ITEMS) 82 + call next_array_object(arr, i, istart, iend) 83 + if (istart == 0) exit 84 + obj = arr(istart:iend) 85 + n = n + 1 86 + items(n)%reason = slice_fit(extract_json_string(obj, 'reason'), 32) 87 + items(n)%indexed_at = slice_fit(extract_json_string(obj, 'indexedAt'), TS_LEN) 88 + author_obj = extract_json_object_after(obj, 'author') 89 + if (len_trim(author_obj) > 0) then 90 + items(n)%author = slice_fit(extract_json_string(author_obj, 'displayName'), FIELD_LEN) 91 + items(n)%handle = slice_fit(extract_json_string(author_obj, 'handle'), HANDLE_LEN) 92 + if (len_trim(items(n)%author) == 0) items(n)%author = items(n)%handle 93 + end if 94 + record_obj = extract_json_object_after(obj, 'record') 95 + if (len_trim(record_obj) > 0) then 96 + items(n)%text = slice_fit(extract_json_string(record_obj, 'text'), FIELD_LEN) 97 + call extract_reply_refs(record_obj, items(n)%parent_uri, items(n)%parent_cid, items(n)%root_uri, items(n)%root_cid) 98 + end if 99 + reason_subject = extract_json_string(obj, 'reasonSubject') 100 + items(n)%uri = slice_fit(reason_subject, URI_LEN) 101 + items(n)%cid = slice_fit(extract_json_string(obj, 'cid'), CID_LEN) 102 + i = iend + 1 103 + end do 104 + end subroutine decode_notifications_json 105 + 106 + subroutine decode_stream_blob(blob, events, n) 107 + character(len=*), intent(in) :: blob 108 + type(stream_event), intent(out) :: events(MAX_ITEMS) 109 + integer, intent(out) :: n 110 + integer :: start, stop, l 111 + character(len=:), allocatable :: line 112 + events = stream_event() 113 + n = 0 114 + l = len_trim(blob) 115 + start = 1 116 + do while (start <= l .and. n < MAX_ITEMS) 117 + stop = index(blob(start:), new_line('a')) 118 + if (stop == 0) then 119 + line = blob(start:l) 120 + start = l + 1 121 + else 122 + stop = start + stop - 2 123 + line = blob(start:stop) 124 + start = stop + 2 125 + end if 126 + if (len_trim(line) == 0) cycle 127 + n = n + 1 128 + call decode_stream_line(line, events(n)) 129 + end do 130 + end subroutine decode_stream_blob 131 + 132 + subroutine decode_post_array(arr, posts, n) 133 + character(len=*), intent(in) :: arr 134 + type(post_view), intent(out) :: posts(MAX_ITEMS) 135 + integer, intent(out) :: n 136 + integer :: i, istart, iend 137 + character(len=:), allocatable :: item, post_obj 138 + n = 0 139 + i = 1 140 + do while (n < MAX_ITEMS) 141 + call next_array_object(arr, i, istart, iend) 142 + if (istart == 0) exit 143 + item = arr(istart:iend) 144 + if (len_trim(extract_json_object_after(item, 'post')) > 0) then 145 + post_obj = extract_json_object_after(item, 'post') 146 + else 147 + post_obj = item 148 + end if 149 + call append_post(post_obj, posts, n) 150 + i = iend + 1 151 + end do 152 + end subroutine decode_post_array 153 + 154 + subroutine append_post(post_obj, posts, n) 155 + character(len=*), intent(in) :: post_obj 156 + type(post_view), intent(inout) :: posts(MAX_ITEMS) 157 + integer, intent(inout) :: n 158 + type(post_view) :: post 159 + call decode_post(post_obj, post) 160 + if (len_trim(post%text) == 0 .and. len_trim(post%uri) == 0) return 161 + if (n >= MAX_ITEMS) return 162 + n = n + 1 163 + posts(n) = post 164 + end subroutine append_post 165 + 166 + subroutine decode_post(post_obj, post) 167 + character(len=*), intent(in) :: post_obj 168 + type(post_view), intent(out) :: post 169 + character(len=:), allocatable :: author_obj, record_obj, reason_obj, reason_type 170 + post = post_view() 171 + post%text = slice_fit(extract_json_string(post_obj, 'text'), FIELD_LEN) 172 + post%uri = slice_fit(extract_json_string(post_obj, 'uri'), URI_LEN) 173 + post%cid = slice_fit(extract_json_string(post_obj, 'cid'), CID_LEN) 174 + post%indexed_at = slice_fit(extract_json_string(post_obj, 'indexedAt'), TS_LEN) 175 + post%like_count = slice_fit(extract_json_string(post_obj, 'likeCount'), 8) 176 + post%repost_count = slice_fit(extract_json_string(post_obj, 'repostCount'), 8) 177 + post%reply_count = slice_fit(extract_json_string(post_obj, 'replyCount'), 8) 178 + post%quote_count = slice_fit(extract_json_string(post_obj, 'quoteCount'), 8) 179 + if (len_trim(post%indexed_at) == 0) post%indexed_at = slice_fit(extract_json_string(post_obj, 'createdAt'), TS_LEN) 180 + author_obj = extract_json_object_after(post_obj, 'author') 181 + if (len_trim(author_obj) > 0) then 182 + post%author = slice_fit(extract_json_string(author_obj, 'displayName'), FIELD_LEN) 183 + post%handle = slice_fit(extract_json_string(author_obj, 'handle'), HANDLE_LEN) 184 + if (len_trim(post%author) == 0) post%author = post%handle 185 + end if 186 + reason_obj = extract_json_object_after(post_obj, 'reason') 187 + if (len_trim(reason_obj) > 0) then 188 + reason_type = extract_json_string(reason_obj, '$type') 189 + if (index(reason_type, 'reasonRepost') > 0) then 190 + post%reason = 'repost' 191 + post%is_repost = .true. 192 + else if (index(reason_type, 'reasonPin') > 0) then 193 + post%reason = 'pin' 194 + else if (index(reason_type, 'reason') > 0) then 195 + post%reason = slice_fit(reason_type, 32) 196 + end if 197 + end if 198 + record_obj = extract_json_object_after(post_obj, 'record') 199 + if (len_trim(record_obj) > 0) then 200 + post%record_type = slice_fit(extract_json_string(record_obj, '$type'), 32) 201 + if (len_trim(post%text) == 0) post%text = slice_fit(extract_json_string(record_obj, 'text'), FIELD_LEN) 202 + if (len_trim(post%indexed_at) == 0) post%indexed_at = slice_fit(extract_json_string(record_obj, 'createdAt'), TS_LEN) 203 + call extract_reply_refs(record_obj, post%parent_uri, post%parent_cid, post%root_uri, post%root_cid) 204 + if (index(record_obj, '"facets"') > 0) post%has_facets = .true. 205 + end if 206 + if (index(post_obj, 'app.bsky.embed.images') > 0) post%has_images = .true. 207 + if (index(post_obj, 'app.bsky.embed.video') > 0) post%has_video = .true. 208 + if (index(post_obj, 'app.bsky.embed.external') > 0) post%has_external = .true. 209 + if (index(post_obj, 'app.bsky.embed.record') > 0) then 210 + post%is_quote = .true. 211 + if (len_trim(post%record_type) == 0) post%record_type = 'quote' 212 + end if 213 + if (len_trim(post%record_type) == 0) post%record_type = 'post' 214 + end subroutine decode_post 215 + 216 + subroutine decode_stream_line(line, event) 217 + character(len=*), intent(in) :: line 218 + type(stream_event), intent(out) :: event 219 + event = stream_event() 220 + event%kind = slice_fit(extract_json_string(line, 'kind'), 32) 221 + if (len_trim(event%kind) == 0) event%kind = slice_fit(extract_json_string(line, 'event'), 32) 222 + event%handle = slice_fit(extract_json_string(line, 'handle'), HANDLE_LEN) 223 + event%did = slice_fit(extract_json_string(line, 'did'), URI_LEN) 224 + event%text = slice_fit(extract_json_string(line, 'text'), FIELD_LEN) 225 + event%time_us = slice_fit(extract_json_string(line, 'time_us'), TS_LEN) 226 + end subroutine decode_stream_line 227 + end module decode_mod
+164
src/atproto/firehose_bridge.f90
··· 1 + module firehose_bridge 2 + use iso_c_binding 3 + implicit none 4 + private 5 + 6 + integer(c_int), parameter, public :: FS_OK = 0 7 + integer(c_int), parameter, public :: FS_ERR_CBOR = 1 8 + integer(c_int), parameter, public :: FS_ERR_ENVELOPE = 2 9 + integer(c_int), parameter, public :: FS_ERR_COMMIT_PARSE = 3 10 + integer(c_int), parameter, public :: FS_ERR_CAR_PARSE = 4 11 + integer(c_int), parameter, public :: FS_ERR_DAGCBOR_PARSE = 5 12 + integer(c_int), parameter, public :: FS_ERR_UNSUPPORTED = 6 13 + integer(c_int), parameter, public :: FS_ERR_OOM = 7 14 + integer(c_int), parameter, public :: FS_ERR_INTERNAL = 8 15 + 16 + integer(c_int), parameter, public :: FS_KIND_COMMIT_OP = 1 17 + integer(c_int), parameter, public :: FS_KIND_IDENTITY = 2 18 + integer(c_int), parameter, public :: FS_KIND_ACCOUNT = 3 19 + integer(c_int), parameter, public :: FS_KIND_INFO = 4 20 + integer(c_int), parameter, public :: FS_KIND_ERROR = 5 21 + 22 + integer(c_int), parameter, public :: FS_OP_NONE = 0 23 + integer(c_int), parameter, public :: FS_OP_CREATE = 1 24 + integer(c_int), parameter, public :: FS_OP_UPDATE = 2 25 + integer(c_int), parameter, public :: FS_OP_DELETE = 3 26 + 27 + type, bind(C), public :: fs_event_t 28 + integer(c_int64_t) :: seq 29 + integer(c_int) :: kind 30 + integer(c_int) :: op_action 31 + type(c_ptr) :: repo_did 32 + type(c_ptr) :: rev 33 + type(c_ptr) :: collection 34 + type(c_ptr) :: rkey 35 + type(c_ptr) :: record_cid 36 + type(c_ptr) :: uri 37 + type(c_ptr) :: record_json 38 + type(c_ptr) :: error_message 39 + end type fs_event_t 40 + 41 + type, bind(C), public :: fs_event_batch_t 42 + type(c_ptr) :: events 43 + integer(c_size_t) :: len 44 + type(c_ptr) :: owner 45 + end type fs_event_batch_t 46 + 47 + public :: bridge_init, bridge_shutdown, bridge_decode_frame, bridge_free_batch 48 + public :: bridge_decode_frame_with_status, c_string_to_fortran, get_event_ptr 49 + 50 + interface 51 + function fs_decoder_init() bind(C, name="fs_decoder_init") result(rc) 52 + import :: c_int 53 + integer(c_int) :: rc 54 + end function fs_decoder_init 55 + 56 + subroutine fs_decoder_shutdown() bind(C, name="fs_decoder_shutdown") 57 + end subroutine fs_decoder_shutdown 58 + 59 + function fs_decode_frame(data, len, out_batch) bind(C, name="fs_decode_frame") result(rc) 60 + import :: c_ptr, c_size_t, c_int, fs_event_batch_t 61 + type(c_ptr), value :: data 62 + integer(c_size_t), value :: len 63 + type(fs_event_batch_t) :: out_batch 64 + integer(c_int) :: rc 65 + end function fs_decode_frame 66 + 67 + subroutine fs_free_batch(batch) bind(C, name="fs_free_batch") 68 + import :: fs_event_batch_t 69 + type(fs_event_batch_t) :: batch 70 + end subroutine fs_free_batch 71 + end interface 72 + 73 + contains 74 + 75 + function bridge_init() result(rc) 76 + integer(c_int) :: rc 77 + rc = fs_decoder_init() 78 + end function bridge_init 79 + 80 + subroutine bridge_shutdown() 81 + call fs_decoder_shutdown() 82 + end subroutine bridge_shutdown 83 + 84 + function bridge_decode_frame(bytes) result(batch) 85 + integer(c_signed_char), intent(in), target :: bytes(:) 86 + type(fs_event_batch_t) :: batch 87 + integer(c_int) :: rc 88 + 89 + batch%events = c_null_ptr 90 + batch%len = 0_c_size_t 91 + batch%owner = c_null_ptr 92 + 93 + if (size(bytes) <= 0) return 94 + rc = fs_decode_frame(c_loc(bytes(1)), int(size(bytes), c_size_t), batch) 95 + if (rc /= FS_OK) then 96 + ! Even on non-OK status, the bridge may still return an error event batch. 97 + ! Callers should inspect batch contents before discarding them. 98 + end if 99 + end function bridge_decode_frame 100 + 101 + function bridge_decode_frame_with_status(bytes, status) result(batch) 102 + integer(c_signed_char), intent(in), target :: bytes(:) 103 + integer(c_int), intent(out) :: status 104 + type(fs_event_batch_t) :: batch 105 + 106 + batch%events = c_null_ptr 107 + batch%len = 0_c_size_t 108 + batch%owner = c_null_ptr 109 + 110 + if (size(bytes) <= 0) then 111 + status = FS_ERR_INTERNAL 112 + return 113 + end if 114 + 115 + status = fs_decode_frame(c_loc(bytes(1)), int(size(bytes), c_size_t), batch) 116 + end function bridge_decode_frame_with_status 117 + 118 + subroutine bridge_free_batch(batch) 119 + type(fs_event_batch_t), intent(inout) :: batch 120 + call fs_free_batch(batch) 121 + batch%events = c_null_ptr 122 + batch%len = 0_c_size_t 123 + batch%owner = c_null_ptr 124 + end subroutine bridge_free_batch 125 + 126 + function get_event_ptr(batch, index) result(p) 127 + type(fs_event_batch_t), intent(in) :: batch 128 + integer, intent(in) :: index 129 + type(c_ptr) :: p 130 + type(fs_event_t), pointer :: events(:) 131 + 132 + p = c_null_ptr 133 + if (.not. c_associated(batch%events)) return 134 + if (index < 1) return 135 + if (index > int(batch%len)) return 136 + 137 + call c_f_pointer(batch%events, events, [int(batch%len)]) 138 + p = c_loc(events(index)) 139 + end function get_event_ptr 140 + 141 + function c_string_to_fortran(cstr) result(out) 142 + type(c_ptr), value :: cstr 143 + character(len=:), allocatable :: out 144 + character(kind=c_char), pointer :: p(:) 145 + integer :: n, i 146 + 147 + if (.not. c_associated(cstr)) then 148 + out = '' 149 + return 150 + end if 151 + 152 + call c_f_pointer(cstr, p, [1000000]) 153 + n = 0 154 + do while (p(n+1) /= c_null_char) 155 + n = n + 1 156 + end do 157 + 158 + allocate(character(len=n) :: out) 159 + do i = 1, n 160 + out(i:i) = achar(iachar(p(i))) 161 + end do 162 + end function c_string_to_fortran 163 + 164 + end module firehose_bridge
+122
src/atproto/http_cbridge.f90
··· 1 + module http_cbridge_mod 2 + use iso_c_binding, only: c_ptr, c_char, c_long, c_size_t, c_null_char, c_associated, c_f_pointer 3 + use strings_mod, only: url_encode 4 + implicit none 5 + private 6 + public :: http_get, http_post_json, http_get_urlencoded, last_http_status 7 + 8 + integer :: last_http_status = 0 9 + 10 + interface 11 + function fortransky_http_get(url, auth_header, status_code, out_len) bind(C, name='fortransky_http_get') result(res) 12 + import :: c_ptr, c_char, c_long, c_size_t 13 + character(kind=c_char), dimension(*), intent(in) :: url 14 + character(kind=c_char), dimension(*), intent(in) :: auth_header 15 + integer(c_long), intent(out) :: status_code 16 + integer(c_size_t), intent(out) :: out_len 17 + type(c_ptr) :: res 18 + end function fortransky_http_get 19 + 20 + function fortransky_http_post_json(url, auth_header, json_body, status_code, out_len) bind(C, name='fortransky_http_post_json') result(res) 21 + import :: c_ptr, c_char, c_long, c_size_t 22 + character(kind=c_char), dimension(*), intent(in) :: url 23 + character(kind=c_char), dimension(*), intent(in) :: auth_header 24 + character(kind=c_char), dimension(*), intent(in) :: json_body 25 + integer(c_long), intent(out) :: status_code 26 + integer(c_size_t), intent(out) :: out_len 27 + type(c_ptr) :: res 28 + end function fortransky_http_post_json 29 + 30 + subroutine fortransky_http_free(ptr) bind(C, name='fortransky_http_free') 31 + import :: c_ptr 32 + type(c_ptr), value :: ptr 33 + end subroutine fortransky_http_free 34 + end interface 35 + contains 36 + function http_get(url, auth_token) result(body) 37 + character(len=*), intent(in) :: url 38 + character(len=*), intent(in), optional :: auth_token 39 + character(len=:), allocatable :: body 40 + character(len=:), allocatable :: header 41 + integer(c_long) :: status_code 42 + integer(c_size_t) :: out_len 43 + type(c_ptr) :: raw 44 + 45 + header = auth_header_value(auth_token) 46 + raw = fortransky_http_get(c_string(trim(url)), c_string(header), status_code, out_len) 47 + last_http_status = int(status_code) 48 + body = from_c_buffer(raw, out_len) 49 + if (c_associated(raw)) call fortransky_http_free(raw) 50 + end function http_get 51 + 52 + function http_get_urlencoded(url, key, value, auth_token) result(body) 53 + character(len=*), intent(in) :: url, key, value 54 + character(len=*), intent(in), optional :: auth_token 55 + character(len=:), allocatable :: body 56 + character(len=:), allocatable :: built 57 + 58 + if (index(url, '?') > 0) then 59 + built = trim(url) // '&' // trim(key) // '=' // url_encode(trim(value)) 60 + else 61 + built = trim(url) // '?' // trim(key) // '=' // url_encode(trim(value)) 62 + end if 63 + body = http_get(built, auth_token) 64 + end function http_get_urlencoded 65 + 66 + function http_post_json(url, json_body, auth_token) result(body) 67 + character(len=*), intent(in) :: url, json_body 68 + character(len=*), intent(in), optional :: auth_token 69 + character(len=:), allocatable :: body 70 + character(len=:), allocatable :: header 71 + integer(c_long) :: status_code 72 + integer(c_size_t) :: out_len 73 + type(c_ptr) :: raw 74 + 75 + header = auth_header_value(auth_token) 76 + raw = fortransky_http_post_json(c_string(trim(url)), c_string(header), c_string(trim(json_body)), status_code, out_len) 77 + last_http_status = int(status_code) 78 + body = from_c_buffer(raw, out_len) 79 + if (c_associated(raw)) call fortransky_http_free(raw) 80 + end function http_post_json 81 + 82 + function auth_header_value(auth_token) result(header) 83 + character(len=*), intent(in), optional :: auth_token 84 + character(len=:), allocatable :: header 85 + if (present(auth_token)) then 86 + if (len_trim(auth_token) > 0) then 87 + header = 'Authorization: Bearer ' // trim(auth_token) 88 + return 89 + end if 90 + end if 91 + header = '' 92 + end function auth_header_value 93 + 94 + function c_string(text) result(buf) 95 + character(len=*), intent(in) :: text 96 + character(kind=c_char,len=:), allocatable :: buf 97 + buf = text // c_null_char 98 + end function c_string 99 + 100 + function from_c_buffer(ptr, nbytes) result(text) 101 + type(c_ptr), intent(in) :: ptr 102 + integer(c_size_t), intent(in) :: nbytes 103 + character(len=:), allocatable :: text 104 + character(kind=c_char), pointer :: chars(:) 105 + integer :: i, n 106 + 107 + if (.not. c_associated(ptr)) then 108 + text = '' 109 + return 110 + end if 111 + n = int(nbytes) 112 + if (n <= 0) then 113 + text = '' 114 + return 115 + end if 116 + call c_f_pointer(ptr, chars, [n]) 117 + allocate(character(len=n) :: text) 118 + do i = 1, n 119 + text(i:i) = chars(i) 120 + end do 121 + end function from_c_buffer 122 + end module http_cbridge_mod
+59
src/atproto/http_cli.f90
··· 1 + module http_cli_mod 2 + use process_mod, only: run_capture, slurp_file 3 + implicit none 4 + contains 5 + function http_get(url, auth_token) result(body) 6 + character(len=*), intent(in) :: url 7 + character(len=*), intent(in), optional :: auth_token 8 + character(len=:), allocatable :: body 9 + character(len=:), allocatable :: cmd 10 + integer :: code 11 + 12 + cmd = 'curl -sL --max-time 20 ' 13 + if (present(auth_token)) then 14 + if (len_trim(auth_token) > 0) then 15 + cmd = cmd // '-H "Authorization: Bearer ' // trim(auth_token) // '" ' 16 + end if 17 + end if 18 + cmd = cmd // '"' // trim(url) // '"' 19 + call run_capture(cmd, '/tmp/fortransky_http.out', code) 20 + body = slurp_file('/tmp/fortransky_http.out') 21 + end function http_get 22 + 23 + function http_get_urlencoded(url, key, value, auth_token) result(body) 24 + character(len=*), intent(in) :: url, key, value 25 + character(len=*), intent(in), optional :: auth_token 26 + character(len=:), allocatable :: body 27 + character(len=:), allocatable :: cmd 28 + integer :: code 29 + 30 + cmd = 'curl -sL --max-time 20 --get ' 31 + if (present(auth_token)) then 32 + if (len_trim(auth_token) > 0) then 33 + cmd = cmd // '-H "Authorization: Bearer ' // trim(auth_token) // '" ' 34 + end if 35 + end if 36 + cmd = cmd // '--data-urlencode "' // trim(key) // '=' // trim(value) // '" ' 37 + cmd = cmd // '"' // trim(url) // '"' 38 + call run_capture(cmd, '/tmp/fortransky_http.out', code) 39 + body = slurp_file('/tmp/fortransky_http.out') 40 + end function http_get_urlencoded 41 + 42 + function http_post_json(url, json_body, auth_token) result(body) 43 + character(len=*), intent(in) :: url, json_body 44 + character(len=*), intent(in), optional :: auth_token 45 + character(len=:), allocatable :: body 46 + character(len=:), allocatable :: cmd 47 + integer :: code 48 + 49 + cmd = 'curl -sL --max-time 20 -X POST -H "Content-Type: application/json" ' 50 + if (present(auth_token)) then 51 + if (len_trim(auth_token) > 0) then 52 + cmd = cmd // '-H "Authorization: Bearer ' // trim(auth_token) // '" ' 53 + end if 54 + end if 55 + cmd = cmd // '--data ' // "'" // trim(json_body) // "'" // ' "' // trim(url) // '"' 56 + call run_capture(cmd, '/tmp/fortransky_http.out', code) 57 + body = slurp_file('/tmp/fortransky_http.out') 58 + end function http_post_json 59 + end module http_cli_mod
+506
src/atproto/json_extract.f90
··· 1 + module json_extract_mod 2 + use strings_mod, only: json_unescape, squeeze_spaces, replace_all 3 + use models_mod, only: post_view, stream_event, actor_profile, notification_view, MAX_ITEMS, FIELD_LEN, HANDLE_LEN, URI_LEN, CID_LEN, TS_LEN 4 + implicit none 5 + private 6 + public :: extract_json_string, extract_json_object_after, extract_json_array_after 7 + public :: next_array_object, extract_reply_refs, slice_fit, find_first_array 8 + public :: extract_posts, extract_thread_posts, extract_stream_events 9 + public :: escape_json_string, extract_profile, extract_notifications 10 + contains 11 + function extract_json_string(json, key, start_at) result(value) 12 + character(len=*), intent(in) :: json, key 13 + integer, intent(in), optional :: start_at 14 + character(len=:), allocatable :: value 15 + integer :: s, vstart, vend, kind 16 + 17 + s = 1 18 + if (present(start_at)) s = max(1, start_at) 19 + call find_key_value(json, key, s, vstart, vend, kind) 20 + if (kind == 1 .and. vstart > 0 .and. vend >= vstart) then 21 + value = squeeze_spaces(json_unescape(json(vstart:vend))) 22 + else if (kind == 4 .and. vstart > 0 .and. vend >= vstart) then 23 + value = adjustl(trim(json(vstart:vend))) 24 + else 25 + value = '' 26 + end if 27 + end function extract_json_string 28 + 29 + function extract_json_object_after(json, key, start_at) result(obj) 30 + character(len=*), intent(in) :: json, key 31 + integer, intent(in), optional :: start_at 32 + character(len=:), allocatable :: obj 33 + integer :: s, vstart, vend, kind 34 + 35 + s = 1 36 + if (present(start_at)) s = max(1, start_at) 37 + call find_key_value(json, key, s, vstart, vend, kind) 38 + if (kind == 2 .and. vstart > 0 .and. vend >= vstart) then 39 + obj = json(vstart:vend) 40 + else 41 + obj = '' 42 + end if 43 + end function extract_json_object_after 44 + 45 + subroutine extract_posts(json, posts, n) 46 + character(len=*), intent(in) :: json 47 + type(post_view), intent(out) :: posts(MAX_ITEMS) 48 + integer, intent(out) :: n 49 + character(len=:), allocatable :: arr 50 + 51 + posts = post_view() 52 + n = 0 53 + arr = find_first_array(json, [character(len=32) :: 'feed', 'posts']) 54 + if (len_trim(arr) == 0) return 55 + call extract_post_array(arr, posts, n) 56 + end subroutine extract_posts 57 + 58 + subroutine extract_thread_posts(json, posts, n) 59 + character(len=*), intent(in) :: json 60 + type(post_view), intent(out) :: posts(MAX_ITEMS) 61 + integer, intent(out) :: n 62 + character(len=:), allocatable :: thread_obj 63 + 64 + posts = post_view() 65 + n = 0 66 + thread_obj = extract_json_object_after(json, 'thread') 67 + if (len_trim(thread_obj) == 0) return 68 + call walk_thread(thread_obj, posts, n) 69 + contains 70 + recursive subroutine walk_thread(obj, posts, n) 71 + character(len=*), intent(in) :: obj 72 + type(post_view), intent(inout) :: posts(MAX_ITEMS) 73 + integer, intent(inout) :: n 74 + character(len=:), allocatable :: post_obj, parent_obj, replies_arr, child_obj 75 + integer :: i, istart, iend 76 + 77 + post_obj = extract_json_object_after(obj, 'post') 78 + if (len_trim(post_obj) > 0) call append_post_object(post_obj, posts, n) 79 + 80 + parent_obj = extract_json_object_after(obj, 'parent') 81 + if (len_trim(parent_obj) > 0) call walk_thread(parent_obj, posts, n) 82 + 83 + replies_arr = extract_json_array_after(obj, 'replies') 84 + if (len_trim(replies_arr) > 0) then 85 + i = 1 86 + do 87 + call next_array_object(replies_arr, i, istart, iend) 88 + if (istart == 0) exit 89 + child_obj = replies_arr(istart:iend) 90 + call walk_thread(child_obj, posts, n) 91 + i = iend + 1 92 + end do 93 + end if 94 + end subroutine walk_thread 95 + end subroutine extract_thread_posts 96 + 97 + subroutine extract_stream_events(blob, events, n) 98 + character(len=*), intent(in) :: blob 99 + type(stream_event), intent(out) :: events(MAX_ITEMS) 100 + integer, intent(out) :: n 101 + integer :: start, stop, l 102 + character(len=:), allocatable :: line 103 + 104 + events = stream_event() 105 + n = 0 106 + l = len_trim(blob) 107 + start = 1 108 + do while (start <= l .and. n < MAX_ITEMS) 109 + stop = index(blob(start:), new_line('a')) 110 + if (stop == 0) then 111 + line = blob(start:l) 112 + start = l + 1 113 + else 114 + stop = start + stop - 2 115 + line = blob(start:stop) 116 + start = stop + 2 117 + end if 118 + if (len_trim(line) == 0) cycle 119 + n = n + 1 120 + events(n)%kind = slice_fit(extract_json_string(line, 'kind'), 32) 121 + if (len_trim(events(n)%kind) == 0) events(n)%kind = slice_fit(extract_json_string(line, 'event'), 32) 122 + events(n)%handle = slice_fit(extract_json_string(line, 'handle'), HANDLE_LEN) 123 + events(n)%did = slice_fit(extract_json_string(line, 'did'), URI_LEN) 124 + events(n)%text = slice_fit(extract_json_string(line, 'text'), FIELD_LEN) 125 + events(n)%time_us = slice_fit(extract_json_string(line, 'time_us'), TS_LEN) 126 + end do 127 + end subroutine extract_stream_events 128 + 129 + subroutine extract_profile(json, profile) 130 + character(len=*), intent(in) :: json 131 + type(actor_profile), intent(out) :: profile 132 + 133 + profile = actor_profile() 134 + profile%display_name = slice_fit(extract_json_string(json, 'displayName'), FIELD_LEN) 135 + profile%handle = slice_fit(extract_json_string(json, 'handle'), HANDLE_LEN) 136 + profile%did = slice_fit(extract_json_string(json, 'did'), URI_LEN) 137 + profile%description = slice_fit(extract_json_string(json, 'description'), FIELD_LEN) 138 + profile%indexed_at = slice_fit(extract_json_string(json, 'indexedAt'), TS_LEN) 139 + profile%followers_count = slice_fit(extract_json_string(json, 'followersCount'), 64) 140 + profile%follows_count = slice_fit(extract_json_string(json, 'followsCount'), 64) 141 + profile%posts_count = slice_fit(extract_json_string(json, 'postsCount'), 64) 142 + end subroutine extract_profile 143 + 144 + subroutine extract_notifications(json, items, n) 145 + character(len=*), intent(in) :: json 146 + type(notification_view), intent(out) :: items(MAX_ITEMS) 147 + integer, intent(out) :: n 148 + character(len=:), allocatable :: arr, obj, author_obj, record_obj, reason_subject 149 + integer :: i, istart, iend 150 + 151 + items = notification_view() 152 + n = 0 153 + arr = extract_json_array_after(json, 'notifications') 154 + if (len_trim(arr) == 0) return 155 + i = 1 156 + do while (n < MAX_ITEMS) 157 + call next_array_object(arr, i, istart, iend) 158 + if (istart == 0) exit 159 + obj = arr(istart:iend) 160 + n = n + 1 161 + items(n)%reason = slice_fit(extract_json_string(obj, 'reason'), 32) 162 + items(n)%indexed_at = slice_fit(extract_json_string(obj, 'indexedAt'), TS_LEN) 163 + author_obj = extract_json_object_after(obj, 'author') 164 + if (len_trim(author_obj) > 0) then 165 + items(n)%author = slice_fit(extract_json_string(author_obj, 'displayName'), FIELD_LEN) 166 + items(n)%handle = slice_fit(extract_json_string(author_obj, 'handle'), HANDLE_LEN) 167 + if (len_trim(items(n)%author) == 0) items(n)%author = items(n)%handle 168 + end if 169 + record_obj = extract_json_object_after(obj, 'record') 170 + if (len_trim(record_obj) > 0) then 171 + items(n)%text = slice_fit(extract_json_string(record_obj, 'text'), FIELD_LEN) 172 + call extract_reply_refs(record_obj, items(n)%parent_uri, items(n)%parent_cid, items(n)%root_uri, items(n)%root_cid) 173 + end if 174 + reason_subject = extract_json_string(obj, 'reasonSubject') 175 + items(n)%uri = slice_fit(reason_subject, URI_LEN) 176 + items(n)%cid = slice_fit(extract_json_string(obj, 'cid'), CID_LEN) 177 + i = iend + 1 178 + end do 179 + end subroutine extract_notifications 180 + 181 + function escape_json_string(text) result(out) 182 + character(len=*), intent(in) :: text 183 + character(len=:), allocatable :: out 184 + out = trim(text) 185 + out = replace_all(out, '\\', '\\\\') 186 + out = replace_all(out, '"', '\\"') 187 + out = replace_all(out, new_line('a'), ' ') 188 + end function escape_json_string 189 + 190 + subroutine extract_post_array(arr, posts, n) 191 + character(len=*), intent(in) :: arr 192 + type(post_view), intent(out) :: posts(MAX_ITEMS) 193 + integer, intent(out) :: n 194 + integer :: i, istart, iend 195 + character(len=:), allocatable :: item, post_obj 196 + 197 + n = 0 198 + i = 1 199 + do while (n < MAX_ITEMS) 200 + call next_array_object(arr, i, istart, iend) 201 + if (istart == 0) exit 202 + item = arr(istart:iend) 203 + if (len_trim(extract_json_object_after(item, 'post')) > 0) then 204 + post_obj = extract_json_object_after(item, 'post') 205 + else 206 + post_obj = item 207 + end if 208 + call append_post_object(post_obj, posts, n) 209 + i = iend + 1 210 + end do 211 + end subroutine extract_post_array 212 + 213 + subroutine append_post_object(post_obj, posts, n) 214 + character(len=*), intent(in) :: post_obj 215 + type(post_view), intent(inout) :: posts(MAX_ITEMS) 216 + integer, intent(inout) :: n 217 + character(len=:), allocatable :: author_obj, record_obj, author, handle, text, uri, cid, ts 218 + character(len=URI_LEN) :: parent_uri, root_uri 219 + character(len=CID_LEN) :: parent_cid, root_cid 220 + 221 + if (n >= MAX_ITEMS) return 222 + text = extract_json_string(post_obj, 'text') 223 + uri = extract_json_string(post_obj, 'uri') 224 + cid = extract_json_string(post_obj, 'cid') 225 + ts = extract_json_string(post_obj, 'indexedAt') 226 + if (len_trim(ts) == 0) ts = extract_json_string(post_obj, 'createdAt') 227 + 228 + author_obj = extract_json_object_after(post_obj, 'author') 229 + author = '' 230 + handle = '' 231 + if (len_trim(author_obj) > 0) then 232 + author = extract_json_string(author_obj, 'displayName') 233 + handle = extract_json_string(author_obj, 'handle') 234 + end if 235 + 236 + record_obj = extract_json_object_after(post_obj, 'record') 237 + if (len_trim(text) == 0) then 238 + if (len_trim(record_obj) > 0) then 239 + text = extract_json_string(record_obj, 'text') 240 + if (len_trim(ts) == 0) ts = extract_json_string(record_obj, 'createdAt') 241 + end if 242 + end if 243 + 244 + parent_uri = '' 245 + parent_cid = '' 246 + root_uri = '' 247 + root_cid = '' 248 + if (len_trim(record_obj) > 0) call extract_reply_refs(record_obj, parent_uri, parent_cid, root_uri, root_cid) 249 + 250 + if (len_trim(author) == 0) author = handle 251 + if (len_trim(text) == 0 .and. len_trim(uri) == 0) return 252 + 253 + n = n + 1 254 + posts(n)%author = slice_fit(author, FIELD_LEN) 255 + posts(n)%handle = slice_fit(handle, HANDLE_LEN) 256 + posts(n)%text = slice_fit(text, FIELD_LEN) 257 + posts(n)%uri = slice_fit(uri, URI_LEN) 258 + posts(n)%cid = slice_fit(cid, CID_LEN) 259 + posts(n)%indexed_at = slice_fit(ts, TS_LEN) 260 + posts(n)%parent_uri = parent_uri 261 + posts(n)%parent_cid = parent_cid 262 + posts(n)%root_uri = root_uri 263 + posts(n)%root_cid = root_cid 264 + end subroutine append_post_object 265 + 266 + subroutine extract_reply_refs(record_obj, parent_uri, parent_cid, root_uri, root_cid) 267 + character(len=*), intent(in) :: record_obj 268 + character(len=*), intent(out) :: parent_uri, parent_cid, root_uri, root_cid 269 + character(len=:), allocatable :: reply_obj, parent_obj, root_obj 270 + 271 + parent_uri = '' 272 + parent_cid = '' 273 + root_uri = '' 274 + root_cid = '' 275 + reply_obj = extract_json_object_after(record_obj, 'reply') 276 + if (len_trim(reply_obj) == 0) return 277 + parent_obj = extract_json_object_after(reply_obj, 'parent') 278 + root_obj = extract_json_object_after(reply_obj, 'root') 279 + if (len_trim(parent_obj) > 0) then 280 + parent_uri = slice_fit(extract_json_string(parent_obj, 'uri'), len(parent_uri)) 281 + parent_cid = slice_fit(extract_json_string(parent_obj, 'cid'), len(parent_cid)) 282 + end if 283 + if (len_trim(root_obj) > 0) then 284 + root_uri = slice_fit(extract_json_string(root_obj, 'uri'), len(root_uri)) 285 + root_cid = slice_fit(extract_json_string(root_obj, 'cid'), len(root_cid)) 286 + end if 287 + end subroutine extract_reply_refs 288 + 289 + function find_first_array(json, keys) result(arr) 290 + character(len=*), intent(in) :: json 291 + character(len=*), dimension(:), intent(in) :: keys 292 + character(len=:), allocatable :: arr 293 + integer :: i, vstart, vend, kind 294 + 295 + arr = '' 296 + do i = 1, size(keys) 297 + call find_key_value(json, trim(keys(i)), 1, vstart, vend, kind) 298 + if (kind == 3 .and. vstart > 0) then 299 + arr = json(vstart:vend) 300 + return 301 + end if 302 + end do 303 + end function find_first_array 304 + 305 + function extract_json_array_after(json, key) result(arr) 306 + character(len=*), intent(in) :: json, key 307 + character(len=:), allocatable :: arr 308 + integer :: vstart, vend, kind 309 + 310 + call find_key_value(json, key, 1, vstart, vend, kind) 311 + if (kind == 3 .and. vstart > 0) then 312 + arr = json(vstart:vend) 313 + else 314 + arr = '' 315 + end if 316 + end function extract_json_array_after 317 + 318 + subroutine next_array_object(arr, pos_inout, obj_start, obj_end) 319 + character(len=*), intent(in) :: arr 320 + integer, intent(inout) :: pos_inout 321 + integer, intent(out) :: obj_start, obj_end 322 + integer :: i, start_pos, end_pos 323 + 324 + obj_start = 0 325 + obj_end = 0 326 + i = max(1, pos_inout) 327 + do while (i <= len_trim(arr)) 328 + if (arr(i:i) == '{') then 329 + start_pos = i 330 + end_pos = match_bracket(arr, i, '{', '}') 331 + if (end_pos > 0) then 332 + obj_start = start_pos 333 + obj_end = end_pos 334 + pos_inout = end_pos + 1 335 + end if 336 + return 337 + end if 338 + i = i + 1 339 + end do 340 + end subroutine next_array_object 341 + 342 + subroutine find_key_value(json, key, start_at, value_start, value_end, value_kind) 343 + character(len=*), intent(in) :: json, key 344 + integer, intent(in) :: start_at 345 + integer, intent(out) :: value_start, value_end, value_kind 346 + integer :: i, kend, colon_pos, depth 347 + character(len=:), allocatable :: key_text 348 + 349 + value_start = 0 350 + value_end = 0 351 + value_kind = 0 352 + key_text = trim(key) 353 + i = max(1, start_at) 354 + depth = 0 355 + do while (i <= len_trim(json)) 356 + select case (json(i:i)) 357 + case ('{', '[') 358 + depth = depth + 1 359 + i = i + 1 360 + case ('}', ']') 361 + depth = depth - 1 362 + i = i + 1 363 + case ('"') 364 + kend = parse_json_string_end(json, i) 365 + if (kend <= i) exit 366 + ! Only match keys at depth 1 (direct children of the top-level object) 367 + if (depth == 1 .and. json_unescape(json(i+1:kend-1)) == key_text) then 368 + colon_pos = skip_ws(json, kend + 1) 369 + if (colon_pos <= len_trim(json) .and. json(colon_pos:colon_pos) == ':') then 370 + colon_pos = skip_ws(json, colon_pos + 1) 371 + call capture_value_bounds(json, colon_pos, value_start, value_end, value_kind) 372 + return 373 + end if 374 + end if 375 + i = kend + 1 376 + case default 377 + i = i + 1 378 + end select 379 + end do 380 + end subroutine find_key_value 381 + 382 + subroutine capture_value_bounds(json, pos, value_start, value_end, value_kind) 383 + character(len=*), intent(in) :: json 384 + integer, intent(in) :: pos 385 + integer, intent(out) :: value_start, value_end, value_kind 386 + integer :: p, e 387 + 388 + value_start = 0 389 + value_end = 0 390 + value_kind = 0 391 + p = skip_ws(json, pos) 392 + if (p > len_trim(json)) return 393 + select case (json(p:p)) 394 + case ('"') 395 + e = parse_json_string_end(json, p) 396 + if (e > p) then 397 + value_start = p + 1 398 + value_end = e - 1 399 + value_kind = 1 400 + end if 401 + case ('{') 402 + e = match_bracket(json, p, '{', '}') 403 + if (e > 0) then 404 + value_start = p 405 + value_end = e 406 + value_kind = 2 407 + end if 408 + case ('[') 409 + e = match_bracket(json, p, '[', ']') 410 + if (e > 0) then 411 + value_start = p 412 + value_end = e 413 + value_kind = 3 414 + end if 415 + case default 416 + e = p 417 + do while (e <= len_trim(json)) 418 + select case (json(e:e)) 419 + case (',','}',']') 420 + exit 421 + case default 422 + e = e + 1 423 + end select 424 + end do 425 + value_start = p 426 + value_end = e - 1 427 + value_kind = 4 428 + end select 429 + end subroutine capture_value_bounds 430 + 431 + integer function skip_ws(text, pos) result(out) 432 + character(len=*), intent(in) :: text 433 + integer, intent(in) :: pos 434 + integer :: i 435 + i = max(1, pos) 436 + do while (i <= len(text)) 437 + select case (text(i:i)) 438 + case (' ', achar(9), achar(10), achar(13)) 439 + i = i + 1 440 + case default 441 + exit 442 + end select 443 + end do 444 + out = i 445 + end function skip_ws 446 + 447 + integer function parse_json_string_end(text, quote_pos) result(out) 448 + character(len=*), intent(in) :: text 449 + integer, intent(in) :: quote_pos 450 + integer :: i, backslashes 451 + 452 + out = 0 453 + i = quote_pos + 1 454 + do while (i <= len_trim(text)) 455 + if (text(i:i) == '"') then 456 + backslashes = 0 457 + do while (i - backslashes - 1 >= quote_pos .and. text(i-backslashes-1:i-backslashes-1) == '\\') 458 + backslashes = backslashes + 1 459 + end do 460 + if (mod(backslashes, 2) == 0) then 461 + out = i 462 + return 463 + end if 464 + end if 465 + i = i + 1 466 + end do 467 + end function parse_json_string_end 468 + 469 + integer function match_bracket(text, start_pos, open_ch, close_ch) result(out) 470 + character(len=*), intent(in) :: text 471 + integer, intent(in) :: start_pos 472 + character(len=1), intent(in) :: open_ch, close_ch 473 + integer :: i, depth, s_end 474 + 475 + out = 0 476 + depth = 0 477 + i = start_pos 478 + do while (i <= len_trim(text)) 479 + if (text(i:i) == '"') then 480 + s_end = parse_json_string_end(text, i) 481 + if (s_end <= i) return 482 + i = s_end + 1 483 + cycle 484 + end if 485 + if (text(i:i) == open_ch) depth = depth + 1 486 + if (text(i:i) == close_ch) then 487 + depth = depth - 1 488 + if (depth == 0) then 489 + out = i 490 + return 491 + end if 492 + end if 493 + i = i + 1 494 + end do 495 + end function match_bracket 496 + 497 + function slice_fit(text, n) result(out) 498 + character(len=*), intent(in) :: text 499 + integer, intent(in) :: n 500 + character(len=n) :: out 501 + integer :: m 502 + out = '' 503 + m = min(len_trim(text), n) 504 + if (m > 0) out(1:m) = text(1:m) 505 + end function slice_fit 506 + end module json_extract_mod
+47
src/core/app_state.f90
··· 1 + module app_state_mod 2 + use models_mod, only: session_state, post_view, actor_profile, notification_view, MAX_ITEMS, HANDLE_LEN, URI_LEN 3 + implicit none 4 + integer, parameter :: VIEW_HOME=1, VIEW_POST_LIST=2, VIEW_PROFILE=3, VIEW_NOTIFICATIONS=4, VIEW_STREAM=5 5 + integer, parameter :: MAX_CACHE = 256 6 + integer, parameter :: DID_CACHE_SIZE = 128 7 + 8 + type :: app_state 9 + type(session_state) :: session 10 + integer :: view = VIEW_HOME 11 + integer :: prev_view = VIEW_HOME 12 + character(len=128) :: view_title = 'Fortransky' 13 + character(len=256) :: status = 'Ready.' 14 + integer :: selected = 1 15 + integer :: page = 1 16 + integer :: page_size = 5 17 + character(len=16) :: stream_mode = 'jetstream' 18 + 19 + type(post_view) :: post_cache(MAX_CACHE) 20 + integer :: post_count = 0 21 + integer :: current_post_ids(MAX_ITEMS) = 0 22 + integer :: current_post_count = 0 23 + 24 + type(notification_view) :: notifications(MAX_ITEMS) 25 + integer :: notification_count = 0 26 + 27 + type(actor_profile) :: profile 28 + 29 + ! DID -> handle resolution cache 30 + character(len=URI_LEN) :: did_cache(DID_CACHE_SIZE) = '' 31 + character(len=HANDLE_LEN) :: handle_cache(DID_CACHE_SIZE) = '' 32 + integer :: did_cache_count = 0 33 + end type app_state 34 + contains 35 + subroutine reset_selection(state) 36 + type(app_state), intent(inout) :: state 37 + state%selected = 1 38 + state%page = 1 39 + end subroutine reset_selection 40 + 41 + subroutine set_status(state, text) 42 + type(app_state), intent(inout) :: state 43 + character(len=*), intent(in) :: text 44 + state%status = '' 45 + state%status(1:min(len_trim(text), len(state%status))) = trim(text) 46 + end subroutine set_status 47 + end module app_state_mod
+13
src/core/config.f90
··· 1 + module config_mod 2 + use models_mod, only: session_state 3 + implicit none 4 + contains 5 + subroutine load_session_from_env(state) 6 + type(session_state), intent(inout) :: state 7 + integer :: stat 8 + 9 + call get_environment_variable('BSKY_PDS_HOST', state%pds_host, status=stat) 10 + if (stat /= 0 .or. len_trim(state%pds_host) == 0) state%pds_host = 'https://bsky.social' 11 + call get_environment_variable('BSKY_IDENTIFIER', state%identifier, status=stat) 12 + end subroutine load_session_from_env 13 + end module config_mod
+75
src/core/models.f90
··· 1 + module models_mod 2 + implicit none 3 + integer, parameter :: MAX_ITEMS = 64 4 + integer, parameter :: FIELD_LEN = 1024 5 + integer, parameter :: HANDLE_LEN = 256 6 + integer, parameter :: URI_LEN = 512 7 + integer, parameter :: CID_LEN = 256 8 + integer, parameter :: TS_LEN = 64 9 + 10 + type :: post_view 11 + character(len=FIELD_LEN) :: author = '' 12 + character(len=HANDLE_LEN) :: handle = '' 13 + character(len=FIELD_LEN) :: text = '' 14 + character(len=URI_LEN) :: uri = '' 15 + character(len=CID_LEN) :: cid = '' 16 + character(len=TS_LEN) :: indexed_at = '' 17 + character(len=URI_LEN) :: parent_uri = '' 18 + character(len=CID_LEN) :: parent_cid = '' 19 + character(len=URI_LEN) :: root_uri = '' 20 + character(len=CID_LEN) :: root_cid = '' 21 + character(len=32) :: reason = '' 22 + character(len=32) :: record_type = '' 23 + character(len=8) :: like_count = '' 24 + character(len=8) :: repost_count = '' 25 + character(len=8) :: reply_count = '' 26 + character(len=8) :: quote_count = '' 27 + logical :: is_repost = .false. 28 + logical :: is_quote = .false. 29 + logical :: has_images = .false. 30 + logical :: has_video = .false. 31 + logical :: has_external = .false. 32 + logical :: has_facets = .false. 33 + end type post_view 34 + 35 + type :: stream_event 36 + character(len=32) :: kind = '' 37 + character(len=HANDLE_LEN) :: handle = '' 38 + character(len=URI_LEN) :: did = '' 39 + character(len=FIELD_LEN) :: text = '' 40 + character(len=TS_LEN) :: time_us = '' 41 + end type stream_event 42 + 43 + type :: actor_profile 44 + character(len=FIELD_LEN) :: display_name = '' 45 + character(len=HANDLE_LEN) :: handle = '' 46 + character(len=URI_LEN) :: did = '' 47 + character(len=FIELD_LEN) :: description = '' 48 + character(len=TS_LEN) :: indexed_at = '' 49 + character(len=64) :: followers_count = '' 50 + character(len=64) :: follows_count = '' 51 + character(len=64) :: posts_count = '' 52 + end type actor_profile 53 + 54 + type :: notification_view 55 + character(len=32) :: reason = '' 56 + character(len=FIELD_LEN) :: author = '' 57 + character(len=HANDLE_LEN) :: handle = '' 58 + character(len=FIELD_LEN) :: text = '' 59 + character(len=URI_LEN) :: uri = '' 60 + character(len=CID_LEN) :: cid = '' 61 + character(len=TS_LEN) :: indexed_at = '' 62 + character(len=URI_LEN) :: parent_uri = '' 63 + character(len=CID_LEN) :: parent_cid = '' 64 + character(len=URI_LEN) :: root_uri = '' 65 + character(len=CID_LEN) :: root_cid = '' 66 + end type notification_view 67 + 68 + type :: session_state 69 + character(len=256) :: pds_host = 'https://bsky.social' 70 + character(len=256) :: identifier = '' 71 + character(len=256) :: did = '' 72 + character(len=1024) :: access_jwt = '' 73 + character(len=1024) :: refresh_jwt = '' 74 + end type session_state 75 + end module models_mod
+64
src/core/post_store.f90
··· 1 + module post_store_mod 2 + use models_mod, only: post_view, MAX_ITEMS 3 + use app_state_mod, only: app_state, MAX_CACHE 4 + implicit none 5 + contains 6 + integer function find_post_index(state, post) result(idx) 7 + type(app_state), intent(in) :: state 8 + type(post_view), intent(in) :: post 9 + integer :: i 10 + 11 + idx = 0 12 + do i = 1, state%post_count 13 + if (len_trim(post%uri) > 0 .and. trim(state%post_cache(i)%uri) == trim(post%uri)) then 14 + idx = i 15 + return 16 + end if 17 + end do 18 + end function find_post_index 19 + 20 + subroutine upsert_posts(state, posts, n) 21 + type(app_state), intent(inout) :: state 22 + type(post_view), intent(in) :: posts(MAX_ITEMS) 23 + integer, intent(in) :: n 24 + integer :: i, idx 25 + 26 + state%current_post_ids = 0 27 + state%current_post_count = 0 28 + do i = 1, n 29 + idx = find_post_index(state, posts(i)) 30 + if (idx == 0) then 31 + if (state%post_count < MAX_CACHE) then 32 + state%post_count = state%post_count + 1 33 + idx = state%post_count 34 + state%post_cache(idx) = posts(i) 35 + else 36 + idx = mod(i-1, MAX_CACHE) + 1 37 + state%post_cache(idx) = posts(i) 38 + end if 39 + else 40 + state%post_cache(idx) = posts(i) 41 + end if 42 + if (state%current_post_count < MAX_ITEMS) then 43 + state%current_post_count = state%current_post_count + 1 44 + state%current_post_ids(state%current_post_count) = idx 45 + end if 46 + end do 47 + end subroutine upsert_posts 48 + 49 + subroutine get_current_post(state, list_index, post, ok) 50 + type(app_state), intent(in) :: state 51 + integer, intent(in) :: list_index 52 + type(post_view), intent(out) :: post 53 + logical, intent(out) :: ok 54 + integer :: idx 55 + 56 + post = post_view() 57 + ok = .false. 58 + if (list_index < 1 .or. list_index > state%current_post_count) return 59 + idx = state%current_post_ids(list_index) 60 + if (idx < 1 .or. idx > state%post_count) return 61 + post = state%post_cache(idx) 62 + ok = .true. 63 + end subroutine get_current_post 64 + end module post_store_mod
+75
src/storage/log_store.f90
··· 1 + module log_store_mod 2 + use iso_fortran_env, only: error_unit 3 + implicit none 4 + contains 5 + subroutine ensure_dir(path) 6 + character(len=*), intent(in) :: path 7 + call execute_command_line('mkdir -p ' // trim(path)) 8 + end subroutine ensure_dir 9 + 10 + function app_state_dir() result(path) 11 + character(len=:), allocatable :: path 12 + character(len=512) :: home 13 + integer :: stat 14 + call get_environment_variable('HOME', home, status=stat) 15 + if (stat == 0 .and. len_trim(home) > 0) then 16 + path = trim(home) // '/.fortransky' 17 + else 18 + path = '.fortransky' 19 + end if 20 + call ensure_dir(path) 21 + end function app_state_dir 22 + 23 + function state_file(name) result(path) 24 + character(len=*), intent(in) :: name 25 + character(len=:), allocatable :: path 26 + path = app_state_dir() // '/' // trim(name) 27 + end function state_file 28 + 29 + subroutine append_line(path, line) 30 + character(len=*), intent(in) :: path, line 31 + integer :: unit, ios 32 + open(newunit=unit, file=trim(path), status='unknown', position='append', action='write', iostat=ios) 33 + if (ios /= 0) then 34 + write(error_unit,'(a)') 'append_line failed: ' // trim(path) 35 + return 36 + end if 37 + write(unit,'(a)') trim(line) 38 + close(unit) 39 + end subroutine append_line 40 + 41 + function read_first_line(path) result(line) 42 + character(len=*), intent(in) :: path 43 + character(len=:), allocatable :: line 44 + integer :: unit, ios 45 + logical :: exists 46 + character(len=4096) :: buf 47 + 48 + inquire(file=trim(path), exist=exists) 49 + if (.not. exists) then 50 + line = '' 51 + return 52 + end if 53 + open(newunit=unit, file=trim(path), status='old', action='read', iostat=ios) 54 + if (ios /= 0) then 55 + line = '' 56 + return 57 + end if 58 + read(unit,'(a)', iostat=ios) buf 59 + close(unit) 60 + if (ios /= 0) then 61 + line = '' 62 + else 63 + line = trim(buf) 64 + end if 65 + end function read_first_line 66 + 67 + subroutine write_text(path, text) 68 + character(len=*), intent(in) :: path, text 69 + integer :: unit, ios 70 + open(newunit=unit, file=trim(path), status='replace', action='write', iostat=ios) 71 + if (ios /= 0) return 72 + write(unit,'(a)') trim(text) 73 + close(unit) 74 + end subroutine write_text 75 + end module log_store_mod
+806
src/ui/tui.f90
··· 1 + module tui_mod 2 + use client_mod, only: login_session, fetch_author_feed, search_posts, fetch_timeline, tail_live_stream, & 3 + fetch_post_thread, create_post, create_reply, create_quote_post, like_post, repost_post, & 4 + fetch_profile_view, fetch_notifications_view, load_saved_session, clear_saved_session, & 5 + resolve_did_to_handle 6 + use models_mod, only: post_view, stream_event, actor_profile, notification_view, MAX_ITEMS 7 + use config_mod, only: load_session_from_env 8 + use app_state_mod, only: app_state, VIEW_HOME, VIEW_POST_LIST, VIEW_PROFILE, VIEW_NOTIFICATIONS, VIEW_STREAM, & 9 + reset_selection, set_status 10 + use post_store_mod, only: upsert_posts, get_current_post 11 + implicit none 12 + contains 13 + subroutine clear_screen() 14 + write(*,'(a)', advance='no') achar(27)//'[2J'//achar(27)//'[H' 15 + end subroutine clear_screen 16 + 17 + subroutine wrap_print(prefix, text, width) 18 + character(len=*), intent(in) :: prefix, text 19 + integer, intent(in) :: width 20 + integer :: start, stop, last_space, maxw, n 21 + character(len=:), allocatable :: line 22 + 23 + maxw = max(20, width - len_trim(prefix)) 24 + if (len_trim(text) == 0) then 25 + write(*,'(a)') trim(prefix) 26 + return 27 + end if 28 + start = 1 29 + n = len_trim(text) 30 + do while (start <= n) 31 + stop = min(n, start + maxw - 1) 32 + if (stop < n) then 33 + last_space = scan(text(start:stop), ' ', back=.true.) 34 + if (last_space > 0 .and. stop < n) stop = start + last_space - 2 35 + end if 36 + if (stop < start) stop = min(n, start + maxw - 1) 37 + line = text(start:stop) 38 + if (start == 1) then 39 + write(*,'(a)') trim(prefix) // trim(line) 40 + else 41 + write(*,'(a)') repeat(' ', len_trim(prefix)) // trim(line) 42 + end if 43 + start = stop + 1 44 + do while (start <= n .and. text(start:start) == ' ') 45 + start = start + 1 46 + end do 47 + end do 48 + end subroutine wrap_print 49 + 50 + subroutine prompt_line(prompt, text) 51 + character(len=*), intent(in) :: prompt 52 + character(len=*), intent(out) :: text 53 + write(*,'(a)', advance='no') trim(prompt) 54 + read(*,'(a)') text 55 + end subroutine prompt_line 56 + 57 + subroutine draw_header(state) 58 + type(app_state), intent(in) :: state 59 + write(*,'(a)') 'Fortransky v1.1 - TUI only' 60 + write(*,'(a)') repeat('=', 28) 61 + write(*,'(a)') 'View : ' // trim(state%view_title) 62 + if (len_trim(state%session%identifier) > 0) write(*,'(a)') 'User : ' // trim(state%session%identifier) 63 + if (len_trim(state%session%did) > 0) write(*,'(a)') 'DID : ' // trim(state%session%did) 64 + if (len_trim(state%session%access_jwt) > 0) then 65 + write(*,'(a)') 'Auth : logged in' 66 + else 67 + write(*,'(a)') 'Auth : anonymous' 68 + end if 69 + write(*,'(a)') 'Stream : ' // trim(state%stream_mode) 70 + write(*,'(a)') 'Status : ' // trim(state%status) 71 + write(*,'(a)') '' 72 + end subroutine draw_header 73 + 74 + subroutine draw_home(state) 75 + type(app_state), intent(in) :: state 76 + call clear_screen() 77 + call draw_header(state) 78 + write(*,'(a)') 'Commands:' 79 + write(*,'(a)') ' a <handle> author feed' 80 + write(*,'(a)') ' s <query> search posts' 81 + write(*,'(a)') ' p <handle> profile view' 82 + write(*,'(a)') ' l login + timeline' 83 + write(*,'(a)') ' x logout + clear saved session' 84 + write(*,'(a)') ' n notifications' 85 + write(*,'(a)') ' c compose post' 86 + write(*,'(a)') ' t <uri/url> open thread' 87 + write(*,'(a)') ' j stream tail' 88 + write(*,'(a)') ' m toggle stream mode (jetstream/relay-raw)' 89 + write(*,'(a)') ' q quit' 90 + end subroutine draw_home 91 + 92 + subroutine draw_post_list(state) 93 + type(app_state), intent(in) :: state 94 + integer :: i, start_idx, end_idx, pages 95 + type(post_view) :: post 96 + logical :: ok 97 + 98 + call clear_screen() 99 + call draw_header(state) 100 + if (state%current_post_count == 0) then 101 + write(*,'(a)') 'No posts loaded.' 102 + write(*,'(a)') '' 103 + write(*,'(a)') 'Commands: b back' 104 + return 105 + end if 106 + pages = max(1, (state%current_post_count + state%page_size - 1) / state%page_size) 107 + start_idx = (state%page - 1) * state%page_size + 1 108 + end_idx = min(state%current_post_count, start_idx + state%page_size - 1) 109 + write(*,'(a,i0,a,i0,a,i0)') 'Page ', state%page, '/', pages, ' Selected ', state%selected 110 + write(*,'(a)') '' 111 + do i = start_idx, end_idx 112 + call get_current_post(state, i, post, ok) 113 + if (.not. ok) cycle 114 + if (i == state%selected) then 115 + write(*,'(a,i0,a)') '>', i, ' <' 116 + else 117 + write(*,'(a,i0)') ' ', i 118 + end if 119 + call wrap_print('Author: ', trim(post%author), 96) 120 + if (len_trim(post%handle) > 0) call wrap_print('Handle: ', trim(post%handle), 96) 121 + if (len_trim(post%indexed_at) > 0) call wrap_print('When : ', trim(post%indexed_at), 96) 122 + call wrap_print('Text : ', trim(post%text), 96) 123 + call wrap_print('Meta : ', post_meta_line(post), 96) 124 + if (len_trim(post%uri) > 0) call wrap_print('URI : ', trim(post%uri), 96) 125 + write(*,'(a)') repeat('-', 72) 126 + end do 127 + write(*,'(a)') 'Commands: j/k move, n/p page, o open thread, r reply, P profile, b back, / search' 128 + end subroutine draw_post_list 129 + 130 + subroutine draw_profile(state) 131 + type(app_state), intent(in) :: state 132 + call clear_screen() 133 + call draw_header(state) 134 + call wrap_print('Name : ', trim(state%profile%display_name), 96) 135 + call wrap_print('Handle: ', trim(state%profile%handle), 96) 136 + call wrap_print('DID : ', trim(state%profile%did), 96) 137 + if (len_trim(state%profile%indexed_at) > 0) call wrap_print('Seen : ', trim(state%profile%indexed_at), 96) 138 + if (len_trim(state%profile%posts_count) > 0) call wrap_print('Posts : ', trim(state%profile%posts_count), 96) 139 + if (len_trim(state%profile%followers_count) > 0) call wrap_print('Followers: ', trim(state%profile%followers_count), 96) 140 + if (len_trim(state%profile%follows_count) > 0) call wrap_print('Follows : ', trim(state%profile%follows_count), 96) 141 + if (len_trim(state%profile%description) > 0) call wrap_print('Bio : ', trim(state%profile%description), 96) 142 + write(*,'(a)') '' 143 + write(*,'(a)') 'Commands: b back, a load author feed' 144 + end subroutine draw_profile 145 + 146 + subroutine draw_notifications(state) 147 + type(app_state), intent(in) :: state 148 + integer :: i, start_idx, end_idx, pages 149 + 150 + call clear_screen() 151 + call draw_header(state) 152 + if (state%notification_count == 0) then 153 + write(*,'(a)') 'No notifications loaded.' 154 + write(*,'(a)') 'Commands: b back' 155 + return 156 + end if 157 + pages = max(1, (state%notification_count + state%page_size - 1) / state%page_size) 158 + start_idx = (state%page - 1) * state%page_size + 1 159 + end_idx = min(state%notification_count, start_idx + state%page_size - 1) 160 + write(*,'(a,i0,a,i0,a,i0)') 'Page ', state%page, '/', pages, ' Selected ', state%selected 161 + write(*,'(a)') '' 162 + do i = start_idx, end_idx 163 + if (i == state%selected) then 164 + write(*,'(a,i0,a)') '>', i, ' <' 165 + else 166 + write(*,'(a,i0)') ' ', i 167 + end if 168 + call wrap_print('Reason: ', trim(state%notifications(i)%reason), 96) 169 + call wrap_print('Actor : ', trim(state%notifications(i)%author), 96) 170 + if (len_trim(state%notifications(i)%handle) > 0) call wrap_print('Handle: ', trim(state%notifications(i)%handle), 96) 171 + if (len_trim(state%notifications(i)%indexed_at) > 0) call wrap_print('When : ', trim(state%notifications(i)%indexed_at), 96) 172 + if (len_trim(state%notifications(i)%text) > 0) call wrap_print('Text : ', trim(state%notifications(i)%text), 96) 173 + if (len_trim(state%notifications(i)%uri) > 0) call wrap_print('URI : ', trim(state%notifications(i)%uri), 96) 174 + write(*,'(a)') repeat('-', 72) 175 + end do 176 + write(*,'(a)') 'Commands: j/k move, n/p page, o open thread, r reply, l like, R repost, q quote, b back' 177 + end subroutine draw_notifications 178 + 179 + subroutine draw_stream(events, n, message) 180 + type(stream_event), intent(in) :: events(MAX_ITEMS) 181 + integer, intent(in) :: n 182 + character(len=*), intent(in) :: message 183 + integer :: i 184 + call clear_screen() 185 + write(*,'(a)') 'Fortransky v1.1 - stream tail' 186 + write(*,'(a)') repeat('=', 28) 187 + write(*,'(a)') trim(message) 188 + write(*,'(a)') '' 189 + if (n == 0) then 190 + write(*,'(a)') 'No events decoded.' 191 + else 192 + do i = 1, n 193 + write(*,'(a,i0)') 'Event ', i 194 + call wrap_print('Kind : ', trim(events(i)%kind), 96) 195 + if (len_trim(events(i)%handle) > 0) call wrap_print('Handle: ', trim(events(i)%handle), 96) 196 + if (len_trim(events(i)%did) > 0) call wrap_print('DID : ', trim(events(i)%did), 96) 197 + if (len_trim(events(i)%time_us) > 0) call wrap_print('Cursor: ', trim(events(i)%time_us), 96) 198 + if (len_trim(events(i)%text) > 0) call wrap_print('Text : ', trim(events(i)%text), 96) 199 + write(*,'(a)') repeat('-', 72) 200 + end do 201 + end if 202 + write(*,'(a)') 'Commands: b back, j refresh' 203 + end subroutine draw_stream 204 + 205 + function post_meta_line(post) result(out) 206 + type(post_view), intent(in) :: post 207 + character(len=:), allocatable :: out 208 + 209 + out = 'type=' // trim(post%record_type) 210 + if (len_trim(post%reason) > 0) out = out // ' reason=' // trim(post%reason) 211 + if (post%is_quote) out = out // ' quote' 212 + if (post%has_images) out = out // ' images' 213 + if (post%has_video) out = out // ' video' 214 + if (post%has_external) out = out // ' link' 215 + if (post%has_facets) out = out // ' facets' 216 + if (len_trim(post%reply_count) > 0) out = out // ' replies=' // trim(post%reply_count) 217 + if (len_trim(post%repost_count) > 0) out = out // ' reposts=' // trim(post%repost_count) 218 + if (len_trim(post%like_count) > 0) out = out // ' likes=' // trim(post%like_count) 219 + if (len_trim(post%quote_count) > 0) out = out // ' quotes=' // trim(post%quote_count) 220 + end function post_meta_line 221 + 222 + subroutine login_flow(state) 223 + type(app_state), intent(inout) :: state 224 + type(post_view) :: posts(MAX_ITEMS) 225 + character(len=256) :: input, password, message 226 + integer :: n 227 + logical :: ok 228 + 229 + if (len_trim(state%session%identifier) == 0) then 230 + call prompt_line('Identifier: ', state%session%identifier) 231 + else 232 + call prompt_line('Identifier [' // trim(state%session%identifier) // ']: ', input) 233 + if (len_trim(input) > 0) state%session%identifier = trim(input) 234 + end if 235 + call prompt_line('Password/app password: ', password) 236 + call login_session(state%session, trim(password), ok, message) 237 + if (.not. ok) then 238 + call set_status(state, trim(message)) 239 + return 240 + end if 241 + call fetch_timeline(state%session, posts, n, ok) 242 + if (ok) then 243 + call upsert_posts(state, posts, n) 244 + state%prev_view = state%view 245 + state%view = VIEW_POST_LIST 246 + state%view_title = 'Home timeline' 247 + call reset_selection(state) 248 + call set_status(state, 'Login OK. Timeline loaded.') 249 + else 250 + call set_status(state, 'Login OK, but timeline fetch failed.') 251 + end if 252 + end subroutine login_flow 253 + 254 + subroutine load_author_feed(state, handle) 255 + type(app_state), intent(inout) :: state 256 + character(len=*), intent(in) :: handle 257 + type(post_view) :: posts(MAX_ITEMS) 258 + integer :: n 259 + call fetch_author_feed(trim(handle), posts, n) 260 + call upsert_posts(state, posts, n) 261 + state%prev_view = state%view 262 + state%view = VIEW_POST_LIST 263 + state%view_title = 'Author feed: ' // trim(handle) 264 + call reset_selection(state) 265 + call set_status(state, 'Loaded author feed.') 266 + end subroutine load_author_feed 267 + 268 + subroutine load_search(state, query) 269 + type(app_state), intent(inout) :: state 270 + character(len=*), intent(in) :: query 271 + type(post_view) :: posts(MAX_ITEMS) 272 + integer :: n 273 + call search_posts(trim(query), posts, n) 274 + call upsert_posts(state, posts, n) 275 + state%prev_view = state%view 276 + state%view = VIEW_POST_LIST 277 + state%view_title = 'Search: ' // trim(query) 278 + call reset_selection(state) 279 + call set_status(state, 'Search loaded.') 280 + end subroutine load_search 281 + 282 + subroutine load_profile(state, handle) 283 + type(app_state), intent(inout) :: state 284 + character(len=*), intent(in) :: handle 285 + logical :: ok 286 + character(len=256) :: message 287 + 288 + call fetch_profile_view(trim(handle), state%profile, ok, message) 289 + if (ok) then 290 + state%prev_view = state%view 291 + state%view = VIEW_PROFILE 292 + state%view_title = 'Profile: ' // trim(handle) 293 + call set_status(state, 'Profile loaded.') 294 + else 295 + call set_status(state, trim(message)) 296 + end if 297 + end subroutine load_profile 298 + 299 + subroutine load_notifications(state) 300 + type(app_state), intent(inout) :: state 301 + logical :: ok 302 + character(len=256) :: message 303 + integer :: n 304 + 305 + call fetch_notifications_view(state%session, state%notifications, n, ok, message) 306 + if (ok) then 307 + state%notification_count = n 308 + state%prev_view = state%view 309 + state%view = VIEW_NOTIFICATIONS 310 + state%view_title = 'Notifications' 311 + call reset_selection(state) 312 + call set_status(state, 'Notifications loaded.') 313 + else 314 + call set_status(state, trim(message)) 315 + end if 316 + end subroutine load_notifications 317 + 318 + subroutine load_thread(state, ref) 319 + type(app_state), intent(inout) :: state 320 + character(len=*), intent(in) :: ref 321 + type(post_view) :: posts(MAX_ITEMS) 322 + integer :: n 323 + logical :: ok 324 + character(len=256) :: message 325 + 326 + call fetch_post_thread(trim(ref), posts, n, ok, message) 327 + if (ok) then 328 + call upsert_posts(state, posts, n) 329 + state%prev_view = state%view 330 + state%view = VIEW_POST_LIST 331 + state%view_title = 'Thread view' 332 + call reset_selection(state) 333 + call set_status(state, 'Thread loaded.') 334 + else 335 + call set_status(state, trim(message)) 336 + end if 337 + end subroutine load_thread 338 + 339 + subroutine compose_flow(state) 340 + type(app_state), intent(inout) :: state 341 + character(len=2000) :: text 342 + character(len=256) :: message, created_uri 343 + logical :: ok 344 + 345 + call prompt_line('Compose text: ', text) 346 + if (len_trim(text) == 0) then 347 + call set_status(state, 'Empty post discarded.') 348 + return 349 + end if 350 + call create_post(state%session, trim(text), ok, message, created_uri) 351 + if (ok) then 352 + call set_status(state, 'Post created: ' // trim(created_uri)) 353 + else 354 + call set_status(state, trim(message)) 355 + end if 356 + end subroutine compose_flow 357 + 358 + subroutine reply_to_selected_post(state) 359 + type(app_state), intent(inout) :: state 360 + type(post_view) :: target 361 + logical :: ok 362 + character(len=2000) :: text 363 + character(len=256) :: message, created_uri 364 + character(len=512) :: root_uri, root_cid 365 + 366 + call get_current_post(state, state%selected, target, ok) 367 + if (.not. ok) then 368 + call set_status(state, 'No selected post.') 369 + return 370 + end if 371 + if (len_trim(target%uri) == 0 .or. len_trim(target%cid) == 0) then 372 + call set_status(state, 'Selected post is missing reply metadata.') 373 + return 374 + end if 375 + call prompt_line('Reply text: ', text) 376 + if (len_trim(text) == 0) then 377 + call set_status(state, 'Empty reply discarded.') 378 + return 379 + end if 380 + if (len_trim(target%root_uri) > 0 .and. len_trim(target%root_cid) > 0) then 381 + root_uri = trim(target%root_uri) 382 + root_cid = trim(target%root_cid) 383 + else 384 + root_uri = trim(target%uri) 385 + root_cid = trim(target%cid) 386 + end if 387 + call create_reply(state%session, trim(text), trim(target%uri), trim(target%cid), trim(root_uri), trim(root_cid), ok, message, created_uri) 388 + if (ok) then 389 + call set_status(state, 'Reply created: ' // trim(created_uri)) 390 + else 391 + call set_status(state, trim(message)) 392 + end if 393 + end subroutine reply_to_selected_post 394 + 395 + subroutine reply_to_selected_notification(state) 396 + type(app_state), intent(inout) :: state 397 + type(post_view) :: temp 398 + 399 + if (state%selected < 1 .or. state%selected > state%notification_count) then 400 + call set_status(state, 'No selected notification.') 401 + return 402 + end if 403 + temp = post_view(state%notifications(state%selected)%author, state%notifications(state%selected)%handle, & 404 + state%notifications(state%selected)%text, state%notifications(state%selected)%uri, & 405 + state%notifications(state%selected)%cid, state%notifications(state%selected)%indexed_at, & 406 + state%notifications(state%selected)%parent_uri, state%notifications(state%selected)%parent_cid, & 407 + state%notifications(state%selected)%root_uri, state%notifications(state%selected)%root_cid) 408 + call reply_to_post_object(state, temp) 409 + end subroutine reply_to_selected_notification 410 + 411 + subroutine reply_to_post_object(state, target) 412 + type(app_state), intent(inout) :: state 413 + type(post_view), intent(in) :: target 414 + logical :: ok 415 + character(len=2000) :: text 416 + character(len=256) :: message, created_uri 417 + character(len=512) :: root_uri, root_cid 418 + 419 + if (len_trim(target%uri) == 0 .or. len_trim(target%cid) == 0) then 420 + call set_status(state, 'Selected item is missing reply metadata.') 421 + return 422 + end if 423 + call prompt_line('Reply text: ', text) 424 + if (len_trim(text) == 0) then 425 + call set_status(state, 'Empty reply discarded.') 426 + return 427 + end if 428 + if (len_trim(target%root_uri) > 0 .and. len_trim(target%root_cid) > 0) then 429 + root_uri = trim(target%root_uri) 430 + root_cid = trim(target%root_cid) 431 + else 432 + root_uri = trim(target%uri) 433 + root_cid = trim(target%cid) 434 + end if 435 + call create_reply(state%session, trim(text), trim(target%uri), trim(target%cid), trim(root_uri), trim(root_cid), ok, message, created_uri) 436 + if (ok) then 437 + call set_status(state, 'Reply created: ' // trim(created_uri)) 438 + else 439 + call set_status(state, trim(message)) 440 + end if 441 + end subroutine reply_to_post_object 442 + 443 + subroutine like_selected_post(state) 444 + type(app_state), intent(inout) :: state 445 + type(post_view) :: target 446 + logical :: ok 447 + character(len=256) :: message, created_uri 448 + 449 + call get_current_post(state, state%selected, target, ok) 450 + if (.not. ok) then 451 + call set_status(state, 'No selected post.') 452 + return 453 + end if 454 + call like_post(state%session, trim(target%uri), trim(target%cid), ok, message, created_uri) 455 + if (ok) then 456 + call set_status(state, 'Liked: ' // trim(created_uri)) 457 + else 458 + call set_status(state, trim(message)) 459 + end if 460 + end subroutine like_selected_post 461 + 462 + subroutine repost_selected_post(state) 463 + type(app_state), intent(inout) :: state 464 + type(post_view) :: target 465 + logical :: ok 466 + character(len=256) :: message, created_uri 467 + 468 + call get_current_post(state, state%selected, target, ok) 469 + if (.not. ok) then 470 + call set_status(state, 'No selected post.') 471 + return 472 + end if 473 + call repost_post(state%session, trim(target%uri), trim(target%cid), ok, message, created_uri) 474 + if (ok) then 475 + call set_status(state, 'Reposted: ' // trim(created_uri)) 476 + else 477 + call set_status(state, trim(message)) 478 + end if 479 + end subroutine repost_selected_post 480 + 481 + subroutine quote_selected_post(state) 482 + type(app_state), intent(inout) :: state 483 + type(post_view) :: target 484 + logical :: ok 485 + character(len=2000) :: text 486 + character(len=256) :: message, created_uri 487 + 488 + call get_current_post(state, state%selected, target, ok) 489 + if (.not. ok) then 490 + call set_status(state, 'No selected post.') 491 + return 492 + end if 493 + if (len_trim(target%uri) == 0 .or. len_trim(target%cid) == 0) then 494 + call set_status(state, 'Selected post is missing URI/CID.') 495 + return 496 + end if 497 + call prompt_line('Quote text: ', text) 498 + if (len_trim(text) == 0) then 499 + call set_status(state, 'Empty quote discarded.') 500 + return 501 + end if 502 + call create_quote_post(state%session, trim(text), trim(target%uri), trim(target%cid), ok, message, created_uri) 503 + if (ok) then 504 + call set_status(state, 'Quote created: ' // trim(created_uri)) 505 + else 506 + call set_status(state, trim(message)) 507 + end if 508 + end subroutine quote_selected_post 509 + 510 + subroutine refresh_stream_view(state, events, n) 511 + type(app_state), intent(inout) :: state 512 + type(stream_event), intent(out) :: events(MAX_ITEMS) 513 + integer, intent(out) :: n 514 + logical :: ok 515 + character(len=256) :: message 516 + integer :: i 517 + character(len=256) :: resolved_handle 518 + 519 + call tail_live_stream(events, n, ok, message, 12, trim(state%stream_mode)) 520 + 521 + ! Resolve DID -> handle for each event (cache hit is fast; miss calls API) 522 + do i = 1, n 523 + if (len_trim(events(i)%did) > 0 .and. len_trim(events(i)%handle) == 0) then 524 + call resolve_did_to_handle(state, trim(events(i)%did), resolved_handle) 525 + events(i)%handle = trim(resolved_handle) 526 + end if 527 + end do 528 + 529 + state%prev_view = state%view 530 + state%view = VIEW_STREAM 531 + state%view_title = 'Live stream tail' 532 + call set_status(state, trim(message)) 533 + end subroutine refresh_stream_view 534 + 535 + subroutine move_selection(state, delta, count) 536 + type(app_state), intent(inout) :: state 537 + integer, intent(in) :: delta, count 538 + integer :: pages 539 + 540 + if (count <= 0) return 541 + state%selected = max(1, min(count, state%selected + delta)) 542 + pages = max(1, (count + state%page_size - 1) / state%page_size) 543 + state%page = max(1, min(pages, (state%selected - 1) / state%page_size + 1)) 544 + end subroutine move_selection 545 + 546 + subroutine next_page(state, count) 547 + type(app_state), intent(inout) :: state 548 + integer, intent(in) :: count 549 + integer :: pages 550 + pages = max(1, (count + state%page_size - 1) / state%page_size) 551 + if (state%page < pages) state%page = state%page + 1 552 + state%selected = min(count, (state%page - 1) * state%page_size + 1) 553 + end subroutine next_page 554 + 555 + subroutine prev_page(state, count) 556 + type(app_state), intent(inout) :: state 557 + integer, intent(in) :: count 558 + if (state%page > 1) state%page = state%page - 1 559 + state%selected = min(count, (state%page - 1) * state%page_size + 1) 560 + end subroutine prev_page 561 + 562 + subroutine handle_home_command(state, line, quit, events, stream_n) 563 + type(app_state), intent(inout) :: state 564 + character(len=*), intent(in) :: line 565 + logical, intent(inout) :: quit 566 + type(stream_event), intent(inout) :: events(MAX_ITEMS) 567 + integer, intent(inout) :: stream_n 568 + character(len=:), allocatable :: cmd, arg 569 + integer :: sp 570 + 571 + sp = index(trim(line), ' ') 572 + if (sp > 0) then 573 + cmd = adjustl(trim(line(1:sp-1))) 574 + arg = adjustl(trim(line(sp+1:))) 575 + else 576 + cmd = adjustl(trim(line)) 577 + arg = '' 578 + end if 579 + 580 + select case (trim(cmd)) 581 + case ('a') 582 + if (len_trim(arg) == 0) then 583 + call set_status(state, 'Usage: a <handle>') 584 + else 585 + call load_author_feed(state, arg) 586 + end if 587 + case ('s') 588 + if (len_trim(arg) == 0) then 589 + call set_status(state, 'Usage: s <query>') 590 + else 591 + call load_search(state, arg) 592 + end if 593 + case ('p') 594 + if (len_trim(arg) == 0) then 595 + call set_status(state, 'Usage: p <handle>') 596 + else 597 + call load_profile(state, arg) 598 + end if 599 + case ('l') 600 + call login_flow(state) 601 + case ('n') 602 + call load_notifications(state) 603 + case ('c') 604 + call compose_flow(state) 605 + case ('t') 606 + if (len_trim(arg) == 0) then 607 + call set_status(state, 'Usage: t <at://uri or bsky.app URL>') 608 + else 609 + call load_thread(state, arg) 610 + end if 611 + case ('j') 612 + call refresh_stream_view(state, events, stream_n) 613 + case ('m') 614 + if (trim(state%stream_mode) == 'jetstream') then 615 + state%stream_mode = 'relay-raw' 616 + else 617 + state%stream_mode = 'jetstream' 618 + end if 619 + call set_status(state, 'Stream mode set to ' // trim(state%stream_mode)) 620 + case ('x') 621 + state%session%access_jwt = '' 622 + state%session%refresh_jwt = '' 623 + state%session%did = '' 624 + call clear_saved_session() 625 + call set_status(state, 'Logged out and cleared saved session.') 626 + case ('q') 627 + quit = .true. 628 + case default 629 + call set_status(state, 'Unknown command on home view.') 630 + end select 631 + end subroutine handle_home_command 632 + 633 + subroutine handle_post_command(state, line) 634 + type(app_state), intent(inout) :: state 635 + character(len=*), intent(in) :: line 636 + character(len=256) :: arg 637 + type(post_view) :: target 638 + logical :: ok 639 + 640 + select case (trim(line)) 641 + case ('j') 642 + call move_selection(state, 1, state%current_post_count) 643 + case ('k') 644 + call move_selection(state, -1, state%current_post_count) 645 + case ('n') 646 + call next_page(state, state%current_post_count) 647 + case ('p') 648 + call prev_page(state, state%current_post_count) 649 + case ('o') 650 + call get_current_post(state, state%selected, target, ok) 651 + if (ok) then 652 + call load_thread(state, trim(target%uri)) 653 + else 654 + call set_status(state, 'No selected post.') 655 + end if 656 + case ('r') 657 + call reply_to_selected_post(state) 658 + case ('l') 659 + call like_selected_post(state) 660 + case ('R') 661 + call repost_selected_post(state) 662 + case ('q') 663 + call quote_selected_post(state) 664 + case ('P') 665 + call get_current_post(state, state%selected, target, ok) 666 + if (ok .and. len_trim(target%handle) > 0) then 667 + call load_profile(state, trim(target%handle)) 668 + else 669 + call set_status(state, 'Selected post has no handle.') 670 + end if 671 + case ('b') 672 + state%view = VIEW_HOME 673 + state%view_title = 'Fortransky' 674 + call set_status(state, 'Back to home.') 675 + case default 676 + if (len_trim(line) >= 2 .and. line(1:1) == '/') then 677 + arg = adjustl(trim(line(2:))) 678 + if (len_trim(arg) > 0) then 679 + call load_search(state, trim(arg)) 680 + else 681 + call set_status(state, 'Usage: /search terms') 682 + end if 683 + else 684 + call set_status(state, 'Unknown command on post list.') 685 + end if 686 + end select 687 + end subroutine handle_post_command 688 + 689 + subroutine handle_profile_command(state, line) 690 + type(app_state), intent(inout) :: state 691 + character(len=*), intent(in) :: line 692 + select case (trim(line)) 693 + case ('b') 694 + state%view = VIEW_HOME 695 + state%view_title = 'Fortransky' 696 + call set_status(state, 'Back to home.') 697 + case ('a') 698 + if (len_trim(state%profile%handle) > 0) then 699 + call load_author_feed(state, trim(state%profile%handle)) 700 + else 701 + call set_status(state, 'Profile has no handle.') 702 + end if 703 + case default 704 + call set_status(state, 'Unknown command on profile view.') 705 + end select 706 + end subroutine handle_profile_command 707 + 708 + subroutine handle_notification_command(state, line) 709 + type(app_state), intent(inout) :: state 710 + character(len=*), intent(in) :: line 711 + 712 + select case (trim(line)) 713 + case ('j') 714 + call move_selection(state, 1, state%notification_count) 715 + case ('k') 716 + call move_selection(state, -1, state%notification_count) 717 + case ('n') 718 + call next_page(state, state%notification_count) 719 + case ('p') 720 + call prev_page(state, state%notification_count) 721 + case ('o') 722 + if (state%selected >= 1 .and. state%selected <= state%notification_count) then 723 + call load_thread(state, trim(state%notifications(state%selected)%uri)) 724 + else 725 + call set_status(state, 'No selected notification.') 726 + end if 727 + case ('r') 728 + call reply_to_selected_notification(state) 729 + case ('b') 730 + state%view = VIEW_HOME 731 + state%view_title = 'Fortransky' 732 + call set_status(state, 'Back to home.') 733 + case default 734 + call set_status(state, 'Unknown command on notifications view.') 735 + end select 736 + end subroutine handle_notification_command 737 + 738 + subroutine handle_stream_command(state, line, events, n) 739 + type(app_state), intent(inout) :: state 740 + character(len=*), intent(in) :: line 741 + type(stream_event), intent(inout) :: events(MAX_ITEMS) 742 + integer, intent(inout) :: n 743 + select case (trim(line)) 744 + case ('b') 745 + state%view = VIEW_HOME 746 + state%view_title = 'Fortransky' 747 + call set_status(state, 'Back to home.') 748 + case ('j') 749 + call refresh_stream_view(state, events, n) 750 + case default 751 + call set_status(state, 'Unknown command on stream view.') 752 + end select 753 + end subroutine handle_stream_command 754 + 755 + subroutine app_loop() 756 + type(app_state) :: state 757 + type(stream_event) :: events(MAX_ITEMS) 758 + character(len=512) :: line 759 + logical :: quit 760 + integer :: stream_n 761 + 762 + state = app_state() 763 + call load_session_from_env(state%session) 764 + call load_saved_session(state%session) 765 + state%view = VIEW_HOME 766 + state%view_title = 'Fortransky' 767 + if (len_trim(state%session%access_jwt) > 0) then 768 + call set_status(state, 'Loaded saved session. TUI commands are line based: type a key or command and press Enter.') 769 + else 770 + call set_status(state, 'Ready. TUI commands are line based: type a key or command and press Enter.') 771 + end if 772 + quit = .false. 773 + stream_n = 0 774 + 775 + do while (.not. quit) 776 + select case (state%view) 777 + case (VIEW_HOME) 778 + call draw_home(state) 779 + case (VIEW_POST_LIST) 780 + call draw_post_list(state) 781 + case (VIEW_PROFILE) 782 + call draw_profile(state) 783 + case (VIEW_NOTIFICATIONS) 784 + call draw_notifications(state) 785 + case (VIEW_STREAM) 786 + call draw_stream(events, stream_n, state%status) 787 + end select 788 + 789 + call prompt_line('> ', line) 790 + if (len_trim(line) == 0) cycle 791 + 792 + select case (state%view) 793 + case (VIEW_HOME) 794 + call handle_home_command(state, trim(line), quit, events, stream_n) 795 + case (VIEW_POST_LIST) 796 + call handle_post_command(state, trim(line)) 797 + case (VIEW_PROFILE) 798 + call handle_profile_command(state, trim(line)) 799 + case (VIEW_NOTIFICATIONS) 800 + call handle_notification_command(state, trim(line)) 801 + case (VIEW_STREAM) 802 + call handle_stream_command(state, trim(line), events, stream_n) 803 + end select 804 + end do 805 + end subroutine app_loop 806 + end module tui_mod
+42
src/util/process.f90
··· 1 + module process_mod 2 + use iso_fortran_env, only: error_unit 3 + implicit none 4 + contains 5 + function slurp_file(path) result(content) 6 + character(len=*), intent(in) :: path 7 + character(len=:), allocatable :: content 8 + integer :: unit, ios, size 9 + logical :: exists 10 + 11 + inquire(file=path, exist=exists, size=size) 12 + if (.not. exists) then 13 + content = '' 14 + return 15 + end if 16 + 17 + open(newunit=unit, file=path, status='old', access='stream', form='unformatted', action='read', iostat=ios) 18 + if (ios /= 0) then 19 + write(error_unit, '(a)') 'Failed to open file: ' // trim(path) 20 + content = '' 21 + return 22 + end if 23 + 24 + allocate(character(len=size) :: content) 25 + if (size > 0) then 26 + read(unit, iostat=ios) content 27 + if (ios /= 0) content = '' 28 + else 29 + content = '' 30 + end if 31 + close(unit) 32 + end function slurp_file 33 + 34 + subroutine run_capture(command, output_path, exitstat) 35 + character(len=*), intent(in) :: command, output_path 36 + integer, intent(out) :: exitstat 37 + character(len=:), allocatable :: cmd 38 + 39 + cmd = trim(command) // ' > ' // trim(output_path) // ' 2>&1' 40 + call execute_command_line(cmd, exitstat=exitstat) 41 + end subroutine run_capture 42 + end module process_mod
+80
src/util/strings.f90
··· 1 + module strings_mod 2 + implicit none 3 + contains 4 + pure function replace_all(text, old, new) result(out) 5 + character(len=*), intent(in) :: text, old, new 6 + character(len=:), allocatable :: out 7 + integer :: pos, start 8 + 9 + out = '' 10 + start = 1 11 + do 12 + pos = index(text(start:), old) 13 + if (pos == 0) then 14 + out = out // text(start:) 15 + exit 16 + end if 17 + pos = pos + start - 1 18 + out = out // text(start:pos-1) // new 19 + start = pos + len(old) 20 + end do 21 + end function replace_all 22 + 23 + pure function json_unescape(text) result(out) 24 + character(len=*), intent(in) :: text 25 + character(len=:), allocatable :: out 26 + out = text 27 + out = replace_all(out, '\\/','/') 28 + out = replace_all(out, '\\n', ' ') 29 + out = replace_all(out, '\\r', ' ') 30 + out = replace_all(out, '\\t', ' ') 31 + out = replace_all(out, '\\"', '"') 32 + out = replace_all(out, '\\\\', '\\') 33 + end function json_unescape 34 + 35 + pure function squeeze_spaces(text) result(out) 36 + character(len=*), intent(in) :: text 37 + character(len=:), allocatable :: out 38 + integer :: i 39 + logical :: prev_space 40 + 41 + out = '' 42 + prev_space = .false. 43 + do i = 1, len_trim(text) 44 + select case (text(i:i)) 45 + case (' ', achar(9), achar(10), achar(13)) 46 + if (.not. prev_space) then 47 + out = out // ' ' 48 + prev_space = .true. 49 + end if 50 + case default 51 + out = out // text(i:i) 52 + prev_space = .false. 53 + end select 54 + end do 55 + end function squeeze_spaces 56 + 57 + 58 + pure function url_encode(text) result(out) 59 + character(len=*), intent(in) :: text 60 + character(len=:), allocatable :: out 61 + integer :: i, code, hi, lo 62 + character(len=16), parameter :: hexdigits = '0123456789ABCDEF' 63 + 64 + out = '' 65 + do i = 1, len_trim(text) 66 + code = iachar(text(i:i)) 67 + select case (text(i:i)) 68 + case ('A':'Z','a':'z','0':'9','-','_','.','~') 69 + out = out // text(i:i) 70 + case (' ') 71 + out = out // '%20' 72 + case default 73 + hi = code / 16 + 1 74 + lo = mod(code, 16) + 1 75 + out = out // '%' // hexdigits(hi:hi) // hexdigits(lo:lo) 76 + end select 77 + end do 78 + end function url_encode 79 + 80 + end module strings_mod