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.4 — DM slide support via chat.bsky.convo.*, PDS auto-detection from plc.directory

FormerLab 1c12b967 20a615ad

+558 -49
+79 -36
README.md
··· 2 2 3 3 Yes, that Fortran. 4 4 5 - A terminal-only Bluesky / AT Protocol client written in Fortran, with a native 6 - firehose decoder for the `relay-raw` stream path. 5 + A terminal-only Bluesky / AT Protocol client written in Fortran. Posts, timelines, 6 + notifications, dithered images, and now DMs — all from an amber terminal, all the 7 + way down to the protocol. 7 8 8 - Project blog post: https://www.patreon.com/posts/153457794 9 + Project blog: https://www.patreon.com/posts/153457794 10 + 11 + This is version 1.4, DM sliding...next up is DM E2EE Germ-style and feed composer (true Fortran territory) 12 + Yes, we got a bit of .py and .h and .c, but phasing them out as we go. Not entirely possible 9 13 10 14 --- 11 15 ··· 17 21 └─ Fortran iso_c_binding module (src/atproto/firehose_bridge.f90) 18 22 └─ Rust staticlib (bridge/firehose-bridge/) 19 23 envelope → CAR → DAG-CBOR → NormalizedEvent → JSONL 20 - + firehose_bridge_cli binary (used by relay_raw_tail.py) 21 24 22 25 relay-raw stream path: 23 26 relay_raw_tail.py 24 - └─ assemblersky_cli (bridge/assemblersky/bin/) ← preferred 25 - └─ firehose_bridge_cli (bridge/firehose-bridge/target/release/) ← fallback 26 - └─ Python cbor2 ← live stream decode 27 + └─ assemblersky_cli (bridge/assemblersky/bin/) ← x86-64 assembly decoder 28 + └─ firehose_bridge_cli (bridge/firehose-bridge/target/release/) ← Rust fallback 29 + └─ Python cbor2 ← live stream decode (CBORTag/CIDv1 aware) 27 30 28 31 image post path (d <imagepath>): 29 - dither_prep.py image → greyscale flat pixel file 30 - dither.f90 Floyd-Steinberg error diffusion (Bill Atkinson's algorithm) 31 - pixels_to_png.py pixel file → PNG 32 - uploadBlob PNG → Bluesky blob 33 - createRecord post with app.bsky.embed.images 32 + dither_prep.py image → greyscale flat pixel file 33 + dither.f90 Floyd-Steinberg error diffusion in Fortran 34 + pixels_to_png.py pixel file → PNG 35 + uploadBlob PNG → AT Protocol blob 36 + createRecord post with app.bsky.embed.images 37 + 38 + DM path (dm <handle>, i): 39 + getConvoForMembers resolve or create conversation 40 + getMessages fetch message thread 41 + sendMessage send a DM 42 + All routed via atproto-proxy header to did:web:api.bsky.chat 43 + PDS host resolved automatically from plc.directory on login 34 44 ``` 35 45 36 - Session state is saved to `~/.fortransky/session.json`. Use an app password, 37 - not your main Bluesky password. 46 + Session state is saved to `~/.fortransky/session.json`. Use an app password 47 + with DM access enabled, not your main Bluesky password. 38 48 39 49 --- 40 50 ··· 44 54 45 55 ```bash 46 56 sudo apt install -y gfortran cmake pkg-config libcurl4-openssl-dev 57 + ``` 58 + 59 + ### System packages (Arch/Garuda) 60 + 61 + ```bash 62 + sudo pacman -S gcc-fortran cmake pkgconf curl python-pillow python-cbor2 python-websockets 47 63 ``` 48 64 49 65 ### Rust toolchain ··· 65 81 Or with a venv (run Fortransky with the venv active): 66 82 67 83 ```bash 68 - python3 -m venv .venv 69 - source .venv/bin/activate 84 + python3 -m venv .venv && source .venv/bin/activate 70 85 pip install cbor2 websockets Pillow 71 86 ``` 72 87 73 88 ### Assemblersky (optional, relay-raw native decoder) 74 89 75 - Assemblersky is an x86-64 assembly AT Protocol firehose decoder. 76 - If the binary is present at `bridge/assemblersky/bin/assemblersky_cli`, 77 - `relay_raw_tail.py` will use it automatically for single-frame decode. 90 + Assemblersky decodes raw AT Protocol firehose frames in x86-64 assembly. 91 + If present at `bridge/assemblersky/bin/assemblersky_cli`, it is preferred 92 + automatically. 78 93 79 - Build from source: https://github.com/FormerLab/assemblersky 94 + 95 + ## We got a non-public repo for Assemblersky, later when we go public with the Assembler module this is the guide: 96 + 97 + Build from source: https://github.com/FormerLab/assemblersky (again, nota public repo right right now) 80 98 81 99 ```bash 82 - cd /path/to/assemblersky 83 - make 100 + cd /path/to/assemblersky && make 84 101 mkdir -p /path/to/fortransky/bridge/assemblersky/bin 85 102 cp rust-harness/target/release/assemblersky-harness \ 86 103 /path/to/fortransky/bridge/assemblersky/bin/assemblersky_cli ··· 90 107 91 108 --- 92 109 93 - ## Build 110 + ## Build and run Fortransky 94 111 95 112 ```bash 96 113 ./scripts/build.sh ··· 101 118 102 119 ## Login 103 120 104 - Use an [app password](https://bsky.app/settings/app-passwords). At the home prompt: 121 + Use an [app password](https://bsky.app/settings/app-passwords) with DM access 122 + enabled. At the home prompt: 105 123 106 124 ``` 107 125 l ··· 110 128 ``` 111 129 112 130 Session is saved to `~/.fortransky/session.json` and restored on next launch. 131 + Your real PDS host is resolved from `plc.directory` on first login and stored 132 + in the session — required for DMs and other PDS-proxied endpoints. 113 133 To log out: `x` 114 134 115 135 --- ··· 128 148 | `n` | notifications | 129 149 | `c` | compose post | 130 150 | `d <imagepath>` | dither image + post to Bluesky | 151 + | `i` | DM inbox | 152 + | `dm <handle>` | open or start a DM conversation | 131 153 | `t <uri/url>` | open thread | 132 154 | `j` | stream tail | 133 155 | `m` | toggle stream mode (jetstream / relay-raw) | ··· 148 170 | `/query` | search | 149 171 | `b` | back to home | 150 172 173 + ### DM thread view 174 + 175 + | Command | Action | 176 + |---------|--------| 177 + | `r` | reply (send message) | 178 + | `j` | refresh messages | 179 + | `b` | back | 180 + 151 181 ### Stream view 152 182 153 183 | Command | Action | ··· 160 190 ## Image posting 161 191 162 192 The `d` command dithers any image using Bill Atkinson's Floyd-Steinberg 163 - algorithm (as used in MacPaint, 1984) and posts it to Bluesky. 193 + algorithm — the same algorithm used in MacPaint in 1984 — and posts it 194 + to Bluesky as an image embed. 164 195 165 196 ``` 166 197 d /path/to/image.jpg ··· 168 199 169 200 The image is converted to greyscale, resized to 576×720 (the original MacPaint 170 201 canvas dimensions), dithered to 1-bit in Fortran, converted to PNG, and posted 171 - with an image embed. Pillow is required. 202 + via `com.atproto.repo.uploadBlob` + `createRecord`. Pillow is required. 203 + 204 + --- 205 + 206 + ## DMs 207 + 208 + Fortransky implements `chat.bsky.convo.*` — the same DM protocol used by the 209 + official Bluesky app. Requests are proxied through your PDS to 210 + `did:web:api.bsky.chat` via the `atproto-proxy` header. 211 + 212 + Your app password must have DM access enabled (the "Allow access to your direct 213 + messages" toggle in Bluesky's app password settings). 214 + 215 + PDS host is resolved automatically from `plc.directory` on login and stored in 216 + the session file — no manual configuration needed. 172 217 173 218 --- 174 219 ··· 193 238 7. `bridge/firehose-bridge/target/debug/firehose_bridge_cli` 194 239 8. `firehose_bridge_cli` on `PATH` 195 240 196 - ### Offline fixture testing 197 - 198 - ```bash 199 - printf 'b\nm\nj\nb\nq\n' | ./build/fortransky 200 - ``` 201 - 202 241 --- 203 242 204 243 ## Known issues / notes 205 244 206 - - JSON parser is hand-rolled and lightweight — not a full schema-driven parser 245 + - JSON parser is hand-rolled — not a full schema-driven parser 207 246 - `relay-raw` only surfaces `app.bsky.feed.post` create ops 208 - - Stream view shows raw DIDs; handle resolution is done where available 247 + - DM thread view shows raw DIDs; handle resolution coming in a future version 209 248 - The TUI is line-based (type command + Enter), not raw keypress 210 249 - `m` and `j` for stream control are home view commands — go `b` back to home 211 250 first if you are in the post list ··· 214 253 215 254 ## Changelog 216 255 256 + **v1.4** — DM support via `chat.bsky.convo.*`. `dm <handle>` opens or creates 257 + a conversation, `i` lists the inbox, `r` sends a reply. PDS host auto-resolved 258 + from `plc.directory` on login. `atproto-proxy` header routing to 259 + `did:web:api.bsky.chat`. App password requires DM access enabled. 260 + 217 261 **v1.3** — Floyd-Steinberg dithering + image post via `d <imagepath>`. Bill 218 262 Atkinson's algorithm (MacPaint, 1984) ported to Fortran. `uploadBlob` + 219 - `createRecord` with image embed wired into the AT Protocol client. Requires 220 - Pillow. 263 + `createRecord` with image embed. Requires Pillow. 221 264 222 265 **v1.2** — Assemblersky integration. `relay_raw_tail.py` detects and prefers 223 266 `assemblersky_cli` over the Rust bridge. Live relay-raw decode via Python cbor2
+28 -3
cshim/http_bridge.c
··· 25 25 return p; 26 26 } 27 27 28 - static char *do_request(const char *url, const char *auth_header, const char *json_body, long *status_code, size_t *out_len) { 28 + static char *do_request_ex(const char *url, const char *auth_header, 29 + const char *json_body, const char *proxy_did, 30 + long *status_code, size_t *out_len) { 29 31 CURL *curl; 30 32 CURLcode res; 31 33 struct curl_slist *headers = NULL; 32 34 struct buffer chunk = {0}; 33 35 char *result = NULL; 36 + char proxy_header[256]; 34 37 35 38 if (status_code) *status_code = 0; 36 39 if (out_len) *out_len = 0; ··· 42 45 curl_easy_setopt(curl, CURLOPT_URL, url); 43 46 curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); 44 47 curl_easy_setopt(curl, CURLOPT_TIMEOUT, 20L); 45 - curl_easy_setopt(curl, CURLOPT_USERAGENT, "fortransky/0.7"); 48 + curl_easy_setopt(curl, CURLOPT_USERAGENT, "fortransky/1.3"); 46 49 curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); 47 50 curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&chunk); 48 51 curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, ""); 49 52 50 - if (auth_header && auth_header[0] != '\0') headers = curl_slist_append(headers, auth_header); 53 + if (auth_header && auth_header[0] != '\0') 54 + headers = curl_slist_append(headers, auth_header); 55 + if (proxy_did && proxy_did[0] != '\0') { 56 + snprintf(proxy_header, sizeof(proxy_header), "atproto-proxy: %s", proxy_did); 57 + headers = curl_slist_append(headers, proxy_header); 58 + } 51 59 if (json_body) { 52 60 headers = curl_slist_append(headers, "Content-Type: application/json"); 53 61 curl_easy_setopt(curl, CURLOPT_POST, 1L); ··· 73 81 return result ? result : dup_empty(); 74 82 } 75 83 84 + static char *do_request(const char *url, const char *auth_header, const char *json_body, long *status_code, size_t *out_len) { 85 + return do_request_ex(url, auth_header, json_body, NULL, status_code, out_len); 86 + } 87 + 76 88 char *fortransky_http_get(const char *url, const char *auth_header, long *status_code, size_t *out_len) { 77 89 return do_request(url, auth_header, NULL, status_code, out_len); 78 90 } 79 91 80 92 char *fortransky_http_post_json(const char *url, const char *auth_header, const char *json_body, long *status_code, size_t *out_len) { 81 93 return do_request(url, auth_header, json_body, status_code, out_len); 94 + } 95 + 96 + /* Proxied variants for chat.bsky.* — adds atproto-proxy header */ 97 + char *fortransky_http_get_proxied(const char *url, const char *auth_header, 98 + const char *proxy_did, 99 + long *status_code, size_t *out_len) { 100 + return do_request_ex(url, auth_header, NULL, proxy_did, status_code, out_len); 101 + } 102 + 103 + char *fortransky_http_post_json_proxied(const char *url, const char *auth_header, 104 + const char *json_body, const char *proxy_did, 105 + long *status_code, size_t *out_len) { 106 + return do_request_ex(url, auth_header, json_body, proxy_did, status_code, out_len); 82 107 } 83 108 84 109 /* Upload raw binary data (e.g. PNG blob) with a given Content-Type.
+204 -3
src/atproto/client.f90
··· 1 1 module client_mod 2 - use http_cbridge_mod, only: http_get, http_post_json, http_post_binary, http_get_urlencoded, last_http_status 2 + use http_cbridge_mod, only: http_get, http_post_json, http_post_binary, http_get_urlencoded, & 3 + http_get_proxied, http_post_json_proxied, last_http_status 3 4 use json_extract_mod, only: extract_json_string, escape_json_string 4 5 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 models_mod, only: session_state, post_view, stream_event, actor_profile, notification_view, & 7 + convo_view, dm_message, MAX_ITEMS, HANDLE_LEN, URI_LEN 6 8 use app_state_mod, only: app_state, DID_CACHE_SIZE 7 9 use process_mod, only: run_capture, slurp_file 8 10 use log_store_mod, only: state_file, append_line, read_first_line, write_text ··· 12 14 public :: tail_live_stream, fetch_post_thread, create_post, create_reply, create_quote_post, like_post, repost_post 13 15 public :: fetch_profile_view, fetch_notifications_view, load_saved_session, save_session, clear_saved_session 14 16 public :: resolve_did_to_handle, upload_blob, create_image_post 17 + public :: list_convos, get_messages, send_dm, get_convo_for_member 15 18 contains 16 19 subroutine load_saved_session(state) 17 20 type(session_state), intent(inout) :: state 18 - character(len=:), allocatable :: body 21 + character(len=:), allocatable :: body, pds 19 22 20 23 body = read_first_line(state_file('session.json')) 21 24 if (len_trim(body) == 0) return ··· 27 30 call copy_fit(extract_json_string(body, 'did'), state%did) 28 31 call copy_fit(extract_json_string(body, 'accessJwt'), state%access_jwt) 29 32 call copy_fit(extract_json_string(body, 'refreshJwt'), state%refresh_jwt) 33 + pds = extract_json_string(body, 'pdsHost') 34 + if (len_trim(pds) > 0 .and. pds(1:4) == 'http') call copy_fit(pds, state%pds_host) 30 35 end subroutine load_saved_session 31 36 32 37 subroutine save_session(state) ··· 34 39 character(len=:), allocatable :: body 35 40 body = '{"identifier":"' // escape_json_string(trim(state%identifier)) // '",' // & 36 41 '"did":"' // escape_json_string(trim(state%did)) // '",' // & 42 + '"pdsHost":"' // escape_json_string(trim(state%pds_host)) // '",' // & 37 43 '"accessJwt":"' // escape_json_string(trim(state%access_jwt)) // '",' // & 38 44 '"refreshJwt":"' // escape_json_string(trim(state%refresh_jwt)) // '"}' 39 45 call write_text(state_file('session.json'), body) ··· 78 84 state%access_jwt(1:min(len_trim(access), len(state%access_jwt))) = access(1:min(len_trim(access), len(state%access_jwt))) 79 85 state%refresh_jwt(1:min(len_trim(refresh), len(state%refresh_jwt))) = refresh(1:min(len_trim(refresh), len(state%refresh_jwt))) 80 86 state%did(1:min(len_trim(did), len(state%did))) = did(1:min(len_trim(did), len(state%did))) 87 + ! Resolve real PDS host from plc.directory 88 + block 89 + character(len=:), allocatable :: plc_body, endpoint 90 + plc_body = http_get('https://plc.directory/' // trim(state%did)) 91 + endpoint = extract_json_string(plc_body, 'serviceEndpoint') 92 + if (len_trim(endpoint) > 0 .and. endpoint(1:4) == 'http') then 93 + call copy_fit(endpoint, state%pds_host) 94 + end if 95 + end block 81 96 ok = .true. 82 97 call save_session(state) 83 98 message = 'Login OK' ··· 703 718 704 719 if (brace_end > brace_start) val = json(brace_start:brace_end) 705 720 end function extract_json_object 721 + 722 + ! ---------------------------------------------------------------- 723 + ! list_convos — fetch conversation list via chat.bsky.convo.listConvos 724 + ! ---------------------------------------------------------------- 725 + subroutine list_convos(state, convos, n, ok, message) 726 + use models_mod, only: convo_view, CHAT_PROXY 727 + type(session_state), intent(in) :: state 728 + type(convo_view), intent(out) :: convos(64) 729 + integer, intent(out) :: n 730 + logical, intent(out) :: ok 731 + character(len=*), intent(out) :: message 732 + 733 + character(len=:), allocatable :: body, url, convo_arr, item 734 + integer :: i, pos, next_pos 735 + 736 + ok = .false.; n = 0; message = 'listConvos failed' 737 + 738 + if (len_trim(state%access_jwt) == 0) then 739 + message = 'Login required.'; return 740 + end if 741 + 742 + url = trim(state%pds_host) // '/xrpc/chat.bsky.convo.listConvos?limit=20' 743 + body = http_get_proxied(url, CHAT_PROXY, auth_token=trim(state%access_jwt)) 744 + 745 + if (len_trim(body) == 0) then 746 + message = 'Empty response from listConvos'; return 747 + end if 748 + 749 + ! Parse convos array — simple scan for convo objects 750 + convo_arr = extract_json_string(body, 'convos') 751 + if (len_trim(convo_arr) == 0) then 752 + ok = .true.; message = 'No conversations'; return 753 + end if 754 + 755 + ! Walk through convo objects 756 + pos = 1 757 + i = 0 758 + do while (pos <= len(body) .and. i < 64) 759 + next_pos = index(body(pos:), '"id"') 760 + if (next_pos == 0) exit 761 + pos = pos + next_pos - 1 762 + i = i + 1 763 + convos(i)%id = extract_json_string(body(pos:pos+500), 'id') 764 + convos(i)%unread_count = 0 765 + pos = pos + 4 766 + end do 767 + 768 + n = i 769 + ok = .true. 770 + message = 'OK' 771 + end subroutine list_convos 772 + 773 + ! ---------------------------------------------------------------- 774 + ! get_convo_for_member — get or create a DM convo with a given DID 775 + ! Returns the convo_id to use for get_messages / send_dm 776 + ! ---------------------------------------------------------------- 777 + subroutine get_convo_for_member(state, member_did, convo_id, ok, message) 778 + use models_mod, only: CHAT_PROXY 779 + type(session_state), intent(in) :: state 780 + character(len=*), intent(in) :: member_did 781 + character(len=*), intent(out) :: convo_id 782 + logical, intent(out) :: ok 783 + character(len=*), intent(out) :: message 784 + 785 + character(len=:), allocatable :: body, url 786 + 787 + ok = .false.; convo_id = ''; message = 'getConvoForMembers failed' 788 + 789 + if (len_trim(state%access_jwt) == 0) then 790 + message = 'Login required.'; return 791 + end if 792 + 793 + url = trim(state%pds_host) // '/xrpc/chat.bsky.convo.getConvoForMembers?members=' // & 794 + escape_json_string(trim(member_did)) 795 + body = http_get_proxied(url, CHAT_PROXY, auth_token=trim(state%access_jwt)) 796 + 797 + ! Response is {"convo":{"id":"..."}}, extract from inside convo object 798 + convo_id = extract_json_string(extract_json_object(body, 'convo'), 'id') 799 + if (len_trim(convo_id) > 0) then 800 + ok = .true.; message = 'OK' 801 + else 802 + message = 'Could not get convo ID. Response: ' // body(1:min(len(body),120)) 803 + end if 804 + end subroutine get_convo_for_member 805 + 806 + ! ---------------------------------------------------------------- 807 + ! get_messages — fetch messages for a conversation 808 + ! ---------------------------------------------------------------- 809 + subroutine get_messages(state, convo_id, msgs, n, ok, message) 810 + use models_mod, only: dm_message, CHAT_PROXY 811 + type(session_state), intent(in) :: state 812 + character(len=*), intent(in) :: convo_id 813 + type(dm_message), intent(out) :: msgs(64) 814 + integer, intent(out) :: n 815 + logical, intent(out) :: ok 816 + character(len=*), intent(out) :: message 817 + 818 + character(len=:), allocatable :: body, url 819 + integer :: i, pos, next_pos 820 + 821 + ok = .false.; n = 0; message = 'getMessages failed' 822 + 823 + if (len_trim(state%access_jwt) == 0) then 824 + message = 'Login required.'; return 825 + end if 826 + 827 + url = trim(state%pds_host) // '/xrpc/chat.bsky.convo.getMessages?convoId=' // & 828 + trim(convo_id) // '&limit=20' 829 + body = http_get_proxied(url, CHAT_PROXY, auth_token=trim(state%access_jwt)) 830 + 831 + if (len_trim(body) == 0) then 832 + message = 'Empty response from getMessages'; return 833 + end if 834 + 835 + ! Walk the messages array object by object using brace-depth tracking. 836 + ! This isolates each message cleanly regardless of field order or length. 837 + block 838 + integer :: arr_pos, depth, obj_start, j 839 + character(len=:), allocatable :: obj 840 + 841 + ! Find start of messages array 842 + arr_pos = index(body, '"messages"') 843 + if (arr_pos > 0) arr_pos = index(body(arr_pos:), '[') + arr_pos - 1 844 + 845 + depth = 0; obj_start = 0; i = 0 846 + do j = arr_pos, len(body) 847 + if (body(j:j) == '{') then 848 + if (depth == 0) obj_start = j 849 + depth = depth + 1 850 + else if (body(j:j) == '}') then 851 + depth = depth - 1 852 + if (depth == 0 .and. obj_start > 0 .and. i < 64) then 853 + obj = body(obj_start:j) 854 + i = i + 1 855 + msgs(i)%id = extract_json_string(obj, 'id') 856 + msgs(i)%text = extract_json_string(obj, 'text') 857 + msgs(i)%sent_at = extract_json_string(obj, 'sentAt') 858 + msgs(i)%sender_did = extract_json_string( & 859 + extract_json_object(obj, 'sender'), 'did') 860 + obj_start = 0 861 + end if 862 + else if (body(j:j) == ']' .and. depth == 0) then 863 + exit 864 + end if 865 + end do 866 + end block 867 + 868 + n = i 869 + ok = .true. 870 + message = 'OK' 871 + end subroutine get_messages 872 + 873 + ! ---------------------------------------------------------------- 874 + ! send_dm — send a message to a conversation 875 + ! ---------------------------------------------------------------- 876 + subroutine send_dm(state, convo_id, text, ok, message) 877 + use models_mod, only: CHAT_PROXY 878 + type(session_state), intent(in) :: state 879 + character(len=*), intent(in) :: convo_id, text 880 + logical, intent(out) :: ok 881 + character(len=*), intent(out) :: message 882 + 883 + character(len=:), allocatable :: body, url, payload, msg_id 884 + 885 + ok = .false.; message = 'sendMessage failed' 886 + 887 + if (len_trim(state%access_jwt) == 0) then 888 + message = 'Login required.'; return 889 + end if 890 + 891 + url = trim(state%pds_host) // '/xrpc/chat.bsky.convo.sendMessage' 892 + payload = '{' // & 893 + '"convoId":"' // escape_json_string(trim(convo_id)) // '",' // & 894 + '"message":{"$type":"chat.bsky.convo.defs#messageInput",' // & 895 + '"text":"' // escape_json_string(trim(text)) // '"}}' 896 + 897 + body = http_post_json_proxied(url, payload, CHAT_PROXY, & 898 + auth_token=trim(state%access_jwt)) 899 + msg_id = extract_json_string(body, 'id') 900 + 901 + if (len_trim(msg_id) > 0) then 902 + ok = .true.; message = 'Message sent' 903 + else 904 + message = 'sendMessage failed. Response: ' // body(1:min(len(body),120)) 905 + end if 906 + end subroutine send_dm 706 907 707 908 end module client_mod
+64 -1
src/atproto/http_cbridge.f90
··· 3 3 use strings_mod, only: url_encode 4 4 implicit none 5 5 private 6 - public :: http_get, http_post_json, http_post_binary, http_get_urlencoded, last_http_status 6 + public :: http_get, http_post_json, http_post_binary, http_get_urlencoded, & 7 + http_get_proxied, http_post_json_proxied, last_http_status 7 8 8 9 integer :: last_http_status = 0 9 10 ··· 44 45 integer(c_size_t), intent(out) :: out_len 45 46 type(c_ptr) :: res 46 47 end function fortransky_http_post_binary 48 + 49 + function fortransky_http_get_proxied(url, auth_header, proxy_did, status_code, out_len) & 50 + bind(C, name='fortransky_http_get_proxied') result(res) 51 + import :: c_ptr, c_char, c_long, c_size_t 52 + character(kind=c_char), dimension(*), intent(in) :: url 53 + character(kind=c_char), dimension(*), intent(in) :: auth_header 54 + character(kind=c_char), dimension(*), intent(in) :: proxy_did 55 + integer(c_long), intent(out) :: status_code 56 + integer(c_size_t), intent(out) :: out_len 57 + type(c_ptr) :: res 58 + end function fortransky_http_get_proxied 59 + 60 + function fortransky_http_post_json_proxied(url, auth_header, json_body, proxy_did, status_code, out_len) & 61 + bind(C, name='fortransky_http_post_json_proxied') result(res) 62 + import :: c_ptr, c_char, c_long, c_size_t 63 + character(kind=c_char), dimension(*), intent(in) :: url 64 + character(kind=c_char), dimension(*), intent(in) :: auth_header 65 + character(kind=c_char), dimension(*), intent(in) :: json_body 66 + character(kind=c_char), dimension(*), intent(in) :: proxy_did 67 + integer(c_long), intent(out) :: status_code 68 + integer(c_size_t), intent(out) :: out_len 69 + type(c_ptr) :: res 70 + end function fortransky_http_post_json_proxied 47 71 end interface 48 72 contains 49 73 function http_get(url, auth_token) result(body) ··· 116 140 body = from_c_buffer(raw, out_len) 117 141 if (c_associated(raw)) call fortransky_http_free(raw) 118 142 end function http_post_binary 143 + 144 + ! GET with atproto-proxy header — for chat.bsky.* endpoints 145 + function http_get_proxied(url, proxy_did, auth_token) result(body) 146 + character(len=*), intent(in) :: url, proxy_did 147 + character(len=*), intent(in), optional :: auth_token 148 + character(len=:), allocatable :: body 149 + character(len=:), allocatable :: header 150 + integer(c_long) :: status_code 151 + integer(c_size_t) :: out_len 152 + type(c_ptr) :: raw 153 + 154 + header = auth_header_value(auth_token) 155 + raw = fortransky_http_get_proxied( & 156 + c_string(trim(url)), c_string(header), & 157 + c_string(trim(proxy_did)), status_code, out_len) 158 + last_http_status = int(status_code) 159 + body = from_c_buffer(raw, out_len) 160 + if (c_associated(raw)) call fortransky_http_free(raw) 161 + end function http_get_proxied 162 + 163 + ! POST JSON with atproto-proxy header — for chat.bsky.* endpoints 164 + function http_post_json_proxied(url, json_body, proxy_did, auth_token) result(body) 165 + character(len=*), intent(in) :: url, json_body, proxy_did 166 + character(len=*), intent(in), optional :: auth_token 167 + character(len=:), allocatable :: body 168 + character(len=:), allocatable :: header 169 + integer(c_long) :: status_code 170 + integer(c_size_t) :: out_len 171 + type(c_ptr) :: raw 172 + 173 + header = auth_header_value(auth_token) 174 + raw = fortransky_http_post_json_proxied( & 175 + c_string(trim(url)), c_string(header), & 176 + c_string(trim(json_body)), c_string(trim(proxy_did)), & 177 + status_code, out_len) 178 + last_http_status = int(status_code) 179 + body = from_c_buffer(raw, out_len) 180 + if (c_associated(raw)) call fortransky_http_free(raw) 181 + end function http_post_json_proxied 119 182 120 183 function auth_header_value(auth_token) result(header) 121 184 character(len=*), intent(in), optional :: auth_token
+2 -1
src/core/app_state.f90
··· 1 1 module app_state_mod 2 2 use models_mod, only: session_state, post_view, actor_profile, notification_view, MAX_ITEMS, HANDLE_LEN, URI_LEN 3 3 implicit none 4 - integer, parameter :: VIEW_HOME=1, VIEW_POST_LIST=2, VIEW_PROFILE=3, VIEW_NOTIFICATIONS=4, VIEW_STREAM=5 4 + integer, parameter :: VIEW_HOME=1, VIEW_POST_LIST=2, VIEW_PROFILE=3, VIEW_NOTIFICATIONS=4, VIEW_STREAM=5, & 5 + VIEW_CONVO_LIST=6, VIEW_MESSAGES=7 5 6 integer, parameter :: MAX_CACHE = 256 6 7 integer, parameter :: DID_CACHE_SIZE = 128 7 8
+23
src/core/models.f90
··· 72 72 character(len=1024) :: access_jwt = '' 73 73 character(len=1024) :: refresh_jwt = '' 74 74 end type session_state 75 + 76 + ! DM / chat types 77 + character(len=*), parameter :: CHAT_PROXY = 'did:web:api.bsky.chat#bsky_chat' 78 + integer, parameter :: CONVO_ID_LEN = 64 79 + integer, parameter :: MSG_ID_LEN = 64 80 + 81 + type :: convo_view 82 + character(len=CONVO_ID_LEN) :: id = '' 83 + character(len=HANDLE_LEN) :: member_handle = '' 84 + character(len=URI_LEN) :: member_did = '' 85 + character(len=FIELD_LEN) :: last_message = '' 86 + character(len=TS_LEN) :: last_message_at = '' 87 + integer :: unread_count = 0 88 + end type convo_view 89 + 90 + type :: dm_message 91 + character(len=MSG_ID_LEN) :: id = '' 92 + character(len=URI_LEN) :: sender_did = '' 93 + character(len=HANDLE_LEN) :: sender_handle = '' 94 + character(len=FIELD_LEN) :: text = '' 95 + character(len=TS_LEN) :: sent_at = '' 96 + end type dm_message 97 + 75 98 end module models_mod
+158 -5
src/ui/tui.f90
··· 2 2 use client_mod, only: login_session, fetch_author_feed, search_posts, fetch_timeline, tail_live_stream, & 3 3 fetch_post_thread, create_post, create_reply, create_quote_post, like_post, repost_post, & 4 4 fetch_profile_view, fetch_notifications_view, load_saved_session, clear_saved_session, & 5 - resolve_did_to_handle, create_image_post 5 + resolve_did_to_handle, create_image_post, & 6 + list_convos, get_messages, send_dm, get_convo_for_member 6 7 use dither_mod, only: run_dither 7 - use models_mod, only: post_view, stream_event, actor_profile, notification_view, MAX_ITEMS 8 + use models_mod, only: post_view, stream_event, actor_profile, notification_view, convo_view, dm_message, MAX_ITEMS 8 9 use config_mod, only: load_session_from_env 9 10 use app_state_mod, only: app_state, VIEW_HOME, VIEW_POST_LIST, VIEW_PROFILE, VIEW_NOTIFICATIONS, VIEW_STREAM, & 10 - reset_selection, set_status 11 + VIEW_CONVO_LIST, VIEW_MESSAGES, reset_selection, set_status 11 12 use post_store_mod, only: upsert_posts, get_current_post 12 13 implicit none 13 14 contains ··· 57 58 58 59 subroutine draw_header(state) 59 60 type(app_state), intent(in) :: state 60 - write(*,'(a)') 'Fortransky v1.3 - TUI only' 61 + write(*,'(a)') 'Fortransky v1.4 - TUI only' 61 62 write(*,'(a)') repeat('=', 28) 62 63 write(*,'(a)') 'View : ' // trim(state%view_title) 63 64 if (len_trim(state%session%identifier) > 0) write(*,'(a)') 'User : ' // trim(state%session%identifier) ··· 85 86 write(*,'(a)') ' n notifications' 86 87 write(*,'(a)') ' c compose post' 87 88 write(*,'(a)') ' d <image> dither + post image' 89 + write(*,'(a)') ' i DM inbox' 90 + write(*,'(a)') ' dm <handle> new DM' 88 91 write(*,'(a)') ' t <uri/url> open thread' 89 92 write(*,'(a)') ' j stream tail' 90 93 write(*,'(a)') ' m toggle stream mode (jetstream/relay-raw)' ··· 184 187 character(len=*), intent(in) :: message 185 188 integer :: i 186 189 call clear_screen() 187 - write(*,'(a)') 'Fortransky v1.3 - stream tail' 190 + write(*,'(a)') 'Fortransky v1.4 - stream tail' 188 191 write(*,'(a)') repeat('=', 28) 189 192 write(*,'(a)') trim(message) 190 193 write(*,'(a)') '' ··· 413 416 end if 414 417 end subroutine dither_flow 415 418 419 + ! ---------------------------------------------------------------- 420 + ! load_inbox — list DM conversations 421 + ! ---------------------------------------------------------------- 422 + subroutine load_inbox(state) 423 + type(app_state), intent(inout) :: state 424 + type(convo_view) :: convos(64) 425 + integer :: n, i 426 + logical :: ok 427 + character(len=256) :: message, line 428 + 429 + call set_status(state, 'Loading DM inbox...') 430 + call list_convos(state%session, convos, n, ok, message) 431 + 432 + call clear_screen() 433 + call draw_header(state) 434 + write(*,'(a)') 'DM Inbox' 435 + write(*,'(a)') repeat('=', 40) 436 + 437 + if (.not. ok .or. n == 0) then 438 + write(*,'(a)') 'No conversations. Use: dm <handle>' 439 + else 440 + do i = 1, n 441 + write(*,'(i3,a,a)') i, ' ', trim(convos(i)%id) 442 + end do 443 + end if 444 + 445 + write(*,*) 446 + write(*,'(a)') 'Commands: dm <handle> new conversation, b back' 447 + write(*,'(a)', advance='no') '> ' 448 + read(*,'(a)') line 449 + line = adjustl(trim(line)) 450 + 451 + if (trim(line) == 'b') return 452 + 453 + ! If they type a number, open that conversation 454 + if (len_trim(line) > 0 .and. line(1:1) >= '1' .and. line(1:1) <= '9') then 455 + read(line, *, iostat=i) i 456 + if (i >= 1 .and. i <= n) then 457 + call view_convo(state, convos(i)%id) 458 + end if 459 + end if 460 + end subroutine load_inbox 461 + 462 + ! ---------------------------------------------------------------- 463 + ! start_dm — open or create a DM with a handle 464 + ! ---------------------------------------------------------------- 465 + subroutine start_dm(state, handle) 466 + type(app_state), intent(inout) :: state 467 + character(len=*), intent(in) :: handle 468 + 469 + character(len=256) :: convo_id, message 470 + type(actor_profile) :: profile 471 + logical :: ok 472 + 473 + ! Resolve handle to DID via getProfile 474 + call set_status(state, 'Resolving ' // trim(handle) // '...') 475 + call fetch_profile_view(trim(handle), profile, ok, message) 476 + 477 + if (.not. ok .or. len_trim(profile%did) == 0) then 478 + call set_status(state, 'Could not resolve handle: ' // trim(handle)) 479 + return 480 + end if 481 + 482 + call set_status(state, 'Opening DM with ' // trim(handle) // '...') 483 + call get_convo_for_member(state%session, trim(profile%did), convo_id, ok, message) 484 + 485 + if (.not. ok) then 486 + call set_status(state, trim(message)) 487 + return 488 + end if 489 + 490 + call view_convo(state, convo_id) 491 + end subroutine start_dm 492 + 493 + ! ---------------------------------------------------------------- 494 + ! view_convo — show messages in a conversation and allow replies 495 + ! ---------------------------------------------------------------- 496 + subroutine view_convo(state, convo_id) 497 + type(app_state), intent(inout) :: state 498 + character(len=*), intent(in) :: convo_id 499 + 500 + type(dm_message) :: msgs(64) 501 + integer :: n, i, ios 502 + logical :: ok 503 + character(len=256) :: message 504 + character(len=2000) :: line 505 + 506 + call set_status(state, 'Loading messages...') 507 + call get_messages(state%session, trim(convo_id), msgs, n, ok, message) 508 + 509 + do 510 + call clear_screen() 511 + call draw_header(state) 512 + write(*,'(a)') 'DM Thread [convo: ' // trim(convo_id(1:min(16,len_trim(convo_id)))) // '...]' 513 + write(*,'(a)') repeat('-', 60) 514 + 515 + if (.not. ok .or. n == 0) then 516 + write(*,'(a)') '(no messages yet)' 517 + else 518 + do i = n, 1, -1 ! newest last (API returns newest first, we reverse) 519 + ! Show sender: me or their DID truncated 520 + if (trim(msgs(i)%sender_did) == trim(state%session%did)) then 521 + write(*,'(a,a,a)') '[', trim(msgs(i)%sent_at(1:min(16,len_trim(msgs(i)%sent_at)))), '] you' 522 + else 523 + write(*,'(a,a,a,a)') '[', trim(msgs(i)%sent_at(1:min(16,len_trim(msgs(i)%sent_at)))), '] ', & 524 + trim(msgs(i)%sender_did(1:min(20,len_trim(msgs(i)%sender_did)))) 525 + end if 526 + write(*,'(a)') ' ' // trim(msgs(i)%text) 527 + write(*,*) 528 + end do 529 + end if 530 + 531 + write(*,'(a)') repeat('-', 60) 532 + write(*,'(a)') 'Commands: r reply, j refresh, b back' 533 + write(*,'(a)', advance='no') '> ' 534 + read(*,'(a)') line 535 + line = adjustl(trim(line)) 536 + 537 + if (trim(line) == 'b') exit 538 + 539 + if (trim(line) == 'j') then 540 + call get_messages(state%session, trim(convo_id), msgs, n, ok, message) 541 + cycle 542 + end if 543 + 544 + if (trim(line) == 'r') then 545 + write(*,'(a)', advance='no') 'Message: ' 546 + read(*,'(a)') line 547 + if (len_trim(line) > 0) then 548 + call send_dm(state%session, trim(convo_id), trim(line), ok, message) 549 + if (ok) then 550 + call get_messages(state%session, trim(convo_id), msgs, n, ok, message) 551 + call set_status(state, 'Message sent.') 552 + else 553 + call set_status(state, trim(message)) 554 + end if 555 + end if 556 + cycle 557 + end if 558 + end do 559 + end subroutine view_convo 560 + 416 561 subroutine reply_to_selected_post(state) 417 562 type(app_state), intent(inout) :: state 418 563 type(post_view) :: target ··· 658 803 call login_flow(state) 659 804 case ('n') 660 805 call load_notifications(state) 806 + case ('i') 807 + call load_inbox(state) 808 + case ('dm') 809 + if (len_trim(arg) == 0) then 810 + call set_status(state, 'Usage: dm <handle>') 811 + else 812 + call start_dm(state, arg) 813 + end if 661 814 case ('c') 662 815 call compose_flow(state) 663 816 case ('d')